cryptic0011_real-tasks/main.js
Grayson Patterson fbcf455e0f Initial commit
2026-04-23 14:07:13 -04:00

126 lines
3.9 KiB
JavaScript

"use strict";
const { Plugin, MarkdownView, Notice, TFile } = require("obsidian");
const TASK_PREFIX = "- [ ] ";
const EDITOR_DELAY_MS = 800;
const VAULT_DELAY_MS = 400;
const COMPLETED_TASK_RE = /^\s*[-*+]\s+\[[xX]\]\s/;
module.exports = class RealTasksPlugin extends Plugin {
onload() {
this._editorTimer = null;
this._vaultTimers = new Map(); // path -> timeout handle
this._suppressUntil = new Map(); // path -> timestamp we wrote the file
// Interactive typing path: preserves undo via the editor API.
this.registerEvent(
this.app.workspace.on("editor-change", (editor) => {
if (this._editorTimer) clearTimeout(this._editorTimer);
this._editorTimer = setTimeout(() => this.sweepEditor(editor), EDITOR_DELAY_MS);
})
);
// Programmatic writes (Dataview checkbox, sync, etc.) — use vault API.
this.registerEvent(
this.app.vault.on("modify", (file) => {
if (!(file instanceof TFile) || file.extension !== "md") return;
const until = this._suppressUntil.get(file.path) || 0;
if (Date.now() < until) return; // our own write bouncing back
const existing = this._vaultTimers.get(file.path);
if (existing) clearTimeout(existing);
const handle = setTimeout(() => {
this._vaultTimers.delete(file.path);
this.sweepFile(file);
}, VAULT_DELAY_MS);
this._vaultTimers.set(file.path, handle);
})
);
this.addCommand({
id: "sweep-now",
name: "Sweep completed tasks in this note now",
editorCallback: (editor) => this.sweepEditor(editor),
});
this.addCommand({
id: "insert-task",
name: "Insert new task",
editorCallback: (editor) => this.insertTask(editor),
});
this.addRibbonIcon("check-square", "Insert new task", () => {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
new Notice("Open a note first to insert a task.");
return;
}
this.insertTask(view.editor);
view.editor.focus();
});
}
onunload() {
if (this._editorTimer) clearTimeout(this._editorTimer);
for (const h of this._vaultTimers.values()) clearTimeout(h);
this._vaultTimers.clear();
}
insertTask(editor) {
if (!editor) return;
const cursor = editor.getCursor();
const currentLine = editor.getLine(cursor.line) || "";
const atLineStart = currentLine.trim() === "";
const prefix = atLineStart ? TASK_PREFIX : "\n" + TASK_PREFIX;
editor.replaceRange(prefix, cursor);
const newLine = atLineStart ? cursor.line : cursor.line + 1;
editor.setCursor({ line: newLine, ch: TASK_PREFIX.length });
}
sweepEditor(editor) {
if (!editor) return;
for (let i = editor.lineCount() - 1; i >= 0; i--) {
const line = editor.getLine(i);
if (line == null) continue;
if (!COMPLETED_TASK_RE.test(line)) continue;
const lastLine = i === editor.lineCount() - 1;
const from = { line: i, ch: 0 };
const to = lastLine
? { line: i, ch: line.length }
: { line: i + 1, ch: 0 };
editor.replaceRange("", from, to);
}
}
async sweepFile(file) {
const leaves = this.app.workspace.getLeavesOfType("markdown");
for (const leaf of leaves) {
const view = leaf.view;
if (view instanceof MarkdownView && view.file && view.file.path === file.path) {
this.sweepEditor(view.editor);
return;
}
}
let content;
try {
content = await this.app.vault.read(file);
} catch {
return;
}
const lines = content.split("\n");
const kept = lines.filter((l) => !COMPLETED_TASK_RE.test(l));
if (kept.length === lines.length) return;
const next = kept.join("\n");
this._suppressUntil.set(file.path, Date.now() + 1500);
try {
await this.app.vault.modify(file, next);
} catch (e) {
console.warn("[real-tasks] vault write failed", file.path, e);
}
}
};