Skip to content
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ logseq-cli get-todos --page "Project Alpha"
# Filter by tag
logseq-cli get-todos --tag urgent

# Tasks standing in a date range, including ones carried forward by ((block-ref))
logseq-cli get-todos --from 2026-09-14 --to 2026-09-16 --json

# Mark as done
logseq-cli set-todo-status --id UUID --status DONE

Expand Down Expand Up @@ -259,7 +262,7 @@ If Logseq is not running, the CLI will print "Cannot connect to Logseq API" and
| `get-block` | Resolve block references `((uuid))` |
| `search-pages` | Find pages by name |
| `smart-query` | Natural language or Datalog queries |
| `get-todos` | List and filter tasks |
| `get-todos` | List and filter tasks; a task carried forward by `((block-ref))` is found on the day it stands and stays one row |
| `get-backlinks` | Find pages linking to a page |
| `insert-block` | Insert at specific position (after/before/child-of, `--first` for first child); `--keep-ids` preserves `id::` values in a tree |
| `find-block` | Find blocks by content; `--limit N` caps the output (what is withheld goes to stderr); `--with-children` prints the subtree |
Expand Down
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- `get-todos --from/--to` now finds a task on every journal it stands in, not
only on the page its block lives on. A task carried forward by a
`((block-ref))` was invisible to any date range: `--from 2026-09-14 --to
2026-09-16` returned nothing on a graph where three tasks stood in exactly
those journals. Carrying an open task forward by reference is the ordinary
way to work in Logseq — the block exists once, every later occurrence is a
reference to it — so the answer was not merely incomplete, it was empty, and
an empty result looks plausible.

The fix reads the `:block/refs` relation, which is a real relation and needs
no string matching on the `((uuid))` form. One extra query for the whole
command, roughly 0.17s against a graph with 256 tasks. A task stays **one**
row: `page` and `uuid` still name the original block, and the days it was
carried into are added as `references`. Measured on that graph, a task is
referenced a median of 2 times and one of them 33 times, which is why it is
an array and why it is capped.

`--refs-limit` (default 10) caps the list per task and the remainder is
reported as `references_withheld`, the same bargain `get-backlinks --limit`
and `find-block --limit` already make — one heavily carried task must not
decide the size of the output, and trimming must not hide that a task has
been carried for months. The default is 10 rather than the 3 used by
`get-backlinks` because an entry here is a date, not a block of text, and
because the measured distribution breaks there: a cap of 3 trims 12 of 58
carried tasks, a cap of 10 trims 4. `--refs-limit 0` keeps all of them.
`--no-follow-refs` restores the old reading, for callers who want to know
where blocks live rather than where they appear, and skips the read rather
than fetching what it will not use.

This is a **breaking** change in the sense that matters: a range query can
now return more tasks than before, up to 58 more on the measured graph.
Nothing was removed, and `page`/`uuid` are unchanged.

A reference on a page carrying no `journal-day` falls out of a range, the
same rule the origin page has followed since 0.11.0 — 44 of 248 reference
occurrences sit on ordinary pages, and letting them through would have
reopened the silent gap that decision closed. See [#15](https://github.com/muellerei/logseq-cli/issues/15).

- A test now holds the README's command tables to the command registry. The
twenty missing options below were not the defect — they were the symptom. The
defect is that a table is a hand-maintained view of something derivable, and
Expand Down Expand Up @@ -50,6 +88,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
does not go stale and names the consequence instead of a count — a figure
maintained by hand is the same defect this project documents elsewhere.

### Added

- `examples/carried-over-todos.sh` lists the tasks standing in the last N days,
longest-carried first, and says for each how many journals it has been taken
along and how many of those fall inside the window. That reading only became
possible with the block-ref work above: before it, a task's date was the day
it was first written down, so "how long have I been moving this?" had no
answer in the payload.

Uses `--refs-limit 0` for the count, which lifts the per-task cap without
widening the window — occurrences before the range stay in
`references_withheld`, and the sum of both is what makes the total a
duration rather than a visible fraction.

### Fixed

- `examples/weekly-todos.sh` counted `data.get('tasks', [])`, a key
`get-todos --json` has never emitted — the payload has carried `todos` since
the initial import. The `.get` default swallowed it: the script reported
"Total: 0 open tasks" against any graph and printed an empty per-page
breakdown under it, which reads as a quiet week rather than as a broken
example. It now reads `data['todos']`, so a future rename fails loudly
instead of counting zero.

Two tests hold both halves — the example may only read keys the payload
carries, and the payload keeps carrying them. Found while checking the
block-ref work above for consistency against the rest of the repo, not by
running the example, which is the part worth noting: an example nobody runs
is documentation that can disagree with its source.

## [0.12.0] - 2026-09-15

### Fixed
Expand Down
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ logseq-cli get-page --name "My Page" # equivalent

| Command | Description |
|---------|-------------|
| `get-todos [--page NAME] [--status S] [--tag TAG] [--from DATE] [--to DATE] [--due-from DATE] [--due-to DATE] [--include-done]` | List tasks (page name shown inline in plain-text output). `--from/--to` date a task by the journal page it sits on — when it was written down. `--due-from/--due-to` filter by `SCHEDULED`/`DEADLINE` instead. For a repeating task the next occurrence is derived (Logseq stores only the first) and reported as `next_due` |
| `get-todos [--page NAME] [--status S] [--tag TAG] [--from DATE] [--to DATE] [--due-from DATE] [--due-to DATE] [--include-done] [--refs-limit N] [--no-follow-refs]` | List tasks (page name shown inline in plain-text output). `--from/--to` date a task by every journal it stands in, the page its block lives on and the ones it was carried into by `((block-ref))` alike; `references` names the latter, `--refs-limit` caps that list (0 lifts the cap) and `references_withheld` counts what was left out — with a range that includes occurrences outside it, so lifting the cap does not make the count zero. `--no-follow-refs` reports only where blocks live. `--due-from/--due-to` filter by `SCHEDULED`/`DEADLINE` instead. For a repeating task the next occurrence is derived (Logseq stores only the first) and reported as `next_due` |
| `get-properties --page NAME [--property KEY]` | Get page properties |
| `doctor` | Health-check: Python, packages, connectivity, token, API, graph kind, graph, config. Exit 0 = ready |
| `init [--dry-run] [--force] [--output PATH]` | Write a config file suggested from your graph, with the counts each suggestion rests on |
Expand Down Expand Up @@ -338,6 +338,14 @@ logseq-cli get-todos --status TODO
# TODO [Project Alpha] Finish the tag support UI
# DOING [2026-04-22, wednesday] Prepare the 1:1

# 3b. A task carried forward by ((block-ref)) is found on the day it stands,
# not only on the journal it was first written down in.
logseq-cli get-todos --from 2026-04-20 --to 2026-04-22
# TODO [2026-03-04, wednesday] Write the migration guide
# also on: 2026-04-22, wednesday; 2026-04-20, monday (+9 more)
# The task is one row: [page] is where the block lives, "also on" where it
# appears. --no-follow-refs reports only the former.

# 4. insert-block --tree: batch-insert a hierarchy in one call
logseq-cli insert-block --child-of "$UUID" --tree "### Meeting
- Agenda
Expand Down Expand Up @@ -494,6 +502,39 @@ complete and is not. `get-page` was silent about this until the count was added
there too; the same page read through two commands had given two different
answers about whether it was whole.

### A task is where it stands, not only where it was written

A todo block exists once. Carrying it forward into later journals is done with
a `((block-ref))`, and that reference is not a copy — it is the same block in a
second place, which is why checking off the reference checks off the original.
A tool that finds tasks through `:block/page` alone therefore sees only the day
a task was first written down, and a query for this week returns nothing about
the tasks that actually stood in it. The failure is quiet: an empty task list
looks like an empty week.

Logseq's own `(between ...)` filter reads the same way, which is how the
problem arrives in the forum rather than in a bug tracker — *"the tasks are not
in the journal pages and the between query only looks at the journal page
dates"*
([discuss.logseq.com](https://discuss.logseq.com/t/creating-a-query-for-overdue-tasks/12408)).
The advanced-query answer given there reaches for `:block/refs`, one block
reference at a time.

So `get-todos` follows that relation by default rather than behind a flag: a
default that answers incompletely is worse than one that costs a read, because
the caller has no way to tell the two apart. The task stays one row — `page`
and `uuid` keep naming the original block, `references` names the days it was
carried into. `--refs-limit` caps that list and `references_withheld` counts
the rest, because a task carried 33 times must not decide the size of the
output, and `--no-follow-refs` restores the older reading for callers who want
to know where blocks live rather than where they appear.

`references_withheld` counts two things a range query leaves out: occurrences
beyond the cap, and occurrences outside the range itself. Lifting the cap with
`--refs-limit 0` therefore does not drive the count to zero — a task carried
since March still reports the days before the queried week. That is the reading
a range query wants, because the alternative is a task that looks new.

### Failure has one exit code, and no resume

A command exits `0` when it did what it said, and non-zero when it did not.
Expand Down Expand Up @@ -571,6 +612,7 @@ Date formatting is locale-independent — weekday and month names are always Eng
See `examples/` directory:

- `backup-graph.sh` - Export all pages as a JSON backup
- `carried-over-todos.sh` - Tasks standing in the last N days, longest-carried first (uses `references` to show how long each has been taken along)
- `daily-todos.sh` - Daily TODO overview (suitable for cronjob)
- `export-all-pages.sh` - Export all pages as individual JSON files
- `export-page.sh` - Export a page as Logseq-compatible markdown
Expand Down
58 changes: 58 additions & 0 deletions examples/carried-over-todos.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Tasks standing in the last N days, longest-carried first
#
# A task carried forward by ((block-ref)) appears in each journal it was pulled
# into, so `references` counts how many days it has been taken along and
# `references_withheld` how many of those fall outside the window asked about.
# Their sum answers "how long have I been moving this?", which the page a task
# lives on cannot: that only says when it was first written down.
#
# Usage: ./carried-over-todos.sh [DAYS] (default: 14)
# Requires: LOGSEQ_TOKEN or --token, and jq

set -euo pipefail

DAYS="${1:-14}"
# BSD date (macOS) and GNU date disagree on relative dates; try both.
FROM=$(date -v-"${DAYS}"d +%Y-%m-%d 2>/dev/null \
|| date -d "${DAYS} days ago" +%Y-%m-%d)
TO=$(date +%Y-%m-%d)

# One read, reused below. --refs-limit 0 lifts the per-task cap so every
# occurrence inside the window is counted; it does not widen the window, so
# days before ${FROM} stay in references_withheld — which is what makes the
# total meaningful rather than just the visible part.
# Declared before assignment on purpose: `local`/`export` on the same line as
# a command substitution swallows its exit status, and so does a bare
# assignment under `set -e` in some shells. Split, the failure propagates and
# the script stops instead of reporting an empty week.
PAYLOAD=""
PAYLOAD=$(logseq-cli get-todos --from "${FROM}" --to "${TO}" --refs-limit 0 --json)

echo "=== Tasks standing between ${FROM} and ${TO} ==="
echo

echo "${PAYLOAD}" | jq -r '
.todos
| map(. + {
days_seen: ((.references // []) | length),
days_outside: (.references_withheld // 0)
})
| sort_by(-(.days_seen + .days_outside), .content)
| .[]
| "\(.marker) \(.content | split("\n")[0] | .[0:60])\n" +
" first written: \(.page)\n" +
(if (.days_seen + .days_outside) == 0
then " not carried — written on the day it stands\n"
else " carried into \(.days_seen + .days_outside) journal(s), " +
"\(.days_seen) in this window\n"
end)
'

echo "${PAYLOAD}" | jq -r '
(.todos | length) as $total
| (.todos | map(select(((.references // []) | length)
+ (.references_withheld // 0) > 0)) | length) as $carried
| "=== \($total) task(s) stood in this window, \($carried) carried over from "
+ "earlier days ==="
'
2 changes: 1 addition & 1 deletion examples/weekly-todos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ echo "=== Count ==="
logseq-cli get-todos --status TODO --status DOING --json "$@" | python3 -c "
import sys, json
data = json.load(sys.stdin)
tasks = data.get('tasks', [])
tasks = data['todos']
print(f'Total: {len(tasks)} open tasks')
pages = {}
for t in tasks:
Expand Down
Loading
Loading