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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ file carries most of the reasoning behind the tool.

## [Unreleased]

### Fixed

- `get-todos --help` names `hash`, which every task under `--json` has carried
since 0.17.0 while only the command reference listed it, and both the help
and the command reference now give each field its type. `references` is a
list of page names as strings: a caller that read it as a list of objects
failed on the first task carried forward. A test takes the keys from the
real output and fails when either text leaves one out.

## [0.17.0] - 2026-10-13

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ A write that ends a section (`add-journal-block`, `add-journal-content`,

| Command | Description |
|---------|-------------|
| `get-todos [--page NAME] [--state open\|done\|cancelled] [--status MARKER] [--page-type journal\|page] [--tag TAG] [--match REGEX] [--from DATE] [--to DATE] [--due-from DATE] [--due-to DATE] [--refs-limit N] [--no-follow-refs]` | List tasks (page name shown inline in plain-text output). Without `--state` or `--status` it lists every open task, `WAIT`, `WAITING`, `IN-PROGRESS` and `STARTED` included. `--state` (repeatable: `open`, `done`, `cancelled`, default `open`) selects by state; `--status` (repeatable, case does not matter, any marker Logseq reads) selects single markers instead and wins when both are given, with a note on stderr. Tasks come ordered by marker, open work first and finished tasks last, then by page. `--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, and with `--from/--to` dates a task by the page its block lives on alone. `--page-type journal|page` (case does not matter) keeps the tasks whose block stands on a journal page, or on any other page; a task carried into a journal by a block ref is a `page` task, and a range still counts it through that journal. Journal tasks older than a date, by their own day: `get-todos --page-type journal --no-follow-refs --to DATE`. `--due-from/--due-to` filter by `SCHEDULED`/`DEADLINE` instead. For a repeating task the next occurrence is derived (the date in its text moves on only when the task is ticked off by its checkbox in Logseq) and the range is applied to it; it is reported as `next_due`, and a repeater whose interval cannot be read is left out, counted in `repeating_excluded` and named on stderr. `--match` filters by what the task says (regex, case-insensitive, properties excluded). `--match` runs over the text as stored: `((refs))` are not resolved, and `^`/`$` anchor the whole text unless the pattern starts with `(?m)`. `--json` gives `{"todos": [...], "count": N}`, plus `repeating_excluded` when a due range left out repeaters it could not place; each task has `marker`, `content` (the text without marker, properties, `SCHEDULED`/`DEADLINE` and `LOGBOOK`), `page`, `uuid`, `hash` (as in `get-block`, computed from the stored text, not from `content`), and `journal_day` when the page its block lives on is a journal. `scheduled`, `deadline`, `next_due` (all dates YYYY-MM-DD), `repeating`, `references` and `references_withheld` appear where they apply; a key that does not apply is absent, not null |
| `get-todos [--page NAME] [--state open\|done\|cancelled] [--status MARKER] [--page-type journal\|page] [--tag TAG] [--match REGEX] [--from DATE] [--to DATE] [--due-from DATE] [--due-to DATE] [--refs-limit N] [--no-follow-refs]` | List tasks (page name shown inline in plain-text output). Without `--state` or `--status` it lists every open task, `WAIT`, `WAITING`, `IN-PROGRESS` and `STARTED` included. `--state` (repeatable: `open`, `done`, `cancelled`, default `open`) selects by state; `--status` (repeatable, case does not matter, any marker Logseq reads) selects single markers instead and wins when both are given, with a note on stderr. Tasks come ordered by marker, open work first and finished tasks last, then by page. `--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, and with `--from/--to` dates a task by the page its block lives on alone. `--page-type journal|page` (case does not matter) keeps the tasks whose block stands on a journal page, or on any other page; a task carried into a journal by a block ref is a `page` task, and a range still counts it through that journal. Journal tasks older than a date, by their own day: `get-todos --page-type journal --no-follow-refs --to DATE`. `--due-from/--due-to` filter by `SCHEDULED`/`DEADLINE` instead. For a repeating task the next occurrence is derived (the date in its text moves on only when the task is ticked off by its checkbox in Logseq) and the range is applied to it; it is reported as `next_due`, and a repeater whose interval cannot be read is left out, counted in `repeating_excluded` and named on stderr. `--match` filters by what the task says (regex, case-insensitive, properties excluded). `--match` runs over the text as stored: `((refs))` are not resolved, and `^`/`$` anchor the whole text unless the pattern starts with `(?m)`. `--json` gives `{"todos": [...], "count": N}` (`todos` a list of task objects, `count` a number), plus `repeating_excluded` (a number) when a due range left out repeaters it could not place; each task has `marker`, `content` (the text without marker, properties, `SCHEDULED`/`DEADLINE` and `LOGBOOK`), `page`, `uuid` and `hash` (as in `get-block`, computed from the stored text, not from `content`), all strings. Where they apply: `journal_day` (when the page its block lives on is a journal), `scheduled`, `deadline`, `next_due` (all four strings, YYYY-MM-DD), `repeating` (`true`), `references` (a list of page names: journals newest first, then other pages, which a date range withholds) and `references_withheld` (a number); a key that does not apply is absent, not null |
| `get-properties --page NAME [--property KEY]` | Get page properties, keyed as Logseq stores them (`due-date`, not the API's `dueDate`), with the original text alongside the parsed value |
| `doctor` | Health-check: Python, packages, connectivity, token, API, graph kind, graph, config, whether writes are off (`read_only`) and whether a precondition is required (`require_preconditions`). Exit 0 = ready to read |
| `init [--dry-run] [--force] [--output PATH]` | Write a config file suggested from your graph, with the counts each suggestion rests on. Writes to the config file in use (else the first search path), so a config in `~/.logseq-cli.toml` gets no second file; that file exists, so `--force` is needed, and it keeps `[safety]`. Allowed under `read_only` |
Expand Down
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ 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
and `uuid` keep naming the original block, `references` names the pages 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
Expand Down
18 changes: 11 additions & 7 deletions logseq_cli/commands/todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,17 @@ def _given(ctx, name):
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.
--json: {"todos": [...], "count": N}, plus "repeating_excluded": N when a
due range left out repeaters it could not place. Each task has marker,
content (the task text without its marker, properties, SCHEDULED/DEADLINE
and LOGBOOK), page and uuid; journal_day when the page its block lives on is
a journal; scheduled, deadline, next_due (dates as YYYY-MM-DD), repeating,
references, references_withheld where they apply. A key that does not apply
is absent, not null.
--json: {"todos": [...], "count": N}: todos is a list of task objects, count
a number, plus "repeating_excluded" (a number) when a due range left out
repeaters it could not place. Each task has marker, content, page, uuid and
hash, all strings: content is the task text without its marker,
properties, SCHEDULED/DEADLINE and LOGBOOK; hash is computed from the stored
text, as in get-block. Where they apply: journal_day (when the page its
block lives on is a journal), scheduled, deadline, next_due (all four
strings, YYYY-MM-DD), repeating (true), references (a list of page names:
journals newest first, then other pages, which a date range withholds) and
references_withheld (a number). A key that does not apply is absent, not
null.
--match is a Python regex over that content as stored: ((refs)) are not
resolved, and ^/$ anchor the whole text unless the pattern starts with (?m).
""")
Expand Down
96 changes: 96 additions & 0 deletions tests/test_get_todos_fields_documented.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""get-todos --json: every key the payload can carry is named in --help and docs.

The field list exists twice by hand, in the command's epilog and in its row of
docs/commands.md, and the two had already drifted: ``hash`` was in the docs and
missing from --help. The keys are therefore taken from real output, not from a
third hand-kept list, so a new field fails here until both texts name it.
"""
import json
import re
from pathlib import Path
from unittest.mock import patch

from logseq_cli.cli import cli
from tests.conftest import split_runner
from tests.test_get_todos import _mock_api_for_todos

DOCS = Path(__file__).resolve().parent.parent / "docs" / "commands.md"

# One task that carries every optional field: it stands on a journal page, is
# scheduled and due with a readable repeater, and is referenced from two other
# journals, of which --refs-limit 1 keeps one.
_TASK = ({"content": "TODO weekly review\nSCHEDULED: <2026-01-05 Mon ++1w>\n"
"DEADLINE: <2026-01-09 Fri ++1w>",
"marker": "TODO", "uuid": "u-full",
"scheduled": 20260105, "deadline": 20260109, "repeated?": True},
{"original-name": "Jan 2nd, 2026", "name": "jan 2nd, 2026",
"journal-day": 20260102})
_REFS = [({"uuid": "u-full"}, {"original-name": "Jan 12th, 2026", "journal-day": 20260112}),
({"uuid": "u-full"}, {"original-name": "Jan 19th, 2026", "journal-day": 20260119})]
# A repeater whose interval cannot be read: only a due range reports it, as
# repeating_excluded, so it needs a call of its own.
_OPAQUE = ({"content": "TODO opaque repeat", "marker": "TODO", "uuid": "u-opaque",
"scheduled": 20260105, "repeated?": True},
{"original-name": "Work", "name": "work"})


def _payload(args, todo_rows, ref_rows=()):
with patch("logseq_cli.group.LogseqAPI",
return_value=_mock_api_for_todos(list(todo_rows), list(ref_rows))):
result = split_runner().invoke(cli, ["get-todos", *args, "--json"])
assert result.exit_code == 0, result.output
return json.loads(result.stdout)


def _emitted_keys():
full = _payload(["--refs-limit", "1"], [_TASK], _REFS)
excluded = _payload(["--due-from", "2026-01-01", "--due-to", "2026-12-31"], [_OPAQUE])
keys = set(full) | set(excluded)
for todo in full["todos"]:
keys |= set(todo)
return keys


def _json_part(text, start):
"""The stretch of ``text`` that describes the payload, from ``start`` to
"not null". Only there does naming a key count: elsewhere ``references``
turns up in --refs-limit and ``page`` in --page, so a key dropped from the
payload description would still be found."""
begin = text.index(start)
end = re.compile(r"not\s+null").search(text, begin)
assert end, f"no 'not null' after {start!r}"
return text[begin:end.start()]


def _help_text():
result = split_runner().invoke(cli, ["get-todos", "--help"])
assert result.exit_code == 0, result.output
return _json_part(result.stdout, "--json:")


def _docs_row():
rows = [line for line in DOCS.read_text(encoding="utf-8").splitlines()
if line.startswith("| `get-todos")]
assert len(rows) == 1, f"expected one get-todos row in {DOCS.name}, found {len(rows)}"
return _json_part(rows[0], "`--json` gives")


def test_the_fixture_reaches_every_optional_field():
"""Guards the two tests below: a key the fixture never produces is never checked."""
assert {"journal_day", "scheduled", "deadline", "next_due", "repeating",
"references", "references_withheld", "repeating_excluded",
"hash"} <= _emitted_keys()


def test_help_names_every_json_key():
text = _help_text()
missing = sorted(k for k in _emitted_keys() if not re.search(rf"\b{k}\b", text))
assert not missing, f"get-todos --help does not name: {missing}"


def test_docs_name_every_json_key():
# Inside a code span, so a key counts only where the row names it as one:
# `hash` or `{"todos": [...], "count": N}`, not the word in a sentence.
spans = " ".join(re.findall(r"`([^`]+)`", _docs_row()))
missing = sorted(k for k in _emitted_keys() if not re.search(rf"\b{k}\b", spans))
assert not missing, f"{DOCS.name} get-todos row does not name: {missing}"
Loading