mirror of
https://github.com/selectstarfromusers/obsidian-task-manager.git
synced 2026-07-22 06:05:56 +00:00
v0.1.4: address Obsidian Community automated review scorecard (code + styles)
Migrated from the legacy obsidianmd/obsidian-releases PR workflow to the new https://community.obsidian.md portal. The new automated scan flagged 128 findings on v0.1.3; this commit addresses the code, style, and infrastructure findings. Code (TypeScript) - Replace document.createElement("div"|"span"|"input"|"datalist"| "option") with Obsidian's createDiv/createSpan/createEl helpers (~30 call sites across src/views/ and src/interactions/). - Replace document.createElementNS(svg-ns, ...) with createSvg(). - Replace bare document.X / document.querySelector / document.createTextNode with activeDocument.X for popout window compatibility. - Replace setTimeout/clearTimeout/window.setTimeout/window.clearTimeout with activeWindow.setTimeout/clearTimeout (7 call sites). - Introduce TaskFrontmatter interface in src/types.ts and narrow all metadataCache.getFileCache(...).frontmatter accesses to it — fixes the bulk of no-unsafe-* lint warnings. - Type-safe loadData() in main.ts via Partial<TasksPluginSettings>. - stripWikilinks() now accepts unknown and narrows internally. - Use configured inlineTaskTag in the inline task regex (the `tag` parameter was declared-but-unused). - Prefix the unused sourceFile parameter on updateStub with `_`. Styles - Remove all !important declarations (5 occurrences); bump specificity via .ot-view parent selector instead. - Merge the duplicate .ot-task-row definition and guard touch-target overrides under @media (pointer: coarse). - Expand #fff -> #ffffff. Infrastructure - Add CONTRIBUTING.md (fixes "missing contributing guide" hygiene flag on the portal scorecard). Deferred for follow-up - package-lock.json — couldn't generate from this session (npmjs.org unreachable). Run `npm install` locally and commit the lockfile to clear the last "no lockfile" finding. - .github/workflows/release.yml — needs `workflow` OAuth scope to push; will be added in a follow-up commit.
This commit is contained in:
parent
e69a0bc2cd
commit
4f68f82e96
18 changed files with 207 additions and 106 deletions
64
CONTRIBUTING.md
Normal file
64
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Contributing to Minimal Task Board
|
||||
|
||||
Thanks for taking a look! This plugin is small and focused. Contributions, bug reports, and feature ideas are all welcome.
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Open an issue at https://github.com/selectstarfromusers/obsidian-task-manager/issues with:
|
||||
|
||||
- Obsidian version (`Settings → About`)
|
||||
- Plugin version (in `Settings → Community plugins → Minimal Task Board`)
|
||||
- A short description of what you saw vs what you expected
|
||||
- A minimal repro: a few example task files (or screenshots) and the bucket/secondary-grouping settings you had configured
|
||||
- The full text of any error from `View → Toggle developer tools → Console`
|
||||
|
||||
## Requesting features
|
||||
|
||||
Open an issue with the `enhancement` label and describe the workflow you're trying to support. Concrete examples beat abstract requests — "I want to triage 50+ tasks per day across 4 customer accounts" gives much better context than "make filtering better".
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/selectstarfromusers/obsidian-task-manager
|
||||
cd obsidian-task-manager
|
||||
npm install
|
||||
npm run dev # watches src/ and rebuilds on change
|
||||
```
|
||||
|
||||
Then symlink the plugin folder into a test vault's `.obsidian/plugins/minimal-task-board/`:
|
||||
|
||||
```bash
|
||||
mkdir -p /path/to/test-vault/.obsidian/plugins
|
||||
ln -s "$(pwd)" /path/to/test-vault/.obsidian/plugins/minimal-task-board
|
||||
```
|
||||
|
||||
Enable the plugin under `Settings → Community plugins`, then `Ctrl/Cmd+P` → `Reload app without saving` after rebuilds.
|
||||
|
||||
### Project layout
|
||||
|
||||
- `src/main.ts` — plugin entry, view registration, settings load/save
|
||||
- `src/types.ts` — shared type definitions (`TaskItem`, `BucketGroup`, `TasksPluginSettings`, `TaskFrontmatter`)
|
||||
- `src/data/` — task store, inline-task watcher, frontmatter writer
|
||||
- `src/views/` — board / focus renderers, bucket component, task row
|
||||
- `src/interactions/` — drag/drop, checkbox toggle, inline-task creation
|
||||
- `src/settings.ts` — settings tab UI
|
||||
- `styles.css` — all styles (CSS variables and specificity, no `!important`)
|
||||
|
||||
### Style and review notes
|
||||
|
||||
- Use Obsidian's DOM helpers (`createDiv`, `createSpan`, `createEl`, `createSvg`, `activeDocument`, `activeWindow.setTimeout`, `this.registerDomEvent`) rather than raw DOM globals — the Obsidian community automated review enforces this.
|
||||
- Frontmatter reads should narrow `frontmatter` to the `TaskFrontmatter` interface in `src/types.ts` rather than using `any`.
|
||||
- Keep styles in `styles.css`, not inline `el.style.X` assignments.
|
||||
- Don't introduce new third-party runtime dependencies without discussing first — the plugin is intentionally lightweight.
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Fork, branch off `main`, keep the change focused
|
||||
2. `npm run build` must succeed and produce a clean `main.js`
|
||||
3. Bump the version in `manifest.json` and add a matching entry in `versions.json` (use semver — patch bumps for fixes, minor for new features)
|
||||
4. Open the PR with a brief summary of the change, screenshots if UI, and any new settings explained
|
||||
5. Be patient — this is a hobby project, I'll get to it
|
||||
|
||||
## License
|
||||
|
||||
By contributing you agree your changes are released under the MIT License (see `LICENSE`).
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "minimal-task-board",
|
||||
"name": "Minimal Task Board",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"minAppVersion": "1.10.0",
|
||||
"description": "A clean, minimal task board with customizable buckets, secondary grouping, and inline task tracking.",
|
||||
"author": "Art Malanok",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "minimal-task-board",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import { App, TFile } from "obsidian";
|
|||
|
||||
/**
|
||||
* Safe frontmatter updates using Obsidian's processFrontMatter API.
|
||||
*
|
||||
* Obsidian's callback is typed as (frontmatter: any), so we narrow it to
|
||||
* Record<string, unknown> inside each method to satisfy no-unsafe-* lints.
|
||||
*/
|
||||
export class FrontmatterWriter {
|
||||
private app: App;
|
||||
|
|
@ -12,15 +15,15 @@ export class FrontmatterWriter {
|
|||
|
||||
/** Update a single frontmatter property */
|
||||
async setProperty(file: TFile, key: string, value: unknown): Promise<void> {
|
||||
await this.app.fileManager.processFrontMatter(file, (fm) => {
|
||||
fm[key] = value;
|
||||
await this.app.fileManager.processFrontMatter(file, (rawFm: Record<string, unknown>) => {
|
||||
rawFm[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle a boolean frontmatter property */
|
||||
async toggleBoolean(file: TFile, key: string): Promise<void> {
|
||||
await this.app.fileManager.processFrontMatter(file, (fm) => {
|
||||
fm[key] = !fm[key];
|
||||
await this.app.fileManager.processFrontMatter(file, (rawFm: Record<string, unknown>) => {
|
||||
rawFm[key] = !rawFm[key];
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -29,9 +32,9 @@ export class FrontmatterWriter {
|
|||
file: TFile,
|
||||
updates: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await this.app.fileManager.processFrontMatter(file, (fm) => {
|
||||
await this.app.fileManager.processFrontMatter(file, (rawFm: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
fm[key] = value;
|
||||
rawFm[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { App, TFile, CachedMetadata, EventRef } from "obsidian";
|
||||
import { TasksPluginSettings } from "../types";
|
||||
import { TasksPluginSettings, TaskFrontmatter } from "../types";
|
||||
|
||||
interface TrackedTask {
|
||||
text: string;
|
||||
|
|
@ -44,7 +44,7 @@ export class InlineTaskWatcher {
|
|||
|
||||
private markRecentlyWritten(path: string): void {
|
||||
this.recentlyWritten.add(path);
|
||||
setTimeout(() => {
|
||||
activeWindow.setTimeout(() => {
|
||||
this.recentlyWritten.delete(path);
|
||||
}, 500);
|
||||
}
|
||||
|
|
@ -90,10 +90,16 @@ export class InlineTaskWatcher {
|
|||
const lines = content.split("\n");
|
||||
const tasks: TrackedTask[] = [];
|
||||
|
||||
// Build a regex from the configured tag so a user-overridden tag is honored.
|
||||
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const taskLineRe = new RegExp(
|
||||
`^[\\s]*-\\s+\\[([ xX])\\]\\s+(.+?)(?:\\s+#${escapedTag})\\s*$`
|
||||
);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Match: - [ ] text #task
|
||||
const match = line.match(/^[\s]*-\s+\[([ xX])\]\s+(.+?)(?:\s+#task)\s*$/);
|
||||
// Match: - [ ] text #<tag>
|
||||
const match = line.match(taskLineRe);
|
||||
if (match) {
|
||||
const done = match[1].toLowerCase() === "x";
|
||||
let text = match[2].trim();
|
||||
|
|
@ -119,7 +125,7 @@ export class InlineTaskWatcher {
|
|||
const folder = s.taskFolder;
|
||||
return this.app.vault.getFiles().filter((f) => {
|
||||
if (!f.path.startsWith(folder + "/") || f.extension !== "md") return false;
|
||||
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
|
||||
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as TaskFrontmatter | undefined;
|
||||
return fm?.source === "inline" && fm?.source_file === sourceFile.path;
|
||||
});
|
||||
}
|
||||
|
|
@ -132,7 +138,7 @@ export class InlineTaskWatcher {
|
|||
// Build a map of existing stubs by source_line
|
||||
const stubByLine = new Map<number, TFile>();
|
||||
for (const stub of existingStubs) {
|
||||
const fm = this.app.metadataCache.getFileCache(stub)?.frontmatter;
|
||||
const fm = this.app.metadataCache.getFileCache(stub)?.frontmatter as TaskFrontmatter | undefined;
|
||||
const line = fm?.source_line;
|
||||
if (typeof line === "number") {
|
||||
stubByLine.set(line, stub);
|
||||
|
|
@ -150,7 +156,7 @@ export class InlineTaskWatcher {
|
|||
if (!stub) {
|
||||
stub = existingStubs.find((s) => {
|
||||
if (matchedStubs.has(s.path)) return false;
|
||||
const fm = this.app.metadataCache.getFileCache(s)?.frontmatter;
|
||||
const fm = this.app.metadataCache.getFileCache(s)?.frontmatter as TaskFrontmatter | undefined;
|
||||
return fm?.action === task.text || fm?.source_line === task.lineNum;
|
||||
});
|
||||
}
|
||||
|
|
@ -173,8 +179,8 @@ export class InlineTaskWatcher {
|
|||
}
|
||||
}
|
||||
|
||||
private async updateStub(stub: TFile, task: TrackedTask, sourceFile: TFile): Promise<void> {
|
||||
const fm = this.app.metadataCache.getFileCache(stub)?.frontmatter;
|
||||
private async updateStub(stub: TFile, task: TrackedTask, _sourceFile: TFile): Promise<void> {
|
||||
const fm = this.app.metadataCache.getFileCache(stub)?.frontmatter as TaskFrontmatter | undefined;
|
||||
if (!fm) return;
|
||||
|
||||
const s = this.settings();
|
||||
|
|
@ -189,7 +195,8 @@ export class InlineTaskWatcher {
|
|||
if (!needsUpdate) return;
|
||||
|
||||
this.markRecentlyWritten(stub.path);
|
||||
await this.app.fileManager.processFrontMatter(stub, (frontmatter) => {
|
||||
await this.app.fileManager.processFrontMatter(stub, (rawFm: Record<string, unknown>) => {
|
||||
const frontmatter = rawFm as TaskFrontmatter;
|
||||
frontmatter.action = task.text;
|
||||
frontmatter.done = task.done;
|
||||
frontmatter.source_line = task.lineNum;
|
||||
|
|
@ -238,11 +245,12 @@ export class InlineTaskWatcher {
|
|||
const groupProp = s.secondaryGroupProperty;
|
||||
let groupValue = task.secondaryValue;
|
||||
if (!groupValue) {
|
||||
const sourceFm = this.app.metadataCache.getFileCache(sourceFile)?.frontmatter;
|
||||
const sourceFm = this.app.metadataCache.getFileCache(sourceFile)?.frontmatter as TaskFrontmatter | undefined;
|
||||
const fmValue = sourceFm?.[groupProp];
|
||||
groupValue = fmValue
|
||||
? String(fmValue).replace(/\[\[|\]\]/g, "")
|
||||
: "";
|
||||
groupValue =
|
||||
typeof fmValue === "string" && fmValue
|
||||
? fmValue.replace(/\[\[|\]\]/g, "")
|
||||
: "";
|
||||
}
|
||||
|
||||
const defaultBucket = s.buckets.find((b) => b.id === s.defaultBucketId)?.name ?? "";
|
||||
|
|
@ -279,7 +287,7 @@ export class InlineTaskWatcher {
|
|||
*/
|
||||
async syncDoneToSource(stubFile: TFile, done: boolean): Promise<void> {
|
||||
const cache = this.app.metadataCache.getFileCache(stubFile);
|
||||
const fm = cache?.frontmatter;
|
||||
const fm = cache?.frontmatter as TaskFrontmatter | undefined;
|
||||
if (!fm?.source_file || fm.source !== "inline") return;
|
||||
|
||||
const sourceFile = this.app.vault.getAbstractFileByPath(fm.source_file);
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ import {
|
|||
BucketGroup,
|
||||
SubGroup,
|
||||
TasksPluginSettings,
|
||||
TaskFrontmatter,
|
||||
} from "../types";
|
||||
|
||||
type ChangeCallback = () => void;
|
||||
|
||||
function stripWikilinks(value: string | undefined | null): string {
|
||||
if (!value) return "";
|
||||
return String(value).replace(/^\[\[/, "").replace(/\]\]$/, "");
|
||||
function stripWikilinks(value: unknown): string {
|
||||
if (typeof value !== "string" || !value) return "";
|
||||
return value.replace(/^\[\[/, "").replace(/\]\]$/, "");
|
||||
}
|
||||
|
||||
export class TaskStore {
|
||||
|
|
@ -100,17 +101,17 @@ export class TaskStore {
|
|||
|
||||
for (const file of taskFiles) {
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const fm = cache?.frontmatter;
|
||||
const fm = cache?.frontmatter as TaskFrontmatter | undefined;
|
||||
if (!fm || fm.type !== "task") continue;
|
||||
|
||||
// P1: Property name normalization — read bucket with lowercase key
|
||||
tasks.push({
|
||||
file,
|
||||
action: fm.action ?? "",
|
||||
account: stripWikilinks(fm.account),
|
||||
bucket: fm[bucketProp] ?? "",
|
||||
source: fm.source ?? "",
|
||||
sourceNote: stripWikilinks(fm.source_note),
|
||||
action: typeof fm.action === "string" ? fm.action : "",
|
||||
account: stripWikilinks(typeof fm.account === "string" ? fm.account : ""),
|
||||
bucket: typeof fm[bucketProp] === "string" ? (fm[bucketProp] as string) : "",
|
||||
source: typeof fm.source === "string" ? fm.source : "",
|
||||
sourceNote: stripWikilinks(typeof fm.source_note === "string" ? fm.source_note : ""),
|
||||
date: fm.date ? String(fm.date) : "",
|
||||
deadline: fm.deadline ? String(fm.deadline) : "",
|
||||
done: fm.done === true,
|
||||
|
|
@ -253,7 +254,7 @@ export class TaskStore {
|
|||
const newIndex = new Map<string, Set<string>>();
|
||||
for (const task of this.tasks) {
|
||||
const cache = this.app.metadataCache.getFileCache(task.file);
|
||||
const fm = cache?.frontmatter;
|
||||
const fm = cache?.frontmatter as TaskFrontmatter | undefined;
|
||||
const sourceFile = fm?.source_file;
|
||||
if (sourceFile && typeof sourceFile === "string") {
|
||||
if (!newIndex.has(sourceFile)) {
|
||||
|
|
@ -266,21 +267,23 @@ export class TaskStore {
|
|||
}
|
||||
|
||||
async toggleDone(file: TFile): Promise<void> {
|
||||
await this.app.fileManager.processFrontMatter(file, (fm) => {
|
||||
await this.app.fileManager.processFrontMatter(file, (rawFm: Record<string, unknown>) => {
|
||||
const fm = rawFm as TaskFrontmatter;
|
||||
fm.done = !fm.done;
|
||||
});
|
||||
}
|
||||
|
||||
async toggleDoneWithSync(file: TFile): Promise<void> {
|
||||
let newDone = false;
|
||||
await this.app.fileManager.processFrontMatter(file, (fm) => {
|
||||
await this.app.fileManager.processFrontMatter(file, (rawFm: Record<string, unknown>) => {
|
||||
const fm = rawFm as TaskFrontmatter;
|
||||
fm.done = !fm.done;
|
||||
newDone = fm.done;
|
||||
});
|
||||
|
||||
// Sync checkbox back to the source note for inline tasks
|
||||
const cache = this.app.metadataCache.getFileCache(file);
|
||||
const fm = cache?.frontmatter;
|
||||
const fm = cache?.frontmatter as TaskFrontmatter | undefined;
|
||||
if (fm?.source === "inline" && fm?.source_file) {
|
||||
const sourceFile = this.app.vault.getAbstractFileByPath(fm.source_file);
|
||||
if (sourceFile instanceof TFile) {
|
||||
|
|
@ -304,13 +307,15 @@ export class TaskStore {
|
|||
async moveToBucket(file: TFile, bucketName: string): Promise<void> {
|
||||
const settings = this.getSettings();
|
||||
// P1: Normalize property name to lowercase
|
||||
await this.app.fileManager.processFrontMatter(file, (fm) => {
|
||||
await this.app.fileManager.processFrontMatter(file, (rawFm: Record<string, unknown>) => {
|
||||
const fm = rawFm as TaskFrontmatter;
|
||||
fm[settings.bucketProperty.toLowerCase()] = bucketName;
|
||||
});
|
||||
}
|
||||
|
||||
async reorder(file: TFile, newSortOrder: number): Promise<void> {
|
||||
await this.app.fileManager.processFrontMatter(file, (fm) => {
|
||||
await this.app.fileManager.processFrontMatter(file, (rawFm: Record<string, unknown>) => {
|
||||
const fm = rawFm as TaskFrontmatter;
|
||||
fm.sort_order = newSortOrder;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,21 +48,21 @@ export class CheckboxHandler {
|
|||
|
||||
// Step 2: fill the circle after 300ms
|
||||
timeouts.push(
|
||||
window.setTimeout(() => {
|
||||
activeWindow.setTimeout(() => {
|
||||
checkbox?.classList.add("checked");
|
||||
}, 300)
|
||||
);
|
||||
|
||||
// Step 3: height collapse after 1500ms
|
||||
timeouts.push(
|
||||
window.setTimeout(() => {
|
||||
activeWindow.setTimeout(() => {
|
||||
rowElement.classList.add("done");
|
||||
}, 1500)
|
||||
);
|
||||
|
||||
// Step 4: write frontmatter after 1700ms
|
||||
timeouts.push(
|
||||
window.setTimeout(() => {
|
||||
activeWindow.setTimeout(() => {
|
||||
this.pendingTimeouts.delete(key);
|
||||
void this.callbacks.onToggleDone(task.file).then(() => {
|
||||
// Signal animation end so the view can resume re-renders
|
||||
|
|
@ -87,7 +87,7 @@ export class CheckboxHandler {
|
|||
const timeouts = this.pendingTimeouts.get(key);
|
||||
if (timeouts) {
|
||||
for (const id of timeouts) {
|
||||
window.clearTimeout(id);
|
||||
activeWindow.clearTimeout(id);
|
||||
}
|
||||
this.pendingTimeouts.delete(key);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,13 +44,13 @@ export class DragManager {
|
|||
const targetRow = this.findNearestRow(rows, event.clientY);
|
||||
|
||||
if (targetRow) {
|
||||
this.dropIndicator = document.createElement("div");
|
||||
this.dropIndicator = createDiv();
|
||||
this.dropIndicator.className = `${CLS}-drop-indicator`;
|
||||
targetRow.parentElement?.insertBefore(this.dropIndicator, targetRow);
|
||||
} else if (rows.length > 0) {
|
||||
// Cursor is below all rows — place indicator after the last row
|
||||
const lastRow = rows[rows.length - 1];
|
||||
this.dropIndicator = document.createElement("div");
|
||||
this.dropIndicator = createDiv();
|
||||
this.dropIndicator.className = `${CLS}-drop-indicator`;
|
||||
lastRow.parentElement?.insertBefore(
|
||||
this.dropIndicator,
|
||||
|
|
@ -89,7 +89,7 @@ export class DragManager {
|
|||
);
|
||||
if (accountGroup && this.callbacks.onMoveToGroup) {
|
||||
const newGroup = accountGroup.dataset.account;
|
||||
const sourceRow = document.querySelector<HTMLElement>(
|
||||
const sourceRow = activeDocument.querySelector<HTMLElement>(
|
||||
`.${CLS}-task-row[data-path="${this.draggedTask.file.path}"]`
|
||||
);
|
||||
const sourceGroup = sourceRow
|
||||
|
|
@ -194,7 +194,7 @@ export class DragManager {
|
|||
this.dropIndicator = null;
|
||||
}
|
||||
// Also clean up any orphaned indicators in the DOM
|
||||
document
|
||||
activeDocument
|
||||
.querySelectorAll(`.${CLS}-drop-indicator`)
|
||||
.forEach((el) => el.remove());
|
||||
}
|
||||
|
|
@ -202,10 +202,10 @@ export class DragManager {
|
|||
private cleanup(): void {
|
||||
this.removeDropIndicator();
|
||||
|
||||
document
|
||||
activeDocument
|
||||
.querySelectorAll(".dragging")
|
||||
.forEach((el) => el.classList.remove("dragging"));
|
||||
document
|
||||
activeDocument
|
||||
.querySelectorAll(".drag-over")
|
||||
.forEach((el) => el.classList.remove("drag-over"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ export class InlineCreator {
|
|||
}
|
||||
|
||||
const s = this.settings();
|
||||
const wrapper = document.createElement("div");
|
||||
const wrapper = createDiv();
|
||||
wrapper.className = `${CLS}-inline-input-wrapper`;
|
||||
|
||||
// Action text input
|
||||
const actionInput = document.createElement("input");
|
||||
const actionInput = createEl("input");
|
||||
actionInput.type = "text";
|
||||
actionInput.className = `${CLS}-inline-input`;
|
||||
actionInput.placeholder = "Task description...";
|
||||
|
|
@ -43,7 +43,7 @@ export class InlineCreator {
|
|||
const groupProp = s.secondaryGroupProperty;
|
||||
const datalistId = `${CLS}-group-suggestions-${Date.now()}`;
|
||||
|
||||
const groupInput = document.createElement("input");
|
||||
const groupInput = createEl("input");
|
||||
groupInput.type = "text";
|
||||
groupInput.className = `${CLS}-inline-input ${CLS}-inline-group-input`;
|
||||
groupInput.placeholder = `${groupProp}...`;
|
||||
|
|
@ -51,10 +51,10 @@ export class InlineCreator {
|
|||
wrapper.appendChild(groupInput);
|
||||
|
||||
// Datalist for autocomplete suggestions
|
||||
const datalist = document.createElement("datalist");
|
||||
const datalist = createEl("datalist");
|
||||
datalist.id = datalistId;
|
||||
for (const value of existingGroups) {
|
||||
const option = document.createElement("option");
|
||||
const option = createEl("option");
|
||||
option.value = value;
|
||||
datalist.appendChild(option);
|
||||
}
|
||||
|
|
@ -90,8 +90,8 @@ export class InlineCreator {
|
|||
|
||||
// Blur handler — remove wrapper if both inputs are empty after a short delay
|
||||
const handleBlur = () => {
|
||||
setTimeout(() => {
|
||||
const activeEl = document.activeElement;
|
||||
activeWindow.setTimeout(() => {
|
||||
const activeEl = activeDocument.activeElement;
|
||||
if (
|
||||
activeEl !== actionInput &&
|
||||
activeEl !== groupInput &&
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export default class TasksPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async loadSettings(): Promise<void> {
|
||||
const loaded = await this.loadData();
|
||||
const loaded = (await this.loadData()) as Partial<TasksPluginSettings> | null;
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);
|
||||
if (loaded?.display) {
|
||||
this.settings.display = Object.assign(
|
||||
|
|
|
|||
|
|
@ -147,13 +147,13 @@ export class TasksSettingTab extends PluginSettingTab {
|
|||
const taskCount = this.countTasksInBucket(bucket);
|
||||
delBtn.textContent = `Confirm? (${taskCount} task${taskCount !== 1 ? "s" : ""})`;
|
||||
confirmPending = true;
|
||||
confirmTimeout = setTimeout(() => {
|
||||
confirmTimeout = activeWindow.setTimeout(() => {
|
||||
delBtn.textContent = "\u00d7";
|
||||
confirmPending = false;
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
if (confirmTimeout) clearTimeout(confirmTimeout);
|
||||
if (confirmTimeout) activeWindow.clearTimeout(confirmTimeout);
|
||||
this.plugin.settings.buckets.splice(index, 1);
|
||||
this.plugin.settings.buckets.forEach((b, i) => (b.sortOrder = i));
|
||||
if (this.plugin.settings.defaultBucketId === bucket.id) {
|
||||
|
|
|
|||
26
src/types.ts
26
src/types.ts
|
|
@ -88,3 +88,29 @@ export const TASKS_VIEW_TYPE = "minimal-task-board-view";
|
|||
|
||||
/** CSS class prefix */
|
||||
export const CLS = "ot";
|
||||
|
||||
/**
|
||||
* Frontmatter shape for a stub file managed by this plugin.
|
||||
*
|
||||
* Obsidian's MetadataCache.frontmatter is typed as Record<string, any>, which
|
||||
* triggers no-unsafe-* lint warnings on every property access. We narrow it to
|
||||
* this interface so reads are type-checked.
|
||||
*
|
||||
* All fields are optional because user-edited frontmatter is best-effort.
|
||||
* Dynamic bucket/group properties (whose key comes from settings) are captured
|
||||
* by the index signature.
|
||||
*/
|
||||
export interface TaskFrontmatter {
|
||||
action?: string;
|
||||
source?: string;
|
||||
source_file?: string;
|
||||
source_line?: number;
|
||||
source_note?: string;
|
||||
date?: string;
|
||||
deadline?: string;
|
||||
done?: boolean;
|
||||
sort_order?: number;
|
||||
type?: string;
|
||||
/** Catch-all for the configurable bucketProperty / secondaryGroupProperty. */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ export function createAccountGroup(
|
|||
callbacks: TaskRowCallbacks,
|
||||
collapsible: boolean
|
||||
): HTMLElement {
|
||||
const group = document.createElement("div");
|
||||
const group = createDiv();
|
||||
group.className = `${CLS}-account-group`;
|
||||
|
||||
if (key) {
|
||||
group.dataset.account = key;
|
||||
|
||||
const header = document.createElement("div");
|
||||
const header = createDiv();
|
||||
header.className = `${CLS}-account-header`;
|
||||
header.textContent = key;
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ export function createAccountGroup(
|
|||
group.appendChild(header);
|
||||
}
|
||||
|
||||
const tasksContainer = document.createElement("div");
|
||||
const tasksContainer = createDiv();
|
||||
tasksContainer.className = `${CLS}-account-tasks`;
|
||||
|
||||
for (const task of tasks) {
|
||||
|
|
|
|||
|
|
@ -15,20 +15,20 @@ export function createBucket(
|
|||
collapsible: boolean,
|
||||
callbacks: BucketCallbacks
|
||||
): HTMLElement {
|
||||
const bucket = document.createElement("div");
|
||||
const bucket = createDiv();
|
||||
bucket.className = `${CLS}-bucket`;
|
||||
bucket.dataset.bucket = group.bucket.name;
|
||||
|
||||
// Header
|
||||
const header = document.createElement("div");
|
||||
const header = createDiv();
|
||||
header.className = `${CLS}-bucket-header`;
|
||||
|
||||
const name = document.createElement("span");
|
||||
const name = createSpan();
|
||||
name.className = `${CLS}-bucket-name`;
|
||||
name.textContent = group.bucket.name;
|
||||
header.appendChild(name);
|
||||
|
||||
const count = document.createElement("span");
|
||||
const count = createSpan();
|
||||
count.className = `${CLS}-bucket-count`;
|
||||
count.textContent = String(group.totalCount);
|
||||
header.appendChild(count);
|
||||
|
|
@ -36,7 +36,7 @@ export function createBucket(
|
|||
bucket.appendChild(header);
|
||||
|
||||
// Tasks container
|
||||
const tasksContainer = document.createElement("div");
|
||||
const tasksContainer = createDiv();
|
||||
tasksContainer.className = `${CLS}-bucket-tasks`;
|
||||
|
||||
if (secondaryGrouping) {
|
||||
|
|
@ -67,7 +67,7 @@ export function createBucket(
|
|||
bucket.appendChild(tasksContainer);
|
||||
|
||||
// Add task button
|
||||
const addBtn = document.createElement("div");
|
||||
const addBtn = createDiv();
|
||||
addBtn.className = `${CLS}-bucket-add`;
|
||||
addBtn.textContent = "Add a task";
|
||||
addBtn.addEventListener("click", () => {
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@ function formatDeadline(deadline: string): string {
|
|||
}
|
||||
|
||||
function createCheckboxSvg(done: boolean): SVGElement {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const svg = createSvg("svg");
|
||||
svg.setAttribute("width", "18");
|
||||
svg.setAttribute("height", "18");
|
||||
svg.setAttribute("viewBox", "0 0 18 18");
|
||||
|
||||
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
const rect = createSvg("rect");
|
||||
rect.setAttribute("x", "1");
|
||||
rect.setAttribute("y", "1");
|
||||
rect.setAttribute("width", "16");
|
||||
|
|
@ -47,7 +47,7 @@ function createCheckboxSvg(done: boolean): SVGElement {
|
|||
svg.appendChild(rect);
|
||||
|
||||
if (done) {
|
||||
const check = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const check = createSvg("path");
|
||||
check.setAttribute("d", "M5.5 9.5l2 2 5-5");
|
||||
check.setAttribute("stroke", "var(--background-primary, #fff)");
|
||||
check.setAttribute("stroke-width", "1.5");
|
||||
|
|
@ -65,7 +65,7 @@ export function createTaskRow(
|
|||
display: DisplayConfig,
|
||||
callbacks: TaskRowCallbacks
|
||||
): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
const row = createDiv();
|
||||
row.className = `${CLS}-task-row`;
|
||||
row.draggable = true;
|
||||
row.dataset.path = task.file.path;
|
||||
|
|
@ -82,13 +82,13 @@ export function createTaskRow(
|
|||
}
|
||||
|
||||
// Drag handle
|
||||
const handle = document.createElement("div");
|
||||
const handle = createDiv();
|
||||
handle.className = `${CLS}-drag-handle`;
|
||||
handle.textContent = "\u{2817}";
|
||||
row.appendChild(handle);
|
||||
|
||||
// Checkbox
|
||||
const checkbox = document.createElement("div");
|
||||
const checkbox = createDiv();
|
||||
checkbox.className = `${CLS}-checkbox`;
|
||||
checkbox.setAttribute("role", "checkbox");
|
||||
checkbox.setAttribute("aria-checked", String(task.done));
|
||||
|
|
@ -101,18 +101,17 @@ export function createTaskRow(
|
|||
row.appendChild(checkbox);
|
||||
|
||||
// Content
|
||||
const content = document.createElement("div");
|
||||
const content = createDiv();
|
||||
content.className = `${CLS}-content`;
|
||||
|
||||
// Action text + source icon on same line
|
||||
const actionLine = document.createElement("span");
|
||||
const actionLine = createSpan();
|
||||
actionLine.className = `${CLS}-action`;
|
||||
|
||||
const actionText = document.createTextNode(task.action);
|
||||
actionLine.appendChild(actionText);
|
||||
actionLine.appendText(task.action);
|
||||
|
||||
if (display.showSourceIcon && task.source && SOURCE_ICONS[task.source]) {
|
||||
const icon = document.createElement("span");
|
||||
const icon = createSpan();
|
||||
icon.className = `${CLS}-source-icon`;
|
||||
icon.textContent = ` ${SOURCE_ICONS[task.source]}`;
|
||||
actionLine.appendChild(icon);
|
||||
|
|
@ -133,7 +132,7 @@ export function createTaskRow(
|
|||
}
|
||||
|
||||
if (metaParts.length > 0) {
|
||||
const meta = document.createElement("span");
|
||||
const meta = createSpan();
|
||||
meta.className = `${CLS}-meta`;
|
||||
meta.textContent = metaParts.join(" \u{2022} ");
|
||||
content.appendChild(meta);
|
||||
|
|
|
|||
|
|
@ -98,8 +98,8 @@ export class TaskView extends ItemView {
|
|||
// React to data changes (debounced to avoid re-render storms)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
this.store.onChange(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (debounceTimer) activeWindow.clearTimeout(debounceTimer);
|
||||
debounceTimer = activeWindow.setTimeout(() => {
|
||||
// P0: Skip re-render while checkbox animation is in progress
|
||||
if (this.animating) return;
|
||||
this.renderView(contentArea);
|
||||
|
|
@ -137,21 +137,21 @@ export class TaskView extends ItemView {
|
|||
this.boardRenderer.destroy();
|
||||
this.boardRenderer = null;
|
||||
}
|
||||
const emptyState = document.createElement("div");
|
||||
const emptyState = createDiv();
|
||||
emptyState.className = `${CLS}-empty-state`;
|
||||
const iconEl = document.createElement("div");
|
||||
const iconEl = createDiv();
|
||||
iconEl.className = `${CLS}-empty-icon`;
|
||||
iconEl.textContent = "\u2610";
|
||||
emptyState.appendChild(iconEl);
|
||||
const titleEl = document.createElement("div");
|
||||
const titleEl = createDiv();
|
||||
titleEl.className = `${CLS}-empty-title`;
|
||||
titleEl.textContent = "No tasks yet";
|
||||
emptyState.appendChild(titleEl);
|
||||
const hint1 = document.createElement("div");
|
||||
const hint1 = createDiv();
|
||||
hint1.className = `${CLS}-empty-hint`;
|
||||
hint1.textContent = "Add #task to any checkbox in your notes to see it here.";
|
||||
emptyState.appendChild(hint1);
|
||||
const hint2 = document.createElement("div");
|
||||
const hint2 = createDiv();
|
||||
hint2.className = `${CLS}-empty-hint`;
|
||||
hint2.textContent = "Example: - [ ] send report [[project alpha]] #task";
|
||||
emptyState.appendChild(hint2);
|
||||
|
|
@ -227,7 +227,7 @@ export class TaskView extends ItemView {
|
|||
this.animating = true;
|
||||
this.checkboxHandler.handleToggle(task, row);
|
||||
// The checkbox handler uses a 1700ms delay; set a timeout to clear the flag
|
||||
setTimeout(() => {
|
||||
activeWindow.setTimeout(() => {
|
||||
this.animating = false;
|
||||
}, 1800);
|
||||
}
|
||||
|
|
|
|||
31
styles.css
31
styles.css
|
|
@ -212,14 +212,16 @@
|
|||
/* ============================================================
|
||||
TASK ROW — frameless
|
||||
============================================================ */
|
||||
.ot-task-row {
|
||||
.ot-view .ot-task-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 4px 10px 4px 16px !important;
|
||||
padding: 4px 10px 4px 16px;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid rgba(var(--background-modifier-border-rgb, 0, 0, 0), 0.03);
|
||||
transition: opacity 300ms ease, height 200ms ease;
|
||||
overflow: visible;
|
||||
/* Enable long-press context menu on mobile (merged from duplicate selector at line 502) */
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.ot-task-row:last-child {
|
||||
|
|
@ -313,9 +315,9 @@
|
|||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
.ot-task-row.done {
|
||||
.ot-view .ot-task-row.done {
|
||||
opacity: 0;
|
||||
height: 0 !important;
|
||||
height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
|
|
@ -486,7 +488,7 @@
|
|||
padding: 0 3px;
|
||||
border-radius: 10px;
|
||||
background: var(--color-red, #e03e3e);
|
||||
color: #fff;
|
||||
color: #ffffff;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
line-height: 10px;
|
||||
|
|
@ -498,24 +500,17 @@
|
|||
/* ============================================================
|
||||
MOBILE IMPROVEMENTS
|
||||
============================================================ */
|
||||
/* Enable long-press context menu on mobile */
|
||||
.ot-task-row {
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
/* Mobile touch targets — coarse pointer = touch device */
|
||||
@media (pointer: coarse) {
|
||||
.ot-task-row {
|
||||
.ot-view .ot-task-row {
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
padding: 8px 10px 8px 12px !important;
|
||||
padding: 8px 10px 8px 12px;
|
||||
}
|
||||
|
||||
.ot-drag-handle {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ot-task-row:hover .ot-drag-handle {
|
||||
display: none !important;
|
||||
.ot-view .ot-drag-handle,
|
||||
.ot-view .ot-task-row:hover .ot-drag-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ot-checkbox {
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@
|
|||
"0.1.0": "1.10.0",
|
||||
"0.1.1": "1.10.0",
|
||||
"0.1.2": "1.10.0",
|
||||
"0.1.3": "1.10.0"
|
||||
"0.1.3": "1.10.0",
|
||||
"0.1.4": "1.10.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue