Skip to content

Read tasks the way Logseq does in every command (#92, part 1) - #117

Merged
muellerei merged 15 commits into
mainfrom
feat/92-task-model-final
Oct 6, 2026
Merged

muellerei merged 15 commits into
mainfrom
feat/92-task-model-final

Conversation

@muellerei

Copy link
Copy Markdown
Owner

Part 1 of #92. Every command that reads or changes tasks now uses one definition of a task (new tasks.py): the marker Logseq stores for a block, mldoc's eleven markers, and the lines that stay with a block (drawers, code fences, SCHEDULED, DEADLINE, LOGBOOK).

What changes for a user

  • get-todos takes --state open|done|cancelled. Without it, it lists every open marker, not only TODO. next_due comes from the repeater of the timestamp it reports.
  • set-todo-status refuses a block Logseq does not read as a task, offers every marker the frontend knows, and keeps the SCHEDULED, DEADLINE and LOGBOOK lines with the block.
  • analyze-graph and analyze-journal-patterns count tasks from the database, like get-todos. tasks replaces total_todos, and tasks.total_complete is gone (see "Removed" in the CHANGELOG).
  • smart-query --request tasks also finds WAIT, WAITING and IN-PROGRESS.

Fixed along the way (all were already on main)

  • A repeater no date can reach crashed get-todos.
  • next_due was one repeat too late for a date from today onward, and skipped an occurrence that falls on today.
  • A change made to a block while a command changed it was written over. Commands that replace a block's text now read it again right before writing and refuse with the new reason block_changed. The window shrinks, it does not close.
  • analyze-graph reported the first cluster's size as largest_cluster.

Not in this PR: renaming to get-tasks / set-task-status, and the repeater choice. Both are part 2.

Checks: 3777 tests, ruff check clean. Each fix has a test that fails without it. The write path was run against a live Logseq 0.10.15 on a throwaway page, replace-text and the id-line case included.

The package kept six marker lists, none complete, and counted tasks two
ways: get-todos from the database marker, analyze-* from a pattern over the
page text. logseq_cli/tasks.py is now the one place for the eleven markers
Logseq's parser reads (measured with mldoc 1.5.7, Logseq 0.10.15), their
three states as Logseq draws them (open, done, cancelled), and how a marker
is read from and swapped in a block's text. get-todos takes the task text
from tasks.task_text; the code moved over unchanged, including how it reads
an unclosed :LOGBOOK: and one inside a code fence. No command behaves
differently, and --help of every command is byte-identical.

tasks.STATES is the three states in the order of the markers, derived
once from STATE.

blocktext now owns the rule for planning lines (PLANNING_LINE_RE moved from
dates) and attached_line_mask, the one definition of which lines belong to
the block above. Nothing reads the mask yet.

tests/test_marker_lists.py scans the package for a second marker list. It
lists the twelve places where one still sits; later changes remove them.
tasks joins the modules that need no API (ADR 0003, package layering test),
and CONTEXT.md defines Task, Marker and Task State.

Mutations tried, each caught by:
- attached_line_mask ignoring code blocks: planning line in a code block
- property lines in attached_line_mask: the disjointness test
- unclosed :LOGBOOK: read as a drawer: opener without closer is text
- planning regex without "<": no angle bracket
- STARTED dropped from ORDER: test_order
- CANCELLED as open: test_states
- no heading prefix, tab as separator, list character before the marker:
  the marker_of cases
- lower-case marker before a newline: marker_before_newline cases
- trailing space after a swap at the end of the text: with_marker cases
- box check on every line, or behind a list character or heading: the
  starts_with_box cases
- task_text built on attached_line_mask, marker removed on every line,
  properties kept: the task_text cases
- a count in the exception list raised, a new marker list added: the scan
- an api parameter in tasks.py: the layering test
Existing tests changed: only PURE and the docstring in
test_package_layering.py.

Part of #92.
get-todos built a task's text with a reading of :LOGBOOK: of its own: any
:LOGBOOK: line opened a drawer, and everything up to a :END: was dropped,
whether or not one ever came. mldoc reads it differently
(measured with mldoc 1.5.7): a :LOGBOOK: that no :END: closes is text, and a
line inside a code fence is code. For a block with such a line the two
disagreed, and content, --match and --tag lost everything after it.

task_text now takes the lines that belong to the block above from the one
rule in blocktext. An unclosed :LOGBOOK: stays in the text, so does a
:LOGBOOK: or a SCHEDULED:/DEADLINE: line inside a code fence and a stray
:END:. Closed drawers, planning lines and property lines outside code read as
before.

Each of the four cases was red before the change; --match and --tag now find
text behind the opener, which the old reading hid.

Mutations tried, each caught by:
- task_text back on the old line-by-line reading: the four task_text cases
  and the --match/--tag cases through get-todos
- a pattern of its own instead of attached_line_mask: the source check
  (test_the_source_reads_the_shared_rule) and the unclosed-opener case

Existing assertions changed: the four cases that pinned the old reading of
task_text (TestTaskTextKeepsTheOldBehaviour, now
TestTaskTextKeepsWhatItAlwaysDid for the rest), pinned to the shared rule
in TestTaskTextReadsTheSharedRule.

Part of #92.
The test doubles worked out a block's marker from the first word of its
text, knew only eight of the eleven markers, and clocked a task in or out by
the marker it moved to. Logseq stores :block/marker when it parses the text,
so a block such as "TODO\nnotes" has none, and it starts or closes the clock
by the pair of the old and the new marker (time tracking, on by default).
A mock that derives the marker from text would confirm the rule a test is
meant to check.

The marker is now stored data on each node, given by the fixture. The HTTP
double sets it again in one place only, after a write, with the rule the
package uses and a test compares with mldoc. Its time tracking follows
tasks.CLOCK_IN_STEPS and CLOCK_OUT_STEPS: for example WAIT -> DOING starts
no clock, which the old target sets did. A source test keeps marker lists
and marker patterns out of the test helpers. No command behaves differently.

One assertion changed: the step DONE -> NOW of the time tracking test
expected a new CLOCK line. With time tracking on, 0.10.15 starts the clock
only for the pairs in CLOCK_IN_STEPS (with-marker-time in handler/editor.cljs,
read in the source, not measured: tracking was off in the measured graph),
and DONE -> NOW is not one of them.

Mutations tried, each caught by:
- a tuple of its own in the double instead of tasks.CLOCK_IN_STEPS: the
  source test for marker lists in the test helpers
- target sets instead of pairs: the WAIT -> DOING case of
  TestTimeTrackingFollowsThePair
- no second reading of the marker after updateBlock: TestMarkerIsStoredState
- DOING -> DOING starting a clock with a logbook present: the DOING -> DOING
  and NOW -> NOW cases with a closed drawer
- PageGraph leaving the marker out of getBlock, with a temporary check in
  set-todo-status that the fixture's marker agrees with its text: 19 tests

Part of #92.
set-todo-status took the first word of the text for the marker. A heading
task (## TODO ship it) became DONE ## TODO ship it, which Logseq no longer
reads as a task, a block without a marker (todo x, no marker) became a task,
and TODO followed by a space and a line break lost the space, which also
made it text (measured with Logseq's parser, mldoc 1.5.7).

The command now decides by the marker Logseq keeps in its database and
swaps it where Logseq reads it, behind a heading prefix. A block with no
marker, one whose marker is not at the start of the text, and one that only
points to another block are refused before the preview, also under
--dry-run. --content that matches blocks but no task is refused too, except
with --follow-refs, where a match that only points to a task is followed as
before. A block that already carries the status answers "No change (block
already has this status)." Not found, ambiguous and a missing selector
answer under --json with a reason.

get-todos reports the content of a heading task without its marker.

Mutations tried, and the test that caught each:
- marker not taken from the database: the "TODO x" refusal case
- no wording for a pointer, none for a line break: their refusal cases
- no refusal for matches without a task, or one that also holds with
  --follow-refs: TestSetTodoStatusNeedsATask
- no "no change" answer: the unchanged cases
- the space before a line break lost: the "space before the line break" case
- only the database marker checked: the "##<TAB>TODO x" case
- old_marker read from the first word: the heading dry-run case
- the page named as given, not as the alias resolves: the alias case
- matches listing only ten uuids, a candidate line cut at 80: the ambiguity case
- --id also searching --content, the selector checked after a request:
  TestSetTodoStatusReasons
- heading prefix of spaces only, of any blank, or dropped: the heading prefix
  cases of task_text and get-todos
- the marker of the pull moved before the uuid, another default, one of the
  four queries left out: test_find_blocks_task_fields

Existing assertions changed, with the reason:
- test_set_todo_status_on_a_block_that_starts_with_a_fence: a block that
  starts with a fence has no marker, so it is refused as no task before a
  marker could be put in front of the fence
- test_a_labelled_ref_is_a_link and test_what_logseq_reads_as_no_embed:
  Logseq reads neither as an embed, and no marker in them; they were
  rewritten into a task, and are refused now
- test_only_the_marker_changes: the bare marker, code under a bare marker and
  no marker cases are refusals now, the heading and line break cases are new
- TestMarkerIsStrippedForEveryMarker takes its markers from tasks.ORDER

Seen red before the change: the refusal, unchanged, reason, heading and
alias cases against the code as it was.

Part of #92.
add-note-content --content $'TODO x\nSCHEDULED: <2026-09-25 Fri>' made two
blocks: the SCHEDULED line became a block of its own and had no effect, and
the dry run said "2 block(s)". A task with a date could not be written in
one call. In Logseq's files these lines, and a :LOGBOOK: drawer, belong to
the block above, as a key:: value line does.

The outline parser now keeps them there, through the one rule in blocktext
that says which lines belong to the block above, in every command that reads
its text as an outline. A :LOGBOOK: that no :END: closes is text, not a
drawer (measured with Logseq's parser, mldoc 1.5.7), so it hides nothing and
swallows no bullet after it.

Confirmed live on a probe page, 2026-10-06, Logseq 0.10.15: one block per
task, Logseq sets the scheduled and deadline dates, the drawer stays in its
block.

Mutations tried, and the test that caught each:
- a SCHEDULED line not attached: the scheduled case of the outline tests
- an unclosed :LOGBOOK: read as a drawer: the mask case for an opener without
  a closer
- a literal of its own in the outline module: the source check that the
  shared rule is the only one
- the mask over the whole line list instead of the lines the loop reads: the
  bullet fence with a drawer inside
- no block to join for a leading line: the leading planning line case

Existing assertions changed, with the reason: none.

A "- ```" line inside a code block that starts without a bullet no longer
opens another code block, so the real closer is not cut out and a drawer
behind it stays with its block. A bullet line ends the reach of a drawer
opener: "- a", ":LOGBOOK:", "- b", ":END:" are four blocks and nothing
is swallowed. A drawer that closes before the next bullet stays with its
block, a blank line inside it still goes.

Part of #92.
get-todos took --status with a default of TODO, DOING, NOW and LATER, so a
task marked WAIT, WAITING, IN-PROGRESS or STARTED never showed up unless the
caller named it. Logseq draws the first three as open tasks, and its parser
reads STARTED as a marker too, although its own interface does not offer it.
--status also took any text: --status foo ran and found nothing.

--state open|done|cancelled (repeatable, default open) now selects by state.
--status takes the eleven markers Logseq reads, in any case, has no default
and wins over --state, with a note when both are given. --include-done stays,
hidden from --help, and adds DONE to either. Tasks sort by marker.

The default changes under the same name and key; only open tasks are added,
each carrying its marker, so nothing reads wrongly. The changelog names the
old default and how to keep it. The two tests that compare the command
reference and the skill with the registry now leave hidden options out.

Mutations tried, and the test that caught each:
- markers_in without WAIT: the open-markers case without a filter
- the old sort table: the marker order case
- --state winning over --status: the status-wins case
- --status with its old default: the source scan for marker lists (no
  behaviour test can see it, since "given" is read from the source)
- "given" read from the value, with the default back: the case of an
  explicit --state equal to the default, and the case without a filter
- --state as free text or case sensitive, --status case sensitive: the case
  and usage error cases
- the note on stdout: the case of the note under --json

Existing assertions changed: none. The reference and skill tests only leave
hidden options out, green before and after.

Cases seen failing before the change: the thirteen cases of the state
selection class, against the code as it was.

The markers go into the query through tasks.marker_clause, which checks
each of them against the eleven; get-todos and its reference query built
that set by hand before.

Part of #92.
--status took a fixed list of six markers, told upper case from lower case,
and so refused WAIT, WAITING, IN-PROGRESS and CANCELLED, which Logseq sets
itself, and --status done, although get-todos took --status todo. The choice
is now tasks.FRONTEND_MARKERS: every marker Logseq's parser reads but
STARTED, which its interface does not offer, so the CLI does not write a
marker Logseq never writes. Case does not matter, and the help says why the
option takes a marker and not a state: only the marker DOING tells DOING
from TODO. The help lists are made from the constant, and the epilog of
update-block no longer lists markers.

Mutations tried, and the test that caught each:
- the choice case sensitive: the lower-case WAITING case
- the old list of six: the WAITING case
- STARTED among the markers: the STARTED case
- the marker hint left out of the help: the help case

Existing assertions changed: none.

Part of #92.
get-todos worked out next_due from the first repeater in a task's text,
whichever timestamp it belonged to. A task with SCHEDULED <... .+1d> and
DEADLINE <... +1w> reports the deadline, but next_due followed the daily
repeater, so --due-from and --due-to could place it up to six days off.

next_due now comes from the timestamp whose date is reported (DEADLINE
before SCHEDULED, as before), with that timestamp's own repeater. A
timestamp without a repeater does not move, as Logseq moves only timestamps
that have one, so next_due is its date. dates.timestamps reads every
timestamp of a line with its kind, date, time and repeater;
dates.parse_timestamp reads the text between the angle brackets and does
not check the weekday. Timestamps are read only from the SCHEDULED and
DEADLINE lines of a block, outside code fences, as Logseq reads them;
parse_repeater also found one in running text.

parse_repeater is gone: nothing calls it any more, and keeping it would
leave a second rule, "the first repeater in the text", next to the right
one. Its seven cases are timestamps cases now, with the same inputs.

Mutations tried, and the test that caught each:
- timestamps reading only the first timestamp of a line: the case of both on
  one line
- the kind taken from the wrong group, the bare + left out of the
  repeaters, .+ read as +: the timestamps table cases
- no line rule and no code fence rule: the running text case
- the time ignored: the case with 18:00
- the weekday checked, the hour read with two digits only: the parse_timestamp
  cases
- next_due from the first repeater in the text, from the other kind, without
  a date for a timestamp without repeater, or with the date from the text:
  the cases of next_due following the reported timestamp

Existing assertions changed: none; the cases of TestParseRepeater moved to
the timestamps cases with the same inputs.

Cases seen failing before the fix: the two-repeater case, the deadline
without a repeater, and the repeater of the other timestamp not borrowed.

timestamps returns early for a text without a SCHEDULED or DEADLINE word,
so the code-block scan runs only for blocks that can hold a timestamp.

Part of #92.
analyze-graph counted tasks with a pattern over the page text, while
get-todos counts blocks that carry a marker. On one graph the two
disagreed: the pattern read TODO: , a lowercase todo, a block that starts
with [ ] and a marker on a later line as tasks, none of which Logseq does.

analyze-graph now asks for every block with one of the eleven markers
Logseq reads and sorts them into open, done and cancelled by one
classification, the same one get-todos uses. total_todos is replaced by
tasks (open, done, cancelled, open_by_marker). A note on stderr says how
many blocks start with [ ], which Logseq shows as text. --days limits only
the Recently updated list, and its help says so.

smart-query --request tasks asks for the same open markers, so it now also
finds WAIT, WAITING, IN-PROGRESS and STARTED. The pattern is matched by
every keyword it lists, so all of them are affected.

The tree walker of meta.py is replaced by outlinetext.preorder_blocks,
which the note uses per block too.

Live check 2026-10-06, Logseq 0.10.15: 8 of 8 quantities equal on a probe
page (and a deliberately shifted expectation was reported as unequal); the
page was removed.

Mutations tried and caught:
- the query asking for the open markers only, the count taken per page:
  the test that the query names exactly the eleven markers, once
- a checkbox looked for on every line, or behind a list character or a
  heading: the note case
- the parenthesis kept at zero open tasks, the open markers sorted by the
  alphabet: the text line and the order cases
- a missing tree not skipped, a marker outside the states not skipped, a
  query answer of none not read as empty: the three cases of that name
- the empty-tree rule copied into a second place: the page text cases
- the old list in the smart-query pattern, done also asking for CANCELED:
  the pattern cases, one per keyword
- the tree walker in post-order, not descending, reading the children key
  unguarded: the walker cases
Changed existing tests: the five total_todos cases of analyze-graph are
replaced; the cases they held, prose, a logbook line, a marker on a later
line, a bracket pair in prose, are marker and checkbox cases of the task
rules now, and the marker cases are covered by the mock that answers the
query.

Two test gaps are closed with it: a checkbox block nested below the top
level is counted, and --days limiting only the Recently updated list has a
behaviour test.

Part of #92.
largest_cluster was the size of the first cluster the search found, not of
the largest one. The clusters come in the order of the pages, so a graph
whose first linked group was small under-reported it: with a group of two
pages before a group of three, the report said two.

largest_cluster is now the largest of the sizes, 0 without a cluster.

Mutations tried, and the test that caught each:
- the first cluster again: the case of two groups of 2 and 3 pages and the
  text line case
- the largest taken over the number of clusters, not their sizes: all four
  cases
- no default for the largest of nothing: the case without a cluster

The case with two groups was red against the code as it was (2 instead
of 3).

Part of #92.
analyze-journal-patterns counted tasks with two patterns over the text of
each journal page. They counted Markdown checkboxes, which Logseq does not
read as tasks, counted CANCELED and CANCELLED as done, and could be fooled
by text that only looks like a task (TODO: x, TODO and a line break).

The count now comes from one query for the blocks that carry one of the
eleven markers, on the journal days of the time frame, sorted into open,
done and cancelled by the same classification as get-todos. tasks holds
open, done, cancelled and done_rate; the tasks of each
entry hold open, done and cancelled. The rate is named done_rate and not completion_rate on purpose:
its value changes (cancelled tasks do not count, checkbox lines drop out),
and a key that keeps its name while it returns another number breaks quietly,
whereas a missing one breaks loudly. tasks.total_complete,
tasks.total_incomplete, tasks.completion_rate and the per-entry
tasks_complete and tasks_incomplete are gone.

Tasks are dated by the page their block is on, as before; get-todos
--from/--to also counts the days a block ref carried a task to. Habit
tracking still counts the text pattern "- [ ]" / "- [x]". Both are said in
--help and in the text output now, not in the JSON. Mood, topics, projects
and habit tracking are unchanged; habit tracking, which had no test, is
pinned by one written before the change.

Mutations tried, and the test that caught each:
- the text counted as well, cancelled as done, rows counted instead of
  blocks: the cases of those names
- the rate under its old name: the empty journal case (the key set)
- the query asking for the open markers only, or for other days than the
  pages: the case of the query text
- no guard for a row of fewer than three fields, for a marker outside the
  states, for an answer of none: one case each
- the sentences left out of the help or the text output, or put into the
  JSON: the cases of the output

Existing tests changed: the task cases of analyze-journal-patterns now give
the database rows and the new keys; the checkbox case says that checkbox
lines are no tasks; the doubled bullet case is gone (Logseq reads no marker
behind a list character; a marker rule case holds it). The class that tested
copies of the old patterns, not the code, is removed. Capitalised words
opening a sentence are cases of the marker rule.

The marker scan loses its last exception with this commit, so the list of
exceptions goes away: a marker list outside the tasks module fails the test
directly; only docstrings and the first argument of output.fail stay out of
it, found by their position in the syntax tree.

Checked against Logseq 0.10.15 on 2026-10-06: the counts of analyze-graph
and get-todos --no-follow-refs equal Logseq's own task query for open, done
and cancelled (6 of 6 pairs equal, nothing written to the graph).

Mutations of the scan, each seen red and reverted:
- a marker set added under commands/, the old marker list back in the
  smart-query pattern, an enumeration of markers in a help text:
  test_no_module_but_tasks_keeps_a_marker_list

Part of #92.
get-todos stopped with a traceback and exit 1 on a block whose repeater
holds a number no date can reach. Two sizes, two ways to fail:

- Eleven digits or so (SCHEDULED: <2026-01-01 Thu +99999999999d>) made
  _add_interval raise OverflowError; 99999 months or years raised
  ValueError for a year past 9999. get_todos catches only (ValueError,
  TypeError) around next_occurrence, so the OverflowError ended the command.
- More than 4300 digits made int() raise ValueError inside parse_timestamp,
  outside that try, so no caller could catch it.

One such block, typed or pasted, made get-todos unusable for the whole
graph. Logseq does not write such a number; it was already on main
(parse_repeater, same two failures).

parse_timestamp now reads a number of more than twelve digits as 10**12, a
value no date lies away. _add_interval returns None for a result no date can
hold, which is what next_occurrence already answers "cannot be derived" with.
The task lists like one whose interval cannot be read: no next_due, left out
of a due range, counted in repeating_excluded and named on stderr.

Mutations tried, each caught:
- int(digits) without the cap: the 5000-digit cases, in dates and through
  get-todos (13 tests)
- _add_interval without the except: all 44 cases of the new class
- only OverflowError caught: the 99999 months and years cases (6)
- the cap from three digits on: the case that .+100y is not cut

The cases were red before the change (44 failed), with the two exceptions
above.
next_occurrence stepped once past a stored date that was today or still
ahead for "+" and ".+", so get-todos reported next_due one repeat too late
and --due-from/--due-to left the task out. Only "++" kept such a date.
next_occurrence compared with "> today" in the single step and in the loop,
so an overdue repeating task whose next date is today was reported for the
occurrence after it, and --due-from today --due-to today missed it.
…read

update_block reads the block again, past the cache, directly before a write
that says what it replaces, and raises BlockChanged when the text is another.
set-todo-status, replace-text, set-property and remove-property sent the
whole text back and wrote over a change made in between, and the read-back
passed because it compares with what was just sent. An id:: line is left
out of the comparison: a ref written earlier in the same call stores the id
of a block that call then replaces.
@muellerei
muellerei merged commit c3ef58f into main Oct 6, 2026
6 checks passed
@muellerei
muellerei deleted the feat/92-task-model-final branch October 6, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant