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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,19 @@ logseq-cli get-todos --page "Project Alpha"
# Filter by tag
logseq-cli get-todos --tag urgent

# Only tasks whose block stands on a journal page (or on any other page: `page`)
logseq-cli get-todos --page-type journal

# Filter by what the task says (regex, case-insensitive)
logseq-cli get-todos --match "review|contract" --json
# --json: {"todos": [{marker, content, page, uuid, journal_day?, ...}], "count": N}

# 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

# Journal tasks older than a date, by the day of their own block
logseq-cli get-todos --page-type journal --no-follow-refs --to 2026-09-01 --json

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

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ file carries most of the reasoning behind the tool.

### Added

- `get-todos --page-type journal|page` keeps the tasks whose block stands on a
journal page, or on any other page (#118). Before, the only way to split the
two was `jq` on `journal_day`, which leaves `count` at the unfiltered value and
has nothing to filter on in text output. The option only narrows: with
`--from/--to` a task still counts as in the range through a journal it was
carried into by a block ref, so a carried task from an ordinary page is a
`page` task that a range can contain. For "journal tasks older than a date" by
their own day, add `--no-follow-refs`, which already measured the range that
way.

- `get-todos --state open|done|cancelled` (repeatable, default `open`) selects
tasks by state, so `--state done` lists the finished ones and `--state
cancelled` both spellings of the cancelled marker (#92).
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,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] [--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. `--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`, 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}`, 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`, 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-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, and whether writes are off (`read_only`). 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
18 changes: 16 additions & 2 deletions logseq_cli/commands/todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ def _given(ctx, name):
logseq-cli --token TOKEN get-todos --page "Projects" --tag urgent
logseq-cli --token TOKEN get-todos --from 2026-05-01 --to 2026-05-31 --state open --state done
logseq-cli --token TOKEN get-todos --due-from today --due-to 2026-05-08
logseq-cli --token TOKEN get-todos --page-type journal --no-follow-refs --to 2025-10-06
Notes:
""" f""" --state {'|'.join(STATES)} (repeatable, default open: {', '.join(markers_in(['open']))}) selects by state;
--status picks single markers and overrides --state.
Expand All @@ -181,6 +182,13 @@ def _given(ctx, name):
help=f"Select single markers instead of states (repeatable, case does not matter): "
f"{', '.join(ORDER)}. Given together with --state, --status applies.")
@click.option("--page", "--name", default=None, help="Filter by page name (substring, case-insensitive)")
@click.option("--page-type", "page_type", default=None,
type=click.Choice(["journal", "page"], case_sensitive=False),
help="Only tasks whose block stands on a journal page ('journal') or on any "
"other page ('page'). The block counts, not the pages it was carried to "
"by ((block-ref)): with --from/--to a task still counts as in the range "
"through such a page. To measure the range by the block's own day, add "
"--no-follow-refs.")
@click.option("--tag", default=None, help="Filter by hashtag (e.g. 'urgent', without #)")
@click.option("--match", "match", default=None, help="Filter by what the task says: a regular expression, case-insensitive, searched in the task text (not its properties)")
@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.")
Expand All @@ -191,11 +199,11 @@ def _given(ctx, name):
@click.option("--refs-limit", "refs_limit", type=int, default=10, show_default=True,
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")
help="Do not resolve block-refs: report only where task blocks live, not where they appear. Saves one read. With --from/--to the range is measured by the day of the page the block lives on")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
@click.pass_context
@handle_connection_error
def get_todos(ctx, state, status, page, tag, match, from_date, to_date, due_from, due_to,
def get_todos(ctx, state, status, page, page_type, tag, match, 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"]
Expand Down Expand Up @@ -294,6 +302,12 @@ def get_todos(ctx, state, status, page, tag, match, from_date, to_date, due_from
page_lower = page.lower()
todos = [t for t in todos if page_lower in t["page"].lower()]

# journal_day is set only for a block on a journal page, so it tells the
# kind of page its block stands on, whatever refs carried it elsewhere.
if page_type:
on_journal = page_type.lower() == "journal"
todos = [t for t in todos if ("journal_day" in t) == on_journal]

# Filter by tag if requested
if tag:
tag_pattern = re.compile(rf"#\b{re.escape(tag)}\b", re.IGNORECASE)
Expand Down
143 changes: 143 additions & 0 deletions tests/test_get_todos_page_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""get-todos --page-type: tasks by the kind of page their block stands on.

Fixture: J1 stands on a journal, P1 on an ordinary page with no reference, P2 on
an ordinary page and is carried into a journal by a ((block-ref)).
"""

import json as _json
from unittest.mock import patch

import pytest
from click.testing import CliRunner

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

J1 = ({"content": "TODO journal task", "marker": "TODO", "uuid": "u-j1"},
{"original-name": "Mar 10th, 2026", "name": "mar 10th, 2026", "journal-day": 20260310})
P1 = ({"content": "TODO plain page task", "marker": "TODO", "uuid": "u-p1"},
{"original-name": "Project Alpha", "name": "project alpha"})
P2 = ({"content": "TODO carried task", "marker": "TODO", "uuid": "u-p2"},
{"original-name": "Project Beta", "name": "project beta"})
P2_ON_MARCH_12 = ({"uuid": "u-p2"},
{"original-name": "Mar 12th, 2026", "name": "mar 12th, 2026",
"journal-day": 20260312})

# The output before the option existed, taken from the unchanged command.
BEFORE_JSON = {
"todos": [
{"marker": "TODO", "content": "journal task", "page": "Mar 10th, 2026",
"uuid": "u-j1", "journal_day": "2026-03-10"},
{"marker": "TODO", "content": "plain page task", "page": "Project Alpha",
"uuid": "u-p1"},
{"marker": "TODO", "content": "carried task", "page": "Project Beta",
"uuid": "u-p2", "references": ["Mar 12th, 2026"]},
],
"count": 3,
}
BEFORE_TEXT = (
"Tasks (3):\n\n"
" TODO [Mar 10th, 2026] journal task\n"
" TODO [Project Alpha] plain page task\n"
" TODO [Project Beta] carried task\n"
" also on: Mar 12th, 2026\n"
)


def _run(args, rows=(J1, P1, P2), refs=(P2_ON_MARCH_12,)):
api = _mock_api_for_todos(list(rows), list(refs))
with patch("logseq_cli.group.LogseqAPI", return_value=api):
return CliRunner().invoke(cli, ["get-todos", *args])


def _uuids(args, **kw):
result = _run([*args, "--json"], **kw)
assert result.exit_code == 0, result.output
return [t["uuid"] for t in _json.loads(result.output)["todos"]]


class TestSelection:
def test_journal_gives_the_journal_task_page_gives_the_others(self): # AK1
assert _uuids(["--page-type", "journal"]) == ["u-j1"]
assert _uuids(["--page-type", "page"]) == ["u-p1", "u-p2"]

def test_count_follows_the_filter(self):
data = _json.loads(_run(["--page-type", "page", "--json"]).output)
assert data["count"] == 2

def test_without_the_option_the_answer_is_what_it_was(self): # AK2
assert _json.loads(_run(["--json"]).output) == BEFORE_JSON
assert _run([]).output == BEFORE_TEXT

def test_a_carried_task_is_a_page_task_even_inside_a_journal_range(self): # AK4
rng = ["--from", "2026-03-11", "--to", "2026-03-13"]
assert _uuids([*rng, "--page-type", "journal"]) == []
assert _uuids([*rng, "--page-type", "page"]) == ["u-p2"]

def test_references_are_left_alone(self):
data = _json.loads(_run(["--page-type", "page", "--json"]).output)
carried = next(t for t in data["todos"] if t["uuid"] == "u-p2")
assert carried["references"] == ["Mar 12th, 2026"]


class TestValue:
def test_an_unknown_value_is_refused_naming_the_choices(self): # AK3a
result = _run(["--page-type", "daily"])
assert result.exit_code == 2
assert "journal" in result.output and "page" in result.output

@pytest.mark.parametrize("spelled,plain", [("Journal", "journal"), ("PAGE", "page")])
def test_case_does_not_matter(self, spelled, plain): # AK3b
assert _uuids(["--page-type", spelled]) == _uuids(["--page-type", plain])


class TestRange:
"""E1: the option only narrows; the range keeps its own rule."""

def _journal_on_march_10(self, with_ref):
refs = ({"uuid": "u-j1"}, {"original-name": "Mar 1st, 2026", "name": "mar 1st, 2026",
"journal-day": 20260301})
return dict(rows=(J1,), refs=(refs,) if with_ref else ())

def test_to_keeps_a_journal_task_with_an_earlier_reference(self): # AK5a
# the existing OR branch: a reference inside the range counts
args = ["--to", "2026-03-05", "--page-type", "journal"]
assert _uuids(args, **self._journal_on_march_10(with_ref=True)) == ["u-j1"]
assert _uuids(args, **self._journal_on_march_10(with_ref=False)) == []

def test_from_keeps_a_journal_task_with_a_later_reference(self): # AK5b
# the existing OR branch: a reference inside the range counts
early = ({"content": "TODO early", "marker": "TODO", "uuid": "u-e"},
{"original-name": "Mar 1st, 2026", "name": "mar 1st, 2026",
"journal-day": 20260301})
late = ({"uuid": "u-e"}, {"original-name": "Mar 10th, 2026", "name": "mar 10th, 2026",
"journal-day": 20260310})
args = ["--from", "2026-03-05", "--page-type", "journal"]
assert _uuids(args, rows=(early,), refs=(late,)) == ["u-e"]
assert _uuids(args, rows=(early,), refs=()) == []

def test_a_carried_ordinary_page_task_is_in_the_range_only_under_page(self): # AK5c
rng = ["--from", "2026-03-11", "--to", "2026-03-13"]
assert "u-p2" not in _uuids([*rng, "--page-type", "journal"])
assert "u-p2" in _uuids([*rng, "--page-type", "page"])

@pytest.mark.parametrize("rng", [[], ["--from", "2026-03-10", "--to", "2026-03-13"]])
def test_the_two_values_split_the_unfiltered_set(self, rng): # AK6
journal = set(_uuids([*rng, "--page-type", "journal"]))
page = set(_uuids([*rng, "--page-type", "page"]))
assert journal and page
assert not journal & page
assert journal | page == set(_uuids(rng))

def test_the_recipe_with_no_follow_refs_dates_by_the_blocks_own_day(self): # AK7
args = ["--no-follow-refs", "--to", "2026-03-10"]
assert _uuids([*args, "--page-type", "journal"]) == ["u-j1"]
assert _uuids([*args, "--page-type", "page"]) == []


class TestWithPage:
def test_both_must_hold(self): # AK8
assert _uuids(["--page", "Alpha", "--page-type", "page"]) == ["u-p1"]
assert _uuids(["--page", "Alpha", "--page-type", "journal"]) == []
assert _uuids(["--page", "Mar", "--page-type", "journal"]) == ["u-j1"]
assert _uuids(["--page", "Mar", "--page-type", "page"]) == []
Loading