From 1b68d0577a434e2116cc4213196f2623706c4ded Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:01:16 +0200 Subject: [PATCH 1/8] feat: get-todos finds tasks carried forward by block reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A todo block exists once. Carrying it into later journals is done with a ((block-ref)), which is not a copy but the same block in a second place — checking off the reference checks off the original. get-todos found tasks through :block/page alone, so a task carried forward was invisible to every date range: --from 2026-09-14 --to 2026-09-16 returned nothing on a graph where three tasks stood in exactly those journals. The failure was quiet, which is what made it hard to catch: an empty task list looks like an empty week. Resolve the :block/refs relation instead, in one extra query for the whole command rather than one per task (~0.17s against 256 tasks). A task stays one row: page and uuid keep naming the original block, and the days it was carried into are added as references, sorted newest first because Datalog guarantees no order. --refs-limit (default 10) caps that list and reports the rest as references_withheld, the bargain get-backlinks --limit and find-block --limit already make. 10 rather than their 3 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. --no-follow-refs restores the older reading and skips the read rather than paying for it. A reference on a page carrying no journal-day falls out of a range, the same rule origin pages have followed since 0.11.0 — 44 of 248 measured reference occurrences sit on ordinary pages, and letting them through would reopen the silent gap that decision closed. Closes #15 --- AGENTS.md | 5 +- CHANGELOG.md | 38 ++++++ README.md | 29 ++++- logseq_cli/cli.py | 179 +++++++++++++++++++++++++--- tests/test_get_todos.py | 251 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 479 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 46c153d..df8e870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aeb697..e6daf12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4dbd7c6..92e9abc 100644 --- a/README.md +++ b/README.md @@ -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 keeps all) and the remainder is reported as `references_withheld`. `--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 | @@ -494,6 +494,33 @@ 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. + ### 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. diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 73248bd..86e4878 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -3606,6 +3606,99 @@ def add_block_ref(ctx, source_id, journal_date, page, under_heading, dry_run, as click.echo(f" uuid: {new_uuid}") +def _fetch_todo_references(api, markers_str: str) -> dict: + """Map each referenced todo's uuid to the pages its references sit on. + + In Logseq a block reference is not a copy, it is the same block appearing in + a second place: checking off a reference checks off the original. Carrying an + open task forward by ``((uuid))`` is therefore the ordinary way to keep it + alive, and the later journals hold references rather than blocks of their + own. ``:block/refs`` is a real relation, so this needs no string matching on + the ``((uuid))`` form. + + Answers ``{uuid: [(journal_day_or_None, page_name), ...]}``, unordered and + with duplicates intact — the caller decides what a date range keeps and how + the rest is counted, which it cannot do once entries are dropped here. + """ + query = ( + '[:find (pull ?src [:block/uuid]) ' + '(pull ?refp [:block/original-name :block/name :block/journal-day]) ' + ':where [?src :block/marker ?m] ' + f'[(contains? #{{{markers_str}}} ?m)] ' + '[?ref :block/refs ?src] ' + '[?ref :block/page ?refp]]' + ) + occurrences = {} + for row in api.datascript_query(query) or []: + if not (isinstance(row, (list, tuple)) and len(row) >= 2): + continue + src, refp = row[0], row[1] + # A pull answers None, not {}, for an entity carrying none of the + # requested attributes — seen on a live graph, and it is the reference + # pages without a name that hit this. + if not isinstance(src, dict) or not isinstance(refp, dict): + continue + uuid = src.get("uuid") + name = refp.get("original-name") or refp.get("name", "") + if not uuid or not name: + continue + jd = refp.get("journal-day") or refp.get("journalDay") + occurrences.setdefault(uuid, []).append((jd, name)) + return occurrences + + +def _place_references(occurrences, date_start, date_end, limit: int): + """Pick the occurrences to report and count the ones left out. + + Answers ``(names, withheld)``. ``names`` is sorted newest first, because + Datalog guarantees no result order and because the most recent occurrence is + the one a caller reaches for first — the origin is already in ``page``. + + Two things fall out rather than being listed. An occurrence outside a given + range is not an answer to the question asked; and an occurrence on a page + with no ``journal-day`` cannot be shown to fall inside a range at all, the + same rule the origin page already follows. Both are counted in ``withheld`` + instead of vanishing: that a task has been carried for months is worth + knowing even when the dates themselves are not asked for. + """ + dated, undated = [], [] + for jd, name in occurrences: + if jd is None: + undated.append(name) + continue + try: + dated.append((journal_day_to_date(jd), name)) + except (ValueError, TypeError): + # An unparseable journal-day places an occurrence no better than a + # missing one does. + undated.append(name) + + if date_start or date_end: + in_range, out_of_range = [], len(undated) + for d, name in dated: + dt = datetime.datetime.combine(d, datetime.time()) + if (date_start and dt < date_start) or (date_end and dt > date_end): + out_of_range += 1 + else: + in_range.append((d, name)) + kept = [name for _, name in sorted(in_range, key=lambda e: e[0], reverse=True)] + withheld = out_of_range + else: + kept = [name for _, name in sorted(dated, key=lambda e: e[0], reverse=True)] + kept += sorted(undated) + withheld = 0 + + # Two references written on the same day are one occurrence of that day: + # the field names where a task stood, not how often it was typed. + deduped = list(dict.fromkeys(kept)) + withheld += len(kept) - len(deduped) + + if limit and len(deduped) > limit: + withheld += len(deduped) - limit + deduped = deduped[:limit] + return deduped, withheld + + # --------------------------------------------------------------------------- # 21. get-todos # --------------------------------------------------------------------------- @@ -3616,22 +3709,29 @@ def add_block_ref(ctx, source_id, journal_date, page, under_heading, dry_run, as logseq-cli --token TOKEN get-todos --from 2026-05-01 --to 2026-05-31 --include-done Notes: --status repeatable. Default: TODO, DOING, NOW, LATER (no DONE). - Returns ORIGINAL blocks only — TODO Block-Refs ((uuid)) inside journals are NOT listed. + A task carried forward by a block-ref ((uuid)) is found on the day it stands, + and reported once: "page" and "uuid" stay the original block, "references" + names the other pages it appears on. --no-follow-refs restores the old reading. Plain-text output: "MARKER [Page] preview" — page name inline, no grouping needed. """) @click.option("--status", multiple=True, default=("TODO", "DOING", "NOW", "LATER"), help="Task status to include (repeatable, default: TODO DOING NOW LATER)") @click.option("--page", "--name", default=None, help="Filter by page name (substring, case-insensitive)") @click.option("--tag", default=None, help="Filter by hashtag (e.g. 'urgent', without #)") -@click.option("--from", "from_date", default=None, help="Only TODOs on or after this date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow'). Dates come from the journal page a task sits on, so tasks on ordinary pages are excluded whenever a range is given.") +@click.option("--from", "from_date", default=None, help="Only TODOs on or after this date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow'). Dates come from the journal pages a task stands on — the one its block lives on and the ones it was carried into by ((block-ref)) — so tasks found only on ordinary pages are excluded whenever a range is given.") @click.option("--to", "to_date", default=None, help="Only TODOs on or before this date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow'). Same page rule as --from.") @click.option("--due-from", "due_from", default=None, help="Only tasks due on or after this date, by SCHEDULED/DEADLINE rather than by the journal page they sit on. Repeating tasks are excluded and reported — Logseq stores their first occurrence, not the next") @click.option("--due-to", "due_to", default=None, help="Only tasks due on or before this date. Same rule as --due-from") @click.option("--include-done", is_flag=True, help="Also include DONE tasks") +@click.option("--refs-limit", "refs_limit", type=int, default=10, show_default=True, + help="Occurrences kept per task in 'references'; the remainder is reported as references_withheld. 0 keeps all") +@click.option("--no-follow-refs", "no_follow_refs", is_flag=True, + help="Do not resolve block-refs: report only where task blocks live, not where they appear. Saves one read") @click.option("--json", "as_json", is_flag=True, help="Output as JSON") @click.pass_context @handle_connection_error -def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, include_done, as_json): +def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, include_done, + refs_limit, no_follow_refs, as_json): """List all TODOs/tasks in the graph.""" api = ctx.obj["api"] @@ -3639,6 +3739,9 @@ def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, incl if include_done: markers.add("DONE") + if refs_limit < 0: + fail("--refs-limit must be 0 or greater (0 keeps all).", as_json) + markers_str = " ".join(edn_string(m) for m in sorted(markers)) query = ( '[:find (pull ?b [:block/content :block/marker :block/uuid '':block/scheduled :block/deadline :block/repeated?]) ' @@ -3649,6 +3752,11 @@ def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, incl ) results = api.datascript_query(query) + # One extra read for the whole command, not one per task: the relation is + # queried in bulk and joined below. --no-follow-refs skips it entirely + # rather than fetching what it will not use. + occurrences = {} if no_follow_refs else _fetch_todo_references(api, markers_str) + todos = [] for block_data, page_data in results: content = block_data.get("content", "") @@ -3727,22 +3835,48 @@ def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, incl tag_pattern = re.compile(rf"#\b{re.escape(tag)}\b", re.IGNORECASE) todos = [t for t in todos if tag_pattern.search(t["content"])] - # Filter by date range. A task whose page carries no journal-day cannot be - # shown to fall inside the range, so it falls out of it. Letting it pass - # instead made the filter apply to the journal subset only and stay silent - # about the rest: a range predating the graph still returned every task on - # an ordinary page, and no caller could tell which part had been filtered. + # Resolve block references. A task carried forward by ((uuid)) stands on the + # later day as much as on the day it was written, so its occurrences are + # attached here — before the date filter, which reads them. + date_start = ( + datetime.datetime.combine(parse_date_keyword(from_date), datetime.time()) + if from_date else None + ) + date_end = ( + datetime.datetime.combine(parse_date_keyword(to_date), datetime.time()) + if to_date else None + ) + for t in todos: + refs = occurrences.get(t["uuid"]) + if not refs: + continue + names, withheld = _place_references(refs, date_start, date_end, refs_limit) + # A task with no occurrence left to report carries no field: a caller + # reading tasks that are not carried forward sees the payload it saw + # before this command learned to follow references. + if names: + t["references"] = names + if withheld: + t["references_withheld"] = withheld + + # Filter by date range. A task counts as inside the range if the journal + # page it sits on is, or if it appears inside it through a reference — + # checking off a reference checks off the original, so both are the same + # task standing on that day. + # + # A page carrying no journal-day cannot be shown to fall inside the range, + # so it falls out of it, and the same rule governs reference pages: 44 of + # 248 reference occurrences measured on a live graph sit on ordinary pages. + # Letting either pass made the filter apply to the journal subset only and + # stay silent about the rest: a range predating the graph still returned + # every task on an ordinary page, and no caller could tell which part had + # been filtered. if from_date or to_date: - date_start = ( - datetime.datetime.combine(parse_date_keyword(from_date), datetime.time()) - if from_date else None - ) - date_end = ( - datetime.datetime.combine(parse_date_keyword(to_date), datetime.time()) - if to_date else None - ) filtered = [] for t in todos: + if t.get("references"): + filtered.append(t) + continue jd = t.get("_journal_day") if jd is None: continue @@ -3832,6 +3966,19 @@ def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, incl for t in todos: preview = t["content"][:100] + ("..." if len(t["content"]) > 100 else "") click.echo(f" {t['marker']} [{t['page']}] {preview}") + # Named here too, not only in JSON: the gap this closes was + # just as invisible in plain text, and "[Mar 4th]" alone still + # reads as though the task had not been touched since. + refs = t.get("references") + if refs: + withheld = t.get("references_withheld") + more = f" (+{withheld} more)" if withheld else "" + # Semicolons, not commas: a journal page is named + # "2026-09-16, Wednesday", so a comma-separated list of + # them reads as twice as many entries as it holds. + click.echo(f" also on: {'; '.join(refs)}{more}") + elif t.get("references_withheld"): + click.echo(f" also on {t['references_withheld']} other page(s)") # --------------------------------------------------------------------------- diff --git a/tests/test_get_todos.py b/tests/test_get_todos.py index 22ff0bc..e305a6d 100644 --- a/tests/test_get_todos.py +++ b/tests/test_get_todos.py @@ -8,13 +8,22 @@ from logseq_cli.cli import cli -def _mock_api_for_todos(todo_rows): +def _mock_api_for_todos(todo_rows, ref_rows=None): """Build a mocked API that returns datascript_query rows for get-todos. - Each row in ``todo_rows`` is (block_dict, page_dict) per the get-todos query. + ``get-todos`` issues two queries: the todos themselves, then — unless + ``--no-follow-refs`` is given — the blocks that reference them. The mock + answers them in that order. + + Each row in ``todo_rows`` is (block_dict, page_dict) per the todo query. + Each row in ``ref_rows`` is (block_dict, page_dict) per the reference + query, where block_dict identifies the *referenced* todo by uuid and + page_dict is the page the reference sits on. """ api = MagicMock() - api.datascript_query.return_value = todo_rows + api.datascript_query.side_effect = lambda q: ( + ref_rows or [] if ":block/refs" in q else todo_rows + ) return api @@ -80,8 +89,9 @@ def test_status_flag_passed_into_query(self): runner = CliRunner() with patch("logseq_cli.cli.LogseqAPI", return_value=api): runner.invoke(cli, ["get-todos", "--status", "DOING"]) - # Verify the query string contained DOING - query = api.datascript_query.call_args[0][0] + # Verify the query string contained DOING. The todo query is the first + # one; the reference query follows it. + query = api.datascript_query.call_args_list[0][0][0] assert "DOING" in query @@ -157,3 +167,234 @@ def test_only_from_still_drops_undated(self): assert not any("plain page" in c for c in contents), ( f"task without a journal date passed a one-sided range: {contents!r}" ) + + +class TestGetTodosBlockReferences: + """A todo carried forward by ``((uuid))`` must be found on the day it stands. + + The defect this guards: ``get-todos`` located a task only through the page + its block lives on. 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 a date range over those later days + returned nothing at all, with no sign that anything had been left out. + """ + + # The origin block sits outside every range used below, so a todo that + # shows up in one can only have been found through its references. + _ORIGIN = ({"content": "TODO write the migration guide", "marker": "TODO", + "uuid": "u-carried"}, + {"original-name": "Mar 4th, 2026", "name": "mar 4th, 2026", + "journal-day": 20260304}) + + def _ref(self, day, name=None): + return ({"uuid": "u-carried"}, + {"original-name": name or f"journal {day}", "journal-day": day}) + + def test_todo_is_found_through_a_reference(self): + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + assert result.exit_code == 0, result.output + todos = _json.loads(result.output)["todos"] + assert len(todos) == 1, f"todo carried into the range was not found: {todos!r}" + assert todos[0]["uuid"] == "u-carried" + + def test_origin_fields_are_unchanged(self): + """``page`` and ``uuid`` keep naming where the block lives.""" + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert todo["page"] == "Mar 4th, 2026", ( + f"page must stay the origin, got {todo['page']!r}") + assert todo["uuid"] == "u-carried" + + def test_many_references_yield_one_row(self): + """A todo referenced N times is one task, not N tasks.""" + refs = [self._ref(20260317 + i) for i in range(3)] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + data = _json.loads(result.output) + assert data["count"] == 1, f"references were not deduplicated: {data!r}" + assert len(data["todos"][0]["references"]) == 3 + + def test_references_are_sorted_newest_first(self): + """Datalog guarantees no order, so the sort has to be explicit.""" + refs = [self._ref(20260317, "Mar 17th, 2026"), + self._ref(20260319, "Mar 19th, 2026"), + self._ref(20260318, "Mar 18th, 2026")] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + refs_out = _json.loads(result.output)["todos"][0]["references"] + assert refs_out == ["Mar 19th, 2026", "Mar 18th, 2026", "Mar 17th, 2026"], refs_out + + def test_occurrences_outside_the_range_are_counted_not_listed(self): + """Trimming must not hide that a todo has been carried for months.""" + refs = [self._ref(20260319)] + [self._ref(20260101 + i) for i in range(5)] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 1, todo["references"] + assert todo["references_withheld"] == 5, todo + + def test_withheld_is_absent_when_nothing_was_withheld(self): + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert "references_withheld" not in todo, todo + + def test_refs_limit_caps_the_list_and_counts_the_rest(self): + refs = [self._ref(20260301 + i) for i in range(5)] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--refs-limit", "2", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 2, todo["references"] + assert todo["references_withheld"] == 3, todo + + def test_refs_limit_zero_keeps_all(self): + refs = [self._ref(20260301 + i) for i in range(5)] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--refs-limit", "0", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 5, todo["references"] + assert "references_withheld" not in todo, todo + + def test_no_follow_refs_restores_the_old_reading(self): + """For callers who want where blocks live, not where they appear.""" + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--no-follow-refs", "--from", "2026-03-17", + "--to", "2026-03-19", "--json"]) + data = _json.loads(result.output) + assert data["todos"] == [], f"--no-follow-refs still resolved refs: {data!r}" + + def test_no_follow_refs_issues_no_second_query(self): + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + runner.invoke(cli, ["get-todos", "--no-follow-refs", "--json"]) + assert api.datascript_query.call_count == 1, ( + "--no-follow-refs must not pay for a read it does not use") + + def test_references_on_non_journal_pages_fall_out_of_a_range(self): + """Same rule as the origin page: no journal-day, no place in the range. + + 44 of 248 reference occurrences in the measured graph sit on ordinary + pages. Letting them count would reopen the silent gap this command + already closed for origin pages. + """ + refs = [({"uuid": "u-carried"}, {"original-name": "Project Alpha"})] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) + data = _json.loads(result.output) + assert data["todos"] == [], ( + f"a reference on a page with no journal-day entered a range: {data!r}") + + def test_non_journal_reference_is_listed_without_a_range(self): + """Without a range there is nothing to fall outside of.""" + refs = [({"uuid": "u-carried"}, {"original-name": "Project Alpha"})] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["Project Alpha"], todo + + def test_a_todo_without_references_has_no_references_field(self): + """Callers reading todos that are not carried see the payload they saw.""" + api = _mock_api_for_todos([self._ORIGIN], []) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert "references" not in todo, todo + assert "references_withheld" not in todo, todo + + def test_page_filter_matches_the_origin_not_the_reference(self): + """--page selects which todos appear; references still count graph-wide. + + The alternative — restricting references to the queried page — would + make the field mean something different per call, and would mean + nothing at all for --tag, which is not a page. + """ + api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319, "Mar 19th, 2026")]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--page", "Mar 4th", "--json"]) + todos = _json.loads(result.output)["todos"] + assert len(todos) == 1, todos + assert todos[0]["references"] == ["Mar 19th, 2026"], todos[0] + + def test_a_reference_alone_does_not_invent_a_todo(self): + """Only blocks the todo query returned may appear; refs add no rows.""" + refs = [({"uuid": "u-unknown"}, {"original-name": "Mar 19th, 2026", + "journal-day": 20260319})] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--json"]) + uuids = [t["uuid"] for t in _json.loads(result.output)["todos"]] + assert uuids == ["u-carried"], uuids + + def test_duplicate_reference_dates_are_collapsed(self): + """Two references on one day are one occurrence of that day.""" + refs = [self._ref(20260319, "Mar 19th, 2026"), + self._ref(20260319, "Mar 19th, 2026")] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["Mar 19th, 2026"], todo + + def test_plain_text_names_the_occurrences(self): + """The defect was invisible in plain text too, not only in JSON.""" + refs = [self._ref(20260319, "Mar 19th, 2026")] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19"]) + assert result.exit_code == 0, result.output + assert "Mar 19th, 2026" in result.output, result.output + + def test_plain_text_separates_occurrences_unambiguously(self): + """Journal names hold a comma, so the list must not be comma-separated. + + A real journal page is named "2026-09-16, Wednesday". Joined with ", " + two of them read as four entries. + """ + refs = [self._ref(20260916, "2026-09-16, Wednesday"), + self._ref(20260914, "2026-09-14, Monday")] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos"]) + line = next(l for l in result.output.splitlines() if "also on" in l) + assert "Wednesday; 2026-09-14" in line, ( + f"occurrences are not separably delimited: {line!r}") From dc4503e705c300c1f379d2618e50074facb9169b Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:16:04 +0200 Subject: [PATCH 2/8] docs: say what references_withheld counts, not just the cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "0 keeps all" reads as though --refs-limit were the only reason an occurrence goes uncounted. It is not: with --from/--to, occurrences outside the range and on pages with no journal-day are withheld too, so --refs-limit 0 leaves a non-zero count and looks like a bug. Found in review against a live graph, where it cost two runs to work out that the withheld occurrences lay outside the queried week rather than above the limit. The docstring on _place_references already explained it; the help text and README did not, and those are what a caller reads. Behaviour unchanged — a task carried since March must not look new just because the query asked about this week. A test now holds the distinction, verified by making --refs-limit 0 zero the counter and watching it fail. --- README.md | 8 +++++++- logseq_cli/cli.py | 2 +- tests/test_get_todos.py | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 92e9abc..40d9acd 100644 --- a/README.md +++ b/README.md @@ -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] [--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 keeps all) and the remainder is reported as `references_withheld`. `--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-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 | @@ -521,6 +521,12 @@ 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. diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 86e4878..b26bf90 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -3724,7 +3724,7 @@ def _place_references(occurrences, date_start, date_end, limit: int): @click.option("--due-to", "due_to", default=None, help="Only tasks due on or before this date. Same rule as --due-from") @click.option("--include-done", is_flag=True, help="Also include DONE tasks") @click.option("--refs-limit", "refs_limit", type=int, default=10, show_default=True, - help="Occurrences kept per task in 'references'; the remainder is reported as references_withheld. 0 keeps all") + help="Occurrences kept per task in 'references'; 0 lifts the cap. references_withheld counts everything left out, which with --from/--to also includes occurrences outside the range and on pages with no journal-day — so 0 does not make it zero") @click.option("--no-follow-refs", "no_follow_refs", is_flag=True, help="Do not resolve block-refs: report only where task blocks live, not where they appear. Saves one read") @click.option("--json", "as_json", is_flag=True, help="Output as JSON") diff --git a/tests/test_get_todos.py b/tests/test_get_todos.py index e305a6d..b9167c3 100644 --- a/tests/test_get_todos.py +++ b/tests/test_get_todos.py @@ -398,3 +398,23 @@ def test_plain_text_separates_occurrences_unambiguously(self): line = next(l for l in result.output.splitlines() if "also on" in l) assert "Wednesday; 2026-09-14" in line, ( f"occurrences are not separably delimited: {line!r}") + + def test_lifting_the_cap_does_not_zero_the_withheld_count(self): + """--refs-limit 0 lifts the cap; it does not widen the range. + + The two are easy to conflate, because "0 keeps all" reads as though the + cap were the only reason an occurrence goes uncounted. It is not: with a + range set, occurrences outside it are withheld too, and that is the + point — a task carried since March must not look new. + """ + refs = [self._ref(20260319)] + [self._ref(20260101 + i) for i in range(3)] + api = _mock_api_for_todos([self._ORIGIN], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--refs-limit", "0", "--from", "2026-03-17", + "--to", "2026-03-19", "--json"]) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 1, todo["references"] + assert todo["references_withheld"] == 3, ( + f"--refs-limit 0 must not smuggle out-of-range occurrences in: {todo!r}") From 760e89d8dc80a0c64aefca6ebaf1c37bf271e948 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:25:08 +0200 Subject: [PATCH 3/8] test: hold the reference machinery at its edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten cases at the boundaries of the new block-ref handling, each written after running it against the implementation and each checked by breaking the branch it covers and confirming this test is the one that fails. Two of them cover branches that would have failed quietly rather than loudly: - The marker filter has to reach the reference query. Pass a fixed marker set instead of markers_str and nothing in the output looks wrong — occurrences simply attach to tasks --status was meant to exclude. The test asserts the query string, not the result, because the result cannot show this. - A null reference result must not kill the command. LogseqAPI.call returns resp.json() and screens only dicts carrying an "error" key, so a null body arrives as None and the command dies on TypeError while the todo query itself succeeded. Also pinned: the default cap of 10 at the boundary where an off-by-one would show, refusal of a negative --refs-limit (without the guard, deduped[:limit] silently drops the last entry and withheld overcounts), the order of dated against undated occurrences, the original-name/name fallback, and the plain-text branch that reports a count when no date survives the range. Three further cases were written, measured, and dropped again: their plausible mutations turned out behaviour-equivalent, so the tests would have recorded coverage without holding anything. Fixes the error text for a negative --refs-limit, which still said "0 keeps all" after the help text stopped saying it. --- logseq_cli/cli.py | 2 +- tests/test_get_todos.py | 159 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index b26bf90..97eb547 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -3740,7 +3740,7 @@ def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, incl markers.add("DONE") if refs_limit < 0: - fail("--refs-limit must be 0 or greater (0 keeps all).", as_json) + fail("--refs-limit must be 0 or greater (0 lifts the cap).", as_json) markers_str = " ".join(edn_string(m) for m in sorted(markers)) query = ( diff --git a/tests/test_get_todos.py b/tests/test_get_todos.py index b9167c3..64d49fb 100644 --- a/tests/test_get_todos.py +++ b/tests/test_get_todos.py @@ -418,3 +418,162 @@ def test_lifting_the_cap_does_not_zero_the_withheld_count(self): assert len(todo["references"]) == 1, todo["references"] assert todo["references_withheld"] == 3, ( f"--refs-limit 0 must not smuggle out-of-range occurrences in: {todo!r}") + + +class TestGetTodosReferenceEdges: + """Boundaries of the reference machinery. + + Every case here was run against the implementation before being written + down, and each was then checked by breaking the branch it covers and + confirming this test is the one that fails. Cases whose plausible + mutations turned out behaviour-equivalent were dropped rather than kept + as decoration. + """ + + _ORIGIN = ({"content": "TODO carried", "marker": "TODO", "uuid": "u-carried"}, + {"original-name": "Mar 4th, 2026", "journal-day": 20260304}) + + def _run(self, args, ref_rows): + api = _mock_api_for_todos([self._ORIGIN], ref_rows) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + return runner.invoke(cli, ["get-todos", *args, "--json"]), api + + def _refs(self, n, start=20260301): + return [({"uuid": "u-carried"}, + {"original-name": f"day {start + i}", "journal-day": start + i}) + for i in range(n)] + + def test_exactly_at_the_limit_withholds_nothing(self): + """The cap is a maximum, not a threshold: 5 kept under a limit of 5.""" + result, _ = self._run(["--refs-limit", "5"], self._refs(5)) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 5, todo["references"] + assert "references_withheld" not in todo, todo + + def test_the_default_limit_is_ten_and_holds_exactly_ten(self): + """Pins the documented default, at the boundary where off-by-one shows. + + Ten is not inherited from `get-backlinks --limit` (3) — an entry here + is a date, not a block of text, and the measured distribution of a + live graph breaks at ten: a cap of 3 trims 12 of 58 carried tasks, + a cap of 10 trims 4. + """ + result, _ = self._run([], self._refs(10)) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 10, todo["references"] + assert "references_withheld" not in todo, todo + + def test_the_default_limit_withholds_the_eleventh(self): + result, _ = self._run([], self._refs(11)) + todo = _json.loads(result.output)["todos"][0] + assert len(todo["references"]) == 10, todo["references"] + assert todo["references_withheld"] == 1, todo + + def test_a_limit_of_one_keeps_the_newest(self): + result, _ = self._run(["--refs-limit", "1"], self._refs(3)) + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["day 20260303"], todo + assert todo["references_withheld"] == 2, todo + + def test_a_negative_limit_is_refused(self): + """Silently treating -1 as 'keep all' would invert what was asked for.""" + result, _ = self._run(["--refs-limit", "-1"], self._refs(2)) + assert "--refs-limit" in result.output, result.output + assert "0 or greater" in result.output, result.output + + def test_the_marker_filter_reaches_the_reference_query(self): + """Otherwise a DOING query would collect references to TODO blocks. + + Nothing in the output would look wrong — the extra occurrences would + simply attach to tasks the filter was meant to exclude. + """ + _, api = self._run(["--status", "DOING"], []) + ref_query = next(c[0][0] for c in api.datascript_query.call_args_list + if ":block/refs" in c[0][0]) + assert "DOING" in ref_query, ref_query + assert "TODO" not in ref_query, ref_query + + def test_dated_occurrences_sort_ahead_of_undated_ones(self): + """Without a range both kinds are listed, in a fixed order. + + Datalog returns rows unordered, so an unstated order would make the + field differ between two identical calls. + """ + refs = [({"uuid": "u-carried"}, {"original-name": "Zzz Page"}), + ({"uuid": "u-carried"}, + {"original-name": "Mar 19", "journal-day": 20260319}), + ({"uuid": "u-carried"}, {"original-name": "Aaa Page"})] + result, _ = self._run([], refs) + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["Mar 19", "Aaa Page", "Zzz Page"], todo + + def test_a_row_the_query_could_not_fill_is_skipped(self): + """A pull answers None, not {}, for an entity with none of the pulled + attributes — observed on a live graph, on reference pages with no name. + """ + refs = [({"uuid": "u-carried"}, None), + (None, {"original-name": "Mar 19", "journal-day": 20260319}), + ({"uuid": "u-carried"}, + {"original-name": "Mar 19", "journal-day": 20260319})] + result, _ = self._run([], refs) + assert result.exit_code == 0, result.output + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["Mar 19"], todo + + def test_a_page_carrying_only_name_is_still_named(self): + """original-name is absent on pages that were never given one.""" + refs = [({"uuid": "u-carried"}, + {"name": "mar 19", "journal-day": 20260319})] + result, _ = self._run([], refs) + todo = _json.loads(result.output)["todos"][0] + assert todo["references"] == ["mar 19"], todo + + def test_plain_text_reports_a_count_when_no_date_survives(self): + """Every occurrence fell outside the range, so only the number is left. + + Printing nothing would let the task read as though it had not been + touched since the day it was written. + """ + refs = [({"uuid": "u-carried"}, + {"original-name": "Jan 1", "journal-day": 20260101})] + api = _mock_api_for_todos( + [({"content": "TODO carried", "marker": "TODO", "uuid": "u-carried"}, + {"original-name": "Mar 18th, 2026", "journal-day": 20260318})], refs) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke( + cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19"]) + assert "1 other page" in result.output, result.output + + def test_a_null_reference_result_does_not_kill_the_command(self): + """The API hands back whatever the body decoded to, `null` included. + + `LogseqAPI.call` returns `resp.json()` and only screens dicts carrying + an "error" key, so a `null` body reaches the caller as None. Without + the guard the command dies on `TypeError: 'NoneType' is not iterable` + while the todo query itself succeeded. + """ + api = MagicMock() + api.datascript_query.side_effect = lambda q: ( + None if ":block/refs" in q else [self._ORIGIN]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--json"]) + assert result.exit_code == 0, result.output + todo = _json.loads(result.output)["todos"][0] + assert "references" not in todo, todo + + def test_tag_filter_and_references_coexist(self): + """--tag selects tasks by content; occurrences still count graph-wide.""" + api = _mock_api_for_todos( + [({"content": "TODO carried #urgent", "marker": "TODO", "uuid": "u-carried"}, + {"original-name": "Mar 4th, 2026", "journal-day": 20260304})], + [({"uuid": "u-carried"}, + {"original-name": "Mar 19", "journal-day": 20260319})]) + runner = CliRunner() + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + result = runner.invoke(cli, ["get-todos", "--tag", "urgent", "--json"]) + todos = _json.loads(result.output)["todos"] + assert len(todos) == 1, todos + assert todos[0]["references"] == ["Mar 19"], todos[0] From 792cd257e585f31e1fec5ed225700c1249ea461c Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:25:16 +0200 Subject: [PATCH 4/8] docs: show a carried-forward task in the example run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's example block showed get-todos with two ordinary tasks, which is the case that never had a problem. Someone who carries tasks forward by ((block-ref)) — the ordinary way to keep a task alive in Logseq — could read the whole section without learning that the command now follows them. Shows the output rather than describing it: [page] and "also on" side by side say by themselves that origin and occurrence are different things and that both are reported. --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 40d9acd..b46cf7f 100644 --- a/README.md +++ b/README.md @@ -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 From 4f1db76ebf33c41418fc86ec8e0a8bb15a0dd9c3 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:28:45 +0200 Subject: [PATCH 5/8] test: hold the escaping contract for both queries get-todos builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_get_todos_markers checked rec.queries[0]. get-todos now builds two queries, and the second one — the :block/refs read — carries the same markers. The test passes today because both share one string formatted by edn_string, so it holds by accident of the implementation rather than by checking what it claims to check: give the reference query its own formatting and the test stays green while the contract is gone. Checks every query the command built instead. Verified by having _fetch_todo_references format the markers itself, which the assertion now catches and did not before. Also names the cost of following refs in the --help notes, where the other commands that pay for an extra read name theirs (--with-children "one extra API read per match", --with-context "costs no extra read"). It sat only in a code comment and in the CHANGELOG, neither of which a caller reads, and the negative form on --no-follow-refs ("saves one read") is not where someone deciding whether a call is expensive would look. --- logseq_cli/cli.py | 3 ++- tests/test_datalog_quoting.py | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 97eb547..049d671 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -3711,7 +3711,8 @@ def _place_references(occurrences, date_start, date_end, limit: int): --status repeatable. Default: TODO, DOING, NOW, LATER (no DONE). A task carried forward by a block-ref ((uuid)) is found on the day it stands, and reported once: "page" and "uuid" stay the original block, "references" - names the other pages it appears on. --no-follow-refs restores the old reading. + names the other pages it appears on. Following refs costs one extra query for + the whole command, not one per task. --no-follow-refs restores the old reading. Plain-text output: "MARKER [Page] preview" — page name inline, no grouping needed. """) @click.option("--status", multiple=True, default=("TODO", "DOING", "NOW", "LATER"), diff --git a/tests/test_datalog_quoting.py b/tests/test_datalog_quoting.py index 1c97a2e..ca02087 100644 --- a/tests/test_datalog_quoting.py +++ b/tests/test_datalog_quoting.py @@ -275,7 +275,12 @@ def test_get_todos_markers(self): r = split_runner().invoke(cli, ["get-todos", "--status", "TODO", "--json"]) assert r.exit_code == 0, r.output assert rec.queries, "no query was built" - assert edn_string("TODO") in rec.queries[0] + # Every query the command builds, not just the first: get-todos issues + # a second one for :block/refs, and it carries the same markers. They + # share one formatted string today, so checking queries[0] alone would + # keep passing if a later change gave the second query its own. + for q in rec.queries: + assert edn_string("TODO") in q, q def test_smart_query_content_search(self): rec = QueryRecorder(result=[]) From 42b62055571d5be17c14998311f7dc8ab7bea09b Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:28:45 +0200 Subject: [PATCH 6/8] fix: weekly-todos example counted a key the CLI never emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/weekly-todos.sh read `data.get('tasks', [])`, but get-todos --json has answered `{"todos": …, "count": …}` since the initial import. The default swallowed the mismatch: the script printed "Total: 0 open tasks" against any graph, and the per-page breakdown under it stayed empty. That reads as a quiet week, not as a broken example — the same shape of silent wrong answer this tool argues against elsewhere. Reads `data['todos']` now, so a future rename fails loudly instead of counting zero. Two tests pin both halves: the example may only read keys the payload carries, and the payload keeps carrying them. Predates this branch by every commit — found while checking the block-ref work for consistency against the rest of the repo. --- CHANGELOG.md | 16 ++++++++++++++++ examples/weekly-todos.sh | 2 +- tests/test_agent_contract.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6daf12..35e34d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,22 @@ 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. +### 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 diff --git a/examples/weekly-todos.sh b/examples/weekly-todos.sh index c969b1e..459c54a 100755 --- a/examples/weekly-todos.sh +++ b/examples/weekly-todos.sh @@ -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: diff --git a/tests/test_agent_contract.py b/tests/test_agent_contract.py index c3284a9..01bd062 100644 --- a/tests/test_agent_contract.py +++ b/tests/test_agent_contract.py @@ -114,3 +114,34 @@ def test_query_error_body_is_structured_like_transport_errors(self): assert r.stdout == "" payload = json.loads(r.stderr) assert payload["reason"] == "datalog_query_failed" + + +class TestShippedExamplesReadTheRealPayload: + """An example that mis-reads the payload teaches the mistake it makes. + + `examples/weekly-todos.sh` read `data.get('tasks', [])` from the day of the + initial import, while `get-todos --json` has always answered `{"todos": …}`. + The default swallowed it: the script printed "Total: 0 open tasks" against + any graph, which reads as an empty week rather than as a broken script. + """ + + def test_every_example_reads_a_key_the_cli_emits(self): + import pathlib + import re + + payload_keys = {"todos", "count", "repeating_excluded"} + script = (pathlib.Path(__file__).parent.parent + / "examples" / "weekly-todos.sh").read_text() + read_keys = set(re.findall(r"data(?:\.get\(|\[)['\"](\w+)['\"]", script)) + assert read_keys, "no payload access found — did the script change shape?" + assert read_keys <= payload_keys, ( + f"the script reads keys get-todos never emits: {read_keys - payload_keys}") + + def test_get_todos_json_still_uses_those_keys(self): + """Pins the other half: the example is only right while this holds.""" + api = MagicMock() + api.datascript_query.return_value = [] + with patch("logseq_cli.cli.LogseqAPI", return_value=api): + r = split_runner().invoke(cli, ["get-todos", "--json"]) + assert r.exit_code == 0, r.stdout + assert set(json.loads(r.stdout)) == {"todos", "count"} From 3ecabc8dd180698d4383216f8d279a096e2bb602 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:33:16 +0200 Subject: [PATCH 7/8] docs: add an example for the question block-refs made answerable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task's date used to be the day it was written down, so "how long have I been carrying this?" had no answer in the payload. With occurrences resolved it does: references counts the journals a task was pulled into inside the window, references_withheld the ones before it, and their sum is a duration. carried-over-todos.sh reports that, longest-carried first. It reads --json once and passes the payload to both jq stages rather than querying twice, and splits the PAYLOAD declaration from its assignment so a failed read stops the script instead of printing an empty week — verified by pointing it at a closed port and checking the exit code. Run against a real graph before being committed, which is how two wordings got fixed: a task written on the day it stands now reads "not carried" instead of "carried: 0 day(s)", and the summary counts the same threshold as the detail lines rather than one higher. The example-payload test now scans the examples directory instead of naming one file, so the next example is covered the day it is added. --- CHANGELOG.md | 14 ++++++++ README.md | 1 + examples/carried-over-todos.sh | 58 ++++++++++++++++++++++++++++++++++ tests/test_agent_contract.py | 33 +++++++++++++++---- 4 files changed, 100 insertions(+), 6 deletions(-) create mode 100755 examples/carried-over-todos.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e34d4..b6baf6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,20 @@ 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 diff --git a/README.md b/README.md index b46cf7f..bb00df3 100644 --- a/README.md +++ b/README.md @@ -612,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 diff --git a/examples/carried-over-todos.sh b/examples/carried-over-todos.sh new file mode 100755 index 0000000..06da66d --- /dev/null +++ b/examples/carried-over-todos.sh @@ -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 ===" +' diff --git a/tests/test_agent_contract.py b/tests/test_agent_contract.py index 01bd062..cea3bc6 100644 --- a/tests/test_agent_contract.py +++ b/tests/test_agent_contract.py @@ -126,16 +126,37 @@ class TestShippedExamplesReadTheRealPayload: """ def test_every_example_reads_a_key_the_cli_emits(self): + """Checks every shipped example, not just the one that was wrong. + + Scanning the directory rather than a list means a new example is + covered the day it is added, without anyone remembering to extend + this test. + """ import pathlib import re payload_keys = {"todos", "count", "repeating_excluded"} - script = (pathlib.Path(__file__).parent.parent - / "examples" / "weekly-todos.sh").read_text() - read_keys = set(re.findall(r"data(?:\.get\(|\[)['\"](\w+)['\"]", script)) - assert read_keys, "no payload access found — did the script change shape?" - assert read_keys <= payload_keys, ( - f"the script reads keys get-todos never emits: {read_keys - payload_keys}") + examples = sorted((pathlib.Path(__file__).parent.parent + / "examples").glob("*.sh")) + assert examples, "no example scripts found" + + checked = [] + for script_path in examples: + script = script_path.read_text() + if "get-todos" not in script: + continue + # Only top-level access counts: Python's data['x'] / data.get('x'), + # and jq expressions rooted at the payload. A field read inside a + # todo (.content, .page, .references) is a different contract, + # held by the get-todos tests. + read_keys = set(re.findall(r"data(?:\.get\(|\[)['\"](\w+)['\"]", script)) + read_keys |= {m for m in re.findall(r"^\s*\.(\w+)", script, re.M)} + read_keys |= set(re.findall(r"\(\.(\w+)\s*\|\s*length\)", script)) + unknown = read_keys - payload_keys + assert not unknown, ( + f"{script_path.name} reads keys get-todos never emits: {unknown}") + checked.append(script_path.name) + assert checked, "no example exercises get-todos any more" def test_get_todos_json_still_uses_those_keys(self): """Pins the other half: the example is only right while this holds.""" From f024edc6a4a60041a42d98f31cc889a8efd35e7a Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:34:31 +0200 Subject: [PATCH 8/8] test: hold the README's example list to the examples directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list under "Scripting Examples" is a hand-maintained view of a directory, which is the defect this file already guards for the command tables: nothing recomputed it, so a new script is listed only if whoever added it remembered. Adding carried-over-todos.sh was that case — the entry was written by hand and could as easily have been forgotten. Derives the expectation from examples/*.sh instead, in both directions: a script that is not listed fails, and a listed script that no longer exists fails too. Verified by removing an entry and by inventing one. CONTRIBUTING.md names only the directory, not the scripts, so it needs nothing. --- tests/test_readme_documents_options.py | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_readme_documents_options.py b/tests/test_readme_documents_options.py index e1575f2..c0d488e 100644 --- a/tests/test_readme_documents_options.py +++ b/tests/test_readme_documents_options.py @@ -107,3 +107,33 @@ def test_every_command_has_a_row(self): if name not in self.ALIASES and f"`{name}" not in text ] assert not missing, f"commands with no README row: {missing}" + + +class TestShippedExamplesAreListed: + """The README's example list is a view of the examples directory. + + A hand-maintained list of files drifts the moment someone adds one — the + same defect this file already guards for the command tables. Deriving the + expectation from the directory means a new script is either listed or the + suite says so. + """ + + def _example_names(self): + import pathlib + return {p.name for p in (pathlib.Path(__file__).parent.parent + / "examples").glob("*.sh")} + + def _listed_names(self): + import re + return set(re.findall(r"^- `([a-z0-9-]+\.sh)`", _readme_text(), re.M)) + + def test_every_example_is_listed(self): + missing = self._example_names() - self._listed_names() + assert not missing, ( + f"these scripts exist but the README never names them: {sorted(missing)}") + + def test_no_listed_example_is_missing(self): + """The other direction: a removed script must leave the list too.""" + stale = self._listed_names() - self._example_names() + assert not stale, ( + f"the README lists scripts that are not in examples/: {sorted(stale)}")