Files
obsidian-global-task-list/main.js
T

354 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
});
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.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;