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
This commit is contained in:
Art Malanok 2026-03-26 12:16:32 -07:00
parent 2f8b72c4b2
commit cb9adf3008
8 changed files with 82 additions and 7 deletions

View file

@ -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.

View file

@ -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<void> {
@ -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 = [

View file

@ -265,6 +265,36 @@ export class TaskStore {
});
}
async toggleDoneWithSync(file: TFile): Promise<void> {
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<void> {
const settings = this.getSettings();
// P1: Normalize property name to lowercase

View file

@ -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;

View file

@ -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();
})
);

View file

@ -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<string, string> = {
@ -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 === " ") {

View file

@ -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);
},
};
}

View file

@ -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;