From cb9adf3008910a8fedca8e3f39663c15357fcf3f Mon Sep 17 00:00:00 2001 From: Art Malanok Date: Thu, 26 Mar 2026 12:16:32 -0700 Subject: [PATCH] fix: board-source sync, mobile context menu, collision handling - Wire toggleDoneWithSync: checking task on board now checks source note - Add right-click/long-press context menu with "Move to..." bucket list - Warn when changing bucket property in settings - Handle stub filename collisions with random suffix - Document #task position and multiple wikilink behavior Co-authored-by: Isaac --- README.md | 2 ++ src/data/inlineTaskWatcher.ts | 17 +++++++++++++---- src/data/taskStore.ts | 30 ++++++++++++++++++++++++++++++ src/interactions/inlineCreator.ts | 7 ++++++- src/settings.ts | 8 +++++++- src/views/taskRow.ts | 9 +++++++++ src/views/taskView.ts | 11 ++++++++++- styles.css | 5 +++++ 8 files changed, 82 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6b59aaf..d8ac10f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ There are three ways to add tasks: ``` The task appears on the board automatically. + **Syntax notes**: The `#task` tag must be at the **end of the line** -- no text after it, or the plugin won't detect the task. If you include multiple `[[wikilinks]]`, only the first one is used for secondary grouping; the rest are stripped from the task text. + 2. **From the board** -- Click **+ Add a task** at the bottom of any bucket. Type the task text, optionally pick a secondary group, and press Enter. 3. **Stub files** -- Create a markdown file in the configured task folder with the appropriate frontmatter. The plugin picks it up on the next scan. diff --git a/src/data/inlineTaskWatcher.ts b/src/data/inlineTaskWatcher.ts index ee961aa..1379795 100644 --- a/src/data/inlineTaskWatcher.ts +++ b/src/data/inlineTaskWatcher.ts @@ -199,7 +199,7 @@ export class InlineTaskWatcher { }); } - private generateStubFilename(secondaryValue: string): string { + private generateStubFilename(secondaryValue: string, folder: string): string { const now = new Date(); const yyyy = now.getFullYear(); const MM = String(now.getMonth() + 1).padStart(2, "0"); @@ -209,11 +209,20 @@ export class InlineTaskWatcher { const ss = String(now.getSeconds()).padStart(2, "0"); const timestamp = `${yyyy}-${MM}-${dd}-${HH}${mm}${ss}`; + let baseName: string; if (secondaryValue) { const safe = secondaryValue.replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").trim(); - return `${safe}-${timestamp}.md`; + baseName = `${safe}-${timestamp}`; + } else { + baseName = `task-${timestamp}`; } - return `task-${timestamp}.md`; + + let fileName = `${baseName}.md`; + if (this.app.vault.getAbstractFileByPath(`${folder}/${fileName}`)) { + const suffix = Math.random().toString(36).substring(2, 5); + fileName = `${baseName}-${suffix}.md`; + } + return fileName; } private async createStub(task: TrackedTask, sourceFile: TFile): Promise { @@ -238,7 +247,7 @@ export class InlineTaskWatcher { const defaultBucket = s.buckets.find((b) => b.id === s.defaultBucketId)?.name ?? ""; const today = new Date().toISOString().split("T")[0]; - const fileName = this.generateStubFilename(task.secondaryValue); + const fileName = this.generateStubFilename(task.secondaryValue, folder); const filePath = `${folder}/${fileName}`; const content = [ diff --git a/src/data/taskStore.ts b/src/data/taskStore.ts index ccd72d8..b8f3ca2 100644 --- a/src/data/taskStore.ts +++ b/src/data/taskStore.ts @@ -265,6 +265,36 @@ export class TaskStore { }); } + async toggleDoneWithSync(file: TFile): Promise { + let newDone = false; + await this.app.fileManager.processFrontMatter(file, (fm) => { + 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; + if (fm?.source === "inline" && fm?.source_file) { + const sourceFile = this.app.vault.getAbstractFileByPath(fm.source_file); + if (sourceFile instanceof TFile) { + const lineNum = fm.source_line; + if (typeof lineNum === "number") { + const content = await this.app.vault.read(sourceFile); + const lines = content.split("\n"); + if (lineNum >= 0 && lineNum < lines.length) { + if (newDone) { + lines[lineNum] = lines[lineNum].replace(/\[ \]/, "[x]"); + } else { + lines[lineNum] = lines[lineNum].replace(/\[[xX]\]/, "[ ]"); + } + await this.app.vault.modify(sourceFile, lines.join("\n")); + } + } + } + } + } + async moveToBucket(file: TFile, bucketName: string): Promise { const settings = this.getSettings(); // P1: Normalize property name to lowercase diff --git a/src/interactions/inlineCreator.ts b/src/interactions/inlineCreator.ts index 9ceb769..2234630 100644 --- a/src/interactions/inlineCreator.ts +++ b/src/interactions/inlineCreator.ts @@ -139,7 +139,12 @@ export class InlineCreator { const prefix = secondaryGroup ? secondaryGroup.replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").toLowerCase() : "task"; - const fileName = `${folder}/${prefix}-${dateStamp}-${timeStamp}.md`; + let baseName = `${prefix}-${dateStamp}-${timeStamp}`; + let fileName = `${folder}/${baseName}.md`; + if (this.app.vault.getAbstractFileByPath(fileName)) { + const suffix = Math.random().toString(36).substring(2, 5); + fileName = `${folder}/${baseName}-${suffix}.md`; + } const today = now.toISOString().split("T")[0]; const groupProp = s.secondaryGroupProperty; diff --git a/src/settings.ts b/src/settings.ts index 316f135..e09065f 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -72,7 +72,13 @@ export class TasksSettingTab extends PluginSettingTab { .setPlaceholder(DEFAULT_SETTINGS.bucketProperty) .setValue(this.plugin.settings.bucketProperty) .onChange(async (value) => { - this.plugin.settings.bucketProperty = value.toLowerCase().trim(); + const newValue = value.toLowerCase().trim(); + if (newValue !== this.plugin.settings.bucketProperty) { + new Notice( + "Changing the bucket property will affect how tasks are grouped. Existing tasks may appear in 'Unclassified' until their frontmatter is updated." + ); + } + this.plugin.settings.bucketProperty = newValue; await this.plugin.saveSettings(); }) ); diff --git a/src/views/taskRow.ts b/src/views/taskRow.ts index c732e6a..18e8eab 100644 --- a/src/views/taskRow.ts +++ b/src/views/taskRow.ts @@ -4,6 +4,7 @@ export interface TaskRowCallbacks { onToggleDone(task: TaskItem): void; onDragStart(task: TaskItem, event: DragEvent): void; onClick(task: TaskItem): void; + onContextMenu?(task: TaskItem, event: MouseEvent): void; } const SOURCE_ICONS: Record = { @@ -144,6 +145,14 @@ export function createTaskRow( row.addEventListener("click", () => callbacks.onClick(task)); row.addEventListener("dragstart", (e) => callbacks.onDragStart(task, e)); + // Context menu for "Move to..." bucket + row.addEventListener("contextmenu", (e) => { + if (callbacks.onContextMenu) { + e.preventDefault(); + callbacks.onContextMenu(task, e); + } + }); + // P2: Keyboard handler — Enter/Space triggers toggle, arrow keys left to parent row.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { diff --git a/src/views/taskView.ts b/src/views/taskView.ts index 95b79dd..1828b48 100644 --- a/src/views/taskView.ts +++ b/src/views/taskView.ts @@ -43,7 +43,7 @@ export class TaskView extends ItemView { }); this.checkboxHandler = new CheckboxHandler({ - onToggleDone: (file) => this.store.toggleDone(file), + onToggleDone: (file) => this.store.toggleDoneWithSync(file), }); this.inlineCreator = new InlineCreator(this.app, settings); @@ -239,6 +239,15 @@ export class TaskView extends ItemView { onDrop: (event: DragEvent, bucketName: string) => { this.dragManager.handleDrop(event, bucketName); }, + onContextMenu: (task: TaskItem, event: MouseEvent) => { + const menu = new Menu(); + for (const bucket of this.settings().buckets) { + menu.addItem((item) => + item.setTitle(bucket.name).onClick(() => this.store.moveToBucket(task.file, bucket.name)) + ); + } + menu.showAtMouseEvent(event); + }, }; } diff --git a/styles.css b/styles.css index a0fdf9c..6c6c5e2 100644 --- a/styles.css +++ b/styles.css @@ -498,6 +498,11 @@ /* ============================================================ MOBILE IMPROVEMENTS ============================================================ */ +/* Enable long-press context menu on mobile */ +.ot-task-row { + -webkit-touch-callout: default; +} + @media (pointer: coarse) { .ot-task-row { min-height: 44px;