initial commit
This commit is contained in:
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+60
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user