18 KiB
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
- A pure crate parses and writes Tasks-format lines (emoji format), including recurrence, and applies "toggle" with done date and next occurrence.
- The engine keeps a vault task index (incremental, cached) and exposes
tasks.*commands. - A
```tasksfenced 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. - 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.
- 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, filtersis blockedin 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) -> Stringre-emits signifiers in the fixed order Tasks uses (🆔 ⛔ priority 🔁 🏁 ➕ 🛫 ⏳ 📅 ❌ ✅) ONLY when the task was modified; an untouched line is never rewritten (Task.originalis kept and used for the stale-line guard, D6).- Hashtags in the description are collected into
tags(same grammar asnotescraft_markdowntag scanner: reuseall_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 recordsparent_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✅ todayifcfg.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🏁 deleteand no recurrence the line is removed (linesempty). - 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 (cheapmemchr-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 whosemtime_msdiffers. No file watcher of its own. - Cache:
.notescraft/cache/tasks.json(bounded, versioned, ignored by sync likebasescache). - The existing
calendar/tasks.rs::scanis switched toTaskIndexin 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) inwidgets/tabs/kind.rs; view incrates/ui-egui/src/views/tasks_view/(mod.rs,state.rsqueues engine calls and pumps after the frame, asbase_view/state.rsdoes;list.rsgrouped virtualised rows;query_bar.rs). Row: checkbox (callstasks.toggle), description with tags as chips, date chips (overdue =dangertoken), 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.tomlthroughcraft-atomic. - Query block: the rich note view already renders
queryblocks (views/note_view/blocks/query/mod.rs, copy it): a fenced block with info stringtasksrenders the same rows with a refresh on index generation change. Checkbox in a row callstasks.toggle. - Editor:
crates/editor/src/keys.rs::on_toggle_task_at(text, pos, today)= old behaviour, but a line with a task + signifiers callsnotescraft_tasks::toggle(editor is L5 and may depend on L3). The oldon_toggle_taskkeeps its tests and is called by the new one for lines without🔁/dates whenset_done_dateis 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
parse_linereads every line of the Tasks docs emoji examples andformatre-emits them identically for fully-signified lines (golden filecrates/tasks/tests/fixtures/lines.txt).- Toggling
- [ ] water 🔁 every week 📅 2026-10-20on 2026-10-22 yields two lines, next due 2026-10-27; withwhen donedue 2026-10-29. every month on the 31stskips November;every monthfrom Jan 31 gives Feb 28/29 then continues from 28.- A fenced
```tasksblock withnot done / due before tomorrow / sort by duelists exactly the matching tasks across the vault and updates within one frame after a task is ticked elsewhere. - 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).
- Stale line: edit the note between listing and ticking; the tick still hits the right task or reports
conflict, never a different line. - 15 000-note vault: first
tasks.queryreturns in < 3 s cold, < 100 ms warm (ignored perf test). - Query errors show the line number inline; JS filters are refused with a clear message.
cargo xtask layersaccepts the crate; nounwrap/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.
- Reminders from due dates:
crates/engine/src/calendar/reminders.rsandcrates/calendar/src/remind.rsalready fire reminders for events; map⏳/📅to the same reminder pipeline ("due at 09:00 default"). Cheap, high daily value, no new infrastructure. - Create/edit modal (
Tasks: Create or edit): one Modal with description, dates (reusing the calendar quick-add date parsercrates/calendar/src/quickadd.rs), priority, recurrence text with live validation. Fixes the Tasks "no live validation" complaint from the docs. file.tasksin Bases and a task base template: rows = tasks, columns = due/status/path; sharesNoteSource-style trait. Gives Table/Cards over tasks for free.- Dependencies:
🆔/⛔, filtersis blocked,is blocking, a "blocked" badge in rows. - Custom status collections (
[?],[!],[>]with name, type, next symbol) as a settings table; today unknown symbols are TODO. - Urgency score (Tasks default: priority + due proximity + scheduled) as
sort by urgencyand a column; a pure function, 30 lines. - On completion: delete (
🏁 delete) and "archive done tasks older than N days" command. - Natural language quick add (
@tomorrow,every mon) turning text into signifiers while typing, reusingquickadd.rs. - Dataview inline format read/write (
[due:: 2026-10-20]) for vaults migrating from Dataview. - Layout options:
hide/show backlink|edit button|urgency,short mode,explainoutput (v1 parsesshort modeandexplain; rendering them is idea-level).