368 lines
13 KiB
JavaScript
368 lines
13 KiB
JavaScript
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;
|