Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,12 @@ U=$(logseq-cli find-block --content "tag support" --page "Project Alpha" --exact
# All open tasks
logseq-cli get-todos

# Filter by status
# Filter by marker
logseq-cli get-todos --status TODO --status DOING

# Filter by state: open (the default), done or cancelled
logseq-cli get-todos --state done

# Filter by page (substring)
logseq-cli get-todos --page "Project Alpha"

Expand Down Expand Up @@ -194,6 +197,14 @@ logseq-cli set-todo-status --id JOURNAL-REF-UUID --status DONE --follow-refs
at the end; `followed` in `--json` lists the blocks it passed. A chain that
loops or reaches a missing block is refused and nothing is written.

`set-todo-status` changes a task only. A block Logseq reads no marker in
(`todo x`, `TODO:`, `TODO` followed directly by a line break) is refused with
`not_a_task`, and nothing is written; so is a block whose marker is not at the
start of the text, and a block that only points to another one (the message
names `--follow-refs`). `--content` that matches blocks but no task is refused
with `no_task_matches`, unless `--follow-refs` is given. A heading task
(`## TODO x`) keeps its heading: `## DONE x`.

Do not use `replace-text` to change a marker: it rewrites by text match, so it
also hits the word elsewhere on the page and silently retypes the rest of the
line. `set-todo-status` swaps only the marker, in one call.
Expand Down Expand Up @@ -257,9 +268,15 @@ logseq-cli add-journal-content \
--content "- ## Section\n\t- Child item\n\t\t- Grandchild"
```

Each line is a block, with two exceptions that stay with the block they belong
Each line is a block, with four exceptions that stay with the block they belong
to, as in Logseq's own files. A `key:: value` line without a bullet goes on the
block above. So does a code block: from a line starting with ```` ``` ```` or
block above. So does a `SCHEDULED: <…>` or `DEADLINE: <…>` line, indented or
not: `TODO x` with `SCHEDULED: <2026-09-25 Fri>` under it is one block, a task
with a date. So does a `:LOGBOOK:` drawer, every line from `:LOGBOOK:` to
`:END:`. A `:LOGBOOK:` that no `:END:` closes before the next bullet line is no
drawer but text, as a code fence nothing closes is, and the lines after it are
read as usual. So does a
code block: from a line starting with ```` ``` ```` or
`~~~` to the next such line without a bullet, every line is code, `- ` lines
included, and keeps its indentation. Put the fence on a bullet line to make the
code block a block of its own:
Expand All @@ -270,6 +287,12 @@ logseq-cli add-note-content --page "Notes" \
# -> "Example" with the code on it, then a block holding only the sh code
```

```bash
logseq-cli add-note-content --page "Notes" \
--content $'TODO Send the report\nSCHEDULED: <2026-09-25 Fri>'
# -> one task block with its date, not a task and a block that says SCHEDULED
```

Text the CLI writes as ONE block must come back from the page file as that
block. Logseq's file parser takes a `- ` or `#` line after the first (indented
too) and a code fence nothing closes as block boundaries, and rebuilds the page
Expand Down Expand Up @@ -440,12 +463,17 @@ rollback), and the message ends with how many, or "Nothing was written.".
| `logseq_error` | Logseq threw on the write and answered with an error object | `method`, `logseq_message` |
| `page_exists` | `create-page` on a page that exists, whose properties Logseq would drop | `page` |
| `rename_refused` | `rename-page` onto a name another page has (Logseq would merge the two) or an empty one | `old`, `new`, `why` (`exists` or `empty`) |
| `block_changed` | `set-todo-status`, `replace-text`, `set-property` and `remove-property` (every write that replaces a block's text) read the block again right before writing, and it no longer holds the text they read: someone changed it in between, and the write would have replaced that change. Nothing was written | `block` |
| `write_not_verified` | The write does not show in Logseq: not written, or not all of it | `method`, `target`, `expected`, `got` |
| `read_only` | Writes are switched off by `[safety] read_only`, `LOGSEQ_CLI_READ_ONLY` or `--read-only`; every command that writes refuses, `--dry-run` included, before its first request | `source` (list of `config`, `env`, `flag`), `config_path` (`null` when no config file was found) |
| `config_error` | The config file cannot be used: it does not parse, `LOGSEQ_CLI_CONFIG` names a file that is not there, or `[safety]` does not check out: a key it does not know, a `[safety]` key outside `[safety]`, a value that is not a boolean, or a `[graph] keep_empty_blocks_last` that is not. A command that writes refuses rather than run without the limits the file may hold. Nothing was sent | none |
| `block_not_found` | The block the command names does not exist; the placeholder Logseq keeps for a missing ref target counts as none | `id` |
| `block_not_found` | The block the command names does not exist; the placeholder Logseq keeps for a missing ref target counts as none | `id`; for `set-todo-status --content` the `content` and `page` searched instead, no `id` |
| `dead_ref` | `set-todo-status --follow-refs`: the chain of refs leads to a block that does not exist | `id` (the missing block), `followed` |
| `ref_cycle` | `set-todo-status --follow-refs`: the chain of refs comes back to a block it passed | `id` (the block met again), `followed` |
| `not_a_task` | `set-todo-status` on a block Logseq reads no marker in, or whose marker is not at the start of the text (after an optional `#` prefix and a space), or that only points to another block and `--follow-refs` is missing | `id`; `points_to` for the pointer case |
| `no_task_matches` | `set-todo-status --content`: blocks match, none is a task; not given with `--follow-refs` | `content`, `page`, `match_count` |
| `ambiguous` | `set-todo-status --content`: several blocks match | `content`, `page`, `matches` (the uuids of all candidates) |
| `missing_selector` | `set-todo-status` without `--id` and without `--content` plus `--page`; refused before any request | none |

`set-property` and `remove-property --name` check the page themselves and
name `page` and `property` instead.
Expand Down Expand Up @@ -479,6 +507,11 @@ write into X). A `--keep-ids` write is refused while any block is open.
`write_not_verified` can be a false alarm when someone else wrote the block
between the write and the read-back; read the block before retrying.

On `block_changed`: nothing was written to that block, and the change made
elsewhere stands. Read the block again and repeat the command if it still
makes sense. The check narrows the time between reading and writing to one
request; it does not close it.

`replace-text` writes the other blocks when one is refused, and names each
refused block's reason in `failed_reasons` (`{id: reason}`) beside `failed`.

Expand Down Expand Up @@ -512,7 +545,7 @@ Every command with all its options: [docs/commands.md](docs/commands.md).
| `insert-block` | Insert at specific position (after/before/child-of, `--first` for first child); `--keep-ids` preserves `id::` values in `--tree` or `--content` (also on `add-note-content`, `add-journal-block`, `add-journal-content`) |
| `find-block` | Find blocks by content; `--limit N` caps the output (what is withheld goes to stderr); `--with-children` prints the subtree; `--uuid-only` prints bare uuids and fails on no match; `--exactly-one` fails unless exactly one block matches |
| `update-block` | Change one block's content (by `--id` or `--where-content`); its properties are kept, a key `--content` sets takes the new value |
| `set-todo-status` | Change a TODO/DOING/DONE marker (never `replace-text`) |
| `set-todo-status` | Change a task's marker (never `replace-text`) |
| `move-block` | Relocate a block, keeping its UUID and refs |
| `copy-block` | Copy a block to another page (new UUID) |
| `remove-block` | Delete a block by UUID |
Expand Down
173 changes: 173 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ file carries most of the reasoning behind the tool.

### Added

- `get-todos --state open|done|cancelled` (repeatable, default `open`) selects
tasks by state, so `--state done` lists the finished ones and `--state
cancelled` both spellings of the cancelled marker (#92).

- `[graph] keep_empty_blocks_last = true` (or `LOGSEQ_CLI_KEEP_EMPTY_BLOCKS_LAST`)
makes a write that ends a section go before the empty blocks that end it, so
they stay last and no empty line is left in front of the entry (#110). Found by
Expand Down Expand Up @@ -92,6 +96,80 @@ file carries most of the reasoning behind the tool.

### Changed

- `analyze-journal-patterns` counts tasks from the database, one count per
journal day. `tasks` holds `open`, `done`, `cancelled` and `done_rate`, and
the `tasks` of each entry holds `open`, `done` and `cancelled`. A task is a
block that carries a marker Logseq reads, so `TODO:`, a lowercase `todo`,
`TODO` followed by a line break and Markdown checkboxes (`- [ ]`) are no
longer counted as tasks, and CANCELED and CANCELLED no longer count as done.
The rate is named `done_rate`, not `completion_rate`, on purpose: its value
changes (cancelled tasks do not count, checkbox lines drop out), and a key
must not keep its name while it returns a different number, so a reader of the
old key now fails loudly instead of reading a wrong figure. `done_rate` is
`done / (open + done)` in percent with one decimal. Tasks are dated by the
page their block is on. `get-todos --from/--to` also counts the days a block
ref carried a task to. Habit tracking still counts the text pattern `- [ ]` /
`- [x]` and now says so in `--help` and in the text output (#92).

- `analyze-graph` counts tasks from the database, like `get-todos`:
`total_todos` is replaced by `tasks` (`open`, `done`, `cancelled`,
`open_by_marker`) and the text line reads `Tasks: N open (…), M done, K
cancelled`. Before, a pattern over the page text counted `TODO:`, a lowercase
`todo`, a block that starts with `[ ]` and a marker on a later line, so the
two commands disagreed on the same graph. A note says how many blocks start
with `[ ]`, which Logseq shows as text. `--days` limits only the Recently
updated list; its help now says so (#92).

- `smart-query --request tasks` now also finds WAIT, WAITING, IN-PROGRESS and
STARTED tasks. The pattern is matched by every keyword it lists (`todo`,
`task`, `tasks`, `incomplete`, `pending`, `aufgaben`, `offene`, `offen`), not
only `tasks`, so all of them return more. The old list was TODO, LATER, NOW,
DOING; `--advanced` with your own query keeps it. `done` is unchanged (#92).

- `set-todo-status --status` no longer tells upper case from lower case
(`--status done` is taken, as `--status todo` always was for `get-todos`),
and sets every marker Logseq's frontend knows: WAIT, WAITING, IN-PROGRESS and
CANCELLED are new. STARTED stays out: Logseq's parser reads it, but the
frontend does not offer it, so the CLI does not write a marker Logseq never
writes (#92).

- `get-todos` without `--status` now lists every open task, not only TODO,
DOING, NOW and LATER: tasks marked WAIT, WAITING, IN-PROGRESS or STARTED come
with 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 (#92).
The result is the same shape and every task carries its `marker`; only more
tasks appear. To keep the old selection, pass `--status TODO --status DOING
--status NOW --status LATER`. `--status` takes only the eleven markers Logseq
reads and does not mind the case (`--status todo` still works);
`get-todos --status foo` used to run and find nothing without a word, and is
now a usage error with a non-zero exit. `--status` has no default any more,
so `--state` decides unless `--status` is given, and a note says so when both
are. `--include-done` still works, adds DONE to either, and is left out of
`--help`. Tasks are ordered by marker, with IN-PROGRESS and STARTED among the
work in hand and WAIT and WAITING after LATER; they used to sort after DONE,
by page.

- `set-todo-status` refuses a block that is no task instead of making it one:
`no marker` became `DONE no marker`, `todo x` became `DONE x`. Logseq reads
no marker in such a block (`todo x`, `TODO:`, `TODO` followed directly by a
line break); the command now stops with `reason: "not_a_task"` and writes
nothing, also under `--dry-run`. A block that only points to another one is
refused too, naming `--follow-refs`. With `--content`, blocks that match but
none of which is a task are refused (`no_task_matches`) instead of one being
rewritten; with `--follow-refs` this holds for the block at the end of the
chain, and a match that only points to a task is followed as before. Under
`--json`, a block not found by `--content`, an ambiguous match and a missing
selector now answer with a JSON object with a `reason` (`block_not_found`,
`ambiguous`, `missing_selector`) instead of plain text. Without `--json`,
these three messages now start with `Error: `, like every other refusal; the
wording after it is unchanged (#92).

- `get-todos` (`content`, `--match`, `--tag`) now reads an unclosed
`:LOGBOOK:` as text and a `:LOGBOOK:` inside a code fence as code, the way
mldoc reads them (measured with mldoc 1.5.7); before, everything after such
a line was dropped from the task text. A `SCHEDULED:` or `DEADLINE:` line inside a code
fence and a stray `:END:` now stay too (#92).

- `doctor` no longer advises removing a config file that does not parse:
"Fix the config file." Once the file carries a limit, removing it lifts the
limit.
Expand Down Expand Up @@ -135,8 +213,103 @@ file carries most of the reasoning behind the tool.
where Logseq shows them. The body is still one `get-block` away. See
[#98](https://github.com/muellerei/logseq-cli/issues/98).

### Removed

- `analyze-graph` no longer reports `total_todos`; `tasks` replaces it (#92).
A key that changes its value never keeps its old name, so a reader of the
old key fails loudly.

- `analyze-journal-patterns` no longer reports `tasks.total_complete`,
`tasks.total_incomplete` and `tasks.completion_rate`, and no longer reports
`tasks_complete` and `tasks_incomplete` per entry (#92).

### Fixed

- A change made to a block while a command was changing it was written over
without a word. `set-todo-status`, `replace-text`, `set-property` and
`remove-property` read the block, build the new text from it and send the
whole text back, so an edit made in between (a parallel call of the CLI, a
sync, Logseq itself) was lost, and the read-back passed because it compares
with what was just sent. Found by an adversarial pass that asked how two calls
on one block could interleave; the gap was already on `main`. `update_block`
now reads the block again, past the cache, directly before the write when it
is told what it replaces, and refuses with the new reason `block_changed`
when the text is another, naming the block and writing nothing. An `id::`
line is left out of the comparison: a ref written earlier in the same
`replace-text` stores the id of a block it then replaces, and that is not a
change made elsewhere (measured, #95). The window is smaller, not closed: it
is now the requests between that read and the write, the same kind of gap the
editor check leaves (#92).

- `get-todos` skipped today when working out `next_due` of an overdue repeating
task whose next occurrence falls on today. With today at 2026-10-06, a task
last due 2026-09-29 with `+1w` reported 2026-10-13, and one due yesterday
with `+1d` reported tomorrow, so `--due-from today --due-to today` missed a
task that is due today. Found while checking the fix below against the case
one step before it; the error was already on `main`. The single step and the
loop now stop at a date that is today or later, as a stored date from today
onward already did. Three tests had written the old reading down as "after
today" and now say "on or after today" (#92).

- `get-todos` reported `next_due` one repeat too late for a repeating task whose
date is today or still ahead. `SCHEDULED: <2026-10-11 Sun +1w>` with today
at 2026-10-06 reported 2026-10-18, and `.+1d` dated today reported tomorrow,
so `--due-from` and `--due-to` left the task out of the range that holds it.
That is the normal state of a task that was ticked off before, because
Logseq then writes the next date into the text. Only `++` kept such a date.
Found by trying every form with a stored date from today onward; the error
was already on `main`. A stored date that is not past is now the next
occurrence for `+`, `.+` and `++` alike. A date that is past is unchanged
(#92).

- `get-todos` stopped with a traceback and exit 1 on a block whose repeater
holds a number no date can reach, such as `SCHEDULED: <2026-01-01 Thu
+99999999999d>`, or one of more than 4300 digits. A single such block, typed
or pasted, made the command unusable for the whole graph. The task now lists
like one whose interval cannot be read: it has no `next_due`, and a
`--due-from` or `--due-to` range leaves it out, counts it in
`repeating_excluded` and names it on stderr. Found by trying oversized numbers on every unit and form of repeater;
the crash was already on `main`.

- `analyze-graph` reported the size of the first cluster it found as
`largest_cluster`, not the size of the largest one. The order of the clusters
follows the order of the pages, so a graph whose first linked group was small
under-reported it (found while reading the code next to the report;
reproduced with two groups of 2 and 3 pages) (#92).

- `get-todos` worked out `next_due` of a repeating task from the first repeater
in its text, not from the timestamp whose date it reports. 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. Found by working the date out by hand with
Logseq's formula. `next_due` now comes from the timestamp whose date is
reported, with that timestamp's own repeater; a timestamp without a repeater
does not move, so `next_due` is its date (#92). Timestamps are read only from
the SCHEDULED and DEADLINE lines of a block, as Logseq reads them; a
`SCHEDULED:` in running text or in a code fence is not one.

- A task with a date can be written in one call. `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)". Found by reproducing it on a probe page. Logseq's own files keep
`SCHEDULED:` and `DEADLINE:` lines and a `:LOGBOOK:` drawer with the block
above, as they keep a `key:: value` line, and the outline parser now does
too, for every command that reads its text as an outline (`add-note-content`,
`add-journal-content`, `add-journal-block`, `insert-block` with an indented
outline, `--tree` given as text). A `:LOGBOOK:` that no `:END:` closes is
text, not a drawer (measured with Logseq's parser, mldoc 1.5.7), and so is
one whose `:END:` comes only after a bullet line, so it hides nothing and
swallows no bullet after it (#92).

- `set-todo-status` no longer breaks a heading task: `## TODO ship it` became
`DONE ## TODO ship it`, a block Logseq reads no task in, because the command
took the first word of the text for the marker. It now swaps the marker where
Logseq reads it, behind the heading prefix (`## DONE ship it`), and
`get-todos` reports `content` without the marker (`## ship it`). It also keeps
the space between the marker and a line break: `TODO \nnotes` became
`DONE\nnotes`, which Logseq no longer reads as a task (measured with Logseq's
parser, mldoc 1.5.7) (#92).

- A uuid that Logseq holds only as the placeholder of a dead ref (content
`id:: <uuid>`, no page, kept once Logseq reads the file with the ref
again) is no block to any command now. `update-block`,
Expand Down
Loading
Loading