const { ItemView, Plugin, TFile, debounce, MarkdownView } = require("obsidian"); const VIEW_TYPE = "org-todo-list-view"; // Dates still come from the line text β€” these are Tasks-plugin emoji conventions, // not something Obsidian's cache parses. const DONE_DATE_RE = /βœ…\s*(\d{4}-\d{2}-\d{2})/; const DUE_DATE_RE = /πŸ“…\s*(\d{4}-\d{2}-\d{2})/; // Used only to strip the checkbox prefix and emoji-dates from the display text. const TASK_RE = /^\s*(?:[-*+]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/; // Strip inline #tags out of the display text (cache gives us the tags themselves). const HASHTAG_RE = /(?:^|\s)#[A-Za-z0-9_\-\/]+/g; class OrgTodoListPlugin extends Plugin { async onload() { this.registerView(VIEW_TYPE, (leaf) => new OrgTodoListView(leaf, this)); this.addCommand({ id: "open-org-todo-list", name: "Open TODO list", callback: () => this.activateView(), }); this.addRibbonIcon("checkmark", "Org TODO List", () => this.activateView()); } async activateView() { const { workspace } = this.app; let leaf = workspace.getLeavesOfType(VIEW_TYPE)[0]; if (!leaf) { leaf = workspace.getRightLeaf(false); await leaf.setViewState({ type: VIEW_TYPE, active: true }); } workspace.revealLeaf(leaf); } onunload() { } } class OrgTodoListView extends ItemView { constructor(leaf, plugin) { super(leaf); this.plugin = plugin; this.tasks = []; this.filterText = ""; this.activeTags = new Set(); this.showDone = false; this.ROW_H = 28; // fixed row height in px β€” MUST match CSS this.BUFFER = 5; // extra rows above/below viewport, hides scroll seams this.filtered = []; } getViewType() { return VIEW_TYPE; } getDisplayText() { return "TODO list"; } getIcon() { return "checkmark"; } async onOpen() { this.debouncedUpdate = debounce( (file) => this.updateFile(file), 400, true ); // Fires AFTER the metadata cache re-parses β€” cache is fresh here. this.registerEvent( this.app.metadataCache.on("changed", (file) => this.debouncedUpdate(file)) ); // Deletions: just drop the file's tasks, nothing to reparse. this.registerEvent( this.app.vault.on("delete", (file) => this.removeFile(file.path)) ); // Rename: remove old-path entries, rescan under the new path. this.registerEvent( this.app.vault.on("rename", (file, oldPath) => { this.removeFile(oldPath); this.updateFile(file); }) ); this.buildChrome(); await this.refresh(); } async onClose() { } buildChrome() { const root = this.containerEl.children[1]; root.empty(); root.addClass("org-todo-root"); root.createEl("h4", { text: "TODO list" }); const filterWrap = root.createDiv({ cls: "org-todo-filterwrap" }); this.pillsEl = filterWrap.createDiv({ cls: "org-todo-pills" }); const input = filterWrap.createEl("input", { type: "text", placeholder: "Filter… (text or #tag)", }); input.addClass("org-todo-filter"); this.inputEl = input; input.addEventListener("input", () => { this.filterText = input.value.toLowerCase(); this.renderList(); this.renderTagBar(); }); // Backspace on an empty box removes the last pill β€” standard token-input feel. input.addEventListener("keydown", (e) => { if (e.key === "Backspace" && input.value === "" && this.activeTags.size > 0) { const last = [...this.activeTags].pop(); this.activeTags.delete(last); this.renderPills(); this.renderList(); this.renderTagBar(); } }); const toggleWrap = root.createDiv({ cls: "org-todo-toggle" }); const cb = toggleWrap.createEl("input", { type: "checkbox" }); toggleWrap.createEl("span", { text: " Show completed" }); cb.addEventListener("change", () => { this.showDone = cb.checked; this.renderList(); this.renderTagBar(); }); this.tagBarEl = root.createDiv({ cls: "org-todo-tagbar" }); this.listEl = root.createDiv({ cls: "org-todo-list" }); } async refresh() { this.tasks = await this.scanVault(); this.renderList(); this.renderTagBar(); this.renderPills(); } async scanFile(file) { if (!(file instanceof TFile) || file.extension !== "md") return []; const cache = this.app.metadataCache.getFileCache(file); const taskItems = cache?.listItems?.filter((li) => li.task !== undefined) ?? []; if (taskItems.length === 0) return []; const tagsByLine = new Map(); for (const t of cache?.tags ?? []) { const ln = t.position.start.line; if (!tagsByLine.has(ln)) tagsByLine.set(ln, []); tagsByLine.get(ln).push(t.tag.replace(/^#/, "")); } const content = await this.app.vault.cachedRead(file); const lines = content.split("\n"); const out = []; for (const li of taskItems) { const line = li.position.start.line; const raw = lines[line] ?? ""; const m = raw.match(TASK_RE); const done = (li.task ?? " ") !== " "; const body = m ? m[2] : raw; const tags = tagsByLine.get(line) ?? []; const doneDate = body.match(DONE_DATE_RE)?.[1] ?? null; const dueDate = body.match(DUE_DATE_RE)?.[1] ?? null; const text = body .replace(HASHTAG_RE, "") .replace(DONE_DATE_RE, "") .replace(DUE_DATE_RE, "") .replace(/\s+/g, " ") .trim(); out.push({ text, raw, done, tags, doneDate, dueDate, file, line }); } return out; } async scanVault() { const out = []; for (const file of this.app.vault.getMarkdownFiles()) { const fileTasks = await this.scanFile(file); for (const t of fileTasks) out.push(t); } return out; } // Remove all tasks belonging to a given path, then re-render. removeFile(path) { this.tasks = this.tasks.filter((t) => t.file.path !== path); this.renderList(); this.renderTagBar(); this.renderPills(); } // Rescan one file and splice its tasks in, replacing its previous entries. async updateFile(file) { if (!(file instanceof TFile) || file.extension !== "md") return; const fresh = await this.scanFile(file); this.tasks = this.tasks.filter((t) => t.file.path !== file.path); for (const t of fresh) this.tasks.push(t); this.renderList(); this.renderTagBar(); this.renderPills(); } renderPills() { this.pillsEl.empty(); for (const tag of [...this.activeTags].sort()) { const pill = this.pillsEl.createSpan({ cls: "org-todo-pill" }); pill.createSpan({ text: "#" + tag }); const x = pill.createSpan({ text: "Γ—", cls: "org-todo-pill-x" }); x.addEventListener("click", () => { this.activeTags.delete(tag); this.renderPills(); this.renderList(); this.renderTagBar(); }); } } renderTagBar() { this.tagBarEl.empty(); // Every tag in the vault β€” the chip bar is a stable, complete palette. const all = new Set(); for (const t of this.tasks) { if (!this.showDone && t.done) continue; t.tags.forEach((x) => all.add(x)); } // Prune active filters whose tag no longer exists (the ghost-filter fix). for (const tag of [...this.activeTags]) { if (!all.has(tag)) this.activeTags.delete(tag); } // Narrow the CHIPS by the text box. Strip a leading '#' the user may type, // so "#wo" and "wo" both match. Active tags always show so they stay clickable. const needle = this.filterText.replace(/^#/, ""); const sorted = [...all].sort().filter( (tag) => !needle || tag.toLowerCase().includes(needle) || this.activeTags.has(tag) ); for (const tag of sorted) { const chip = this.tagBarEl.createEl("button", { text: "#" + tag }); chip.addClass("org-todo-chip"); if (this.activeTags.has(tag)) chip.addClass("is-active"); chip.addEventListener("click", () => { if (this.activeTags.has(tag)) this.activeTags.delete(tag); else this.activeTags.add(tag); this.renderPills(); this.renderList(); this.renderTagBar(); }); } } renderList() { // 1. Filter (cheap β€” pure in-memory). this.filtered = this.tasks.filter((t) => { if (!this.showDone && t.done) return false; if (this.activeTags.size > 0) { for (const tag of this.activeTags) if (!t.tags.includes(tag)) return false; } if (this.filterText) { const hay = (t.text + " " + t.tags.map((x) => "#" + x).join(" ")).toLowerCase(); if (!hay.includes(this.filterText)) return false; } return true; }); // sort by due date - items with due dates first! this.filtered.sort((a, b) => { if (a.dueDate && b.dueDate) return a.dueDate.localeCompare(b.dueDate); if (a.dueDate) return -1; // a has a date, b doesn't β†’ a first if (b.dueDate) return 1; // b has a date, a doesn't β†’ b first return 0; // neither has a date β†’ keep as-is }); this.listEl.empty(); if (this.filtered.length === 0) { this.listEl.createEl("div", { text: "No matching tasks.", cls: "org-todo-empty" }); return; } // 2. A tall spacer establishes the full scrollable height... this.spacerEl = this.listEl.createDiv({ cls: "org-todo-spacer" }); this.spacerEl.style.height = this.filtered.length * this.ROW_H + "px"; // 3. ...and an absolutely-positioned layer holds the handful of visible rows. this.rowsEl = this.spacerEl.createDiv({ cls: "org-todo-rows" }); // Repaint visible rows on scroll. Registered once per renderList; the old // listEl (and its listener) is discarded by empty() above. this.listEl.onscroll = () => this.renderVisible(); this.renderVisible(); } // Paints only the rows currently in the viewport, recycling on scroll. renderVisible() { if (!this.filtered || this.filtered.length === 0) return; const scrollTop = this.listEl.scrollTop; const viewportH = this.listEl.clientHeight; const first = Math.max(0, Math.floor(scrollTop / this.ROW_H) - this.BUFFER); const visibleCount = Math.ceil(viewportH / this.ROW_H) + this.BUFFER * 2; const last = Math.min(this.filtered.length, first + visibleCount); // Offset the rows layer so the painted slice sits at the right scroll position. this.rowsEl.style.transform = `translateY(${first * this.ROW_H}px)`; this.rowsEl.empty(); for (let i = first; i < last; i++) { const t = this.filtered[i]; const row = this.rowsEl.createDiv({ cls: "org-todo-row" }); const box = row.createEl("input", { type: "checkbox" }); box.checked = t.done; box.addEventListener("change", () => this.toggleTask(t, box.checked)); const label = row.createSpan({ cls: "org-todo-text" }); label.setText(t.text); if (t.done) label.addClass("is-done"); for (const tag of t.tags) { row.createSpan({ text: "#" + tag, cls: "org-todo-rowtag" }); } if (t.tags.length === 0) { const note = row.createSpan({ text: t.file.basename, cls: "org-todo-note" }); note.addEventListener("click", () => this.openTask(t)); } if (t.dueDate) row.createSpan({ text: "πŸ“… " + t.dueDate, cls: "org-todo-date" }); label.addEventListener("click", () => this.openTask(t)); } } async toggleTask(t, done) { const content = await this.plugin.app.vault.read(t.file); const lines = content.split("\n"); if (lines[t.line] === t.raw) { lines[t.line] = t.raw.replace(/\[( |x|X)\]/, done ? "[x]" : "[ ]"); await this.plugin.app.vault.modify(t.file, lines.join("\n")); } } async openTask(t) { const leaf = this.app.workspace.getLeaf(false); await leaf.openFile(t.file); const view = leaf.view; if (view instanceof MarkdownView) { view.editor.setCursor({ line: t.line, ch: 0 }); view.editor.scrollIntoView( { from: { line: t.line, ch: 0 }, to: { line: t.line, ch: 0 } }, true ); } } } module.exports = OrgTodoListPlugin;