Files
Marcos Paulo de3ec375ae
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
docs: specs and briefs for Tasks, Templater, Kanban and icons/banners
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 06:06:54 -03:00

18 KiB
Raw Permalink Blame History

Tasks: vault-wide task index, query blocks and a Tasks view (roadmap item 11)

Part of specs/plugins-parity/. Replaces the community plugin Tasks (4.47M downloads) and Checklist. Research sources (2026-10-21): Tasks user guide (publish.obsidian.md/tasks: Introduction, Recurring Tasks, Filters, Statuses, Tasks Emoji Format). Where this spec and those pages differ, the pages win for the line format (we must read lines Tasks wrote and Tasks must read ours).

Problem

Tasks live as - [ ] ... lines in hundreds of notes. Today only crates/engine/src/calendar/tasks.rs reads them, and only the 📅 due date, to show them on the calendar agenda. There is no way to ask "what is due this week, not done, under projects/", to tick a task from a list and have the source note change, or to get the next occurrence of a repeating task when it is ticked. on_toggle_task in crates/editor/src/keys.rs flips a box and nothing else.

Goal

  1. A pure crate parses and writes Tasks-format lines (emoji format), including recurrence, and applies "toggle" with done date and next occurrence.
  2. The engine keeps a vault task index (incremental, cached) and exposes tasks.* commands.
  3. A ```tasks fenced block in a note renders live results (the query language of Tasks, a subset); a Tasks tab (notescraft://tasks) shows the same with a query bar.
  4. Ticking a task anywhere (editor, rich note view, query block, Tasks tab) goes through one function and gives the same result: status change, done date, recurrence.
  5. Everything is an engine command (SPEC 10); the calendar agenda reads the same index.

Non-Goals (v1)

  • JavaScript filters (filter by function), group by function, sort by function. A line using them is shown as an error row, never executed.
  • Dataview inline format ([due:: 2026-10-20]) read/write: listed in Feature ideas, not in v1.
  • The "Create or edit task" modal (Feature ideas).
  • Task dependencies (🆔, ⛔) beyond keeping the text intact: parsed and preserved, filters is blocked in Feature ideas.
  • Custom status collections (Feature ideas); v1 knows , x/X, /, -.
  • A task database file; the notes stay the only store.

Design decisions

D1 - Crate and layering

New crate crates/tasks, package notescraft-tasks, layer L3 (it needs notescraft_templates::Date and DateTime, which are L2, so it cannot sit at L2). Depends on notescraft-markdown and notescraft-templates and thiserror. No I/O, no clock (today is a parameter), no egui. Registration in xtask/src/layers.rs and the root [workspace.dependencies] is lead work (step 0 in tasks.md). Do NOT reuse notescraft_calendar::recur for the Tasks every ... rules: that is RFC 5545 (FREQ=) and the Tasks grammar is different (D4). Do not make notescraft-calendar depend on tasks; the engine adapts.

D2 - The task line (emoji format)

<indent><marker> [<status>] <description> <signifiers...>
marker  = '-' | '*' | '+' | digits ('.' | ')')
status  = one char; ' ' TODO, 'x' 'X' DONE, '/' IN_PROGRESS, '-' CANCELLED, anything else TODO (kept verbatim)
signifiers (each emoji then its value, any order; unknown text stays in the description):
  ➕ created   🛫 start   ⏳ scheduled   📅 due   ✅ done   ❌ cancelled   (value YYYY-MM-DD)
  priority: 🔺 highest  ⏫ high  🔼 medium  (no emoji) normal  🔽 low  ⏬ lowest
  🔁 <rule>  (rule runs to the next signifier or end of line)   🏁 <keep|delete>
  🆔 <id>   ⛔ <id>[,<id>...]

Rules:

  • Parse from the END of the line: peel signifiers while the tail matches <emoji> <value>; the rest is the description. A signifier inside the description (followed by text that is not a value) stays text.
  • format(&Task) -> String re-emits signifiers in the fixed order Tasks uses (🆔 ⛔ priority 🔁 🏁 ➕ 🛫 ⏳ 📅 ❌ ✅) ONLY when the task was modified; an untouched line is never rewritten (Task.original is kept and used for the stale-line guard, D6).
  • Hashtags in the description are collected into tags (same grammar as notescraft_markdown tag scanner: reuse all_tags, do not write a second regex).
  • A line is a task only when it is a list item with a [c] box. Lines inside fenced code, inside frontmatter, or in a note excluded by settings are not tasks. Sub-items: a task nested under another records parent_line; "exclude sub-items" works from that.
  • Limits (hostile input): line over 2 000 chars is not parsed (kept as non-task), 1 000 tasks per note, rule text 128 chars, description kept whole.
pub enum StatusType { Todo, InProgress, Done, Cancelled }
pub enum Priority { Lowest, Low, Normal, Medium, High, Highest }   // ordered, Normal default
pub struct Task {
    pub path: String, pub line: u32,              // 0-based line in the note
    pub indent: u16, pub marker: String,          // "-", "*", "1." ...
    pub status: char, pub description: String,    // without signifiers, trimmed
    pub tags: Vec<String>, pub priority: Priority,
    pub created: Option<Date>, pub start: Option<Date>, pub scheduled: Option<Date>,
    pub due: Option<Date>, pub done: Option<Date>, pub cancelled: Option<Date>,
    pub recurrence: Option<String>,               // the raw rule text, validated on use
    pub on_completion: OnCompletion,              // Keep (default) | Delete
    pub id: Option<String>, pub depends_on: Vec<String>,
    pub heading: Option<String>,                  // nearest heading above, text only
    pub parent_line: Option<u32>,
    pub original: String,                         // the exact source line
}

D3 - Status, toggle and done date

status_type(c) -> StatusType. next_status(c) -> char: ->x, /->x, x/X-> , --> , other->x. toggle(task, today, &ToggleCfg) -> Toggle { lines: Vec<String>, placement }:

  • To DONE: set box x, add ✅ today if cfg.set_done_date (default true), then if the task has a valid recurrence and at least one of due/scheduled/start, build the next task (D4) and return BOTH lines: the next occurrence first (Tasks default: new task ABOVE the original; cfg.next_on_new_line_below), keeping indent and marker. With 🏁 delete and no recurrence the line is removed (lines empty).
  • From DONE to TODO: remove ✅ ..., nothing else (never deletes a generated next task).
  • To CANCELLED: -, ❌ today.
  • Text outside signifiers is byte-preserved; signifier order is the fixed order only for touched lines.

D4 - Recurrence (Tasks grammar, subset)

Rule always starts with every. Supported v1:

Rule Meaning
every day, every N days days
every weekday Monday to Friday
every week, every N weeks, every week on Tuesday, Friday weeks, weekday list
every month, every N months, every month on the 1st, every month on the last, every month on the last Friday, every month on the 2nd Wednesday months
every year, every N years, every January on the 15th years
any rule + when done reference date = completion date, not the task's date

Rule::parse(&str) -> Result<Rule>; Rule::next(&self, reference: Date) -> Option<Date>; None for an invalid date (Feb 30) as the Tasks page says: the recurrence disappears, no new task. Reference date priority: due, else scheduled, else start. The other dates keep their distance from the reference. scheduled is removed from the next task only if cfg.remove_scheduled_on_recurrence. Monthly edge cases follow the Tasks page: every month clamps to the last valid day ("31st" in November -> 30th) and keeps going from the clamped date; every month on the 31st SKIPS short months; every 3 months from Jan 31 -> Apr 30. Next task drops done, cancelled, 🆔 and ⛔ (as Tasks does), sets created to nothing (not re-stamped), status back to TODO. Not supported: end conditions (Tasks has none either), every other phrasing, RRULE text (error).

D5 - Scanning a note and the vault index

scan_note(path, text, &ScanCfg) -> Vec<Task>: walks lines once, tracks fenced code and frontmatter, tracks the heading stack for heading, indentation stack for parent_line. ScanCfg { global_filter: Option<String> }: when set (e.g. #task), only lines containing it are tasks (Tasks "global filter"); the filter text is removed from description display only. Default None: every checkbox is a task.

Engine (crates/engine/src/tasks/): TaskIndex = BTreeMap<String, NoteTasks { mtime_ms, tasks }>.

  • Lazy: built on first use of any tasks.* command on a worker thread, notes whose bytes contain [+] after a list marker only (cheap memchr-style pre-check "] "), 15 000 notes target < 2 s warm FS.
  • Incremental: every Session::write_note/rename/delete path already notifies the index; the task index subscribes through the same generation counter (Index::generation, as bases D7 does) and rescans only notes whose mtime_ms differs. No file watcher of its own.
  • Cache: .notescraft/cache/tasks.json (bounded, versioned, ignored by sync like bases cache).
  • The existing calendar/tasks.rs::scan is switched to TaskIndex in a late task (T10); until then both coexist and the agenda is unchanged.

D6 - Writing back: one function, stale-line guard

tasks.toggle {path, line, expect}: the engine re-reads the note (or the open buffer), checks that line line still equals expect (the original the UI saw); if not, it searches +/- 20 lines for expect; else conflict error ("task changed, refresh"). Then applies toggle (D3) and writes through the buffer as one undoable edit when the note is open, else Session::write_note. After a write the index rescans that note and bumps the generation so every open query block updates. Other writes: tasks.set {path, line, expect, field, value} for due|scheduled|start|priority|description (value null removes). Same guard.

D7 - Query language (subset of Tasks queries)

A query is lines; each line is a filter, sort by, group by, limit N, hide|show <field>, short mode, explain, # comment. Lines combine with AND. Boolean forms (a) AND (b), OR, NOT, AND NOT, OR NOT, XOR with parenthesised filters, as the Tasks page.

v1 filters:

  • status: done, not done, cancelled, status.name includes|does not include X, status.type is TODO|IN_PROGRESS|DONE|CANCELLED
  • dates (due, scheduled, start, created, done, cancelled, happens): has X date, no X date, X date is invalid, due today, due before 2026-11-01, due on or after next monday, due in this week, due in 2026-10, due in 2026-Q4, due after tomorrow (relative words: today, tomorrow, yesterday, next|last <weekday>, this|next|last week|month|quarter|year)
  • text: description|path|filename|folder|root|heading|recurrence includes|does not include|regex matches|regex does not match <text>
  • tags: no tags, has tags, tags include #x, tags do not include #x
  • priority: priority is high, priority above none, priority below medium, priority is not none
  • recurrence: is recurring, is not recurring; exclude sub-items

sort by (due, scheduled, start, created, done, priority, path, filename, description, status.name, tag, heading, urgency; reverse suffix; multiple lines = tie-breakers; default order: path then line). group by (path, folder, filename, heading, status, status.type, priority, due, scheduled, start, done, tags, recurrence; reverse). limit N, limit groups to N. Date words are relative to today passed by the caller; regex uses notescraft-regex (already a dependency of bases; tasks may depend on it, approved set: it is workspace-internal). Query text limit 8 KiB, 100 lines. Unknown line -> QueryError { line, message }, shown inline, never a panic; results above the error line are not shown (Tasks does the same).

pub struct Query { /* parsed */ }
impl Query { pub fn parse(src: &str) -> Result<Query, QueryError>; }
pub struct Ran { pub groups: Vec<TaskGroup>, pub total: usize, pub shown: usize }
pub struct TaskGroup { pub name: Option<String>, pub tasks: Vec<usize> }   // indexes into the input slice
pub fn run(query: &Query, tasks: &[Task], today: Date, week_start: Weekday) -> Ran;

this = the note that holds the block: {path} passed through a QueryCtx so path includes {{query.file.path}} style placeholders work: v1 supports {{query.file.path}}, {{query.file.folder}}, {{query.file.filename}} as the only placeholders.

D8 - Commands

id params result writes
tasks.list {path?, status?, limit?, offset?} {tasks:[Task JSON], total} no
tasks.query {query, from_path?} {groups:[{name,tasks}], total, shown, error?} no
tasks.toggle {path, line, expect} {lines_changed, path} yes
tasks.set {path, line, expect, field, value} {path} yes
tasks.reindex {} {notes, tasks} no (cache file only)

Both write commands: writable guard, added to the WRITE list of ai/assistant_gate.rs; the read ones to the READ list; all in CORE_IDS. Settings (prefs/tasks_config.rs, section [tasks]): global_filter (string, empty = none), set_done_date (true), remove_scheduled_on_recurrence (false), next_on_new_line_below (false), exclude_folders (list), show_toolbar_shortcut ignored.

D9 - UI

  • Tab kind Tasks (TASKS_PATH = "notescraft://tasks", single tab) in widgets/tabs/kind.rs; view in crates/ui-egui/src/views/tasks_view/ (mod.rs, state.rs queues engine calls and pumps after the frame, as base_view/state.rs does; list.rs grouped virtualised rows; query_bar.rs). Row: checkbox (calls tasks.toggle), description with tags as chips, date chips (overdue = danger token), path link (opens note at the line), right-click menu (open, set due today/tomorrow, priority). Presets in a left list: Today, Overdue, Upcoming (7 days), No date, Done recently, plus saved queries stored in .notescraft/tasks-queries.toml through craft-atomic.
  • Query block: the rich note view already renders query blocks (views/note_view/blocks/query/mod.rs, copy it): a fenced block with info string tasks renders the same rows with a refresh on index generation change. Checkbox in a row calls tasks.toggle.
  • Editor: crates/editor/src/keys.rs::on_toggle_task_at(text, pos, today) = old behaviour, but a line with a task + signifiers calls notescraft_tasks::toggle (editor is L5 and may depend on L3). The old on_toggle_task keeps its tests and is called by the new one for lines without 🔁/dates when set_done_date is off.
  • Palette entries: "Tasks: Open tasks view", "Tasks: Toggle task at cursor", "Tasks: Set due today".

D10 - Performance and safety

15 000 notes / 40 000 tasks: run for a typical query under 30 ms; Tasks tab virtualises rows. Task text is data, never a command. Writes never create files, never touch .obsidian/ or .notescraft/ through the note path. No network.

Acceptance criteria

  1. parse_line reads every line of the Tasks docs emoji examples and format re-emits them identically for fully-signified lines (golden file crates/tasks/tests/fixtures/lines.txt).
  2. Toggling - [ ] water 🔁 every week 📅 2026-10-20 on 2026-10-22 yields two lines, next due 2026-10-27; with when done due 2026-10-29.
  3. every month on the 31st skips November; every month from Jan 31 gives Feb 28/29 then continues from 28.
  4. A fenced ```tasks block with not done / due before tomorrow / sort by due lists exactly the matching tasks across the vault and updates within one frame after a task is ticked elsewhere.
  5. Ticking in the Tasks tab with the note open in an editor produces ONE undo step in that editor; with the note closed, the file changes only on that line (byte compare).
  6. Stale line: edit the note between listing and ticking; the tick still hits the right task or reports conflict, never a different line.
  7. 15 000-note vault: first tasks.query returns in < 3 s cold, < 100 ms warm (ignored perf test).
  8. Query errors show the line number inline; JS filters are refused with a clear message.
  9. cargo xtask layers accepts the crate; no unwrap/expect/[i] on input data.

Open questions

  • Q1: global filter default off (this spec) vs #task. Owner to confirm.
  • Q2: should the calendar agenda show tasks with only ⏳/🛫? Today only 📅; propose yes after T10.
  • Q3: Dataview inline fields ([due:: ]) as an alternative format: ask before building.

Feature ideas (researched, ranked by value)

Sources: Tasks docs (Filters, Statuses, Recurring Tasks, Emoji format), plugin feature list.

  1. Reminders from due dates: crates/engine/src/calendar/reminders.rs and crates/calendar/src/remind.rs already fire reminders for events; map ⏳/📅 to the same reminder pipeline ("due at 09:00 default"). Cheap, high daily value, no new infrastructure.
  2. Create/edit modal (Tasks: Create or edit): one Modal with description, dates (reusing the calendar quick-add date parser crates/calendar/src/quickadd.rs), priority, recurrence text with live validation. Fixes the Tasks "no live validation" complaint from the docs.
  3. file.tasks in Bases and a task base template: rows = tasks, columns = due/status/path; shares NoteSource-style trait. Gives Table/Cards over tasks for free.
  4. Dependencies: 🆔 / ⛔, filters is blocked, is blocking, a "blocked" badge in rows.
  5. Custom status collections ([?], [!], [>] with name, type, next symbol) as a settings table; today unknown symbols are TODO.
  6. Urgency score (Tasks default: priority + due proximity + scheduled) as sort by urgency and a column; a pure function, 30 lines.
  7. On completion: delete (🏁 delete) and "archive done tasks older than N days" command.
  8. Natural language quick add (@tomorrow, every mon) turning text into signifiers while typing, reusing quickadd.rs.
  9. Dataview inline format read/write ([due:: 2026-10-20]) for vaults migrating from Dataview.
  10. Layout options: hide/show backlink|edit button|urgency, short mode, explain output (v1 parses short mode and explain; rendering them is idea-level).