commit a0d432a3ffa0d6e8fea03a3479d4f16388783dee Author: Rostyslav Hnatyshyn Date: Tue Jun 16 20:16:06 2026 -0700 initial commit diff --git a/main.js b/main.js new file mode 100644 index 0000000..ad8850f --- /dev/null +++ b/main.js @@ -0,0 +1,237 @@ +const { ItemView, Plugin, TFile, 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; + } + + getViewType() { + return VIEW_TYPE; + } + getDisplayText() { + return "TODO list"; + } + getIcon() { + return "checkmark"; + } + + async onOpen() { + this.registerEvent(this.app.vault.on("modify", () => this.refresh())); + this.registerEvent(this.app.vault.on("delete", () => this.refresh())); + this.registerEvent(this.app.vault.on("rename", () => this.refresh())); + + 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 input = root.createEl("input", { + type: "text", + placeholder: "Filter… (text or #tag)", + }); + input.addClass("org-todo-filter"); + input.addEventListener("input", () => { + this.filterText = input.value.toLowerCase(); + this.renderList(); + }); + + 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.tagBarEl = root.createDiv({ cls: "org-todo-tagbar" }); + this.listEl = root.createDiv({ cls: "org-todo-list" }); + } + + async refresh() { + this.tasks = await this.scanVault(); + this.renderTagBar(); + this.renderList(); + } + + async scanVault() { + const out = []; + const files = this.app.vault.getMarkdownFiles(); + + for (const file of files) { + const cache = this.app.metadataCache.getFileCache(file); + const taskItems = cache?.listItems?.filter((li) => li.task !== undefined) ?? []; + + // Cache-gating: no task list items means we never read the file. + if (taskItems.length === 0) continue; + + // Build a quick lookup of tags by line, from the cache. tag includes the '#'. + const tagsByLine = new Map(); + for (const t of cache?.tags ?? []) { + const ln = t.position.start.line; + if (!tagsByLine.has(ln)) tagsByLine.set(ln, []); + // store without the leading '#' + tagsByLine.get(ln).push(t.tag.replace(/^#/, "")); + } + + // Read the file once (only because dates + display text need the raw line). + const content = await this.app.vault.cachedRead(file); + const lines = content.split("\n"); + + 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; + + // Tags straight from the cache for this line β€” no regex needed. + 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; + } + + renderTagBar() { + this.tagBarEl.empty(); + const all = new Set(); + for (const t of this.tasks) t.tags.forEach((x) => all.add(x)); + const sorted = [...all].sort(); + 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.renderTagBar(); + this.renderList(); + }); + } + } + + renderList() { + this.listEl.empty(); + + const 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; + }); + + if (filtered.length === 0) { + this.listEl.createEl("div", { text: "No matching tasks.", cls: "org-todo-empty" }); + return; + } + + for (const t of filtered) { + const row = this.listEl.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.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; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0540f37 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "org-todo-list", + "name": "Org TODO List", + "version": "0.1.0", + "minAppVersion": "1.4.0", + "description": "An org-agenda-style TODO buffer with live filtering. Scans the vault for checkbox tasks, tagged with @, and lets you narrow the list as you type.", + "author": "you", + "isDesktopOnly": false +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..9583db7 --- /dev/null +++ b/styles.css @@ -0,0 +1,60 @@ +.org-todo-root { + padding: 0 8px; +} +.org-todo-filter { + width: 100%; + margin-bottom: 8px; +} +.org-todo-toggle { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 8px; + font-size: 0.85em; + color: var(--text-muted); +} +.org-todo-tagbar { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 10px; +} +.org-todo-chip { + font-size: 0.8em; + padding: 2px 8px; + border-radius: 12px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + cursor: pointer; +} +.org-todo-chip.is-active { + background: var(--interactive-accent); + color: var(--text-on-accent); +} +.org-todo-row { + display: flex; + align-items: baseline; + gap: 6px; + padding: 3px 0; + border-bottom: 1px solid var(--background-modifier-border-hover); +} +.org-todo-text { + cursor: pointer; + flex: 1; +} +.org-todo-text.is-done { + text-decoration: line-through; + color: var(--text-muted); +} +.org-todo-rowtag { + font-size: 0.75em; + color: var(--text-accent); +} +.org-todo-date { + font-size: 0.75em; + color: var(--text-muted); +} +.org-todo-empty { + color: var(--text-muted); + padding: 8px 0; +}