From b6f7d69ac100bdf7e20880dd786f676810a3322a Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:42:08 +0200 Subject: [PATCH 01/15] Add the tasks module: one place for what counts as a task The package kept six marker lists, none complete, and counted tasks two ways: get-todos from the database marker, analyze-* from a pattern over the page text. logseq_cli/tasks.py is now the one place for the eleven markers Logseq's parser reads (measured with mldoc 1.5.7, Logseq 0.10.15), their three states as Logseq draws them (open, done, cancelled), and how a marker is read from and swapped in a block's text. get-todos takes the task text from tasks.task_text; the code moved over unchanged, including how it reads an unclosed :LOGBOOK: and one inside a code fence. No command behaves differently, and --help of every command is byte-identical. tasks.STATES is the three states in the order of the markers, derived once from STATE. blocktext now owns the rule for planning lines (PLANNING_LINE_RE moved from dates) and attached_line_mask, the one definition of which lines belong to the block above. Nothing reads the mask yet. tests/test_marker_lists.py scans the package for a second marker list. It lists the twelve places where one still sits; later changes remove them. tasks joins the modules that need no API (ADR 0003, package layering test), and CONTEXT.md defines Task, Marker and Task State. Mutations tried, each caught by: - attached_line_mask ignoring code blocks: planning line in a code block - property lines in attached_line_mask: the disjointness test - unclosed :LOGBOOK: read as a drawer: opener without closer is text - planning regex without "<": no angle bracket - STARTED dropped from ORDER: test_order - CANCELLED as open: test_states - no heading prefix, tab as separator, list character before the marker: the marker_of cases - lower-case marker before a newline: marker_before_newline cases - trailing space after a swap at the end of the text: with_marker cases - box check on every line, or behind a list character or heading: the starts_with_box cases - task_text built on attached_line_mask, marker removed on every line, properties kept: the task_text cases - a count in the exception list raised, a new marker list added: the scan - an api parameter in tasks.py: the layering test Existing tests changed: only PURE and the docstring in test_package_layering.py. Part of #92. --- CONTEXT.md | 20 + CONTRIBUTING.md | 3 +- ...shared-code-is-split-by-what-it-decides.md | 8 +- logseq_cli/blocktext.py | 28 +- logseq_cli/commands/todos.py | 33 +- logseq_cli/dates.py | 5 - logseq_cli/render.py | 5 +- logseq_cli/tasks.py | 156 ++++ tests/test_marker_lists.py | 180 ++++ tests/test_package_layering.py | 6 +- tests/test_property_line_rule.py | 42 + tests/test_tasks.py | 871 ++++++++++++++++++ 12 files changed, 1308 insertions(+), 49 deletions(-) create mode 100644 logseq_cli/tasks.py create mode 100644 tests/test_marker_lists.py create mode 100644 tests/test_tasks.py diff --git a/CONTEXT.md b/CONTEXT.md index 8ce0b83..58d0349 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -66,6 +66,26 @@ _Avoid_: link (for the whole family) kind of Reference that points at a Block rather than a page. _Avoid_: block reference link, embed +**Task**: +A Block Logseq reads as a task: one with a Marker. Text that only looks like +one — `TODO:`, a lower-case `todo`, `TODO` followed by a line break, a +Markdown checkbox — is not a Task. +_Avoid_: todo (for the Block), checkbox, to-do item + +**Marker**: +The keyword at the start of a Task's text that states where it stands — +`TODO`, `DOING`, `DONE` and others. Logseq's parser fixes which keywords are +Markers; the graph's config only picks the ones new Tasks get. +_Avoid_: status keyword, workflow keyword + +**Task State**: +Where a Task stands, read from its Marker as Logseq draws it: open (an empty +box for `NOW`, `LATER`, `TODO`, `DOING`, `IN-PROGRESS`, `WAIT` and `WAITING`; +`STARTED`, which Logseq parses but draws no box for, counts as open too), done (a ticked box: +`DONE`) or cancelled (no box, struck through: `CANCELED`, `CANCELLED`). +Cancelled is not done. +_Avoid_: status (unqualified), complete, closed + ### Guarantees the tool makes **Refusal**: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 830780b..3799c40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ logseq-cli/ ├── logseq_cli/ │ ├── api.py # HTTP API client: the methods it may send, the editor gate, the proof of each write │ ├── blockprops.py # Property keys and values: what Logseq reads back, what a write keeps -│ ├── blocktext.py # How Logseq reads a block's lines: code blocks, block boundaries, property and id:: lines +│ ├── blocktext.py # How Logseq reads a block's lines: code blocks, block boundaries, property, drawer, planning and id:: lines │ ├── cliinput.py # --content, --content-file and --tree, taken from the command line │ ├── config.py # Config file discovery, loading and lookup │ ├── datalog.py # EDN/datalog query building (value quoting, keywords) @@ -41,6 +41,7 @@ logseq-cli/ │ ├── render.py # Blocks to text; finding and resolving references │ ├── safety.py # What keeps a command from writing: [safety] read_only, the gate every writing command carries │ ├── strictinsert.py # Strict Insert: trees and --keep-ids writes land where sent; whether a move is possible; which empty block a write at the end goes before +│ ├── tasks.py # What a task is: the eleven markers, their states, reading and swapping a marker │ ├── writerefused.py # The ways a write ends without being done or proven, each with its reason │ ├── commands/ # One module per group of commands │ │ ├── pages.py # create/get/search/rename/delete a page diff --git a/docs/adr/0003-shared-code-is-split-by-what-it-decides.md b/docs/adr/0003-shared-code-is-split-by-what-it-decides.md index 27bc411..7563ebc 100644 --- a/docs/adr/0003-shared-code-is-split-by-what-it-decides.md +++ b/docs/adr/0003-shared-code-is-split-by-what-it-decides.md @@ -6,7 +6,8 @@ each one answers: `dates`, `headings`, `outlinetext`, `ids`, `cliinput`, `datalog` and `render`. They replaced `helpers.py`, 2165 lines that held date parsing, the outline parser, the `id::` contract, Strict Insert, property rules and graph lookups side by side. The modules sit flat in `logseq_cli/`, and a -caller imports a name from the module that defines it. +caller imports a name from the module that defines it. `tasks` came later, for +the question what a task is, and is split by the same rule. ## Considered Options @@ -45,8 +46,9 @@ tests anyway. A trial run with the re-export in place still failed two tests module whose question it answers. If none fits, it gets a new module named for its own question, not another collection. -The split was not made by test level, but three of the modules turned out to -need no API: `dates`, `outlinetext` and `cliinput`. Their docstrings say so. +The split was not made by test level, but four of the modules need no +API: `dates`, `outlinetext`, `cliinput` and, added later, `tasks`. Their +docstrings say so. `tests/test_package_layering.py` keeps it true: none of them may take `api` or import a module that does. The same file also checks that the import graph of the package has no cycle, counting imports inside functions. Python accepts some diff --git a/logseq_cli/blocktext.py b/logseq_cli/blocktext.py index 0d9eeba..d469f8e 100644 --- a/logseq_cli/blocktext.py +++ b/logseq_cli/blocktext.py @@ -62,6 +62,10 @@ def code_block_lines(lines: list) -> tuple: _INDENT = r'[ \t\f\r]*' PROPERTY_LINE_RE = re.compile(rf'^{_INDENT}(?!#)([^\s{PROPERTY_KEY_STOP}]+)::(?: |$)') # group 1: the key +# A SCHEDULED or DEADLINE line of a block: get-todos leaves it out of a task's +# text, and a resolved Block Ref shows its date with the title. +PLANNING_LINE_RE = re.compile(r"^\s*(SCHEDULED|DEADLINE):\s*<") + def property_line_mask(lines: list) -> list: """For each line of a block's content, whether Logseq reads it as a property. @@ -164,7 +168,7 @@ def without_block_ids(content: str) -> str: return "\n".join(line for line, value in id_lines(content) if not value) -def _drawer_lines(lines: list) -> set: +def drawer_lines(lines: list) -> set: """Indexes of the lines of every closed ``:LOGBOOK:`` drawer outside code. An opener that no ``:END:`` closes is text, as for a fence: taking it for @@ -183,6 +187,22 @@ def _drawer_lines(lines: list) -> set: return drawer +def attached_line_mask(lines: list) -> list: + """For each line of a block's content, whether it belongs to the block + above it rather than to its text: a SCHEDULED/DEADLINE line, or a line of + a closed ``:LOGBOOK:`` drawer (opener and ``:END:`` included), in either + case outside a code block (code_block_lines). + + An opener that no ``:END:`` closes is text, not a drawer (mldoc 1.5.7 + reads it as a paragraph). Property lines are not in this mask; they have + property_line_mask, and a reader that needs both combines the two. + """ + inside, _ = code_block_lines(lines) + drawer = drawer_lines(lines) + return [i in drawer or (not inside[i] and bool(PLANNING_LINE_RE.match(line))) + for i, line in enumerate(lines)] + + def normalize_block_text(text: str) -> str: """``text`` as a write's proof compares it: what Logseq rewrites on its own is taken out, on the sent and the read side alike. @@ -209,7 +229,7 @@ def normalize_block_text(text: str) -> str: write through that did not land. """ lines = without_block_ids(text).split("\n") - drawer = _drawer_lines(lines) + drawer = drawer_lines(lines) kept = [line.rstrip() for i, line in enumerate(lines) if i not in drawer] return "\n".join(kept).strip() @@ -223,8 +243,8 @@ def block_text_matches(sent: str, read: str) -> bool: """ if normalize_block_text(sent) != normalize_block_text(read): return False - had_drawer = bool(_drawer_lines(sent.split("\n"))) - return not had_drawer or bool(_drawer_lines(read.split("\n"))) + had_drawer = bool(drawer_lines(sent.split("\n"))) + return not had_drawer or bool(drawer_lines(read.split("\n"))) # A Block Ref is what Logseq gives the target an Id Line for (#95), measured diff --git a/logseq_cli/commands/todos.py b/logseq_cli/commands/todos.py index 95e34b2..e9e939c 100644 --- a/logseq_cli/commands/todos.py +++ b/logseq_cli/commands/todos.py @@ -4,10 +4,9 @@ import click -from logseq_cli.blocktext import pointer_target, property_line_mask, refuse_split_block +from logseq_cli.blocktext import pointer_target, refuse_split_block from logseq_cli.datalog import edn_string from logseq_cli.dates import ( - PLANNING_LINE_RE, journal_day_to_date, next_occurrence, parse_date_keyword, @@ -18,6 +17,7 @@ from logseq_cli.notes import print_note from logseq_cli.output import fail, follow_page, handle_connection_error, output from logseq_cli.safety import WriteCommand +from logseq_cli.tasks import task_text _TODO_MARKERS = {"TODO", "DOING", "DONE", "LATER", "NOW", "CANCELED", "WAIT", "WAITING"} @@ -239,34 +239,7 @@ def get_todos(ctx, status, page, tag, match, from_date, to_date, due_from, due_t page_name = page_data.get("original-name") or page_data.get("name", "") journal_day = page_data.get("journal-day") or page_data.get("journalDay") - # Strip properties (key:: value), the SCHEDULED/DEADLINE lines and the - # LOGBOOK drawer. Those are metadata of the task, not the task: left in, - # a repeating task reported on stderr printed its own timestamp line and - # a ":LOGBOOK:" fragment instead of what it says. - content_lines = [] - in_logbook = False - raw_lines = content.split("\n") - for line, is_property in zip(raw_lines, property_line_mask(raw_lines)): - stripped = line.strip() - if stripped == ":LOGBOOK:": - in_logbook = True - continue - if stripped == ":END:": - in_logbook = False - continue - if in_logbook: - continue - if is_property: - continue - if PLANNING_LINE_RE.match(line): - continue - content_lines.append(line) - clean_content = "\n".join(content_lines).strip() - # Strip the leading marker ("TODO some task" -> "some task"). Which word - # that is comes from :block/marker, not from a list kept here: the list - # had drifted and missed CANCELED and WAIT. - if marker: - clean_content = re.sub(rf"^{re.escape(marker)}(?:\s+|$)", "", clean_content) + clean_content = task_text(content, marker) record = { "marker": marker, diff --git a/logseq_cli/dates.py b/logseq_cli/dates.py index 2838f49..e66c922 100644 --- a/logseq_cli/dates.py +++ b/logseq_cli/dates.py @@ -88,11 +88,6 @@ def journal_day_to_date(jd: int) -> datetime.date: return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:8])) -# A SCHEDULED or DEADLINE line of a block: get-todos leaves it out of a task's -# text, and a resolved Block Ref shows its date with the title. -PLANNING_LINE_RE = re.compile(r"^\s*(SCHEDULED|DEADLINE):\s*<") - - # Repeating tasks # --------------- # Logseq stores a repeater's date as written in the text, and :block/scheduled diff --git a/logseq_cli/render.py b/logseq_cli/render.py index bbf680a..05f029f 100644 --- a/logseq_cli/render.py +++ b/logseq_cli/render.py @@ -14,9 +14,8 @@ """ import re -from logseq_cli.blocktext import (BLOCK_REF_RE, PROPERTY_LINE_RE, code_block_lines, is_fence, - property_line_mask) -from logseq_cli.dates import PLANNING_LINE_RE +from logseq_cli.blocktext import (BLOCK_REF_RE, PLANNING_LINE_RE, PROPERTY_LINE_RE, + code_block_lines, is_fence, property_line_mask) from logseq_cli.headings import normalize_heading from logseq_cli.outlinetext import bullet_lines diff --git a/logseq_cli/tasks.py b/logseq_cli/tasks.py new file mode 100644 index 0000000..b160408 --- /dev/null +++ b/logseq_cli/tasks.py @@ -0,0 +1,156 @@ +"""What a task is, and how its marker is read and swapped. + +Logseq decides what a task is with its parser (mldoc, 1.5.7 in Logseq +0.10.15), never with a pattern over text; this module holds the one place the +CLI states that rule: the eleven markers mldoc reads, their grouping into +three states, and how a marker is found at the start of a block's text and +replaced. Measured against mldoc 1.5.7 and Logseq 0.10.15. + +Nothing here talks to Logseq, so all of it is tested without a mock; the +layering tests keep it that way (ADR 0003). +""" +import re + +from logseq_cli.blocktext import PLANNING_LINE_RE, property_line_mask +from logseq_cli.datalog import edn_string + +# The words mldoc reads as :block/marker, in the order a list of tasks is +# shown: what is going on first, finished and cancelled last. STARTED is read +# by mldoc, although Logseq's own marker-pattern does not name it. +ORDER = ("DOING", "NOW", "IN-PROGRESS", "STARTED", "TODO", "LATER", "WAIT", "WAITING", + "DONE", "CANCELED", "CANCELLED") + +# A repeating task has the state of its marker: TODO with a repeater is open, +# DONE with a repeater is done. There is no extra rule for repeaters. +STATE = { + "DOING": "open", "NOW": "open", "IN-PROGRESS": "open", "STARTED": "open", + "TODO": "open", "LATER": "open", "WAIT": "open", "WAITING": "open", + "DONE": "done", + "CANCELED": "cancelled", "CANCELLED": "cancelled", +} + +# The three states, in the order ORDER meets them: derived, so a state added +# to STATE reaches every reader of it. +STATES = tuple(dict.fromkeys(STATE[m] for m in ORDER)) + +# What Logseq offers in its own UI: every marker but STARTED. +FRONTEND_MARKERS = tuple(m for m in ORDER if m != "STARTED") + +# Derived from ORDER, longest first, so WAITING is tried before WAIT. +_WORD = "|".join(re.escape(m) for m in sorted(ORDER, key=len, reverse=True)) + +# A heading prefix: hashes, then spaces only. mldoc 1.5.7 also reads +# "##TODO x" as a task; this is stricter on purpose, because with_marker +# writes on what these functions read. +_PREFIX = r"#+ +" + +# Measured with mldoc 1.5.7: a marker counts at the start of the text (after +# an optional heading prefix), in capitals, followed by a space or the end of +# the text. "TODO\nnotes" and "TODO\tx" are no tasks, "TODO \nnotes" is one. +_MARKER_RE = re.compile(rf"^({_PREFIX})?({_WORD})(?: |\Z)") +_BEFORE_NEWLINE_RE = re.compile(rf"^(?:{_PREFIX})?({_WORD})\n") +_BOX_RE = re.compile(r"\s*\[[ xX]\]") +_VAR_RE = re.compile(r"^\?[A-Za-z][A-Za-z0-9_-]*\Z") + + +def markers_in(states) -> list: + """The markers of the given states, in ORDER, however the states come. + + Raises ValueError for a state that is none of open, done, cancelled. + """ + states = set(states) + unknown = states - set(STATE.values()) + if unknown: + raise ValueError(f"unknown task state: {sorted(unknown)[0]!r}") + return [m for m in ORDER if STATE[m] in states] + + +def marker_of(text: str): + """The marker mldoc reads at the start of a block's text, or None.""" + m = _MARKER_RE.match(text) + return m.group(2) if m else None + + +def marker_before_newline(text: str): + """The marker when it is directly followed by a line break, else None. + + mldoc reads no task there ("TODO\\nnotes"), but the text is one a marker + change would have to treat with care. + """ + m = _BEFORE_NEWLINE_RE.match(text) + return m.group(1) if m else None + + +def with_marker(text: str, marker: str) -> str: + """``text`` with its marker replaced by ``marker``. + + The heading prefix stays as written; the spaces and tabs after the old + marker become one space, none at the end of the text. A line break is + never replaced. Raises ValueError for a marker outside ORDER and for a + text without a marker: a swap must not put a marker on a block that is no + task. + """ + if marker not in ORDER: + raise ValueError(f"unknown marker: {marker!r}") + m = _MARKER_RE.match(text) + if not m: + raise ValueError("text has no marker to replace") + head = m.group(1) or "" + rest = text[m.end(2):].lstrip(" \t") + return head + marker + ("" if rest == "" else " " + rest) + + +def starts_with_box(content: str) -> bool: + """Whether the first line opens with a checkbox: ``[ ]``, ``[x]``, ``[X]``.""" + return bool(_BOX_RE.match(content.split("\n", 1)[0])) + + +def marker_clause(var: str, markers) -> str: + """A Datalog clause that holds for the given markers, in the given order. + + Raises ValueError for a variable that is not ``?name``, an empty list (an + empty set would match nothing, silently) and a marker outside ORDER. + """ + if not _VAR_RE.match(var): + raise ValueError(f"not a Datalog variable: {var!r}") + markers = list(markers) + if not markers: + raise ValueError("no markers") + for m in markers: + if m not in ORDER: + raise ValueError(f"unknown marker: {m!r}") + return f'[(contains? #{{{" ".join(edn_string(m) for m in markers)}}} {var})]' + + +def task_text(content: str, marker) -> str: + """The text of a task: ``content`` without its properties, its + SCHEDULED/DEADLINE lines and its logbook drawer, and without the marker. + + Those lines are metadata of the task, not the task: left in, a repeating + task reported on stderr printed its own timestamp line and a ``:LOGBOOK:`` + fragment instead of what it says. Which word the marker is comes from the + DB marker (``marker``), not from a list kept here: the list had drifted + and missed CANCELED and WAIT. Without a marker nothing is removed. + """ + content_lines = [] + in_logbook = False + raw_lines = content.split("\n") + for line, is_property in zip(raw_lines, property_line_mask(raw_lines)): + stripped = line.strip() + if stripped == ":LOGBOOK:": + in_logbook = True + continue + if stripped == ":END:": + in_logbook = False + continue + if in_logbook: + continue + if is_property: + continue + if PLANNING_LINE_RE.match(line): + continue + content_lines.append(line) + clean = "\n".join(content_lines).strip() + if marker: + clean = re.sub(rf"^{re.escape(marker)}(?:\s+|$)", "", clean) + return clean diff --git a/tests/test_marker_lists.py b/tests/test_marker_lists.py new file mode 100644 index 0000000..295ac83 --- /dev/null +++ b/tests/test_marker_lists.py @@ -0,0 +1,180 @@ +"""One list of task markers: tasks.ORDER and tasks.STATE. + +Before them the code kept six lists of its own, and none was complete. This +reads the source of every module but tasks.py and finds each place where two +or more different marker words stand as whole words in one string literal or +one collection (a set, list, tuple, the keys of a dict): Datalog strings and +regexes included. EXCEPTIONS names the places that are still to be replaced; +the test wants the counts equal, so a new list in a module with an old +exception does not pass as "one of the old ones". + +Prose is not a list. Docstrings and the first argument of ``fail`` are left +out; in ``help=`` and ``epilog=`` only an enumeration counts (two markers in a +row, separated by blanks, commas or slashes), not a sentence or an example +line, and not an arrow such as TODO->DONE. +""" +import ast +import collections +import pathlib +import re + +import pytest + +import logseq_cli.cli + +PACKAGE = pathlib.Path(logseq_cli.cli.__file__).parent + +# IN-PROGRESS first, so that it counts as one word. +MARKERS = ("IN-PROGRESS", "NOW", "LATER", "TODO", "DOING", "WAITING", "WAIT", + "STARTED", "DONE", "CANCELED", "CANCELLED") +# A whole word: no letter, digit or underscore before or after. A hyphen is a +# boundary, so "TODO->DONE" holds TODO and DONE; WAIT does not count inside WAITING. +_ALT = "|".join(MARKERS) +WORD = re.compile(rf"(? places still holding a marker list of their own. +EXCEPTIONS = { + ("commands/todos.py", "_TODO_MARKERS"): 1, + ("commands/todos.py", "get_todos"): 4, + ("commands/todos.py", "set_todo_status"): 2, + ("commands/edit.py", "update_block"): 1, + ("commands/query.py", "smart_query"): 1, + ("commands/analysis.py", "analyze_graph"): 1, + ("commands/analysis.py", "analyze_journal_patterns"): 2, +} + + +def _words(text): + return {m.group(1) for m in WORD.finditer(text)} + + +def _literal_text(node): + """The text of a str literal; of an f-string, its constant parts.""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.JoinedStr): + return "".join(v.value for v in node.values + if isinstance(v, ast.Constant) and isinstance(v.value, str)) + return None + + +def _is_fail(call): + func = call.func + return (isinstance(func, ast.Name) and func.id == "fail") or \ + (isinstance(func, ast.Attribute) and func.attr == "fail") + + +def sites(tree): + """The places of a module as (line, markers).""" + skip, enumerations, covered = set(), set(), set() + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + first = node.body[0] if node.body else None + if isinstance(first, ast.Expr) and _literal_text(first.value) is not None: + skip.add(id(first.value)) + elif isinstance(node, ast.Call): + if _is_fail(node) and node.args: + skip.update(id(n) for n in ast.walk(node.args[0])) + for kw in node.keywords: + if kw.arg in ("help", "epilog") and _literal_text(kw.value) is not None: + enumerations.add(id(kw.value)) + elif isinstance(node, ast.JoinedStr): + # Its constant parts count with it, once, not again one by one. + covered.update(id(v) for v in node.values) + found = [] + for node in ast.walk(tree): + if isinstance(node, (ast.Set, ast.List, ast.Tuple, ast.Dict)): + elts = node.keys if isinstance(node, ast.Dict) else node.elts + elts = [e for e in elts if e is not None and id(e) not in skip] + texts = [_literal_text(e) for e in elts] + hit = set() + for t in texts: + hit |= _words(t or "") + if len(hit) >= 2: + found.append((node.lineno, hit)) + covered.update(id(n) for e in elts for n in ast.walk(e)) + for node in ast.walk(tree): + if id(node) in covered or id(node) in skip: + continue + text = _literal_text(node) + if text is None: + continue + if id(node) in enumerations: + if ENUMERATION.search(text): + found.append((node.lineno, _words(text))) + elif len(_words(text)) >= 2: + found.append((node.lineno, _words(text))) + return sorted(found, key=lambda s: s[0]) + + +def _symbol(tree, line): + """The module-level function, class or variable holding ``line``.""" + for node in tree.body: + start = min([node.lineno] + [d.lineno for d in getattr(node, "decorator_list", [])]) + if start <= line <= node.end_lineno: + if hasattr(node, "name"): + return node.name + targets = getattr(node, "targets", None) or [getattr(node, "target", None)] + if isinstance(targets[0], ast.Name): + return targets[0].id + return "" + return "" + + +def found_in_package(): + found = collections.Counter() + for path in sorted(PACKAGE.rglob("*.py")): + if path.name == "tasks.py": + continue + tree = ast.parse(path.read_text(encoding="utf-8")) + for line, _ in sites(tree): + found[(path.relative_to(PACKAGE).as_posix(), _symbol(tree, line))] += 1 + return found + + +def test_no_module_but_tasks_keeps_a_marker_list(): + found = collections.Counter(found_in_package()) + more = {k: n - EXCEPTIONS.get(k, 0) for k, n in found.items() if n > EXCEPTIONS.get(k, 0)} + fewer = {k: n - found.get(k, 0) for k, n in EXCEPTIONS.items() if n > found.get(k, 0)} + assert not more, f"more found than allowed, a new marker list; use tasks.ORDER/STATE: {more}" + assert not fewer, f"fewer found than allowed, an outdated entry; lower the count or drop it: {fewer}" + + +def _count(source): + return len(sites(ast.parse(source))) + + +class TestTheScanFindsWhatItShould: + """The scanner on source of its own, so that it catches and not only counts. + Every case is an assignment: an expression alone as a statement is a + docstring, and docstrings are left out.""" + + @pytest.mark.parametrize("source,expected", [ + ('x = {"TODO", "DONE"}', 1), + ('x = ("TODO", "TODO")', 0), + ('x = "a TODO and DONE b"', 1), + ('x = "std::TODO_DONE"', 0), + ('x = "TODO->DONE"', 1), + ('x = f"{x} TODO DONE"', 1), + ('x = f"{TODO} and {DONE}"', 0), + ('x = "WAITING for"', 0), + ('x = ["TODO", "DONE"]', 1), + ('x = {"TODO": 1, "DONE": 2}', 1), + ('"""TODO and DONE"""', 0), + ('class A:\n """TODO and DONE"""', 0), + ('def f():\n """TODO and DONE"""', 0), + ('fail("TODO or DONE")', 0), + ('output.fail("TODO or DONE")', 0), + ('@cli.command(epilog="TODO->DONE")\ndef f(): pass', 0), + ('@click.option(help="TODO, DOING")\ndef f(): pass', 1), + ('@click.option(help="TODO/DOING")\ndef f(): pass', 1), + ('@click.option(help="TODO DOING")\ndef f(): pass', 1), + ('@click.option(help="only the marker DOING separates it from TODO")\ndef f(): pass', 0), + ('@click.option(help="TODO, " "DOING")\ndef f(): pass', 1), + ('@click.option(help=f"{x} TODO, DOING")\ndef f(): pass', 1), + ('@cli.command(epilog="get-todos --status TODO --status DOING")\ndef f(): pass', 0), + ]) + def test_scan(self, source, expected): + assert _count(source) == expected diff --git a/tests/test_package_layering.py b/tests/test_package_layering.py index c69e700..31cfeab 100644 --- a/tests/test_package_layering.py +++ b/tests/test_package_layering.py @@ -159,7 +159,7 @@ def visit(name): # Written out, not derived: the list is a decision, and adding or removing a # module here should be a visible change. -PURE = ("dates", "outlinetext", "cliinput") +PURE = ("dates", "outlinetext", "cliinput", "tasks") def _takes_api(path): @@ -181,8 +181,8 @@ def _takes_api(path): def test_the_pure_modules_need_no_logseq_api(): - """These three need no LogseqAPI, and their docstrings and ADR 0003 say - so. That was not why they were cut out, but it keeps them testable without + """The modules in PURE need no LogseqAPI, and their docstrings and ADR 0003 + say so. That was not why they were cut out, but it keeps them testable without a mock. Code that needs the API belongs elsewhere.""" api_side = {_module_name(p) for p in PACKAGE.glob("*.py") if _takes_api(p)} offenders = {} diff --git a/tests/test_property_line_rule.py b/tests/test_property_line_rule.py index 8c28fc5..2e5d8a3 100644 --- a/tests/test_property_line_rule.py +++ b/tests/test_property_line_rule.py @@ -226,6 +226,48 @@ def test_replace_text_leaves_a_property_after_an_unclosed_fence(self): "A call doc\n```\nkind:: meeting" +SCHED = "SCHEDULED: <2026-09-25 Fri>" +CLOCK = "CLOCK: [2026-09-29 Tue 10:00:00]--[2026-09-29 Tue 10:05:00] => 00:05:00" +ATTACHED = [ + ("scheduled", ["TODO x", SCHED], [False, True]), + ("deadline", ["TODO x", "DEADLINE: <2026-09-25 Fri>"], [False, True]), + ("indented", ["TODO x", " DEADLINE: <2026-09-25 Fri>"], [False, True]), + ("no angle bracket", ["TODO x", "SCHEDULED: soon"], [False, False]), + ("closed drawer", ["TODO x", ":LOGBOOK:", CLOCK, ":END:", "after"], + [False, True, True, True, False]), + ("indented drawer", ["TODO x", " :LOGBOOK:", + ' * State "DONE" from "TODO" [2026-09-29 Tue 10:00]', " :END:"], + [False, True, True, True]), + ("opener without closer is text", ["TODO x", ":LOGBOOK:", "no end"], [False, False, False]), + ("closer without opener is text", ["TODO x", ":END:", "y"], [False, False, False]), + ("drawer in a code block", ["TODO x", "```", ":LOGBOOK:", CLOCK, ":END:", "```"], + [False] * 6), + ("planning line in a code block", ["TODO x", "```", SCHED, "```"], [False] * 4), + ("property line is not attached", ["TODO x", "prio:: high", SCHED], [False, False, True]), + ("no lines", [], []), +] + + +class TestAttachedLineMask: + """The one rule for which lines belong to the block above them.""" + + @pytest.mark.parametrize("name,lines,expected", ATTACHED, ids=[n for n, _, _ in ATTACHED]) + def test_mask(self, name, lines, expected): + from logseq_cli.blocktext import attached_line_mask + assert attached_line_mask(lines) == expected + + def test_it_is_disjoint_from_the_property_mask(self): + from logseq_cli.blocktext import attached_line_mask, property_line_mask + lines = ["TODO x", "prio:: high", SCHED] + assert property_line_mask(lines) == [False, True, False] + assert attached_line_mask(lines) == [False, False, True] + + def test_drawer_lines_is_public(self): + from logseq_cli.blocktext import drawer_lines + assert drawer_lines(["a", ":LOGBOOK:", "b", ":END:"]) == {1, 2, 3} + assert drawer_lines([":LOGBOOK:", "x"]) == set() + + class TestOnlyThePageOwnPropertiesLoseTheirBullet: """Logseq writes the page's properties, its first block, without a bullet; every other block keeps one, properties-only or not (an empty diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..497ddff --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,871 @@ +"""The task rules: markers, states, reading and swapping a marker.""" +import pytest + +from logseq_cli import tasks + +# Generated with mldoc 1.5.7, the parser Logseq ships; not edited by hand. +# (prefix, word, separator, text, marker): marker is what mldoc reads in the +# block "- ", None if none. +ROWS = [ + ('', 'NOW', 'space', 'NOW x', 'NOW'), + ('', 'NOW', 'two_spaces', 'NOW x', 'NOW'), + ('', 'NOW', 'tab', 'NOW\tx', None), + ('', 'NOW', 'newline', 'NOW\nnotes', None), + ('', 'NOW', 'space_newline', 'NOW \nnotes', 'NOW'), + ('', 'NOW', 'end', 'NOW', 'NOW'), + ('', 'LATER', 'space', 'LATER x', 'LATER'), + ('', 'LATER', 'two_spaces', 'LATER x', 'LATER'), + ('', 'LATER', 'tab', 'LATER\tx', None), + ('', 'LATER', 'newline', 'LATER\nnotes', None), + ('', 'LATER', 'space_newline', 'LATER \nnotes', 'LATER'), + ('', 'LATER', 'end', 'LATER', 'LATER'), + ('', 'TODO', 'space', 'TODO x', 'TODO'), + ('', 'TODO', 'two_spaces', 'TODO x', 'TODO'), + ('', 'TODO', 'tab', 'TODO\tx', None), + ('', 'TODO', 'newline', 'TODO\nnotes', None), + ('', 'TODO', 'space_newline', 'TODO \nnotes', 'TODO'), + ('', 'TODO', 'end', 'TODO', 'TODO'), + ('', 'DOING', 'space', 'DOING x', 'DOING'), + ('', 'DOING', 'two_spaces', 'DOING x', 'DOING'), + ('', 'DOING', 'tab', 'DOING\tx', None), + ('', 'DOING', 'newline', 'DOING\nnotes', None), + ('', 'DOING', 'space_newline', 'DOING \nnotes', 'DOING'), + ('', 'DOING', 'end', 'DOING', 'DOING'), + ('', 'IN-PROGRESS', 'space', 'IN-PROGRESS x', 'IN-PROGRESS'), + ('', 'IN-PROGRESS', 'two_spaces', 'IN-PROGRESS x', 'IN-PROGRESS'), + ('', 'IN-PROGRESS', 'tab', 'IN-PROGRESS\tx', None), + ('', 'IN-PROGRESS', 'newline', 'IN-PROGRESS\nnotes', None), + ('', 'IN-PROGRESS', 'space_newline', 'IN-PROGRESS \nnotes', 'IN-PROGRESS'), + ('', 'IN-PROGRESS', 'end', 'IN-PROGRESS', 'IN-PROGRESS'), + ('', 'WAIT', 'space', 'WAIT x', 'WAIT'), + ('', 'WAIT', 'two_spaces', 'WAIT x', 'WAIT'), + ('', 'WAIT', 'tab', 'WAIT\tx', None), + ('', 'WAIT', 'newline', 'WAIT\nnotes', None), + ('', 'WAIT', 'space_newline', 'WAIT \nnotes', 'WAIT'), + ('', 'WAIT', 'end', 'WAIT', 'WAIT'), + ('', 'WAITING', 'space', 'WAITING x', 'WAITING'), + ('', 'WAITING', 'two_spaces', 'WAITING x', 'WAITING'), + ('', 'WAITING', 'tab', 'WAITING\tx', None), + ('', 'WAITING', 'newline', 'WAITING\nnotes', None), + ('', 'WAITING', 'space_newline', 'WAITING \nnotes', 'WAITING'), + ('', 'WAITING', 'end', 'WAITING', 'WAITING'), + ('', 'STARTED', 'space', 'STARTED x', 'STARTED'), + ('', 'STARTED', 'two_spaces', 'STARTED x', 'STARTED'), + ('', 'STARTED', 'tab', 'STARTED\tx', None), + ('', 'STARTED', 'newline', 'STARTED\nnotes', None), + ('', 'STARTED', 'space_newline', 'STARTED \nnotes', 'STARTED'), + ('', 'STARTED', 'end', 'STARTED', 'STARTED'), + ('', 'DONE', 'space', 'DONE x', 'DONE'), + ('', 'DONE', 'two_spaces', 'DONE x', 'DONE'), + ('', 'DONE', 'tab', 'DONE\tx', None), + ('', 'DONE', 'newline', 'DONE\nnotes', None), + ('', 'DONE', 'space_newline', 'DONE \nnotes', 'DONE'), + ('', 'DONE', 'end', 'DONE', 'DONE'), + ('', 'CANCELED', 'space', 'CANCELED x', 'CANCELED'), + ('', 'CANCELED', 'two_spaces', 'CANCELED x', 'CANCELED'), + ('', 'CANCELED', 'tab', 'CANCELED\tx', None), + ('', 'CANCELED', 'newline', 'CANCELED\nnotes', None), + ('', 'CANCELED', 'space_newline', 'CANCELED \nnotes', 'CANCELED'), + ('', 'CANCELED', 'end', 'CANCELED', 'CANCELED'), + ('', 'CANCELLED', 'space', 'CANCELLED x', 'CANCELLED'), + ('', 'CANCELLED', 'two_spaces', 'CANCELLED x', 'CANCELLED'), + ('', 'CANCELLED', 'tab', 'CANCELLED\tx', None), + ('', 'CANCELLED', 'newline', 'CANCELLED\nnotes', None), + ('', 'CANCELLED', 'space_newline', 'CANCELLED \nnotes', 'CANCELLED'), + ('', 'CANCELLED', 'end', 'CANCELLED', 'CANCELLED'), + ('', 'todo', 'space', 'todo x', None), + ('', 'todo', 'two_spaces', 'todo x', None), + ('', 'todo', 'tab', 'todo\tx', None), + ('', 'todo', 'newline', 'todo\nnotes', None), + ('', 'todo', 'space_newline', 'todo \nnotes', None), + ('', 'todo', 'end', 'todo', None), + ('', 'Todo', 'space', 'Todo x', None), + ('', 'Todo', 'two_spaces', 'Todo x', None), + ('', 'Todo', 'tab', 'Todo\tx', None), + ('', 'Todo', 'newline', 'Todo\nnotes', None), + ('', 'Todo', 'space_newline', 'Todo \nnotes', None), + ('', 'Todo', 'end', 'Todo', None), + ('', 'TODOa', 'space', 'TODOa x', None), + ('', 'TODOa', 'two_spaces', 'TODOa x', None), + ('', 'TODOa', 'tab', 'TODOa\tx', None), + ('', 'TODOa', 'newline', 'TODOa\nnotes', None), + ('', 'TODOa', 'space_newline', 'TODOa \nnotes', None), + ('', 'TODOa', 'end', 'TODOa', None), + ('', 'TODO:', 'space', 'TODO: x', None), + ('', 'TODO:', 'two_spaces', 'TODO: x', None), + ('', 'TODO:', 'tab', 'TODO:\tx', None), + ('', 'TODO:', 'newline', 'TODO:\nnotes', None), + ('', 'TODO:', 'space_newline', 'TODO: \nnotes', None), + ('', 'TODO:', 'end', 'TODO:', None), + ('# ', 'NOW', 'space', '# NOW x', 'NOW'), + ('# ', 'NOW', 'two_spaces', '# NOW x', 'NOW'), + ('# ', 'NOW', 'tab', '# NOW\tx', None), + ('# ', 'NOW', 'newline', '# NOW\nnotes', None), + ('# ', 'NOW', 'space_newline', '# NOW \nnotes', 'NOW'), + ('# ', 'NOW', 'end', '# NOW', 'NOW'), + ('# ', 'LATER', 'space', '# LATER x', 'LATER'), + ('# ', 'LATER', 'two_spaces', '# LATER x', 'LATER'), + ('# ', 'LATER', 'tab', '# LATER\tx', None), + ('# ', 'LATER', 'newline', '# LATER\nnotes', None), + ('# ', 'LATER', 'space_newline', '# LATER \nnotes', 'LATER'), + ('# ', 'LATER', 'end', '# LATER', 'LATER'), + ('# ', 'TODO', 'space', '# TODO x', 'TODO'), + ('# ', 'TODO', 'two_spaces', '# TODO x', 'TODO'), + ('# ', 'TODO', 'tab', '# TODO\tx', None), + ('# ', 'TODO', 'newline', '# TODO\nnotes', None), + ('# ', 'TODO', 'space_newline', '# TODO \nnotes', 'TODO'), + ('# ', 'TODO', 'end', '# TODO', 'TODO'), + ('# ', 'DOING', 'space', '# DOING x', 'DOING'), + ('# ', 'DOING', 'two_spaces', '# DOING x', 'DOING'), + ('# ', 'DOING', 'tab', '# DOING\tx', None), + ('# ', 'DOING', 'newline', '# DOING\nnotes', None), + ('# ', 'DOING', 'space_newline', '# DOING \nnotes', 'DOING'), + ('# ', 'DOING', 'end', '# DOING', 'DOING'), + ('# ', 'IN-PROGRESS', 'space', '# IN-PROGRESS x', 'IN-PROGRESS'), + ('# ', 'IN-PROGRESS', 'two_spaces', '# IN-PROGRESS x', 'IN-PROGRESS'), + ('# ', 'IN-PROGRESS', 'tab', '# IN-PROGRESS\tx', None), + ('# ', 'IN-PROGRESS', 'newline', '# IN-PROGRESS\nnotes', None), + ('# ', 'IN-PROGRESS', 'space_newline', '# IN-PROGRESS \nnotes', 'IN-PROGRESS'), + ('# ', 'IN-PROGRESS', 'end', '# IN-PROGRESS', 'IN-PROGRESS'), + ('# ', 'WAIT', 'space', '# WAIT x', 'WAIT'), + ('# ', 'WAIT', 'two_spaces', '# WAIT x', 'WAIT'), + ('# ', 'WAIT', 'tab', '# WAIT\tx', None), + ('# ', 'WAIT', 'newline', '# WAIT\nnotes', None), + ('# ', 'WAIT', 'space_newline', '# WAIT \nnotes', 'WAIT'), + ('# ', 'WAIT', 'end', '# WAIT', 'WAIT'), + ('# ', 'WAITING', 'space', '# WAITING x', 'WAITING'), + ('# ', 'WAITING', 'two_spaces', '# WAITING x', 'WAITING'), + ('# ', 'WAITING', 'tab', '# WAITING\tx', None), + ('# ', 'WAITING', 'newline', '# WAITING\nnotes', None), + ('# ', 'WAITING', 'space_newline', '# WAITING \nnotes', 'WAITING'), + ('# ', 'WAITING', 'end', '# WAITING', 'WAITING'), + ('# ', 'STARTED', 'space', '# STARTED x', 'STARTED'), + ('# ', 'STARTED', 'two_spaces', '# STARTED x', 'STARTED'), + ('# ', 'STARTED', 'tab', '# STARTED\tx', None), + ('# ', 'STARTED', 'newline', '# STARTED\nnotes', None), + ('# ', 'STARTED', 'space_newline', '# STARTED \nnotes', 'STARTED'), + ('# ', 'STARTED', 'end', '# STARTED', 'STARTED'), + ('# ', 'DONE', 'space', '# DONE x', 'DONE'), + ('# ', 'DONE', 'two_spaces', '# DONE x', 'DONE'), + ('# ', 'DONE', 'tab', '# DONE\tx', None), + ('# ', 'DONE', 'newline', '# DONE\nnotes', None), + ('# ', 'DONE', 'space_newline', '# DONE \nnotes', 'DONE'), + ('# ', 'DONE', 'end', '# DONE', 'DONE'), + ('# ', 'CANCELED', 'space', '# CANCELED x', 'CANCELED'), + ('# ', 'CANCELED', 'two_spaces', '# CANCELED x', 'CANCELED'), + ('# ', 'CANCELED', 'tab', '# CANCELED\tx', None), + ('# ', 'CANCELED', 'newline', '# CANCELED\nnotes', None), + ('# ', 'CANCELED', 'space_newline', '# CANCELED \nnotes', 'CANCELED'), + ('# ', 'CANCELED', 'end', '# CANCELED', 'CANCELED'), + ('# ', 'CANCELLED', 'space', '# CANCELLED x', 'CANCELLED'), + ('# ', 'CANCELLED', 'two_spaces', '# CANCELLED x', 'CANCELLED'), + ('# ', 'CANCELLED', 'tab', '# CANCELLED\tx', None), + ('# ', 'CANCELLED', 'newline', '# CANCELLED\nnotes', None), + ('# ', 'CANCELLED', 'space_newline', '# CANCELLED \nnotes', 'CANCELLED'), + ('# ', 'CANCELLED', 'end', '# CANCELLED', 'CANCELLED'), + ('# ', 'todo', 'space', '# todo x', None), + ('# ', 'todo', 'two_spaces', '# todo x', None), + ('# ', 'todo', 'tab', '# todo\tx', None), + ('# ', 'todo', 'newline', '# todo\nnotes', None), + ('# ', 'todo', 'space_newline', '# todo \nnotes', None), + ('# ', 'todo', 'end', '# todo', None), + ('# ', 'Todo', 'space', '# Todo x', None), + ('# ', 'Todo', 'two_spaces', '# Todo x', None), + ('# ', 'Todo', 'tab', '# Todo\tx', None), + ('# ', 'Todo', 'newline', '# Todo\nnotes', None), + ('# ', 'Todo', 'space_newline', '# Todo \nnotes', None), + ('# ', 'Todo', 'end', '# Todo', None), + ('# ', 'TODOa', 'space', '# TODOa x', None), + ('# ', 'TODOa', 'two_spaces', '# TODOa x', None), + ('# ', 'TODOa', 'tab', '# TODOa\tx', None), + ('# ', 'TODOa', 'newline', '# TODOa\nnotes', None), + ('# ', 'TODOa', 'space_newline', '# TODOa \nnotes', None), + ('# ', 'TODOa', 'end', '# TODOa', None), + ('# ', 'TODO:', 'space', '# TODO: x', None), + ('# ', 'TODO:', 'two_spaces', '# TODO: x', None), + ('# ', 'TODO:', 'tab', '# TODO:\tx', None), + ('# ', 'TODO:', 'newline', '# TODO:\nnotes', None), + ('# ', 'TODO:', 'space_newline', '# TODO: \nnotes', None), + ('# ', 'TODO:', 'end', '# TODO:', None), + ('## ', 'NOW', 'space', '## NOW x', 'NOW'), + ('## ', 'NOW', 'two_spaces', '## NOW x', 'NOW'), + ('## ', 'NOW', 'tab', '## NOW\tx', None), + ('## ', 'NOW', 'newline', '## NOW\nnotes', None), + ('## ', 'NOW', 'space_newline', '## NOW \nnotes', 'NOW'), + ('## ', 'NOW', 'end', '## NOW', 'NOW'), + ('## ', 'LATER', 'space', '## LATER x', 'LATER'), + ('## ', 'LATER', 'two_spaces', '## LATER x', 'LATER'), + ('## ', 'LATER', 'tab', '## LATER\tx', None), + ('## ', 'LATER', 'newline', '## LATER\nnotes', None), + ('## ', 'LATER', 'space_newline', '## LATER \nnotes', 'LATER'), + ('## ', 'LATER', 'end', '## LATER', 'LATER'), + ('## ', 'TODO', 'space', '## TODO x', 'TODO'), + ('## ', 'TODO', 'two_spaces', '## TODO x', 'TODO'), + ('## ', 'TODO', 'tab', '## TODO\tx', None), + ('## ', 'TODO', 'newline', '## TODO\nnotes', None), + ('## ', 'TODO', 'space_newline', '## TODO \nnotes', 'TODO'), + ('## ', 'TODO', 'end', '## TODO', 'TODO'), + ('## ', 'DOING', 'space', '## DOING x', 'DOING'), + ('## ', 'DOING', 'two_spaces', '## DOING x', 'DOING'), + ('## ', 'DOING', 'tab', '## DOING\tx', None), + ('## ', 'DOING', 'newline', '## DOING\nnotes', None), + ('## ', 'DOING', 'space_newline', '## DOING \nnotes', 'DOING'), + ('## ', 'DOING', 'end', '## DOING', 'DOING'), + ('## ', 'IN-PROGRESS', 'space', '## IN-PROGRESS x', 'IN-PROGRESS'), + ('## ', 'IN-PROGRESS', 'two_spaces', '## IN-PROGRESS x', 'IN-PROGRESS'), + ('## ', 'IN-PROGRESS', 'tab', '## IN-PROGRESS\tx', None), + ('## ', 'IN-PROGRESS', 'newline', '## IN-PROGRESS\nnotes', None), + ('## ', 'IN-PROGRESS', 'space_newline', '## IN-PROGRESS \nnotes', 'IN-PROGRESS'), + ('## ', 'IN-PROGRESS', 'end', '## IN-PROGRESS', 'IN-PROGRESS'), + ('## ', 'WAIT', 'space', '## WAIT x', 'WAIT'), + ('## ', 'WAIT', 'two_spaces', '## WAIT x', 'WAIT'), + ('## ', 'WAIT', 'tab', '## WAIT\tx', None), + ('## ', 'WAIT', 'newline', '## WAIT\nnotes', None), + ('## ', 'WAIT', 'space_newline', '## WAIT \nnotes', 'WAIT'), + ('## ', 'WAIT', 'end', '## WAIT', 'WAIT'), + ('## ', 'WAITING', 'space', '## WAITING x', 'WAITING'), + ('## ', 'WAITING', 'two_spaces', '## WAITING x', 'WAITING'), + ('## ', 'WAITING', 'tab', '## WAITING\tx', None), + ('## ', 'WAITING', 'newline', '## WAITING\nnotes', None), + ('## ', 'WAITING', 'space_newline', '## WAITING \nnotes', 'WAITING'), + ('## ', 'WAITING', 'end', '## WAITING', 'WAITING'), + ('## ', 'STARTED', 'space', '## STARTED x', 'STARTED'), + ('## ', 'STARTED', 'two_spaces', '## STARTED x', 'STARTED'), + ('## ', 'STARTED', 'tab', '## STARTED\tx', None), + ('## ', 'STARTED', 'newline', '## STARTED\nnotes', None), + ('## ', 'STARTED', 'space_newline', '## STARTED \nnotes', 'STARTED'), + ('## ', 'STARTED', 'end', '## STARTED', 'STARTED'), + ('## ', 'DONE', 'space', '## DONE x', 'DONE'), + ('## ', 'DONE', 'two_spaces', '## DONE x', 'DONE'), + ('## ', 'DONE', 'tab', '## DONE\tx', None), + ('## ', 'DONE', 'newline', '## DONE\nnotes', None), + ('## ', 'DONE', 'space_newline', '## DONE \nnotes', 'DONE'), + ('## ', 'DONE', 'end', '## DONE', 'DONE'), + ('## ', 'CANCELED', 'space', '## CANCELED x', 'CANCELED'), + ('## ', 'CANCELED', 'two_spaces', '## CANCELED x', 'CANCELED'), + ('## ', 'CANCELED', 'tab', '## CANCELED\tx', None), + ('## ', 'CANCELED', 'newline', '## CANCELED\nnotes', None), + ('## ', 'CANCELED', 'space_newline', '## CANCELED \nnotes', 'CANCELED'), + ('## ', 'CANCELED', 'end', '## CANCELED', 'CANCELED'), + ('## ', 'CANCELLED', 'space', '## CANCELLED x', 'CANCELLED'), + ('## ', 'CANCELLED', 'two_spaces', '## CANCELLED x', 'CANCELLED'), + ('## ', 'CANCELLED', 'tab', '## CANCELLED\tx', None), + ('## ', 'CANCELLED', 'newline', '## CANCELLED\nnotes', None), + ('## ', 'CANCELLED', 'space_newline', '## CANCELLED \nnotes', 'CANCELLED'), + ('## ', 'CANCELLED', 'end', '## CANCELLED', 'CANCELLED'), + ('## ', 'todo', 'space', '## todo x', None), + ('## ', 'todo', 'two_spaces', '## todo x', None), + ('## ', 'todo', 'tab', '## todo\tx', None), + ('## ', 'todo', 'newline', '## todo\nnotes', None), + ('## ', 'todo', 'space_newline', '## todo \nnotes', None), + ('## ', 'todo', 'end', '## todo', None), + ('## ', 'Todo', 'space', '## Todo x', None), + ('## ', 'Todo', 'two_spaces', '## Todo x', None), + ('## ', 'Todo', 'tab', '## Todo\tx', None), + ('## ', 'Todo', 'newline', '## Todo\nnotes', None), + ('## ', 'Todo', 'space_newline', '## Todo \nnotes', None), + ('## ', 'Todo', 'end', '## Todo', None), + ('## ', 'TODOa', 'space', '## TODOa x', None), + ('## ', 'TODOa', 'two_spaces', '## TODOa x', None), + ('## ', 'TODOa', 'tab', '## TODOa\tx', None), + ('## ', 'TODOa', 'newline', '## TODOa\nnotes', None), + ('## ', 'TODOa', 'space_newline', '## TODOa \nnotes', None), + ('## ', 'TODOa', 'end', '## TODOa', None), + ('## ', 'TODO:', 'space', '## TODO: x', None), + ('## ', 'TODO:', 'two_spaces', '## TODO: x', None), + ('## ', 'TODO:', 'tab', '## TODO:\tx', None), + ('## ', 'TODO:', 'newline', '## TODO:\nnotes', None), + ('## ', 'TODO:', 'space_newline', '## TODO: \nnotes', None), + ('## ', 'TODO:', 'end', '## TODO:', None), + ('##', 'NOW', 'space', '##NOW x', None), + ('##', 'NOW', 'two_spaces', '##NOW x', None), + ('##', 'NOW', 'tab', '##NOW\tx', None), + ('##', 'NOW', 'newline', '##NOW\nnotes', None), + ('##', 'NOW', 'space_newline', '##NOW \nnotes', None), + ('##', 'NOW', 'end', '##NOW', None), + ('##', 'LATER', 'space', '##LATER x', None), + ('##', 'LATER', 'two_spaces', '##LATER x', None), + ('##', 'LATER', 'tab', '##LATER\tx', None), + ('##', 'LATER', 'newline', '##LATER\nnotes', None), + ('##', 'LATER', 'space_newline', '##LATER \nnotes', None), + ('##', 'LATER', 'end', '##LATER', None), + ('##', 'TODO', 'space', '##TODO x', None), + ('##', 'TODO', 'two_spaces', '##TODO x', None), + ('##', 'TODO', 'tab', '##TODO\tx', None), + ('##', 'TODO', 'newline', '##TODO\nnotes', None), + ('##', 'TODO', 'space_newline', '##TODO \nnotes', None), + ('##', 'TODO', 'end', '##TODO', None), + ('##', 'DOING', 'space', '##DOING x', None), + ('##', 'DOING', 'two_spaces', '##DOING x', None), + ('##', 'DOING', 'tab', '##DOING\tx', None), + ('##', 'DOING', 'newline', '##DOING\nnotes', None), + ('##', 'DOING', 'space_newline', '##DOING \nnotes', None), + ('##', 'DOING', 'end', '##DOING', None), + ('##', 'IN-PROGRESS', 'space', '##IN-PROGRESS x', None), + ('##', 'IN-PROGRESS', 'two_spaces', '##IN-PROGRESS x', None), + ('##', 'IN-PROGRESS', 'tab', '##IN-PROGRESS\tx', None), + ('##', 'IN-PROGRESS', 'newline', '##IN-PROGRESS\nnotes', None), + ('##', 'IN-PROGRESS', 'space_newline', '##IN-PROGRESS \nnotes', None), + ('##', 'IN-PROGRESS', 'end', '##IN-PROGRESS', None), + ('##', 'WAIT', 'space', '##WAIT x', None), + ('##', 'WAIT', 'two_spaces', '##WAIT x', None), + ('##', 'WAIT', 'tab', '##WAIT\tx', None), + ('##', 'WAIT', 'newline', '##WAIT\nnotes', None), + ('##', 'WAIT', 'space_newline', '##WAIT \nnotes', None), + ('##', 'WAIT', 'end', '##WAIT', None), + ('##', 'WAITING', 'space', '##WAITING x', None), + ('##', 'WAITING', 'two_spaces', '##WAITING x', None), + ('##', 'WAITING', 'tab', '##WAITING\tx', None), + ('##', 'WAITING', 'newline', '##WAITING\nnotes', None), + ('##', 'WAITING', 'space_newline', '##WAITING \nnotes', None), + ('##', 'WAITING', 'end', '##WAITING', None), + ('##', 'STARTED', 'space', '##STARTED x', None), + ('##', 'STARTED', 'two_spaces', '##STARTED x', None), + ('##', 'STARTED', 'tab', '##STARTED\tx', None), + ('##', 'STARTED', 'newline', '##STARTED\nnotes', None), + ('##', 'STARTED', 'space_newline', '##STARTED \nnotes', None), + ('##', 'STARTED', 'end', '##STARTED', None), + ('##', 'DONE', 'space', '##DONE x', None), + ('##', 'DONE', 'two_spaces', '##DONE x', None), + ('##', 'DONE', 'tab', '##DONE\tx', None), + ('##', 'DONE', 'newline', '##DONE\nnotes', None), + ('##', 'DONE', 'space_newline', '##DONE \nnotes', None), + ('##', 'DONE', 'end', '##DONE', None), + ('##', 'CANCELED', 'space', '##CANCELED x', None), + ('##', 'CANCELED', 'two_spaces', '##CANCELED x', None), + ('##', 'CANCELED', 'tab', '##CANCELED\tx', None), + ('##', 'CANCELED', 'newline', '##CANCELED\nnotes', None), + ('##', 'CANCELED', 'space_newline', '##CANCELED \nnotes', None), + ('##', 'CANCELED', 'end', '##CANCELED', None), + ('##', 'CANCELLED', 'space', '##CANCELLED x', None), + ('##', 'CANCELLED', 'two_spaces', '##CANCELLED x', None), + ('##', 'CANCELLED', 'tab', '##CANCELLED\tx', None), + ('##', 'CANCELLED', 'newline', '##CANCELLED\nnotes', None), + ('##', 'CANCELLED', 'space_newline', '##CANCELLED \nnotes', None), + ('##', 'CANCELLED', 'end', '##CANCELLED', None), + ('##', 'todo', 'space', '##todo x', None), + ('##', 'todo', 'two_spaces', '##todo x', None), + ('##', 'todo', 'tab', '##todo\tx', None), + ('##', 'todo', 'newline', '##todo\nnotes', None), + ('##', 'todo', 'space_newline', '##todo \nnotes', None), + ('##', 'todo', 'end', '##todo', None), + ('##', 'Todo', 'space', '##Todo x', None), + ('##', 'Todo', 'two_spaces', '##Todo x', None), + ('##', 'Todo', 'tab', '##Todo\tx', None), + ('##', 'Todo', 'newline', '##Todo\nnotes', None), + ('##', 'Todo', 'space_newline', '##Todo \nnotes', None), + ('##', 'Todo', 'end', '##Todo', None), + ('##', 'TODOa', 'space', '##TODOa x', None), + ('##', 'TODOa', 'two_spaces', '##TODOa x', None), + ('##', 'TODOa', 'tab', '##TODOa\tx', None), + ('##', 'TODOa', 'newline', '##TODOa\nnotes', None), + ('##', 'TODOa', 'space_newline', '##TODOa \nnotes', None), + ('##', 'TODOa', 'end', '##TODOa', None), + ('##', 'TODO:', 'space', '##TODO: x', None), + ('##', 'TODO:', 'two_spaces', '##TODO: x', None), + ('##', 'TODO:', 'tab', '##TODO:\tx', None), + ('##', 'TODO:', 'newline', '##TODO:\nnotes', None), + ('##', 'TODO:', 'space_newline', '##TODO: \nnotes', None), + ('##', 'TODO:', 'end', '##TODO:', None), + ('# ', 'NOW', 'space', '# NOW x', 'NOW'), + ('# ', 'NOW', 'two_spaces', '# NOW x', 'NOW'), + ('# ', 'NOW', 'tab', '# NOW\tx', None), + ('# ', 'NOW', 'newline', '# NOW\nnotes', None), + ('# ', 'NOW', 'space_newline', '# NOW \nnotes', 'NOW'), + ('# ', 'NOW', 'end', '# NOW', 'NOW'), + ('# ', 'LATER', 'space', '# LATER x', 'LATER'), + ('# ', 'LATER', 'two_spaces', '# LATER x', 'LATER'), + ('# ', 'LATER', 'tab', '# LATER\tx', None), + ('# ', 'LATER', 'newline', '# LATER\nnotes', None), + ('# ', 'LATER', 'space_newline', '# LATER \nnotes', 'LATER'), + ('# ', 'LATER', 'end', '# LATER', 'LATER'), + ('# ', 'TODO', 'space', '# TODO x', 'TODO'), + ('# ', 'TODO', 'two_spaces', '# TODO x', 'TODO'), + ('# ', 'TODO', 'tab', '# TODO\tx', None), + ('# ', 'TODO', 'newline', '# TODO\nnotes', None), + ('# ', 'TODO', 'space_newline', '# TODO \nnotes', 'TODO'), + ('# ', 'TODO', 'end', '# TODO', 'TODO'), + ('# ', 'DOING', 'space', '# DOING x', 'DOING'), + ('# ', 'DOING', 'two_spaces', '# DOING x', 'DOING'), + ('# ', 'DOING', 'tab', '# DOING\tx', None), + ('# ', 'DOING', 'newline', '# DOING\nnotes', None), + ('# ', 'DOING', 'space_newline', '# DOING \nnotes', 'DOING'), + ('# ', 'DOING', 'end', '# DOING', 'DOING'), + ('# ', 'IN-PROGRESS', 'space', '# IN-PROGRESS x', 'IN-PROGRESS'), + ('# ', 'IN-PROGRESS', 'two_spaces', '# IN-PROGRESS x', 'IN-PROGRESS'), + ('# ', 'IN-PROGRESS', 'tab', '# IN-PROGRESS\tx', None), + ('# ', 'IN-PROGRESS', 'newline', '# IN-PROGRESS\nnotes', None), + ('# ', 'IN-PROGRESS', 'space_newline', '# IN-PROGRESS \nnotes', 'IN-PROGRESS'), + ('# ', 'IN-PROGRESS', 'end', '# IN-PROGRESS', 'IN-PROGRESS'), + ('# ', 'WAIT', 'space', '# WAIT x', 'WAIT'), + ('# ', 'WAIT', 'two_spaces', '# WAIT x', 'WAIT'), + ('# ', 'WAIT', 'tab', '# WAIT\tx', None), + ('# ', 'WAIT', 'newline', '# WAIT\nnotes', None), + ('# ', 'WAIT', 'space_newline', '# WAIT \nnotes', 'WAIT'), + ('# ', 'WAIT', 'end', '# WAIT', 'WAIT'), + ('# ', 'WAITING', 'space', '# WAITING x', 'WAITING'), + ('# ', 'WAITING', 'two_spaces', '# WAITING x', 'WAITING'), + ('# ', 'WAITING', 'tab', '# WAITING\tx', None), + ('# ', 'WAITING', 'newline', '# WAITING\nnotes', None), + ('# ', 'WAITING', 'space_newline', '# WAITING \nnotes', 'WAITING'), + ('# ', 'WAITING', 'end', '# WAITING', 'WAITING'), + ('# ', 'STARTED', 'space', '# STARTED x', 'STARTED'), + ('# ', 'STARTED', 'two_spaces', '# STARTED x', 'STARTED'), + ('# ', 'STARTED', 'tab', '# STARTED\tx', None), + ('# ', 'STARTED', 'newline', '# STARTED\nnotes', None), + ('# ', 'STARTED', 'space_newline', '# STARTED \nnotes', 'STARTED'), + ('# ', 'STARTED', 'end', '# STARTED', 'STARTED'), + ('# ', 'DONE', 'space', '# DONE x', 'DONE'), + ('# ', 'DONE', 'two_spaces', '# DONE x', 'DONE'), + ('# ', 'DONE', 'tab', '# DONE\tx', None), + ('# ', 'DONE', 'newline', '# DONE\nnotes', None), + ('# ', 'DONE', 'space_newline', '# DONE \nnotes', 'DONE'), + ('# ', 'DONE', 'end', '# DONE', 'DONE'), + ('# ', 'CANCELED', 'space', '# CANCELED x', 'CANCELED'), + ('# ', 'CANCELED', 'two_spaces', '# CANCELED x', 'CANCELED'), + ('# ', 'CANCELED', 'tab', '# CANCELED\tx', None), + ('# ', 'CANCELED', 'newline', '# CANCELED\nnotes', None), + ('# ', 'CANCELED', 'space_newline', '# CANCELED \nnotes', 'CANCELED'), + ('# ', 'CANCELED', 'end', '# CANCELED', 'CANCELED'), + ('# ', 'CANCELLED', 'space', '# CANCELLED x', 'CANCELLED'), + ('# ', 'CANCELLED', 'two_spaces', '# CANCELLED x', 'CANCELLED'), + ('# ', 'CANCELLED', 'tab', '# CANCELLED\tx', None), + ('# ', 'CANCELLED', 'newline', '# CANCELLED\nnotes', None), + ('# ', 'CANCELLED', 'space_newline', '# CANCELLED \nnotes', 'CANCELLED'), + ('# ', 'CANCELLED', 'end', '# CANCELLED', 'CANCELLED'), + ('# ', 'todo', 'space', '# todo x', None), + ('# ', 'todo', 'two_spaces', '# todo x', None), + ('# ', 'todo', 'tab', '# todo\tx', None), + ('# ', 'todo', 'newline', '# todo\nnotes', None), + ('# ', 'todo', 'space_newline', '# todo \nnotes', None), + ('# ', 'todo', 'end', '# todo', None), + ('# ', 'Todo', 'space', '# Todo x', None), + ('# ', 'Todo', 'two_spaces', '# Todo x', None), + ('# ', 'Todo', 'tab', '# Todo\tx', None), + ('# ', 'Todo', 'newline', '# Todo\nnotes', None), + ('# ', 'Todo', 'space_newline', '# Todo \nnotes', None), + ('# ', 'Todo', 'end', '# Todo', None), + ('# ', 'TODOa', 'space', '# TODOa x', None), + ('# ', 'TODOa', 'two_spaces', '# TODOa x', None), + ('# ', 'TODOa', 'tab', '# TODOa\tx', None), + ('# ', 'TODOa', 'newline', '# TODOa\nnotes', None), + ('# ', 'TODOa', 'space_newline', '# TODOa \nnotes', None), + ('# ', 'TODOa', 'end', '# TODOa', None), + ('# ', 'TODO:', 'space', '# TODO: x', None), + ('# ', 'TODO:', 'two_spaces', '# TODO: x', None), + ('# ', 'TODO:', 'tab', '# TODO:\tx', None), + ('# ', 'TODO:', 'newline', '# TODO:\nnotes', None), + ('# ', 'TODO:', 'space_newline', '# TODO: \nnotes', None), + ('# ', 'TODO:', 'end', '# TODO:', None), + ('##\t', 'NOW', 'space', '##\tNOW x', 'NOW'), + ('##\t', 'NOW', 'two_spaces', '##\tNOW x', 'NOW'), + ('##\t', 'NOW', 'tab', '##\tNOW\tx', None), + ('##\t', 'NOW', 'newline', '##\tNOW\nnotes', None), + ('##\t', 'NOW', 'space_newline', '##\tNOW \nnotes', 'NOW'), + ('##\t', 'NOW', 'end', '##\tNOW', 'NOW'), + ('##\t', 'LATER', 'space', '##\tLATER x', 'LATER'), + ('##\t', 'LATER', 'two_spaces', '##\tLATER x', 'LATER'), + ('##\t', 'LATER', 'tab', '##\tLATER\tx', None), + ('##\t', 'LATER', 'newline', '##\tLATER\nnotes', None), + ('##\t', 'LATER', 'space_newline', '##\tLATER \nnotes', 'LATER'), + ('##\t', 'LATER', 'end', '##\tLATER', 'LATER'), + ('##\t', 'TODO', 'space', '##\tTODO x', 'TODO'), + ('##\t', 'TODO', 'two_spaces', '##\tTODO x', 'TODO'), + ('##\t', 'TODO', 'tab', '##\tTODO\tx', None), + ('##\t', 'TODO', 'newline', '##\tTODO\nnotes', None), + ('##\t', 'TODO', 'space_newline', '##\tTODO \nnotes', 'TODO'), + ('##\t', 'TODO', 'end', '##\tTODO', 'TODO'), + ('##\t', 'DOING', 'space', '##\tDOING x', 'DOING'), + ('##\t', 'DOING', 'two_spaces', '##\tDOING x', 'DOING'), + ('##\t', 'DOING', 'tab', '##\tDOING\tx', None), + ('##\t', 'DOING', 'newline', '##\tDOING\nnotes', None), + ('##\t', 'DOING', 'space_newline', '##\tDOING \nnotes', 'DOING'), + ('##\t', 'DOING', 'end', '##\tDOING', 'DOING'), + ('##\t', 'IN-PROGRESS', 'space', '##\tIN-PROGRESS x', 'IN-PROGRESS'), + ('##\t', 'IN-PROGRESS', 'two_spaces', '##\tIN-PROGRESS x', 'IN-PROGRESS'), + ('##\t', 'IN-PROGRESS', 'tab', '##\tIN-PROGRESS\tx', None), + ('##\t', 'IN-PROGRESS', 'newline', '##\tIN-PROGRESS\nnotes', None), + ('##\t', 'IN-PROGRESS', 'space_newline', '##\tIN-PROGRESS \nnotes', 'IN-PROGRESS'), + ('##\t', 'IN-PROGRESS', 'end', '##\tIN-PROGRESS', 'IN-PROGRESS'), + ('##\t', 'WAIT', 'space', '##\tWAIT x', 'WAIT'), + ('##\t', 'WAIT', 'two_spaces', '##\tWAIT x', 'WAIT'), + ('##\t', 'WAIT', 'tab', '##\tWAIT\tx', None), + ('##\t', 'WAIT', 'newline', '##\tWAIT\nnotes', None), + ('##\t', 'WAIT', 'space_newline', '##\tWAIT \nnotes', 'WAIT'), + ('##\t', 'WAIT', 'end', '##\tWAIT', 'WAIT'), + ('##\t', 'WAITING', 'space', '##\tWAITING x', 'WAITING'), + ('##\t', 'WAITING', 'two_spaces', '##\tWAITING x', 'WAITING'), + ('##\t', 'WAITING', 'tab', '##\tWAITING\tx', None), + ('##\t', 'WAITING', 'newline', '##\tWAITING\nnotes', None), + ('##\t', 'WAITING', 'space_newline', '##\tWAITING \nnotes', 'WAITING'), + ('##\t', 'WAITING', 'end', '##\tWAITING', 'WAITING'), + ('##\t', 'STARTED', 'space', '##\tSTARTED x', 'STARTED'), + ('##\t', 'STARTED', 'two_spaces', '##\tSTARTED x', 'STARTED'), + ('##\t', 'STARTED', 'tab', '##\tSTARTED\tx', None), + ('##\t', 'STARTED', 'newline', '##\tSTARTED\nnotes', None), + ('##\t', 'STARTED', 'space_newline', '##\tSTARTED \nnotes', 'STARTED'), + ('##\t', 'STARTED', 'end', '##\tSTARTED', 'STARTED'), + ('##\t', 'DONE', 'space', '##\tDONE x', 'DONE'), + ('##\t', 'DONE', 'two_spaces', '##\tDONE x', 'DONE'), + ('##\t', 'DONE', 'tab', '##\tDONE\tx', None), + ('##\t', 'DONE', 'newline', '##\tDONE\nnotes', None), + ('##\t', 'DONE', 'space_newline', '##\tDONE \nnotes', 'DONE'), + ('##\t', 'DONE', 'end', '##\tDONE', 'DONE'), + ('##\t', 'CANCELED', 'space', '##\tCANCELED x', 'CANCELED'), + ('##\t', 'CANCELED', 'two_spaces', '##\tCANCELED x', 'CANCELED'), + ('##\t', 'CANCELED', 'tab', '##\tCANCELED\tx', None), + ('##\t', 'CANCELED', 'newline', '##\tCANCELED\nnotes', None), + ('##\t', 'CANCELED', 'space_newline', '##\tCANCELED \nnotes', 'CANCELED'), + ('##\t', 'CANCELED', 'end', '##\tCANCELED', 'CANCELED'), + ('##\t', 'CANCELLED', 'space', '##\tCANCELLED x', 'CANCELLED'), + ('##\t', 'CANCELLED', 'two_spaces', '##\tCANCELLED x', 'CANCELLED'), + ('##\t', 'CANCELLED', 'tab', '##\tCANCELLED\tx', None), + ('##\t', 'CANCELLED', 'newline', '##\tCANCELLED\nnotes', None), + ('##\t', 'CANCELLED', 'space_newline', '##\tCANCELLED \nnotes', 'CANCELLED'), + ('##\t', 'CANCELLED', 'end', '##\tCANCELLED', 'CANCELLED'), + ('##\t', 'todo', 'space', '##\ttodo x', None), + ('##\t', 'todo', 'two_spaces', '##\ttodo x', None), + ('##\t', 'todo', 'tab', '##\ttodo\tx', None), + ('##\t', 'todo', 'newline', '##\ttodo\nnotes', None), + ('##\t', 'todo', 'space_newline', '##\ttodo \nnotes', None), + ('##\t', 'todo', 'end', '##\ttodo', None), + ('##\t', 'Todo', 'space', '##\tTodo x', None), + ('##\t', 'Todo', 'two_spaces', '##\tTodo x', None), + ('##\t', 'Todo', 'tab', '##\tTodo\tx', None), + ('##\t', 'Todo', 'newline', '##\tTodo\nnotes', None), + ('##\t', 'Todo', 'space_newline', '##\tTodo \nnotes', None), + ('##\t', 'Todo', 'end', '##\tTodo', None), + ('##\t', 'TODOa', 'space', '##\tTODOa x', None), + ('##\t', 'TODOa', 'two_spaces', '##\tTODOa x', None), + ('##\t', 'TODOa', 'tab', '##\tTODOa\tx', None), + ('##\t', 'TODOa', 'newline', '##\tTODOa\nnotes', None), + ('##\t', 'TODOa', 'space_newline', '##\tTODOa \nnotes', None), + ('##\t', 'TODOa', 'end', '##\tTODOa', None), + ('##\t', 'TODO:', 'space', '##\tTODO: x', None), + ('##\t', 'TODO:', 'two_spaces', '##\tTODO: x', None), + ('##\t', 'TODO:', 'tab', '##\tTODO:\tx', None), + ('##\t', 'TODO:', 'newline', '##\tTODO:\nnotes', None), + ('##\t', 'TODO:', 'space_newline', '##\tTODO: \nnotes', None), + ('##\t', 'TODO:', 'end', '##\tTODO:', None), + ('# \t', 'NOW', 'space', '# \tNOW x', 'NOW'), + ('# \t', 'NOW', 'two_spaces', '# \tNOW x', 'NOW'), + ('# \t', 'NOW', 'tab', '# \tNOW\tx', None), + ('# \t', 'NOW', 'newline', '# \tNOW\nnotes', None), + ('# \t', 'NOW', 'space_newline', '# \tNOW \nnotes', 'NOW'), + ('# \t', 'NOW', 'end', '# \tNOW', 'NOW'), + ('# \t', 'LATER', 'space', '# \tLATER x', 'LATER'), + ('# \t', 'LATER', 'two_spaces', '# \tLATER x', 'LATER'), + ('# \t', 'LATER', 'tab', '# \tLATER\tx', None), + ('# \t', 'LATER', 'newline', '# \tLATER\nnotes', None), + ('# \t', 'LATER', 'space_newline', '# \tLATER \nnotes', 'LATER'), + ('# \t', 'LATER', 'end', '# \tLATER', 'LATER'), + ('# \t', 'TODO', 'space', '# \tTODO x', 'TODO'), + ('# \t', 'TODO', 'two_spaces', '# \tTODO x', 'TODO'), + ('# \t', 'TODO', 'tab', '# \tTODO\tx', None), + ('# \t', 'TODO', 'newline', '# \tTODO\nnotes', None), + ('# \t', 'TODO', 'space_newline', '# \tTODO \nnotes', 'TODO'), + ('# \t', 'TODO', 'end', '# \tTODO', 'TODO'), + ('# \t', 'DOING', 'space', '# \tDOING x', 'DOING'), + ('# \t', 'DOING', 'two_spaces', '# \tDOING x', 'DOING'), + ('# \t', 'DOING', 'tab', '# \tDOING\tx', None), + ('# \t', 'DOING', 'newline', '# \tDOING\nnotes', None), + ('# \t', 'DOING', 'space_newline', '# \tDOING \nnotes', 'DOING'), + ('# \t', 'DOING', 'end', '# \tDOING', 'DOING'), + ('# \t', 'IN-PROGRESS', 'space', '# \tIN-PROGRESS x', 'IN-PROGRESS'), + ('# \t', 'IN-PROGRESS', 'two_spaces', '# \tIN-PROGRESS x', 'IN-PROGRESS'), + ('# \t', 'IN-PROGRESS', 'tab', '# \tIN-PROGRESS\tx', None), + ('# \t', 'IN-PROGRESS', 'newline', '# \tIN-PROGRESS\nnotes', None), + ('# \t', 'IN-PROGRESS', 'space_newline', '# \tIN-PROGRESS \nnotes', 'IN-PROGRESS'), + ('# \t', 'IN-PROGRESS', 'end', '# \tIN-PROGRESS', 'IN-PROGRESS'), + ('# \t', 'WAIT', 'space', '# \tWAIT x', 'WAIT'), + ('# \t', 'WAIT', 'two_spaces', '# \tWAIT x', 'WAIT'), + ('# \t', 'WAIT', 'tab', '# \tWAIT\tx', None), + ('# \t', 'WAIT', 'newline', '# \tWAIT\nnotes', None), + ('# \t', 'WAIT', 'space_newline', '# \tWAIT \nnotes', 'WAIT'), + ('# \t', 'WAIT', 'end', '# \tWAIT', 'WAIT'), + ('# \t', 'WAITING', 'space', '# \tWAITING x', 'WAITING'), + ('# \t', 'WAITING', 'two_spaces', '# \tWAITING x', 'WAITING'), + ('# \t', 'WAITING', 'tab', '# \tWAITING\tx', None), + ('# \t', 'WAITING', 'newline', '# \tWAITING\nnotes', None), + ('# \t', 'WAITING', 'space_newline', '# \tWAITING \nnotes', 'WAITING'), + ('# \t', 'WAITING', 'end', '# \tWAITING', 'WAITING'), + ('# \t', 'STARTED', 'space', '# \tSTARTED x', 'STARTED'), + ('# \t', 'STARTED', 'two_spaces', '# \tSTARTED x', 'STARTED'), + ('# \t', 'STARTED', 'tab', '# \tSTARTED\tx', None), + ('# \t', 'STARTED', 'newline', '# \tSTARTED\nnotes', None), + ('# \t', 'STARTED', 'space_newline', '# \tSTARTED \nnotes', 'STARTED'), + ('# \t', 'STARTED', 'end', '# \tSTARTED', 'STARTED'), + ('# \t', 'DONE', 'space', '# \tDONE x', 'DONE'), + ('# \t', 'DONE', 'two_spaces', '# \tDONE x', 'DONE'), + ('# \t', 'DONE', 'tab', '# \tDONE\tx', None), + ('# \t', 'DONE', 'newline', '# \tDONE\nnotes', None), + ('# \t', 'DONE', 'space_newline', '# \tDONE \nnotes', 'DONE'), + ('# \t', 'DONE', 'end', '# \tDONE', 'DONE'), + ('# \t', 'CANCELED', 'space', '# \tCANCELED x', 'CANCELED'), + ('# \t', 'CANCELED', 'two_spaces', '# \tCANCELED x', 'CANCELED'), + ('# \t', 'CANCELED', 'tab', '# \tCANCELED\tx', None), + ('# \t', 'CANCELED', 'newline', '# \tCANCELED\nnotes', None), + ('# \t', 'CANCELED', 'space_newline', '# \tCANCELED \nnotes', 'CANCELED'), + ('# \t', 'CANCELED', 'end', '# \tCANCELED', 'CANCELED'), + ('# \t', 'CANCELLED', 'space', '# \tCANCELLED x', 'CANCELLED'), + ('# \t', 'CANCELLED', 'two_spaces', '# \tCANCELLED x', 'CANCELLED'), + ('# \t', 'CANCELLED', 'tab', '# \tCANCELLED\tx', None), + ('# \t', 'CANCELLED', 'newline', '# \tCANCELLED\nnotes', None), + ('# \t', 'CANCELLED', 'space_newline', '# \tCANCELLED \nnotes', 'CANCELLED'), + ('# \t', 'CANCELLED', 'end', '# \tCANCELLED', 'CANCELLED'), + ('# \t', 'todo', 'space', '# \ttodo x', None), + ('# \t', 'todo', 'two_spaces', '# \ttodo x', None), + ('# \t', 'todo', 'tab', '# \ttodo\tx', None), + ('# \t', 'todo', 'newline', '# \ttodo\nnotes', None), + ('# \t', 'todo', 'space_newline', '# \ttodo \nnotes', None), + ('# \t', 'todo', 'end', '# \ttodo', None), + ('# \t', 'Todo', 'space', '# \tTodo x', None), + ('# \t', 'Todo', 'two_spaces', '# \tTodo x', None), + ('# \t', 'Todo', 'tab', '# \tTodo\tx', None), + ('# \t', 'Todo', 'newline', '# \tTodo\nnotes', None), + ('# \t', 'Todo', 'space_newline', '# \tTodo \nnotes', None), + ('# \t', 'Todo', 'end', '# \tTodo', None), + ('# \t', 'TODOa', 'space', '# \tTODOa x', None), + ('# \t', 'TODOa', 'two_spaces', '# \tTODOa x', None), + ('# \t', 'TODOa', 'tab', '# \tTODOa\tx', None), + ('# \t', 'TODOa', 'newline', '# \tTODOa\nnotes', None), + ('# \t', 'TODOa', 'space_newline', '# \tTODOa \nnotes', None), + ('# \t', 'TODOa', 'end', '# \tTODOa', None), + ('# \t', 'TODO:', 'space', '# \tTODO: x', None), + ('# \t', 'TODO:', 'two_spaces', '# \tTODO: x', None), + ('# \t', 'TODO:', 'tab', '# \tTODO:\tx', None), + ('# \t', 'TODO:', 'newline', '# \tTODO:\nnotes', None), + ('# \t', 'TODO:', 'space_newline', '# \tTODO: \nnotes', None), + ('# \t', 'TODO:', 'end', '# \tTODO:', None), +] + +# mldoc reads a marker after a tab behind the hashes; marker_of deliberately +# does not (it asks for spaces, because with_marker writes on what it reads). +TAB_PREFIXES = ("##\t", "# \t") +MARKER_PREFIXES = ("", "# ", "## ", "# ") + +OPEN = ["DOING", "NOW", "IN-PROGRESS", "STARTED", "TODO", "LATER", "WAIT", "WAITING"] + + +class TestOrderAndStates: + """mldoc 1.5.7 reads these eleven words as :block/marker (measured on a + page, Logseq 0.10.15, STARTED included, which Logseq's own marker-pattern + does not name). block-checkbox (components/block.cljs, 0.10.15) draws an + empty box for NOW LATER DOING IN-PROGRESS TODO WAIT WAITING, a ticked one + for DONE, none for CANCELED/CANCELLED and none for STARTED either; that + STARTED is open here is this module's decision, not a drawing.""" + + def test_order(self): + assert tasks.ORDER == ("DOING", "NOW", "IN-PROGRESS", "STARTED", "TODO", "LATER", + "WAIT", "WAITING", "DONE", "CANCELED", "CANCELLED") + + def test_states(self): + assert tasks.STATE == { + "DOING": "open", "NOW": "open", "IN-PROGRESS": "open", "STARTED": "open", + "TODO": "open", "LATER": "open", "WAIT": "open", "WAITING": "open", + "DONE": "done", "CANCELED": "cancelled", "CANCELLED": "cancelled"} + + def test_every_marker_has_a_state(self): + assert set(tasks.STATE) == set(tasks.ORDER) + assert len(tasks.ORDER) == 11 + + def test_the_frontend_offers_every_marker_but_started(self): + assert tasks.FRONTEND_MARKERS == tuple(m for m in tasks.ORDER if m != "STARTED") + assert "STARTED" not in tasks.FRONTEND_MARKERS + + +class TestStates: + def test_the_three_states_in_the_order_of_the_markers(self): + assert tasks.STATES == ("open", "done", "cancelled") + + def test_every_state_of_a_marker_is_listed(self): + assert set(tasks.STATE.values()) == set(tasks.STATES) + + +class TestMarkersIn: + def test_open(self): + assert tasks.markers_in(["open"]) == OPEN + + def test_done(self): + assert tasks.markers_in(["done"]) == ["DONE"] + + def test_cancelled(self): + assert tasks.markers_in(["cancelled"]) == ["CANCELED", "CANCELLED"] + + def test_the_order_does_not_depend_on_the_order_of_the_states(self): + assert tasks.markers_in(["done", "open"]) == tasks.markers_in(["open", "done"]) \ + == OPEN + ["DONE"] + + def test_no_state_no_markers(self): + assert tasks.markers_in([]) == [] + + def test_an_unknown_state_raises(self): + with pytest.raises(ValueError): + tasks.markers_in(["finished"]) + + +class TestMarkerClause: + def test_two_markers(self): + assert tasks.marker_clause("?m", ["TODO", "DOING"]) == '[(contains? #{"TODO" "DOING"} ?m)]' + + def test_every_marker_in_the_order_given(self): + clause = tasks.marker_clause("?m", tasks.ORDER) + assert clause == "[(contains? #{" + " ".join(f'"{m}"' for m in tasks.ORDER) + "} ?m)]" + + def test_another_variable(self): + assert tasks.marker_clause("?x", ["DONE"]) == '[(contains? #{"DONE"} ?x)]' + + @pytest.mark.parametrize("var", ["m", "?m ?n", "", "?"]) + def test_a_bad_variable_raises(self, var): + with pytest.raises(ValueError): + tasks.marker_clause(var, ["TODO"]) + + @pytest.mark.parametrize("marker", ["FOO", "todo"]) + def test_a_marker_outside_the_order_raises(self, marker): + with pytest.raises(ValueError): + tasks.marker_clause("?m", [marker]) + + def test_an_empty_list_raises(self): + with pytest.raises(ValueError): + tasks.marker_clause("?m", []) + + +MARKER_OF = [ + ("## TODO a", "TODO"), ("TODO", "TODO"), ("TODO a", "TODO"), ("# DONE x", "DONE"), + ("IN-PROGRESS a", "IN-PROGRESS"), ("WAITING a", "WAITING"), ("WAIT a", "WAIT"), + ("TODO \nnotes", "TODO"), +] +NO_MARKER = [ + "todo a", "Todo a", "TODO: a", "TODOa", "TODO\na", "TODO\ta", "##TODO a", "", + "a TODO", "a note\nTODO is only a word here", "[ ] a", "[x] a", + "Now that works", "Waiting for x", + # mldoc reads these as tasks, marker_of deliberately does not + "##\tTODO x", "# \tTODO x", "#\tTODO x", "##\t TODO x", + # mldoc 1.5.7 reads no marker behind a list character (a block holding + # "- TODO x" is a heading without a marker); the old regex counted "- - TODO" + "- TODO a", "- DONE a", "* TODO a", +] + + +class TestMarkerOf: + @pytest.mark.parametrize("text,marker", MARKER_OF) + def test_a_marker(self, text, marker): + assert tasks.marker_of(text) == marker + + @pytest.mark.parametrize("text", NO_MARKER) + def test_no_marker(self, text): + assert tasks.marker_of(text) is None + + def test_it_reads_like_mldoc_for_every_row(self): + skipped = checked = 0 + for prefix, word, sep, text, marker in ROWS: + if text.startswith(TAB_PREFIXES): + assert tasks.marker_of(text) is None, text + skipped += 1 + else: + assert tasks.marker_of(text) == marker, text + checked += 1 + assert skipped and checked + + +class TestMarkerBeforeNewline: + @pytest.mark.parametrize("text,marker", [ + ("TODO\nnotes", "TODO"), ("DONE\nnotes", "DONE"), ("## TODO\nnotes", "TODO")]) + def test_a_marker(self, text, marker): + assert tasks.marker_before_newline(text) == marker + + @pytest.mark.parametrize("text", [ + "TODO \nnotes", "todo\nnotes", "TODO:\nx", "TODO x", "TODO", "##TODO\nnotes"]) + def test_none(self, text): + assert tasks.marker_before_newline(text) is None + + def test_it_is_what_mldoc_does_not_read_for_every_newline_row(self): + seen = 0 + for prefix, word, sep, text, marker in ROWS: + if sep != "newline": + continue + expected = word if (marker is None and prefix in MARKER_PREFIXES + and word in tasks.ORDER) else None + assert tasks.marker_before_newline(text) == expected, text + seen += expected is not None + assert seen + + +class TestWithMarker: + @pytest.mark.parametrize("text,new,expected", [ + ("## TODO a", "DONE", "## DONE a"), + ("TODO a", "DONE", "DONE a"), + ("TODO \tx", "DONE", "DONE x"), + ("TODO", "DONE", "DONE"), + ("TODO ", "DONE", "DONE"), + ("DONE", "DONE", "DONE"), + ("TODO \nnotes", "DONE", "DONE \nnotes"), + ("TODO a\nsecond TODO line", "DONE", "DONE a\nsecond TODO line"), + ("# TODO a", "DONE", "# DONE a"), + ("WAITING x", "DONE", "DONE x"), + ("WAIT x", "DONE", "DONE x"), + ("STARTED x", "TODO", "TODO x"), + ("IN-PROGRESS x", "DONE", "DONE x"), + ("CANCELLED x", "TODO", "TODO x"), + ("## WAITING x", "DONE", "## DONE x"), + ]) + def test_swap(self, text, new, expected): + assert tasks.with_marker(text, new) == expected + + @pytest.mark.parametrize("text", ["todo a", "TODO\nnotes", "plain"]) + def test_a_text_without_a_marker_raises(self, text): + with pytest.raises(ValueError): + tasks.with_marker(text, "DONE") + + def test_an_unknown_marker_raises(self): + with pytest.raises(ValueError): + tasks.with_marker("TODO a", "FOO") + + +class TestStartsWithBox: + @pytest.mark.parametrize("text", [ + "[ ] a", "[x] a", "[X] a", "[ ]a", " [ ] a", "[ ] a\nsecond line"]) + def test_a_box(self, text): + assert tasks.starts_with_box(text) + + @pytest.mark.parametrize("text", [ + "## [ ] x", "* [ ] a", "- [ ] a", "1. [ ] a", "TODO [ ] x", "a [ ] b", "a\n[ ] b", + "[] a", "[ ] a", "[y] a", "", + "tags = [ ] means an empty list", "see [ ](https://example.com)", + "a sentence about [ ] brackets"]) + def test_no_box(self, text): + assert not tasks.starts_with_box(text) + + +LOG = "CLOCK: [2026-09-29 Tue 10:00:00]--[2026-09-29 Tue 10:05:00] => 00:05:00" + + +class TestTaskTextKeepsTheOldBehaviour: + """Where task_text lived inside get-todos, case by case; moving it changed + nothing. The first four are what an unclosed drawer, a drawer in a code + fence, a planning line in a fence and a stray closer did there.""" + + @pytest.mark.parametrize("content,marker,expected", [ + ("TODO x\n:LOGBOOK:\nno end", "TODO", "x"), + ("TODO x\n```\n:LOGBOOK:\n```\ny", "TODO", "x\n```"), + ("TODO x\n```\nSCHEDULED: <2026-09-25 Fri>\n```", "TODO", "x\n```\n```"), + ("TODO x\n:END:\ny", "TODO", "x\ny"), + ("TODO", "TODO", ""), + ("TODO x", "TODO", "x"), + ("DONE x", "DONE", "x"), + ("TODO x\nprio:: high", "TODO", "x"), + ("TODO x\n owner:: someone", "TODO", "x"), + ("TODO x\nSCHEDULED: <2026-09-25 Fri>", "TODO", "x"), + ("TODO x\nDEADLINE: <2026-09-25 Fri>", "TODO", "x"), + (f"TODO x\n:LOGBOOK:\n{LOG}\n:END:\nafter", "TODO", "x\nafter"), + ("TODO x\nsecond line", "TODO", "x\nsecond line"), + ("plain text\nprio:: a", None, "plain text"), + ("TODO a\nTODO b", "TODO", "a\nTODO b"), + ("TODO\nx", "TODO", "x"), + (" TODO x\n\n", "TODO", "x"), + ("TODO x", None, "TODO x"), + ("TODO x", "", "TODO x"), + ("TODO x", "IN-PROGRESS", "TODO x"), + ]) + def test_task_text(self, content, marker, expected): + assert tasks.task_text(content, marker) == expected From 8f6235d585fbfe8537006dd7c54367a81592a0e2 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:44:16 +0200 Subject: [PATCH 02/15] Read task text with the shared rule for lines that stay with the block get-todos built a task's text with a reading of :LOGBOOK: of its own: any :LOGBOOK: line opened a drawer, and everything up to a :END: was dropped, whether or not one ever came. mldoc reads it differently (measured with mldoc 1.5.7): a :LOGBOOK: that no :END: closes is text, and a line inside a code fence is code. For a block with such a line the two disagreed, and content, --match and --tag lost everything after it. task_text now takes the lines that belong to the block above from the one rule in blocktext. An unclosed :LOGBOOK: stays in the text, so does a :LOGBOOK: or a SCHEDULED:/DEADLINE: line inside a code fence and a stray :END:. Closed drawers, planning lines and property lines outside code read as before. Each of the four cases was red before the change; --match and --tag now find text behind the opener, which the old reading hid. Mutations tried, each caught by: - task_text back on the old line-by-line reading: the four task_text cases and the --match/--tag cases through get-todos - a pattern of its own instead of attached_line_mask: the source check (test_the_source_reads_the_shared_rule) and the unclosed-opener case Existing assertions changed: the four cases that pinned the old reading of task_text (TestTaskTextKeepsTheOldBehaviour, now TestTaskTextKeepsWhatItAlwaysDid for the rest), pinned to the shared rule in TestTaskTextReadsTheSharedRule. Part of #92. --- CHANGELOG.md | 6 +++ logseq_cli/tasks.py | 33 +++++------------ tests/test_tasks.py | 77 +++++++++++++++++++++++++++++++++++---- tests/test_todos_match.py | 30 +++++++++++++++ 4 files changed, 115 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb563e..2a81f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,12 @@ file carries most of the reasoning behind the tool. ### Changed +- `get-todos` (`content`, `--match`, `--tag`) now reads an unclosed + `:LOGBOOK:` as text and a `:LOGBOOK:` inside a code fence as code, the way + mldoc reads them (measured with mldoc 1.5.7); before, everything after such + a line was dropped from the task text. A `SCHEDULED:` or `DEADLINE:` line inside a code + fence and a stray `:END:` now stay too (#92). + - `doctor` no longer advises removing a config file that does not parse: "Fix the config file." Once the file carries a limit, removing it lifts the limit. diff --git a/logseq_cli/tasks.py b/logseq_cli/tasks.py index b160408..9907dd8 100644 --- a/logseq_cli/tasks.py +++ b/logseq_cli/tasks.py @@ -11,7 +11,7 @@ """ import re -from logseq_cli.blocktext import PLANNING_LINE_RE, property_line_mask +from logseq_cli.blocktext import attached_line_mask, property_line_mask from logseq_cli.datalog import edn_string # The words mldoc reads as :block/marker, in the order a list of tasks is @@ -128,29 +128,16 @@ def task_text(content: str, marker) -> str: Those lines are metadata of the task, not the task: left in, a repeating task reported on stderr printed its own timestamp line and a ``:LOGBOOK:`` - fragment instead of what it says. Which word the marker is comes from the - DB marker (``marker``), not from a list kept here: the list had drifted - and missed CANCELED and WAIT. Without a marker nothing is removed. + fragment instead of what it says. Which lines they are is blocktext's rule + (attached_line_mask, property_line_mask): an unclosed drawer opener is + text, and nothing inside a code block counts. Which word the marker is + comes from the DB marker (``marker``), not from a list kept here: the list + had drifted and missed CANCELED and WAIT. Without a marker nothing is + removed. """ - content_lines = [] - in_logbook = False - raw_lines = content.split("\n") - for line, is_property in zip(raw_lines, property_line_mask(raw_lines)): - stripped = line.strip() - if stripped == ":LOGBOOK:": - in_logbook = True - continue - if stripped == ":END:": - in_logbook = False - continue - if in_logbook: - continue - if is_property: - continue - if PLANNING_LINE_RE.match(line): - continue - content_lines.append(line) - clean = "\n".join(content_lines).strip() + raw = content.split("\n") + drop = [a or p for a, p in zip(attached_line_mask(raw), property_line_mask(raw))] + clean = "\n".join(line for line, d in zip(raw, drop) if not d).strip() if marker: clean = re.sub(rf"^{re.escape(marker)}(?:\s+|$)", "", clean) return clean diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 497ddff..1ae0837 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1,4 +1,7 @@ """The task rules: markers, states, reading and swapping a marker.""" +import ast +import pathlib + import pytest from logseq_cli import tasks @@ -840,16 +843,10 @@ def test_no_box(self, text): LOG = "CLOCK: [2026-09-29 Tue 10:00:00]--[2026-09-29 Tue 10:05:00] => 00:05:00" -class TestTaskTextKeepsTheOldBehaviour: - """Where task_text lived inside get-todos, case by case; moving it changed - nothing. The first four are what an unclosed drawer, a drawer in a code - fence, a planning line in a fence and a stray closer did there.""" +class TestTaskTextKeepsWhatItAlwaysDid: + """Cases that read the same under the old inline code and the shared rule.""" @pytest.mark.parametrize("content,marker,expected", [ - ("TODO x\n:LOGBOOK:\nno end", "TODO", "x"), - ("TODO x\n```\n:LOGBOOK:\n```\ny", "TODO", "x\n```"), - ("TODO x\n```\nSCHEDULED: <2026-09-25 Fri>\n```", "TODO", "x\n```\n```"), - ("TODO x\n:END:\ny", "TODO", "x\ny"), ("TODO", "TODO", ""), ("TODO x", "TODO", "x"), ("DONE x", "DONE", "x"), @@ -869,3 +866,67 @@ class TestTaskTextKeepsTheOldBehaviour: ]) def test_task_text(self, content, marker, expected): assert tasks.task_text(content, marker) == expected + + +class TestTaskTextReadsTheSharedRule: + """The lines that belong to a block are the ones blocktext says: a closed + drawer, a planning line outside a code block. An opener nothing closes is + text (mldoc 1.5.7), a drawer or planning line in a code fence is code, and + a closer without an opener is text.""" + + @pytest.mark.parametrize("content,expected", [ + ("TODO x\n:LOGBOOK:\nno end", "x\n:LOGBOOK:\nno end"), + ("TODO x\n```\n:LOGBOOK:\n```\ny", "x\n```\n:LOGBOOK:\n```\ny"), + ("TODO x\n```\nSCHEDULED: <2026-09-25 Fri>\n```", + "x\n```\nSCHEDULED: <2026-09-25 Fri>\n```"), + ("TODO x\n:END:\ny", "x\n:END:\ny"), + ]) + def test_task_text(self, content, expected): + assert tasks.task_text(content, "TODO") == expected + + +def _source_uses_the_shared_rule(path, function_name): + """Whether the source (one function of it, or the whole module when + function_name is None) calls attached_line_mask and keeps a rule of its + own for the lines of a block out: no PLANNING_LINE_RE, no string holding + the drawer opener. A docstring may name it.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + scope = tree + if function_name is not None: + scope = next(n for n in ast.walk(tree) + if isinstance(n, ast.FunctionDef) and n.name == function_name) + docstrings = set() + for node in ast.walk(scope): + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef)): + first = node.body[0] if node.body else None + if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant): + docstrings.add(id(first.value)) + calls = own_pattern = False + for node in ast.walk(scope): + if isinstance(node, ast.Name) and node.id == "attached_line_mask": + calls = True + if isinstance(node, ast.Name) and node.id == "PLANNING_LINE_RE": + own_pattern = True + if isinstance(node, ast.Attribute) and node.attr == "PLANNING_LINE_RE": + own_pattern = True + if (isinstance(node, ast.Constant) and isinstance(node.value, str) + and id(node) not in docstrings and ":LOGBOOK:" in node.value): + own_pattern = True + return calls and not own_pattern + + +PACKAGE = pathlib.Path(tasks.__file__).parent + + +class TestOneRuleForAttachedLines: + @pytest.mark.parametrize("filename,function", [("tasks.py", "task_text")]) + def test_the_source_reads_the_shared_rule(self, filename, function): + assert _source_uses_the_shared_rule(PACKAGE / filename, function) + + def test_the_check_catches_a_rule_of_its_own(self, tmp_path): + own = tmp_path / "own.py" + own.write_text('import re\n\ndef f(x):\n return re.match(r"\\s*:LOGBOOK:", x)\n') + assert not _source_uses_the_shared_rule(own, "f") + shared = tmp_path / "shared.py" + shared.write_text("def f(x):\n return attached_line_mask(x)\n") + assert _source_uses_the_shared_rule(shared, "f") diff --git a/tests/test_todos_match.py b/tests/test_todos_match.py index 283726b..cdad4b9 100644 --- a/tests/test_todos_match.py +++ b/tests/test_todos_match.py @@ -120,3 +120,33 @@ def test_a_bare_marker_leaves_no_text(self): with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke(cli, ["get-todos", "--no-follow-refs", "--json"]) assert json.loads(r.stdout)["todos"][0]["content"] == "" + + +class TestLogbookOpenerAndFenceAreText: + """An unclosed :LOGBOOK: is text and one inside a code fence is code + (mldoc 1.5.7): the task keeps them, and --match and --tag see them.""" + + def _run_block(self, content, *args): + api = MagicMock() + api.datascript_query.return_value = [ + [{"content": content, "marker": "TODO", "uuid": "u1"}, + {"original-name": "Page A", "name": "page a"}]] + with patch("logseq_cli.group.LogseqAPI", return_value=api): + r = split_runner().invoke(cli, ["get-todos", "--no-follow-refs", "--json", *args]) + return json.loads(r.stdout) + + @pytest.mark.parametrize("content,match", [ + ("TODO x\n:LOGBOOK:\nno end #urgent", "no end"), + ("TODO x\n```\n:LOGBOOK:\n```\ny #urgent", "y #urgent"), + ]) + def test_the_text_stays_and_is_found(self, content, match): + out = self._run_block(content) + assert out["todos"][0]["content"] == content[len("TODO "):] + assert self._run_block(content, "--match", match)["count"] == 1 + assert self._run_block(content, "--tag", "urgent")["count"] == 1 + + def test_a_closed_drawer_stays_out(self): + content = "TODO x\n:LOGBOOK:\nCLOCK: [2026-09-29 Tue 10:00:00]\n:END:\n#urgent" + out = self._run_block(content) + assert out["todos"][0]["content"] == "x\n#urgent" + assert self._run_block(content, "--match", "CLOCK")["count"] == 0 From 73b7c74166153a9de3d82bb52d55f6d770518e1c Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:50:52 +0200 Subject: [PATCH 03/15] Give test mocks an explicit task marker The test doubles worked out a block's marker from the first word of its text, knew only eight of the eleven markers, and clocked a task in or out by the marker it moved to. Logseq stores :block/marker when it parses the text, so a block such as "TODO\nnotes" has none, and it starts or closes the clock by the pair of the old and the new marker (time tracking, on by default). A mock that derives the marker from text would confirm the rule a test is meant to check. The marker is now stored data on each node, given by the fixture. The HTTP double sets it again in one place only, after a write, with the rule the package uses and a test compares with mldoc. Its time tracking follows tasks.CLOCK_IN_STEPS and CLOCK_OUT_STEPS: for example WAIT -> DOING starts no clock, which the old target sets did. A source test keeps marker lists and marker patterns out of the test helpers. No command behaves differently. One assertion changed: the step DONE -> NOW of the time tracking test expected a new CLOCK line. With time tracking on, 0.10.15 starts the clock only for the pairs in CLOCK_IN_STEPS (with-marker-time in handler/editor.cljs, read in the source, not measured: tracking was off in the measured graph), and DONE -> NOW is not one of them. Mutations tried, each caught by: - a tuple of its own in the double instead of tasks.CLOCK_IN_STEPS: the source test for marker lists in the test helpers - target sets instead of pairs: the WAIT -> DOING case of TestTimeTrackingFollowsThePair - no second reading of the marker after updateBlock: TestMarkerIsStoredState - DOING -> DOING starting a clock with a logbook present: the DOING -> DOING and NOW -> NOW cases with a closed drawer - PageGraph leaving the marker out of getBlock, with a temporary check in set-todo-status that the fixture's marker agrees with its text: 19 tests Part of #92. --- logseq_cli/tasks.py | 17 +++ tests/conftest.py | 10 +- tests/logseq_http_double.py | 74 ++++++++----- tests/test_alias_resolution.py | 2 +- tests/test_dry_run_coverage.py | 6 +- tests/test_follow_refs_chain.py | 14 +-- tests/test_logseq_http_double.py | 138 +++++++++++++++++++++++- tests/test_normalize_block_text.py | 2 +- tests/test_one_block_round_trip.py | 2 +- tests/test_tasks.py | 120 +++++++++++++++++++++ tests/test_todo_marker_keeps_lines.py | 19 ++-- tests/test_where_content.py | 6 +- tests/test_write_proof_every_command.py | 9 +- 13 files changed, 360 insertions(+), 59 deletions(-) diff --git a/logseq_cli/tasks.py b/logseq_cli/tasks.py index 9907dd8..b3ea8ea 100644 --- a/logseq_cli/tasks.py +++ b/logseq_cli/tasks.py @@ -36,6 +36,23 @@ # What Logseq offers in its own UI: every marker but STARTED. FRONTEND_MARKERS = tuple(m for m in ORDER if m != "STARTED") +# Which marker changes move Logseq's clock, as (old marker, new marker), None +# for a block without one. The pair decides, not the target: WAIT -> DOING and +# DONE -> NOW leave the text alone. Read from Logseq 0.10.15, not measured: +# with-marker-time in handler/editor.cljs and clock-in/clock-out in +# util/clock.cljs; only with time tracking on, Logseq's default. The last two +# starting pairs count only while the block has no logbook yet. Known gaps: +# Logseq judges that on the stored block's body, a double on the text after +# the write, and Logseq reads the new marker with its own marker-pattern +# instead of mldoc. +CLOCK_IN_STEPS = ( + (None, "DOING"), (None, "NOW"), ("TODO", "DOING"), ("LATER", "NOW"), + ("NOW", "NOW"), ("DOING", "DOING"), +) +CLOCK_OUT_STEPS = ( + ("DOING", "TODO"), ("NOW", "LATER"), ("DOING", "DONE"), ("NOW", "DONE"), +) + # Derived from ORDER, longest first, so WAITING is tried before WAIT. _WORD = "|".join(re.escape(m) for m in sorted(ORDER, key=len, reverse=True)) diff --git a/tests/conftest.py b/tests/conftest.py index c068f09..79db1cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -459,9 +459,12 @@ def _node(self, spec, *, keep=False, strip_ids=False, prefix=""): if strip_ids: content = "\n".join(ln for ln in content.split("\n") if not re.match(r"(?i)[\s\ufeff]*id:: ", ln)) - return {"uuid": uuid, "content": prefix + content, + node = {"uuid": uuid, "content": prefix + content, "children": [self._node(c, keep=keep, strip_ids=strip_ids, prefix=prefix) for c in spec.get("children") or []]} + if "marker" in spec: + node["marker"] = spec["marker"] + return node # --- lookup ------------------------------------------------------------ def page_named(self, name): @@ -512,7 +515,7 @@ def _out(self, block, page, parent, children=True): # Logseq's shapes, measured 0.10.15: parent and page by database id; # without children the page as {id} alone and each direct child as a # ["uuid", ] pair; with them the page named and children blocks. - return {"id": self._db_id(block["uuid"]), + out = {"id": self._db_id(block["uuid"]), "uuid": block["uuid"], "content": block["content"], "preBlock?": bool(block.get("pre")), "page": ({"id": page["id"], "name": page["name"].lower(), @@ -521,6 +524,9 @@ def _out(self, block, page, parent, children=True): "parent": {"id": self._db_id(parent["uuid"]) if parent else page["id"]}, "children": [self._out(c, page, block) for c in block["children"]] if children else [["uuid", c["uuid"]] for c in block["children"]]} + if block.get("marker"): + out["marker"] = block["marker"] + return out # --- API surface ------------------------------------------------------- def get_page(self, name): diff --git a/tests/logseq_http_double.py b/tests/logseq_http_double.py index b6e0cc9..ec77d8b 100644 --- a/tests/logseq_http_double.py +++ b/tests/logseq_http_double.py @@ -41,6 +41,7 @@ import requests import logseq_cli.api +from logseq_cli import tasks from tests.conftest import _logseq_block_id # The write methods, as LogseqAPI calls them; set_mode takes these names. @@ -61,7 +62,6 @@ _PAGE_LINK = re.compile(r"\[\[([^\[\]]+)\]\]") _TAG = re.compile(r"(?:^|(?<=\s))#([^\s#\[\],]+)") _BLOCK_REF = re.compile(r"\(\(([0-9a-fA-F-]{36})\)\)") -_MARKERS = ("TODO", "DOING", "DONE", "LATER", "NOW", "WAITING", "CANCELED", "CANCELLED") # The date format of the measured graph. DATE_FORMAT = "yyyy-MM-dd, EEEE" @@ -208,11 +208,6 @@ def _only_properties(content: str): return _property_texts(content) -def _marker(content: str): - first = content.split(" ", 1)[0].split("\n", 1)[0] - return first if first in _MARKERS else None - - def _page_names(content: str, texts: dict) -> list: """Page names ``content`` refers to: [[Name]], #Name, tags::/alias:: values (all three count alike).""" @@ -408,7 +403,9 @@ def walk(nodes): def add_page(self, name, blocks=()): """A page as a file read in makes it: a first block holding only property lines is its property block. ``blocks`` are texts or - ``{"content", "children", "uuid"}``. Returns the page.""" + ``{"content", "children", "uuid", "marker"}``; ``marker`` is what + Logseq stored as the block's marker, and a block without the key has + none, whatever its text says. Returns the page.""" page = self._new_page(name, [self._build(b) for b in blocks]) self._settle(page) for node in self._walk(page["blocks"]): @@ -432,10 +429,25 @@ def _build(self, spec, *, keep=False, strip_ids=False, prefix=""): if strip_ids: content = "\n".join(ln for ln in content.split("\n") if not re.match(r"(?i)[\s]*id:: ", ln)) - return {"id": self._fresh_id(), "uuid": uuid, "content": prefix + content, + node = {"id": self._fresh_id(), "uuid": uuid, "content": prefix + content, "pre": False, "typed": {}, "children": [self._build(c, keep=keep, strip_ids=strip_ids, prefix=prefix) for c in spec.get("children") or []]} + if spec.get("marker"): + # What Logseq stored as :block/marker; never guessed from the text. + node["marker"] = spec["marker"] + return node + + def _reparse_task_fields(self, node): + """Logseq reads a block's text again after a write and sets + :block/marker from it. This is the one place the double does that, + and it uses the rule the production code uses, which a test checks + on its own against mldoc.""" + marker = tasks.marker_of(node["content"]) + if marker: + node["marker"] = marker + else: + node.pop("marker", None) def _new_page(self, name, blocks): day = _parse_date(name, self.date_format) @@ -551,9 +563,8 @@ def _block_out(self, page, node, parent, children): "propertiesTextValues": {_camel(k): v for k, v in texts.items()}, "preBlock?": bool(node["pre"]), "refs": self._refs(node)} - marker = _marker(node["content"]) - if marker: - out["marker"] = marker + if node.get("marker"): + out["marker"] = node["marker"] out["children"] = ([self._block_out(page, c, node, True) for c in node["children"]] if children else [["uuid", c["uuid"]] for c in node["children"]]) return out @@ -786,8 +797,12 @@ def _content_search(self, query): for page in (p for p in pages if p): for node in self._walk(page["blocks"]): if needle is None or needle in node["content"]: - rows.append([{"content": node["content"], "uuid": node["uuid"], - "page": self._page_pull(page)}]) + row = {"content": node["content"], "uuid": node["uuid"], + "page": self._page_pull(page)} + # A pull gives back what it names. + if node.get("marker") and ":block/marker" in query.split(":where")[0]: + row["marker"] = node["marker"] + rows.append([row]) return rows # --- writes -------------------------------------------------------------- @@ -922,6 +937,7 @@ def _append_block_in_page(self, args): if page is None: return None node = self._build({"content": content, "uuid": wanted}) + self._reparse_task_fields(node) page["blocks"].append(node) self._index(node) self._opened(page, node, options) @@ -941,6 +957,7 @@ def _insert_block(self, args): return None page, siblings, i, parent = found node = self._build({"content": content, "uuid": wanted}) + self._reparse_task_fields(node) if not options.get("sibling"): kids = siblings[i]["children"] kids.insert(0, node) if options.get("before") else kids.append(node) @@ -986,6 +1003,7 @@ def _insert_batch_block(self, args): target[at:at] = nodes made = list(self._walk(nodes)) for node in made: + self._reparse_task_fields(node) self._index(node) if made and page["id"] in self._visible: if self.batch_opens_after_checks: @@ -994,22 +1012,25 @@ def _insert_batch_block(self, args): self.editing = made[-1]["uuid"] return None - def _track_time(self, old, new): - """With time tracking on, a marker change to DOING/NOW clocks in, one - to DONE/LATER/TODO closes the open CLOCK line (upstream - util/clock.cljs ``clock-in`` and ``clock-out``, 0.10.15; format - assumed: time tracking was off in the measured graph).""" - before, after = _marker(old), _marker(new) - if not self.time_tracking or before == after: - return new - lines = new.split("\n") - if after in ("DOING", "NOW"): + def _track_time(self, old_marker, new_marker, text): + """With time tracking on, the pair (old marker, new marker) decides: + tasks.CLOCK_IN_STEPS start a clock, tasks.CLOCK_OUT_STEPS close the + open CLOCK line, any other pair leaves the text alone (upstream + util/clock.cljs, 0.10.15; format assumed: time tracking was off in the + measured graph). Two pairs start only without a logbook yet.""" + if not self.time_tracking: + return text + pair = (old_marker, new_marker) + lines = text.split("\n") + if pair in tasks.CLOCK_IN_STEPS: + if pair in (("NOW", "NOW"), ("DOING", "DOING")) and ":LOGBOOK:" in lines: + return text if ":LOGBOOK:" in lines and ":END:" in lines[lines.index(":LOGBOOK:"):]: end = lines.index(":END:", lines.index(":LOGBOOK:")) lines.insert(end, CLOCK_IN) return "\n".join(lines) return "\n".join([*lines, ":LOGBOOK:", CLOCK_IN, ":END:"]) - if after in ("DONE", "LATER", "TODO"): + if pair in tasks.CLOCK_OUT_STEPS: open_clocks = [i for i, ln in enumerate(lines) if ln.startswith("CLOCK: [") and "--" not in ln] if open_clocks: @@ -1047,7 +1068,10 @@ def _update_block(self, args): new = "\n".join(lines) if new == node["content"]: return None - node["content"] = self._track_time(node["content"], new) + before = node.get("marker") + node["content"] = new + self._reparse_task_fields(node) + node["content"] = self._track_time(before, node.get("marker"), new) wanted = _logseq_block_id(node["content"]) if wanted: node["uuid"] = wanted.lower() diff --git a/tests/test_alias_resolution.py b/tests/test_alias_resolution.py index bc36476..7c3bb5c 100644 --- a/tests/test_alias_resolution.py +++ b/tests/test_alias_resolution.py @@ -29,7 +29,7 @@ def _graph(**extra): pages = {TARGET: [f"alias:: {ALIAS}\ncolor:: red", - {"content": "TODO errand", "uuid": TODO}]} + {"content": "TODO errand", "uuid": TODO, "marker": "TODO"}]} pages.update(extra.pop("pages", {})) return PageGraph(pages, aliases=extra.pop("aliases", {ALIAS: [TARGET]}), **extra) diff --git a/tests/test_dry_run_coverage.py b/tests/test_dry_run_coverage.py index fca0e5d..8d04029 100644 --- a/tests/test_dry_run_coverage.py +++ b/tests/test_dry_run_coverage.py @@ -52,7 +52,7 @@ def _json_payload(result): # set-todo-status # --------------------------------------------------------------------------- # As Logseq answers getBlock without children: the page as its id. -TASK_BLOCK = {"uuid": "00000000-0000-4000-8000-0000000000a1", "content": "TODO Write the report", +TASK_BLOCK = {"uuid": "00000000-0000-4000-8000-0000000000a1", "content": "TODO Write the report", "marker": "TODO", "page": {"id": 1}} @@ -98,8 +98,8 @@ def test_missing_block_still_fails_under_dry_run(self, api): def test_ambiguous_content_still_aborts_under_dry_run(self, api): # Two TODO blocks match: the command refuses to guess, preview or not. api.datascript_query.return_value = [ - [{"uuid": "a", "content": "TODO Ship the release"}], - [{"uuid": "b", "content": "TODO Ship the docs"}], + [{"uuid": "a", "content": "TODO Ship the release", "marker": "TODO"}], + [{"uuid": "b", "content": "TODO Ship the docs", "marker": "TODO"}], ] result = split_runner().invoke(cli, ["set-todo-status", "--content", "Ship", "--page", "Project Alpha", diff --git a/tests/test_follow_refs_chain.py b/tests/test_follow_refs_chain.py index 32fc387..40388e4 100644 --- a/tests/test_follow_refs_chain.py +++ b/tests/test_follow_refs_chain.py @@ -32,7 +32,7 @@ def run(blocks, *args, placeholders=()): def task(): - return {"uuid": TASK, "content": f"TODO ship it\nid:: {TASK}"} + return {"uuid": TASK, "content": f"TODO ship it\nid:: {TASK}", "marker": "TODO"} def pointer(uuid, to, embed=False): @@ -54,7 +54,7 @@ class TestWhatStays: @pytest.mark.parametrize("ref", [TASK, TASK.upper()], ids=["lower", "capitals"]) def test_a_ref_to_a_task_changes_the_task(self, ref): - result, content, _ = run([{"uuid": TASK, "content": "TODO ship it"}, + result, content, _ = run([{"uuid": TASK, "content": "TODO ship it", "marker": "TODO"}, {"uuid": HOST, "content": f"(({ref}))"}], "--id", HOST, "--follow-refs") assert result.exit_code == 0, result.stderr @@ -62,13 +62,13 @@ def test_a_ref_to_a_task_changes_the_task(self, ref): assert content(HOST) == f"(({ref}))" def test_a_block_without_a_ref_is_changed_itself(self): - result, content, _ = run([{"uuid": HOST, "content": "TODO ship it"}], + result, content, _ = run([{"uuid": HOST, "content": "TODO ship it", "marker": "TODO"}], "--id", HOST, "--follow-refs") assert result.exit_code == 0, result.stderr assert content(HOST) == "DONE ship it" def test_a_labelled_ref_is_a_link_and_is_changed_itself(self): - result, content, _ = run([{"uuid": TASK, "content": "TODO ship it"}, + result, content, _ = run([{"uuid": TASK, "content": "TODO ship it", "marker": "TODO"}, {"uuid": HOST, "content": f"[see]((({TASK})))"}], "--id", HOST, "--follow-refs") assert result.exit_code == 0, result.stderr @@ -81,7 +81,7 @@ def test_an_unknown_block_is_not_found_in_the_same_words(self): assert f"Block {GONE} not found." in result.stderr def test_without_follow_refs_the_output_has_no_chain(self): - result, _, _ = run([{"uuid": HOST, "content": "TODO ship it"}], "--id", HOST, "--json") + result, _, _ = run([{"uuid": HOST, "content": "TODO ship it", "marker": "TODO"}], "--id", HOST, "--json") assert "followed" not in json.loads(result.stdout) @@ -220,13 +220,13 @@ def test_the_write_result_carries_it(self): assert json.loads(result.stdout)["followed"] == [TASK] def test_an_unchanged_result_carries_it(self): - result, _, _ = run([{"uuid": TASK, "content": "DONE ship it"}, pointer(HOST, TASK)], + result, _, _ = run([{"uuid": TASK, "content": "DONE ship it", "marker": "DONE"}, pointer(HOST, TASK)], "--id", HOST, "--follow-refs", "--json") payload = json.loads(result.stdout) assert payload["status"] == "unchanged" and payload["followed"] == [TASK] def test_without_a_chain_it_is_empty(self): - result, _, _ = run([{"uuid": HOST, "content": "TODO ship it"}], + result, _, _ = run([{"uuid": HOST, "content": "TODO ship it", "marker": "TODO"}], "--id", HOST, "--follow-refs", "--json") assert json.loads(result.stdout)["followed"] == [] diff --git a/tests/test_logseq_http_double.py b/tests/test_logseq_http_double.py index 2f6958e..42a4851 100644 --- a/tests/test_logseq_http_double.py +++ b/tests/test_logseq_http_double.py @@ -19,7 +19,7 @@ from logseq_cli.lookup import find_blocks_by_content, incoming_block_refs from logseq_cli.pagenames import alias_sources, resolve_page from tests.conftest import split_runner -from tests.logseq_http_double import LogseqHttpDouble +from tests.logseq_http_double import CLOCK_IN, CLOCK_OUT, LogseqHttpDouble URL = "http://127.0.0.1:12315/api" UNKNOWN = "5f0c7a8e-1b2d-4c3e-9f4a-6b7c8d9e0f1a" @@ -656,7 +656,7 @@ def test_insert_opens_the_new_block_unless_focus_false(double): def test_time_tracking_off_by_default(double): - double.add_page("Probe Page", ["TODO task"]) + double.add_page("Probe Page", [{"content": "TODO task", "marker": "TODO"}]) uuid = double.uuid_of("TODO task") answer(double, "updateBlock", uuid, "DOING task") assert double.tree("Probe Page") == [("DOING task", [])] @@ -666,7 +666,7 @@ def test_time_tracking_clocks_in_and_out(double): # Upstream util/clock.cljs clock-in and clock-out, 0.10.15; the format # is assumed, not measured. double.time_tracking = True - double.add_page("Probe Page", ["TODO task"]) + double.add_page("Probe Page", [{"content": "TODO task", "marker": "TODO"}]) uuid = double.uuid_of("TODO task") answer(double, "updateBlock", uuid, "DOING task") content = answer(double, "getBlock", uuid, {"includeChildren": False})["content"] @@ -678,7 +678,137 @@ def test_time_tracking_clocks_in_and_out(double): ":END:") answer(double, "updateBlock", uuid, content.replace("DONE", "NOW", 1)) content = answer(double, "getBlock", uuid, {"includeChildren": False})["content"] - assert content.endswith("00:05:00\nCLOCK: [2026-09-26 Sat 14:00]\n:END:") + # DONE -> NOW is in neither list of pairs: Logseq leaves the text alone + # (with-marker-time, 0.10.15), so the closed clock stays and no new one starts. + assert content == ("NOW task\n:LOGBOOK:\n" + "CLOCK: [2026-09-26 Sat 14:00]--[2026-09-26 Sat 14:05] => 00:05:00\n" + ":END:") + + +def _block(double, uuid): + return answer(double, "getBlock", uuid, {"includeChildren": False}) + + +class TestMarkerIsStoredState: + """A fixture says what Logseq stored as the block's marker; the double + does not guess it from the text. After a write it reads the text again, + as Logseq does.""" + + def test_a_fixture_marker_is_read_back(self, double): + double.add_page("Probe Page", [{"content": "TODO x", "marker": "TODO"}]) + assert _block(double, double.uuid_of("TODO x"))["marker"] == "TODO" + + def test_the_double_does_not_guess_from_the_text(self, double): + double.add_page("Probe Page", ["TODO plain", {"content": "TODO\nnotes"}]) + assert "marker" not in _block(double, double.uuid_of("TODO plain")) + assert "marker" not in _block(double, double.uuid_of("TODO\nnotes")) + + @pytest.mark.parametrize("text,marker", [ + ("DOING x", "DOING"), ("todo x", None), ("## DONE x", "DONE"), ("TODO\nnotes", None)]) + def test_update_block_reads_the_marker_again(self, double, text, marker): + double.add_page("Probe Page", [{"content": "TODO a", "marker": "TODO"}]) + uuid = double.uuid_of("TODO a") + answer(double, "updateBlock", uuid, text) + assert _block(double, uuid).get("marker") == marker + + def test_insert_block_reads_the_marker(self, double): + double.add_page("Probe Page", [{"content": "TODO a", "marker": "TODO"}]) + out = answer(double, "insertBlock", double.uuid_of("TODO a"), "WAIT y", {"sibling": True}) + assert out["marker"] == "WAIT" + + def test_append_block_in_page_reads_the_marker(self, double): + double.add_page("Probe Page", ["a"]) + out = answer(double, "appendBlockInPage", "Probe Page", "STARTED z") + assert out["marker"] == "STARTED" + + def test_insert_batch_block_reads_the_marker_of_every_node(self, double): + double.add_page("Probe Page", ["a"]) + anchor = double.uuid_of("a") + answer(double, "insertBatchBlock", anchor, [{"content": "IN-PROGRESS q"}], + {"sibling": True}) + assert _block(double, double.uuid_of("IN-PROGRESS q"))["marker"] == "IN-PROGRESS" + answer(double, "insertBatchBlock", anchor, + [{"content": "TODO p", "children": [{"content": "DOING c"}]}], {"sibling": False}) + assert _block(double, double.uuid_of("TODO p"))["marker"] == "TODO" + assert _block(double, double.uuid_of("DOING c"))["marker"] == "DOING" + + +# Logseq 0.10.15 with-marker-time (handler/editor.cljs) and clock-in/clock-out +# (util/clock.cljs), read in the source, not measured (time tracking was off +# in the measured graph). The pair (old marker, new marker) decides, not the +# target. Two known gaps: Logseq reads the new marker with its own +# marker-pattern, not with mldoc (DOING followed by a line break starts a +# clock there, not here; STARTED is not in marker-pattern); and "without a +# LOGBOOK" is judged on the stored block's body there, on the text after the +# write here: the cases put the same drawer in both texts, so the difference +# stays untested, and none of the cases is affected by it. +CLOCKS = [ + (None, "DOING", None, "in"), (None, "NOW", None, "in"), ("TODO", "DOING", None, "in"), + ("LATER", "NOW", None, "in"), ("TODO", "DOING", "closed", "in"), + ("DOING", "DOING", None, "in"), ("NOW", "NOW", None, "in"), + ("DOING", "DOING", "closed", "none"), ("NOW", "NOW", "closed", "none"), + ("DOING", "TODO", "open", "out"), ("NOW", "LATER", "open", "out"), + ("DOING", "DONE", "open", "out"), ("NOW", "DONE", "open", "out"), + ("WAIT", "DOING", None, "none"), ("WAITING", "DOING", None, "none"), + ("TODO", "NOW", None, "none"), ("LATER", "DOING", None, "none"), + ("TODO", "DONE", None, "none"), ("DONE", "NOW", "closed", "none"), + ("DOING", "DONE", "closed", "none"), +] + + +def _clock_texts(old, new, drawer): + head_old = f"{old} task" if old else "task" + head_new = f"{new} task changed" if old == new else f"{new} task" + lines = {None: [], "closed": [CLOCK_OUT], "open": [CLOCK_IN]}[drawer] + + def text(head): + return head if drawer is None else "\n".join([head, ":LOGBOOK:", *lines, ":END:"]) + + return text(head_old), text(head_new), head_new, lines + + +class TestTimeTrackingFollowsThePair: + @pytest.mark.parametrize("old,new,drawer,effect", CLOCKS, + ids=[f"{o}-{n}-{d}-{e}" for o, n, d, e in CLOCKS]) + def test_clock(self, double, old, new, drawer, effect): + double.time_tracking = True + old_text, new_text, head, lines = _clock_texts(old, new, drawer) + fixture = {"content": old_text} + if old: + fixture["marker"] = old + double.add_page("Probe Page", [fixture]) + uuid = double.uuid_of(old_text) + answer(double, "updateBlock", uuid, new_text) + content = _block(double, uuid)["content"] + if effect == "in": + expected = "\n".join([head, ":LOGBOOK:", *lines, CLOCK_IN, ":END:"]) + elif effect == "out": + expected = "\n".join([head, ":LOGBOOK:", CLOCK_OUT, ":END:"]) + else: + expected = new_text + assert content == expected + + +class TestContentSearchCarriesTheMarker: + QUERY = ('[:find (pull ?b [:block/content :block/uuid {PULL} ' + '{:block/page [:block/original-name :block/name]}]) ' + ':where [?p :block/name "probe page"] [?b :block/page ?p] ' + '[?b :block/content ?c] [(clojure.string/includes? ?c "task")]]') + + def _rows(self, double, pull): + double.add_page("Probe Page", [{"content": "TODO task", "marker": "TODO"}, "plain task"]) + query = self.QUERY.replace("{PULL} ", pull) + return {r[0]["content"]: r[0] for r in + answer(double, "logseq.DB.datascriptQuery", query)} + + def test_a_pull_that_names_the_marker_gets_it(self, double): + rows = self._rows(double, ":block/marker ") + assert rows["TODO task"]["marker"] == "TODO" + assert "marker" not in rows["plain task"] + + def test_a_pull_that_does_not_name_it_gets_none(self, double): + rows = self._rows(double, "") + assert all("marker" not in r for r in rows.values()) # --- protocol ------------------------------------------------------------------ diff --git a/tests/test_normalize_block_text.py b/tests/test_normalize_block_text.py index d2a75dc..00f7d4f 100644 --- a/tests/test_normalize_block_text.py +++ b/tests/test_normalize_block_text.py @@ -93,7 +93,7 @@ def test_replace_text_appended_logbook_is_success(monkeypatch): # drawer: the write landed, and replace-text must not report it failed. double = LogseqHttpDouble().install(monkeypatch) double.time_tracking = True - double.add_page("Probe Page", ["TODO task one", "other"]) + double.add_page("Probe Page", [{"content": "TODO task one", "marker": "TODO"}, "other"]) r = split_runner().invoke(cli, ["--token", "t", "replace-text", "--page", "Probe Page", "--find", "TODO", "--replace", "DOING", "--json"]) assert double.tree("Probe Page")[0] == ("DOING task one", []) diff --git a/tests/test_one_block_round_trip.py b/tests/test_one_block_round_trip.py index 4a5509f..0c75cd3 100644 --- a/tests/test_one_block_round_trip.py +++ b/tests/test_one_block_round_trip.py @@ -239,7 +239,7 @@ def test_replace_text_and_set_todo_status_leave_an_existing_line_alone(): # Logseq's editor makes such blocks too; changing another line of one is # no reason to refuse. api = page_graph_api(PageGraph({"Page A": [ - {"uuid": ANCHOR, "content": "TODO a word\n- b"}]})) + {"uuid": ANCHOR, "content": "TODO a word\n- b", "marker": "TODO"}]})) api.update_block.side_effect = lambda u, c, properties=None, replacing=None: \ api.graph.locate(u)[1][api.graph.locate(u)[2]].update(content=c) r = _run(["replace-text", "--page", "Page A", "--find", "word", "--replace", "term"], api) diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 1ae0837..1a852c3 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -5,6 +5,7 @@ import pytest from logseq_cli import tasks +from tests.test_marker_lists import WORD, sites # Generated with mldoc 1.5.7, the parser Logseq ships; not edited by hand. # (prefix, word, separator, text, marker): marker is what mldoc reads in the @@ -930,3 +931,122 @@ def test_the_check_catches_a_rule_of_its_own(self, tmp_path): shared = tmp_path / "shared.py" shared.write_text("def f(x):\n return attached_line_mask(x)\n") assert _source_uses_the_shared_rule(shared, "f") + + +# --- the test helpers keep no marker rule of their own ------------------------ + +TESTS = pathlib.Path(__file__).parent +DOUBLE = "logseq_http_double.py" +ALLOWED_CALLER = ("LogseqHttpDouble", "_reparse_task_fields") +_RE_CALLS = {"compile", "match", "search", "sub", "findall", "fullmatch"} + + +def helper_files(): + """The helpers of the tests: every file in tests/ that is not a test.""" + return sorted(p for p in TESTS.glob("*.py") + if not p.name.startswith("test_") and p.name != "__init__.py") + + +def marker_lists(tree): + return sites(tree) + + +def marker_regexes(tree): + """Calls of re.compile/match/... with a string holding a marker word.""" + found = [] + for node in ast.walk(tree): + if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) + and node.func.attr in _RE_CALLS and isinstance(node.func.value, ast.Name) + and node.func.value.id == "re"): + for arg in node.args: + if isinstance(arg, ast.Constant) and isinstance(arg.value, str) \ + and WORD.search(arg.value): + found.append(node.lineno) + return found + + +def old_names(tree): + """The module name _MARKERS and a function _marker.""" + found = [] + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "_marker": + found.append(node.lineno) + if isinstance(node, ast.Name) and node.id == "_MARKERS": + found.append(node.lineno) + return found + + +def marker_of_callers(tree): + """(class, function) around each call of marker_of, by name or attribute.""" + found = [] + + def visit(node, cls, func): + if isinstance(node, ast.ClassDef): + cls = node.name + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + func = node.name + if isinstance(node, ast.Call): + f = node.func + if (isinstance(f, ast.Name) and f.id == "marker_of") or \ + (isinstance(f, ast.Attribute) and f.attr == "marker_of"): + found.append((cls, func)) + for child in ast.iter_child_nodes(node): + visit(child, cls, func) + + visit(tree, None, None) + return found + + +def _parse(path): + return ast.parse(path.read_text(encoding="utf-8")) + + +class TestTestHelpersKeepNoMarkerRule: + """The tests decide what a task is the way Logseq does: a double that + derived the marker from the text with a rule of its own would confirm the + code under test with the very rule it is to check. The helpers carry no + marker list, no marker regex and the old names; only one method of the + double reads the text for a marker, as Logseq does after a write.""" + + def test_the_helpers_are_found(self): + names = {p.name for p in helper_files()} + assert {"conftest.py", DOUBLE} <= names + + @pytest.mark.parametrize("path", helper_files(), ids=lambda p: p.name) + def test_no_marker_list(self, path): + assert not marker_lists(_parse(path)), \ + f"{path.name} lists marker words; use tasks.ORDER or a fixture's own marker" + + @pytest.mark.parametrize("path", helper_files(), ids=lambda p: p.name) + def test_no_marker_regex(self, path): + assert not marker_regexes(_parse(path)), f"{path.name} matches a marker with a regex" + + @pytest.mark.parametrize("path", helper_files(), ids=lambda p: p.name) + def test_no_old_names(self, path): + assert not old_names(_parse(path)), f"{path.name} keeps _MARKERS or _marker" + + def test_marker_of_is_called_in_one_place(self): + calls = [] + for path in helper_files(): + calls += [(path.name, *c) for c in marker_of_callers(_parse(path))] + assert calls == [(DOUBLE, *ALLOWED_CALLER)] + + def test_the_guard_sees_a_list(self): + assert marker_lists(ast.parse('X = ("TODO", "DONE")')) + assert not marker_lists(ast.parse("from logseq_cli import tasks\nX = tasks.CLOCK_IN_STEPS\n" + "Y = tasks.CLOCK_OUT_STEPS")) + + def test_the_guard_sees_a_regex(self): + assert marker_regexes(ast.parse('import re\nre.compile(r"^(TODO|DONE) ")')) + assert not marker_regexes(ast.parse('import re\nre.compile(r"^x")')) + + def test_the_guard_sees_the_old_names(self): + assert old_names(ast.parse("def _marker(c):\n pass")) + assert old_names(ast.parse("_MARKERS = 1")) + + def test_the_guard_places_a_marker_of_call(self): + elsewhere = ast.parse("class A:\n def f(self):\n return tasks.marker_of(x)") + assert marker_of_callers(elsewhere) == [("A", "f")] + here = ast.parse("class LogseqHttpDouble:\n def _reparse_task_fields(self):\n" + " return tasks.marker_of(x)") + assert marker_of_callers(here) == [ALLOWED_CALLER] diff --git a/tests/test_todo_marker_keeps_lines.py b/tests/test_todo_marker_keeps_lines.py index 2f5dfb4..3ed8b1f 100644 --- a/tests/test_todo_marker_keeps_lines.py +++ b/tests/test_todo_marker_keeps_lines.py @@ -17,16 +17,19 @@ UUID = "7e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a70" -@pytest.mark.parametrize("old,new", [ - ("TODO\nnotes", "DONE\nnotes"), - ("TODO\n```js\nx()\n```", "DONE\n```js\nx()\n```"), - ("TODO write it\nnotes:: more", "DONE write it\nnotes:: more"), - ("TODO spaced", "DONE spaced"), - ("no marker\nsecond", "DONE no marker\nsecond"), +# The marker is what Logseq stored for the block, the third value: a bare +# marker before a line break is no task to Logseq, so it has none. +@pytest.mark.parametrize("old,new,marker", [ + ("TODO\nnotes", "DONE\nnotes", None), + ("TODO\n```js\nx()\n```", "DONE\n```js\nx()\n```", None), + ("TODO write it\nnotes:: more", "DONE write it\nnotes:: more", "TODO"), + ("TODO spaced", "DONE spaced", "TODO"), + ("no marker\nsecond", "DONE no marker\nsecond", None), ], ids=["bare marker", "code under a bare marker", "property line", "extra space", "no marker"]) -def test_only_the_marker_changes(old, new): - api = page_graph_api(PageGraph({"Page A": [{"uuid": UUID, "content": old}]})) +def test_only_the_marker_changes(old, new, marker): + block = {"uuid": UUID, "content": old, **({"marker": marker} if marker else {})} + api = page_graph_api(PageGraph({"Page A": [block]})) api.update_block.side_effect = lambda u, c, properties=None, replacing=None: \ api.graph.locate(u)[1][api.graph.locate(u)[2]].update(content=c) with patch("logseq_cli.group.LogseqAPI", return_value=api): diff --git a/tests/test_where_content.py b/tests/test_where_content.py index 24f3e23..24f6807 100644 --- a/tests/test_where_content.py +++ b/tests/test_where_content.py @@ -120,8 +120,8 @@ class TestSetTodoStatusAmbiguity: """Regression: it used to take candidates[0] and rewrite it silently.""" def test_two_matching_todos_abort(self): - api = _api([{"uuid": "u-A", "content": "TODO Report (A)"}, - {"uuid": "u-B", "content": "TODO Report (B)"}]) + api = _api([{"uuid": "u-A", "content": "TODO Report (A)", "marker": "TODO"}, + {"uuid": "u-B", "content": "TODO Report (B)", "marker": "TODO"}]) with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "set-todo-status", "--content", "Report", "--page", "X", @@ -132,7 +132,7 @@ def test_two_matching_todos_abort(self): def test_todo_marker_still_disambiguates_prose(self): """A TODO plus a prose mention is not ambiguous: the marker decides.""" - api = _api([{"uuid": "u-A", "content": "TODO Write report"}, + api = _api([{"uuid": "u-A", "content": "TODO Write report", "marker": "TODO"}, {"uuid": "u-B", "content": "see Write report above"}]) with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ diff --git a/tests/test_write_proof_every_command.py b/tests/test_write_proof_every_command.py index 85523ff..87d9be2 100644 --- a/tests/test_write_proof_every_command.py +++ b/tests/test_write_proof_every_command.py @@ -200,7 +200,7 @@ def _graph(): double.add_page("Probe Page", [ {"content": "## Heading", "children": ["child one"]}, "alpha block", - "TODO task one", + {"content": "TODO task one", "marker": "TODO"}, "beta\nprio:: 1", {"content": "parent block", "children": ["kid block"]}, ]) @@ -341,7 +341,8 @@ def test_update_block_with_properties_is_not_a_false_alarm(monkeypatch): def test_set_todo_status_with_logbook_is_not_a_false_alarm(monkeypatch): # With time tracking on, TODO -> DOING appends a drawer (read in the code, # not measured). - double = _one_page(monkeypatch, "TODO task one", time_tracking=True) + double = _one_page(monkeypatch, {"content": "TODO task one", "marker": "TODO"}, + time_tracking=True) r = _invoke(double, ["set-todo-status", "--id", "@TODO task one", "--status", "DOING"]) assert r.exit_code == 0, r.stderr assert double.tree("Probe Page")[0][0] == "DOING task one" @@ -352,7 +353,7 @@ def test_doing_to_done_with_existing_drawer_is_success(monkeypatch): # set-todo-status sends the old drawer back; Logseq closes its CLOCK line. from tests.logseq_http_double import CLOCK_IN, CLOCK_OUT text = f"DOING task one\n:LOGBOOK:\n{CLOCK_IN}\n:END:" - double = _one_page(monkeypatch, text, time_tracking=True) + double = _one_page(monkeypatch, {"content": text, "marker": "DOING"}, time_tracking=True) r = _invoke(double, ["set-todo-status", "--id", f"@{text}", "--status", "DONE"]) assert r.exit_code == 0, r.stderr assert double.uuid_of(f"DONE task one\n:LOGBOOK:\n{CLOCK_OUT}\n:END:") @@ -360,7 +361,7 @@ def test_doing_to_done_with_existing_drawer_is_success(monkeypatch): def test_marker_change_without_drawer_is_success(monkeypatch): # Time tracking off: read back is what was written (measured). - double = _one_page(monkeypatch, "TODO task one") + double = _one_page(monkeypatch, {"content": "TODO task one", "marker": "TODO"}) for old, new in (("TODO", "DOING"), ("DOING", "DONE")): r = _invoke(double, ["set-todo-status", "--id", f"@{old} task one", "--status", new]) assert r.exit_code == 0, r.stderr From e9b98575430aa8d9de4426f3494e096b2d82b787 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:57:18 +0200 Subject: [PATCH 04/15] Refuse set-todo-status on a block Logseq does not read as a task set-todo-status took the first word of the text for the marker. A heading task (## TODO ship it) became DONE ## TODO ship it, which Logseq no longer reads as a task, a block without a marker (todo x, no marker) became a task, and TODO followed by a space and a line break lost the space, which also made it text (measured with Logseq's parser, mldoc 1.5.7). The command now decides by the marker Logseq keeps in its database and swaps it where Logseq reads it, behind a heading prefix. A block with no marker, one whose marker is not at the start of the text, and one that only points to another block are refused before the preview, also under --dry-run. --content that matches blocks but no task is refused too, except with --follow-refs, where a match that only points to a task is followed as before. A block that already carries the status answers "No change (block already has this status)." Not found, ambiguous and a missing selector answer under --json with a reason. get-todos reports the content of a heading task without its marker. Mutations tried, and the test that caught each: - marker not taken from the database: the "TODO x" refusal case - no wording for a pointer, none for a line break: their refusal cases - no refusal for matches without a task, or one that also holds with --follow-refs: TestSetTodoStatusNeedsATask - no "no change" answer: the unchanged cases - the space before a line break lost: the "space before the line break" case - only the database marker checked: the "##TODO x" case - old_marker read from the first word: the heading dry-run case - the page named as given, not as the alias resolves: the alias case - matches listing only ten uuids, a candidate line cut at 80: the ambiguity case - --id also searching --content, the selector checked after a request: TestSetTodoStatusReasons - heading prefix of spaces only, of any blank, or dropped: the heading prefix cases of task_text and get-todos - the marker of the pull moved before the uuid, another default, one of the four queries left out: test_find_blocks_task_fields Existing assertions changed, with the reason: - test_set_todo_status_on_a_block_that_starts_with_a_fence: a block that starts with a fence has no marker, so it is refused as no task before a marker could be put in front of the fence - test_a_labelled_ref_is_a_link and test_what_logseq_reads_as_no_embed: Logseq reads neither as an embed, and no marker in them; they were rewritten into a task, and are refused now - test_only_the_marker_changes: the bare marker, code under a bare marker and no marker cases are refusals now, the heading and line break cases are new - TestMarkerIsStrippedForEveryMarker takes its markers from tasks.ORDER Seen red before the change: the refusal, unchanged, reason, heading and alias cases against the code as it was. Part of #92. --- AGENTS.md | 14 ++- CHANGELOG.md | 24 ++++ docs/commands.md | 2 +- logseq_cli/blocktext.py | 5 - logseq_cli/commands/todos.py | 116 ++++++++++--------- logseq_cli/lookup.py | 26 ++++- logseq_cli/tasks.py | 10 +- tests/test_find_blocks_task_fields.py | 80 +++++++++++++ tests/test_follow_refs_chain.py | 31 +++-- tests/test_marker_lists.py | 1 - tests/test_one_block_round_trip.py | 12 +- tests/test_tasks.py | 27 +++++ tests/test_todo_marker_keeps_lines.py | 122 +++++++++++++++++--- tests/test_todos_match.py | 158 +++++++++++++++++++++++++- 14 files changed, 527 insertions(+), 101 deletions(-) create mode 100644 tests/test_find_blocks_task_fields.py diff --git a/AGENTS.md b/AGENTS.md index f75f068..dde41cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,14 @@ logseq-cli set-todo-status --id JOURNAL-REF-UUID --status DONE --follow-refs at the end; `followed` in `--json` lists the blocks it passed. A chain that loops or reaches a missing block is refused and nothing is written. +`set-todo-status` changes a task only. A block Logseq reads no marker in +(`todo x`, `TODO:`, `TODO` followed directly by a line break) is refused with +`not_a_task`, and nothing is written; so is a block whose marker is not at the +start of the text, and a block that only points to another one (the message +names `--follow-refs`). `--content` that matches blocks but no task is refused +with `no_task_matches`, unless `--follow-refs` is given. A heading task +(`## TODO x`) keeps its heading: `## DONE x`. + Do not use `replace-text` to change a marker: it rewrites by text match, so it also hits the word elsewhere on the page and silently retypes the rest of the line. `set-todo-status` swaps only the marker, in one call. @@ -443,9 +451,13 @@ rollback), and the message ends with how many, or "Nothing was written.". | `write_not_verified` | The write does not show in Logseq: not written, or not all of it | `method`, `target`, `expected`, `got` | | `read_only` | Writes are switched off by `[safety] read_only`, `LOGSEQ_CLI_READ_ONLY` or `--read-only`; every command that writes refuses, `--dry-run` included, before its first request | `source` (list of `config`, `env`, `flag`), `config_path` (`null` when no config file was found) | | `config_error` | The config file cannot be used: it does not parse, `LOGSEQ_CLI_CONFIG` names a file that is not there, or `[safety]` does not check out: a key it does not know, a `[safety]` key outside `[safety]`, a value that is not a boolean, or a `[graph] keep_empty_blocks_last` that is not. A command that writes refuses rather than run without the limits the file may hold. Nothing was sent | none | -| `block_not_found` | The block the command names does not exist; the placeholder Logseq keeps for a missing ref target counts as none | `id` | +| `block_not_found` | The block the command names does not exist; the placeholder Logseq keeps for a missing ref target counts as none | `id`; for `set-todo-status --content` the `content` and `page` searched instead, no `id` | | `dead_ref` | `set-todo-status --follow-refs`: the chain of refs leads to a block that does not exist | `id` (the missing block), `followed` | | `ref_cycle` | `set-todo-status --follow-refs`: the chain of refs comes back to a block it passed | `id` (the block met again), `followed` | +| `not_a_task` | `set-todo-status` on a block Logseq reads no marker in, or whose marker is not at the start of the text (after an optional `#` prefix and a space), or that only points to another block and `--follow-refs` is missing | `id`; `points_to` for the pointer case | +| `no_task_matches` | `set-todo-status --content`: blocks match, none is a task; not given with `--follow-refs` | `content`, `page`, `match_count` | +| `ambiguous` | `set-todo-status --content`: several blocks match | `content`, `page`, `matches` (the uuids of all candidates) | +| `missing_selector` | `set-todo-status` without `--id` and without `--content` plus `--page`; refused before any request | none | `set-property` and `remove-property --name` check the page themselves and name `page` and `property` instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a81f23..32492af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,21 @@ file carries most of the reasoning behind the tool. ### Changed +- `set-todo-status` refuses a block that is no task instead of making it one: + `no marker` became `DONE no marker`, `todo x` became `DONE x`. Logseq reads + no marker in such a block (`todo x`, `TODO:`, `TODO` followed directly by a + line break); the command now stops with `reason: "not_a_task"` and writes + nothing, also under `--dry-run`. A block that only points to another one is + refused too, naming `--follow-refs`. With `--content`, blocks that match but + none of which is a task are refused (`no_task_matches`) instead of one being + rewritten; with `--follow-refs` this holds for the block at the end of the + chain, and a match that only points to a task is followed as before. Under + `--json`, a block not found by `--content`, an ambiguous match and a missing + selector now answer with a JSON object with a `reason` (`block_not_found`, + `ambiguous`, `missing_selector`) instead of plain text. Without `--json`, + these three messages now start with `Error: `, like every other refusal; the + wording after it is unchanged (#92). + - `get-todos` (`content`, `--match`, `--tag`) now reads an unclosed `:LOGBOOK:` as text and a `:LOGBOOK:` inside a code fence as code, the way mldoc reads them (measured with mldoc 1.5.7); before, everything after such @@ -143,6 +158,15 @@ file carries most of the reasoning behind the tool. ### Fixed +- `set-todo-status` no longer breaks a heading task: `## TODO ship it` became + `DONE ## TODO ship it`, a block Logseq reads no task in, because the command + took the first word of the text for the marker. It now swaps the marker where + Logseq reads it, behind the heading prefix (`## DONE ship it`), and + `get-todos` reports `content` without the marker (`## ship it`). It also keeps + the space between the marker and a line break: `TODO \nnotes` became + `DONE\nnotes`, which Logseq no longer reads as a task (measured with Logseq's + parser, mldoc 1.5.7) (#92). + - A uuid that Logseq holds only as the placeholder of a dead ref (content `id:: `, no page, kept once Logseq reads the file with the ref again) is no block to any command now. `update-block`, diff --git a/docs/commands.md b/docs/commands.md index 41a28a8..6f25d21 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -123,7 +123,7 @@ A write that ends a section (`add-journal-block`, `add-journal-content`, | `update-block (--id UUID \| --where-content TEXT [--page NAME] [--regex]) (--content TEXT \| --content-file FILE) [--dry-run]` | Update block content. `--content` is ONE block and has no tree path: a `- ` or `# ` line after the first (indented too) or a code fence nothing closes is refused, since Logseq would read it as a block of its own: use `insert-block --child-of` for children. `--where-content` selects by text instead of UUID and aborts unless exactly one block matches. The block's properties are kept as written, values as their original text, unless `--content` sets the key itself; the one change is Logseq's own spelling of a key (`created_at` is stored and written back as `created-at`). The block's own `id::` line passes; one naming another uuid is dropped with a note, since it would become this block's uuid | | `remove-block --id UUID [--ignore-refs] [--dry-run]` | Delete a block and its children (alias: `delete-block`). `--dry-run` reports the descendant count. Refuses while `((block-refs))` from elsewhere point into the block or its children, and lists them; `--ignore-refs` removes anyway | | `add-block-ref --source-id UUID (--journal-date DATE \| --page NAME) [--under-heading "## X"] [--dry-run]` | Write a `((block-ref))` pointing at an existing block. Journal defaults to today, heading to `LOGSEQ_JOURNAL_HEADING`. A source uuid no block has is refused before anything is written, `--dry-run` included — a ref to it would render as nothing. A source without an `id::` line gets one, as Logseq's editor gives it; every command that writes a `((uuid))` does this | -| `set-todo-status (--id UUID \| --content TEXT --page NAME) --status DONE [--follow-refs] [--dry-run]` | Swap a TODO/DOING/DONE marker without retyping the line. `--follow-refs` updates the original when the block is just a `((ref))` or `{{embed ((ref))}}`, through a chain of them; a chain that loops or reaches a missing block is refused. Ambiguous `--content` aborts and lists candidates. `--dry-run` shows the old and new marker | +| `set-todo-status (--id UUID \| --content TEXT --page NAME) --status DONE [--follow-refs] [--dry-run]` | Swap a TODO/DOING/DONE marker without retyping the line. `--follow-refs` updates the original when the block is just a `((ref))` or `{{embed ((ref))}}`, through a chain of them; a chain that loops or reaches a missing block is refused. Ambiguous `--content` aborts and lists candidates. A block Logseq reads no marker in (`todo x`, `TODO:`, `TODO` followed directly by a line break, a block that only holds a ref) is refused, nothing written; `--content` matching blocks without a task is refused too, unless `--follow-refs` is given. Each refusal carries a `reason` under `--json`. `--dry-run` shows the old and new marker | | `replace-text --page NAME --find TEXT --replace TEXT` | Search & replace with regex and dry-run support. `--replace` is literal; with `--regex` it takes `\1` group references. A replacement that turns a line into an `id::` line is refused before any block is written | | `insert-block (--content TEXT \| --content-file FILE) (--page NAME \| --after UUID \| --before UUID \| --child-of UUID)` | Insert one block at a position: appended to a page, as a sibling after or before a block, or as a child. `--property K=V` sets properties on it, repeatable. Without indentation `--content` is one block, and a line Logseq would read as a block of its own is refused before anything is written: a `- ` or `# ` line after the first, or a code fence nothing closes (fine inside a closed code block). The same holds for every write of one block's text: a `--tree` node, `add-journal-block`, `update-block`, `create-page --content`. A quote followed by a blank line and a paragraph is written, with a `Note:` on stderr: Logseq ends the quote at the blank line | | `insert-block --tree "" [--quiet]` | `--quiet` prints only the confirmation line, not one uuid line per block | diff --git a/logseq_cli/blocktext.py b/logseq_cli/blocktext.py index d469f8e..f47ba9f 100644 --- a/logseq_cli/blocktext.py +++ b/logseq_cli/blocktext.py @@ -441,16 +441,11 @@ def __init__(self, message: str, line: int = None, kind: str = None): "copy-block": (" - relocating it? -> move-block keeps the block as it is\n" " - copying it? -> split the source block first"), "replace-text": " - choose a --replace that starts no line with - or #", - "set-todo-status": (" - the marker goes in front of the first line, and this block starts\n" - " with a code fence: give it a first line of text (update-block)"), } _CLOSE_FENCE = (" - close it -> end the code with a line that starts " "with ``` (or ~~~); in outline text a line without a bullet") _FENCE_WAYS = { "add-journal-entry": _CLOSE_FENCE + "\n - keep the code in one block -> leave out --multi-block", - # The marker in front of an opening fence makes it text, and the closing - # fence is left open: nothing the caller wrote. - "set-todo-status": _WAYS["set-todo-status"], } diff --git a/logseq_cli/commands/todos.py b/logseq_cli/commands/todos.py index e9e939c..01b1503 100644 --- a/logseq_cli/commands/todos.py +++ b/logseq_cli/commands/todos.py @@ -1,10 +1,9 @@ import datetime import re -import sys import click -from logseq_cli.blocktext import pointer_target, refuse_split_block +from logseq_cli.blocktext import pointer_target from logseq_cli.datalog import edn_string from logseq_cli.dates import ( journal_day_to_date, @@ -17,25 +16,7 @@ from logseq_cli.notes import print_note from logseq_cli.output import fail, follow_page, handle_connection_error, output from logseq_cli.safety import WriteCommand -from logseq_cli.tasks import task_text - - -_TODO_MARKERS = {"TODO", "DOING", "DONE", "LATER", "NOW", "CANCELED", "WAIT", "WAITING"} - -def _swap_todo_marker(content: str, new_status: str) -> str: - """Replace the leading TODO-marker in content with new_status. - - On the first line only: split over the whole content, a line break counts - as the whitespace after the marker, and "TODO\nnotes" came out joined as - "DONE notes". - """ - first, newline, rest = content.partition("\n") - parts = first.split(None, 1) - if parts and parts[0].upper() in _TODO_MARKERS: - first = f"{new_status} {parts[1]}".strip() if len(parts) > 1 else new_status - else: - first = f"{new_status} {first}" - return first + newline + rest +from logseq_cli.tasks import marker_before_newline, marker_of, task_text, with_marker def follow_ref_chain(api, block: dict, as_json: bool): @@ -441,6 +422,32 @@ def get_todos(ctx, status, page, tag, match, from_date, to_date, due_from, due_t elif t.get("references_withheld"): click.echo(f" also on {t['references_withheld']} other page(s)") +def _refuse_no_task(block: dict, content: str, as_json: bool): + """Refuse a block that is no task to this command (never returns). Which + sentence fits is checked in this order: a block that only points to + another, a marker Logseq stored that this command cannot reach, a line + break right behind a marker word, anything else. The marker rules + themselves are tasks'.""" + uuid = block.get("uuid") + target = pointer_target(content) + if target: + fail(f"Block {uuid} is not a task: it only points to block {target}. " + "Pass --follow-refs to change the task it points to.", + as_json, reason="not_a_task", id=uuid, points_to=target) + if block.get("marker"): + fail(f"Block {uuid} is a task, but its marker is not where this command can change " + 'it: it must start the text, after an optional "#" prefix and a space. Change ' + "the marker in Logseq, or rewrite the text so the marker starts it.", + as_json, reason="not_a_task", id=uuid) + word = marker_before_newline(content) + if word: + fail(f"Block {uuid} is not a task: Logseq reads no marker in it, because a line " + f'break follows "{word}" directly. Put text or a space after "{word}" on the ' + "first line.", as_json, reason="not_a_task", id=uuid) + fail(f"Block {uuid} is not a task: Logseq reads no marker in it. A task starts with a " + 'marker, e.g. "TODO …".', as_json, reason="not_a_task", id=uuid) + + @cli.command("set-todo-status", cls=WriteCommand, epilog="""\b Examples: logseq-cli --token TOKEN set-todo-status --id UUID --status DONE @@ -453,6 +460,11 @@ def get_todos(ctx, status, page, tag, match, from_date, to_date, due_from, due_t follows it, and on through every such block, and updates the one at the end. A chain that loops or reaches a missing block is refused, nothing written. Prefer this over replace-text for marker changes — 1 call, deterministic. + A block Logseq reads no marker in is not a task: it is refused and nothing is + written, also with --dry-run. A block that holds only a ((uuid)) ref is refused + too, and the message names --follow-refs. + --content that matches blocks but none with a marker is refused too (without + --follow-refs). """) @click.option("--id", "block_id", default=None, help="Block UUID (find by UUID)") @click.option("--content", default=None, help="Content substring to find the block (used with --page)") @@ -483,24 +495,25 @@ def set_todo_status(ctx, block_id, content, page, status, follow_refs, dry_run, api = ctx.obj["api"] if not block_id and not (content and page): - click.echo("Specify either --id or both --content and --page.", err=True) - sys.exit(1) + fail("Specify either --id or both --content and --page.", as_json, + reason="missing_selector") # Resolve UUID via content search if needed if not block_id: - if page: - ref = follow_page(api, page, as_json) - page = ref.page - matches = find_blocks_by_content(api, content, page=page) - # Prefer blocks that actually carry a TODO marker: a status change is - # only meaningful there, and it disambiguates a text that also appears - # in prose. - todo_matches = [m for m in matches if m.get("content", "").split()[0:1] and - m.get("content", "").split()[0].upper() in _TODO_MARKERS] - candidates = todo_matches or matches + page = follow_page(api, page, as_json).page + matches = find_blocks_by_content(api, content, page=page, with_task_fields=True) + # Prefer blocks that carry a marker: a status change is only meaningful + # there, and it disambiguates a text that also appears in prose. + task_matches = [m for m in matches if m.get("marker")] + candidates = task_matches or matches + if matches and not task_matches and not follow_refs: + fail(f"{len(matches)} blocks match '{content}' on page '{page}', none is a task " + f'(no marker). Pass --id of a task, or write one as "TODO …".', + as_json, reason="no_task_matches", content=content, page=page, + match_count=len(matches)) if not candidates: - click.echo(f"No block found matching '{content}' on page '{page}'.", err=True) - sys.exit(1) + fail(f"No block found matching '{content}' on page '{page}'.", as_json, + reason="block_not_found", content=content, page=page) if len(candidates) > 1: # Taking the first match would silently rewrite one of several # equally valid blocks, and the caller could not tell which. This @@ -510,11 +523,10 @@ def set_todo_status(ctx, block_id, content, page, status, follow_refs, dry_run, for m in candidates[:10] ) more = f"\n ... and {len(candidates) - 10} more" if len(candidates) > 10 else "" - click.echo( - f"{len(candidates)} blocks match '{content}' on page '{page}'; refusing " - f"to guess which one to update. Narrow --content or pass --id:\n" - f"{listing}{more}", err=True) - sys.exit(1) + fail(f"{len(candidates)} blocks match '{content}' on page '{page}'; refusing " + f"to guess which one to update. Narrow --content or pass --id:\n" + f"{listing}{more}", as_json, reason="ambiguous", content=content, page=page, + matches=[m.get("uuid") for m in candidates]) block = candidates[0] else: block_id = block_id.strip("()") @@ -529,28 +541,24 @@ def set_todo_status(ctx, block_id, content, page, status, follow_refs, dry_run, block_id = block.get("uuid") old_content = block.get("content", "") - new_content = _swap_todo_marker(old_content, status) + # A task is a block Logseq stored a marker for. The text must show it where + # with_marker can change it, too: it writes on what marker_of reads. + old_marker = marker_of(old_content) + if not block.get("marker") or old_marker is None: + _refuse_no_task(block, old_content, as_json) + + new_content = with_marker(old_content, status) if new_content == old_content: if as_json: output({"uuid": block_id, "status": "unchanged", "content": old_content, **chain}, True) else: - click.echo("No change (block already has status or no marker found).") + click.echo("No change (block already has this status).") return # The marker swap is the whole change, so the preview shows both markers and - # the line they sit on — enough to tell the right block from a near-identical + # the line they sit on: enough to tell the right block from a near-identical # one before committing. Resolution and the ambiguity guard above already ran. - old_marker = old_content.split()[0] if old_content.split() else "" - if old_marker.upper() not in _TODO_MARKERS: - old_marker = "" - - # Checked before the preview, which would otherwise show what the write - # refuses: a marker put in front of an opening fence leaves the code block - # open (#47). Lines the block already had may stay. - refuse_split_block(new_content, command="set-todo-status", where="The block", - replacing=old_content) - if dry_run: if as_json: output({"uuid": block_id, "old_marker": old_marker, "new_marker": status, @@ -560,7 +568,7 @@ def set_todo_status(ctx, block_id, content, page, status, follow_refs, dry_run, click.echo(f"[DRY RUN] Would set status on block {block_id}") if chain.get("followed"): click.echo(f" followed: {' -> '.join(chain['followed'])}") - click.echo(f" marker: {old_marker or '(none)'} -> {status}") + click.echo(f" marker: {old_marker} -> {status}") preview = old_content[:60] + ("..." if len(old_content) > 60 else "") click.echo(f" was: {preview}") preview = new_content[:60] + ("..." if len(new_content) > 60 else "") diff --git a/logseq_cli/lookup.py b/logseq_cli/lookup.py index e47b888..79c2340 100644 --- a/logseq_cli/lookup.py +++ b/logseq_cli/lookup.py @@ -48,25 +48,41 @@ def find_backlinks(api, page_name: str) -> list: return sorted(backlink_pages) -def find_blocks_by_content(api, content: str, page: str = None, use_regex: bool = False) -> list: +def _block_pull(with_task_fields: bool) -> str: + """The pull of a content search: content, uuid, the page, and with + ``with_task_fields`` the marker Logseq stored. The marker comes after the + uuid, where the double's query switch looks for the start of the pull.""" + task_fields = " :block/marker" if with_task_fields else "" + return ("(pull ?b [:block/content :block/uuid" + task_fields + + " {:block/page [:block/original-name :block/name]}])") + + +def find_blocks_by_content(api, content: str, page: str = None, use_regex: bool = False, + with_task_fields: bool = False) -> list: """Blocks whose content matches ``content``, optionally scoped to a page. Single source for the content lookup shared by ``find-block`` and the ``--where-content`` selectors, so a query fix cannot land in one and miss the other. Substring matching happens in datalog; ``use_regex`` pulls the candidates and filters them here, because datalog has no regex predicate. + + ``with_task_fields`` also pulls ``:block/marker``, what Logseq stored for + the block (a row without a marker has no ``marker`` key). Only + ``set-todo-status`` asks for it; ``find-block`` and ``--where-content`` do + not, so their queries stay as they were. """ + pull = _block_pull(with_task_fields) if use_regex: if page: query = ( - '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' + f'[:find {pull}' f' :where [?p :block/name {page_name_literal(page)}]' ' [?b :block/page ?p]' ' [?b :block/content _]]' ) else: query = ( - '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' + f'[:find {pull}' ' :where [?b :block/content _]]' ) raw = api.datascript_query(query) or [] @@ -76,7 +92,7 @@ def find_blocks_by_content(api, content: str, page: str = None, use_regex: bool content_literal = edn_string(content) if page: query = ( - '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' + f'[:find {pull}' f' :where [?p :block/name {page_name_literal(page)}]' ' [?b :block/page ?p]' ' [?b :block/content ?c]' @@ -84,7 +100,7 @@ def find_blocks_by_content(api, content: str, page: str = None, use_regex: bool ) else: query = ( - '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' + f'[:find {pull}' ' :where [?b :block/content ?c]' f' [(clojure.string/includes? ?c {content_literal})]]' ) diff --git a/logseq_cli/tasks.py b/logseq_cli/tasks.py index b3ea8ea..61c0b0b 100644 --- a/logseq_cli/tasks.py +++ b/logseq_cli/tasks.py @@ -103,7 +103,9 @@ def with_marker(text: str, marker: str) -> str: The heading prefix stays as written; the spaces and tabs after the old marker become one space, none at the end of the text. A line break is - never replaced. Raises ValueError for a marker outside ORDER and for a + never replaced, and only the first line changes: split over the whole + text, a line break counts as the blank after the marker, and "TODO\\nnotes" + came out joined as "DONE notes". Raises ValueError for a marker outside ORDER and for a text without a marker: a swap must not put a marker on a block that is no task. """ @@ -156,5 +158,9 @@ def task_text(content: str, marker) -> str: drop = [a or p for a, p in zip(attached_line_mask(raw), property_line_mask(raw))] clean = "\n".join(line for line, d in zip(raw, drop) if not d).strip() if marker: - clean = re.sub(rf"^{re.escape(marker)}(?:\s+|$)", "", clean) + # The DB marker has decided; mldoc also reads the marker after "#" plus + # a tab, which marker_of refuses on purpose (it is used for writing). + # A heading prefix stays, hashes and blanks only: "\s" would take a line + # break and a no-break space, which mldoc does not. + clean = re.sub(rf"^(#+[ \t]+)?{re.escape(marker)}(?:\s+|$)", r"\1", clean).rstrip() return clean diff --git a/tests/test_find_blocks_task_fields.py b/tests/test_find_blocks_task_fields.py new file mode 100644 index 0000000..63f9ebc --- /dev/null +++ b/tests/test_find_blocks_task_fields.py @@ -0,0 +1,80 @@ +"""find_blocks_by_content can ask for the task fields of a block. + +set-todo-status decides by the marker Logseq stored, and a content search is +how it finds the block with --content. The pull holds the marker only when +asked for, so the queries of find-block and --where-content stay as they were. +""" +from unittest.mock import MagicMock, patch + +import pytest + +from logseq_cli.cli import cli +from logseq_cli.lookup import find_blocks_by_content +from tests.conftest import split_runner +from tests.logseq_http_double import LogseqHttpDouble +from tests.test_datalog_quoting import QueryRecorder + +PULL = "[:block/content :block/uuid {:block/page [:block/original-name :block/name]}]" +PULL_WITH_MARKER = ("[:block/content :block/uuid :block/marker " + "{:block/page [:block/original-name :block/name]}]") + +# The four queries as they were sent before the parameter existed. +UNCHANGED = { + ("page", "regex"): ( + f'[:find (pull ?b {PULL})' + ' :where [?p :block/name "page a"] [?b :block/page ?p] [?b :block/content _]]'), + ("page", "substring"): ( + f'[:find (pull ?b {PULL})' + ' :where [?p :block/name "page a"] [?b :block/page ?p] [?b :block/content ?c]' + ' [(clojure.string/includes? ?c "x")]]'), + ("everywhere", "regex"): ( + f'[:find (pull ?b {PULL}) :where [?b :block/content _]]'), + ("everywhere", "substring"): ( + f'[:find (pull ?b {PULL}) :where [?b :block/content ?c]' + ' [(clojure.string/includes? ?c "x")]]'), +} + + +def _query(where, how, **kwargs): + rec = QueryRecorder() + find_blocks_by_content(rec, "x", page="Page A" if where == "page" else None, + use_regex=(how == "regex"), **kwargs) + return rec.queries[0] + + +@pytest.mark.parametrize("form", UNCHANGED, ids=["-".join(f) for f in UNCHANGED]) +def test_the_default_queries_are_unchanged(form): + assert _query(*form) == UNCHANGED[form] + + +@pytest.mark.parametrize("form", UNCHANGED, ids=["-".join(f) for f in UNCHANGED]) +def test_task_fields_add_the_marker_after_the_uuid_in_every_form(form): + query = _query(*form, with_task_fields=True) + assert query == UNCHANGED[form].replace(PULL, PULL_WITH_MARKER) + assert "[:block/content :block/uuid :block/marker" in query + + +def test_the_marker_comes_back_in_the_row(): + double = LogseqHttpDouble() + double.add_page("Page A", [{"content": "TODO task", "marker": "TODO"}, "plain task"]) + api = MagicMock() + api.datascript_query.side_effect = lambda q: double._datascript_query([q]) + rows = {r["content"]: r for r in + find_blocks_by_content(api, "task", page="Page A", with_task_fields=True)} + assert rows["TODO task"]["marker"] == "TODO" + assert "marker" not in rows["plain task"] + rows = find_blocks_by_content(api, "task", page="Page A") + assert all("marker" not in r for r in rows) + + +@pytest.mark.parametrize("args", [ + ["find-block", "--content", "x", "--json"], + ["update-block", "--where-content", "x", "--page", "P", "--content", "y", "--dry-run"], +], ids=["find-block", "update-block"]) +def test_find_block_and_where_content_do_not_ask_for_the_marker(args): + api = MagicMock() + api.datascript_query.return_value = [] + with patch("logseq_cli.group.LogseqAPI", return_value=api): + split_runner().invoke(cli, args) + assert api.datascript_query.call_args_list + assert all(":block/marker" not in c.args[0] for c in api.datascript_query.call_args_list) diff --git a/tests/test_follow_refs_chain.py b/tests/test_follow_refs_chain.py index 40388e4..3ce0c7b 100644 --- a/tests/test_follow_refs_chain.py +++ b/tests/test_follow_refs_chain.py @@ -67,12 +67,16 @@ def test_a_block_without_a_ref_is_changed_itself(self): assert result.exit_code == 0, result.stderr assert content(HOST) == "DONE ship it" - def test_a_labelled_ref_is_a_link_and_is_changed_itself(self): - result, content, _ = run([{"uuid": TASK, "content": "TODO ship it", "marker": "TODO"}, - {"uuid": HOST, "content": f"[see]((({TASK})))"}], - "--id", HOST, "--follow-refs") - assert result.exit_code == 0, result.stderr - assert content(HOST) == f"DONE [see]((({TASK})))" + def test_a_labelled_ref_is_a_link_and_no_task(self): + # A labelled ref is a link with text of its own, no ref to follow, and + # Logseq reads no marker in it: the block is no task, nothing is written. + result, content, api = run([{"uuid": TASK, "content": "TODO ship it", "marker": "TODO"}, + {"uuid": HOST, "content": f"[see]((({TASK})))"}], + "--id", HOST, "--follow-refs", "--json") + assert refusal(result)["reason"] == "not_a_task" + assert refusal(result)["id"] == HOST + api.update_block.assert_not_called() + assert content(HOST) == f"[see]((({TASK})))" assert content(TASK) == "TODO ship it" def test_an_unknown_block_is_not_found_in_the_same_words(self): @@ -125,14 +129,17 @@ def test_an_embed_with_the_spaces_logseq_allows_is_followed(self, embed): assert content(TASK) == f"DONE ship it\nid:: {TASK}" @pytest.mark.parametrize("text", ["{{ embed ((T))}}", "{{EMBED ((T))}}"]) - def test_what_logseq_reads_as_no_embed_is_changed_itself(self, text): + def test_what_logseq_reads_as_no_embed_is_no_task(self, text): # Neither is an embed to Logseq, the second holds no ref at all - # (measured, 0.10.15); the block is what the caller named. + # (measured, 0.10.15): nothing to follow, and no marker either, so the + # block the caller named is refused and nothing is written. host = text.replace("T", TASK) - result, content, _ = run([task(), {"uuid": HOST, "content": host}], - "--id", HOST, "--follow-refs") - assert result.exit_code == 0, result.stderr - assert content(HOST) == f"DONE {host}" + result, content, api = run([task(), {"uuid": HOST, "content": host}], + "--id", HOST, "--follow-refs", "--json") + out = refusal(result) + assert (out["reason"], out["id"]) == ("not_a_task", HOST) + api.update_block.assert_not_called() + assert content(HOST) == host assert content(TASK) == f"TODO ship it\nid:: {TASK}" def test_a_block_found_by_content_is_followed(self): diff --git a/tests/test_marker_lists.py b/tests/test_marker_lists.py index 295ac83..117be53 100644 --- a/tests/test_marker_lists.py +++ b/tests/test_marker_lists.py @@ -36,7 +36,6 @@ # (file, symbol) -> places still holding a marker list of their own. EXCEPTIONS = { - ("commands/todos.py", "_TODO_MARKERS"): 1, ("commands/todos.py", "get_todos"): 4, ("commands/todos.py", "set_todo_status"): 2, ("commands/edit.py", "update_block"): 1, diff --git a/tests/test_one_block_round_trip.py b/tests/test_one_block_round_trip.py index 0c75cd3..4212844 100644 --- a/tests/test_one_block_round_trip.py +++ b/tests/test_one_block_round_trip.py @@ -302,12 +302,14 @@ def test_other_text_is_checked_before_the_first_write(args): @pytest.mark.parametrize("dry", [[], ["--dry-run"]], ids=["write", "dry-run"]) -def test_set_todo_status_on_a_block_that_starts_with_a_fence(dry): - # "TODO ```js" is no fence: the closing one would be left open. +def test_set_todo_status_refuses_a_block_that_starts_with_a_fence(dry): + # Logseq reads no marker in it, so it is no task and nothing is written; the + # refusal comes before a marker could be put in front of the fence, which + # would leave the closing one open. api = page_graph_api(PageGraph({"Page A": [{"uuid": ANCHOR, "content": "```js\nrun()\n```"}]})) - r = _run(["set-todo-status", "--id", ANCHOR, "--status", "TODO", *dry], api) - assert r.exit_code == 2 - assert "first line of text" in r.stderr + r = _run(["set-todo-status", "--id", ANCHOR, "--status", "TODO", "--json", *dry], api) + assert r.exit_code != 0 + assert json.loads(r.stderr)["reason"] == "not_a_task" assert _writes(api) == 0 diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 1a852c3..aad43b9 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1050,3 +1050,30 @@ def test_the_guard_places_a_marker_of_call(self): here = ast.parse("class LogseqHttpDouble:\n def _reparse_task_fields(self):\n" " return tasks.marker_of(x)") assert marker_of_callers(here) == [ALLOWED_CALLER] + + +class TestTaskTextKeepsTheHeadingPrefix: + """The DB marker decides; mldoc 1.5.7 also reads the marker after a tab + behind the hashes, which marker_of refuses on purpose because it is used + for writing. A prefix is hashes and blanks (space or tab), nothing else.""" + + @pytest.mark.parametrize("content,marker,expected", [ + ("## TODO x", "TODO", "## x"), + ("# TODO x", "TODO", "# x"), + ("TODO x", "TODO", "x"), + ("TODO", "TODO", ""), + ("## DONE x", "DONE", "## x"), + ("## IN-PROGRESS x", "IN-PROGRESS", "## x"), + ("## TODO", "TODO", "##"), + ("## TODO x\nkey:: v\nSCHEDULED: <2026-09-25 Fri>", "TODO", "## x"), + ("##\tTODO x", "TODO", "##\tx"), + ("# \tTODO x", "TODO", "# \tx"), + ("#\tTODO x", "TODO", "#\tx"), + # mldoc reads no marker in these, so a marker given with them + # contradicts the text; the prefix rule must not take them as blanks. + ("##TODO x", "TODO", "##TODO x"), + ("##\nTODO x", "TODO", "##\nTODO x"), + ("##\u00a0TODO x", "TODO", "##\u00a0TODO x"), + ]) + def test_task_text(self, content, marker, expected): + assert tasks.task_text(content, marker) == expected diff --git a/tests/test_todo_marker_keeps_lines.py b/tests/test_todo_marker_keeps_lines.py index 3ed8b1f..6f5eaf9 100644 --- a/tests/test_todo_marker_keeps_lines.py +++ b/tests/test_todo_marker_keeps_lines.py @@ -7,6 +7,7 @@ opener, and Logseq lets such a fence swallow the blocks after it when it reads the page file again (#47). The marker is now changed on the first line only. """ +import json from unittest.mock import patch import pytest @@ -17,22 +18,117 @@ UUID = "7e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a70" -# The marker is what Logseq stored for the block, the third value: a bare -# marker before a line break is no task to Logseq, so it has none. -@pytest.mark.parametrize("old,new,marker", [ - ("TODO\nnotes", "DONE\nnotes", None), - ("TODO\n```js\nx()\n```", "DONE\n```js\nx()\n```", None), - ("TODO write it\nnotes:: more", "DONE write it\nnotes:: more", "TODO"), - ("TODO spaced", "DONE spaced", "TODO"), - ("no marker\nsecond", "DONE no marker\nsecond", None), -], ids=["bare marker", "code under a bare marker", "property line", "extra space", - "no marker"]) -def test_only_the_marker_changes(old, new, marker): - block = {"uuid": UUID, "content": old, **({"marker": marker} if marker else {})} +def _api_with(block): api = page_graph_api(PageGraph({"Page A": [block]})) api.update_block.side_effect = lambda u, c, properties=None, replacing=None: \ api.graph.locate(u)[1][api.graph.locate(u)[2]].update(content=c) + return api + + +def _set_status(block, *extra, status="DONE", api=None): + api = api or _api_with(block) with patch("logseq_cli.group.LogseqAPI", return_value=api): - r = split_runner().invoke(cli, ["set-todo-status", "--id", UUID, "--status", "DONE"]) + r = split_runner().invoke(cli, ["set-todo-status", "--id", block["uuid"], + "--status", status, *extra]) + return r, api + + +def _block(content, marker=None, uuid=UUID): + return {"uuid": uuid, "content": content, **({"marker": marker} if marker else {})} + + +# The marker is what Logseq stored for the block, the third value. +@pytest.mark.parametrize("old,new,marker", [ + ("TODO write it\nnotes:: more", "DONE write it\nnotes:: more", "TODO"), + ("TODO spaced", "DONE spaced", "TODO"), + ("## TODO ship it", "## DONE ship it", "TODO"), + ("TODO \nnotes", "DONE \nnotes", "TODO"), + ("STARTED x", "DONE x", "STARTED"), + ("WAITING x", "DONE x", "WAITING"), +], ids=["property line", "extra space", "heading prefix", "space before the line break", + "started", "waiting"]) +def test_only_the_marker_changes(old, new, marker): + r, api = _set_status(_block(old, marker)) assert r.exit_code == 0, r.stderr assert api.update_block.call_args.args[1] == new + + +GENERAL = 'is not a task: Logseq reads no marker in it. A task starts with a marker, e.g. "TODO' +NOT_THERE = "not where this command can change it" +NOT_A_TASK = [ + ("todo a", None, GENERAL), + ("no marker", None, GENERAL), + ("TODO x", None, GENERAL), + ("TODO\nnotes", None, 'a line break follows "TODO" directly'), + ("TODO\n```js\nx()\n```", None, 'a line break follows "TODO" directly'), + ("##\tTODO x", "TODO", NOT_THERE), + ("TODO\nnotes", "TODO", NOT_THERE), + ("```py\nx\n```", None, GENERAL), + ("status:: open\nid:: T", None, GENERAL), + ("id:: T", None, GENERAL), +] + + +@pytest.mark.parametrize("dry", [[], ["--dry-run"]], ids=["write", "dry-run"]) +@pytest.mark.parametrize("text,marker,fragment", NOT_A_TASK, + ids=[t[0].replace("\n", "|")[:24] + ("+db" if t[1] else "") for t in NOT_A_TASK]) +def test_a_block_that_is_no_task_is_refused(text, marker, fragment, dry): + r, api = _set_status(_block(text, marker), "--json", *dry) + assert r.exit_code != 0 + out = json.loads(r.stderr) + assert out["reason"] == "not_a_task" + assert out["id"] == UUID + assert fragment in out["error"] + api.update_block.assert_not_called() + + +def test_a_heading_task_previews_both_markers(): + r, _ = _set_status(_block("## TODO x", "TODO"), "--dry-run", "--json") + out = json.loads(r.stdout) + assert (out["old_marker"], out["new_marker"], out["new"]) == ("TODO", "DONE", "## DONE x") + + +@pytest.mark.parametrize("text", ["DONE x", "DONE"]) +@pytest.mark.parametrize("dry", [[], ["--dry-run"]], ids=["write", "dry-run"]) +def test_a_task_that_already_has_the_status_is_unchanged(text, dry): + r, api = _set_status(_block(text, "DONE"), *dry) + assert r.exit_code == 0, r.stderr + assert r.stdout.strip() == "No change (block already has this status)." + api.update_block.assert_not_called() + + +def test_unchanged_under_json(): + r, _ = _set_status(_block("DONE x", "DONE"), "--json") + assert json.loads(r.stdout)["status"] == "unchanged" + + +def test_extra_spaces_after_the_marker_are_a_change_not_a_no_op(): + """with_marker puts one space behind the marker, so the text changes and is + written; the no-op is "same text after the swap", not "same marker".""" + r, api = _set_status(_block("DONE x", "DONE")) + assert r.exit_code == 0, r.stderr + assert api.update_block.call_args.args[1] == "DONE x" + + +def test_a_pointer_is_refused_naming_follow_refs_not_a_todo(): + target = "7e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a71" + graph = PageGraph({"Page A": [_block(f"(({target}))\nid:: {UUID}", None), + _block("TODO ship it", "TODO", target)]}) + api = page_graph_api(graph) + r, _ = _set_status(_block("", None), "--json", api=api) + out = json.loads(r.stderr) + assert out["reason"] == "not_a_task" + assert out["points_to"] == target + assert "--follow-refs" in out["error"] + assert "TODO" not in out["error"] + api.update_block.assert_not_called() + + +def test_a_task_property_block_at_the_end_of_a_chain_is_refused(): + host, end = UUID, "7e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a72" + graph = PageGraph({"Page A": [_block(f"(({end}))\nid:: {host}", None, host), + _block("status:: open\nid:: T", None, end)]}) + api = page_graph_api(graph) + r, _ = _set_status(_block("", None, host), "--follow-refs", "--json", api=api) + assert json.loads(r.stderr)["reason"] == "not_a_task" + api.update_block.assert_not_called() diff --git a/tests/test_todos_match.py b/tests/test_todos_match.py index cdad4b9..4bfb452 100644 --- a/tests/test_todos_match.py +++ b/tests/test_todos_match.py @@ -10,8 +10,9 @@ import pytest from logseq_cli.cli import cli -from logseq_cli.commands.todos import _TODO_MARKERS +from logseq_cli import tasks from tests.conftest import split_runner +from tests.logseq_http_double import LogseqHttpDouble def _rows(): @@ -100,7 +101,7 @@ class TestMarkerIsStrippedForEveryMarker: "^ship" missed "CANCELED ship it" and --match cancel hit them all. The block's own :block/marker now says what to strip.""" - @pytest.mark.parametrize("marker", sorted(_TODO_MARKERS | {"CANCELLED", "IN-PROGRESS"})) + @pytest.mark.parametrize("marker", sorted(tasks.ORDER)) def test_marker_is_not_part_of_the_content(self, marker): api = MagicMock() api.datascript_query.return_value = [ @@ -150,3 +151,156 @@ def test_a_closed_drawer_stays_out(self): out = self._run_block(content) assert out["todos"][0]["content"] == "x\n#urgent" assert self._run_block(content, "--match", "CLOCK")["count"] == 0 + + +HOST = "00000000-0000-4000-8000-0000000000b1" +TARGET = "00000000-0000-4000-8000-0000000000b2" + + +def _status_run(rows, *args, blocks=None): + """set-todo-status --content over a mocked search that answers ``rows`` + (pull dicts, a marker where Logseq stored one); ``blocks`` answers getBlock.""" + api = MagicMock() + api.datascript_query.return_value = [[r] for r in rows] + api.get_block.side_effect = lambda uuid, **_: (blocks or {}).get(uuid) + with patch("logseq_cli.group.LogseqAPI", return_value=api): + r = split_runner().invoke(cli, ["set-todo-status", "--content", "x", "--page", "P", + "--status", "DONE", *args]) + return r, api + + +class TestSetTodoStatusNeedsATask: + """--content finds blocks by text; only a block with a marker is a task.""" + + NOT_TASKS = [{"uuid": "u-A", "content": "see x above"}, {"uuid": "u-B", "content": "x again"}] + + @pytest.mark.parametrize("dry", [[], ["--dry-run"]], ids=["write", "dry-run"]) + def test_matches_without_a_task_are_refused(self, dry): + r, api = _status_run(self.NOT_TASKS, "--json", *dry) + assert r.exit_code != 0 + out = json.loads(r.stderr) + assert out["reason"] == "no_task_matches" + assert (out["content"], out["match_count"]) == ("x", 2) + assert out["error"].endswith( + 'none is a task (no marker). Pass --id of a task, or write one as "TODO …".') + assert "matches" not in out + api.update_block.assert_not_called() + + def test_the_page_is_named_after_the_alias_resolution(self, monkeypatch): + double = LogseqHttpDouble().install(monkeypatch) + double.add_page("Target", ["alias:: zz-al", "see x above"]) + r = split_runner().invoke(cli, ["--token", "t", "set-todo-status", "--content", "x", + "--page", "zz-al", "--status", "DONE", "--json"]) + out = json.loads(r.stderr) + assert out["reason"] == "no_task_matches" + assert out["page"] == "Target" + assert "on page 'Target'" in out["error"] + + @pytest.mark.parametrize("dry", [[], ["--dry-run"]], ids=["write", "dry-run"]) + def test_with_follow_refs_one_match_without_a_marker_is_refused_as_no_task(self, dry): + r, api = _status_run([{"uuid": "u-A", "content": "see x above"}], + "--follow-refs", "--json", *dry) + assert r.exit_code != 0 + assert json.loads(r.stderr)["reason"] == "not_a_task" + api.update_block.assert_not_called() + + def test_with_follow_refs_two_matches_without_a_marker_are_ambiguous(self): + r, _ = _status_run(self.NOT_TASKS, "--follow-refs", "--json") + assert json.loads(r.stderr)["reason"] == "ambiguous" + + def test_follow_refs_takes_a_match_that_only_points_to_a_task(self): + pointer = {"uuid": HOST, "content": f"(({TARGET}))"} + target = {"uuid": TARGET, "content": "TODO x", "marker": "TODO"} + r, api = _status_run([pointer], "--follow-refs", "--json", blocks={TARGET: target}) + assert r.exit_code == 0, r.stderr + out = json.loads(r.stdout) + assert out["new"] == "DONE x" + assert out["followed"] == [TARGET] + assert api.update_block.call_args.args[0] == TARGET + + def test_a_pointer_without_follow_refs_names_follow_refs(self): + api = MagicMock() + api.get_block.return_value = {"uuid": HOST, "content": f"{{{{embed (({TARGET}))}}}}"} + with patch("logseq_cli.group.LogseqAPI", return_value=api): + r = split_runner().invoke(cli, ["set-todo-status", "--id", HOST, + "--status", "DONE", "--dry-run", "--json"]) + out = json.loads(r.stderr) + assert out["reason"] == "not_a_task" and out["points_to"] == TARGET + assert "--follow-refs" in out["error"] and "TODO" not in out["error"] + + +class TestSetTodoStatusReasons: + """The three refusals that were plain text now carry a reason.""" + + def test_not_found_over_content_has_no_id(self): + r, _ = _status_run([], "--json") + assert r.exit_code != 0 + out = json.loads(r.stderr) + assert out["reason"] == "block_not_found" + assert (out["content"], "id" in out) == ("x", False) + assert "No block found matching 'x' on page" in out["error"] + + def test_ambiguous_lists_every_candidate_in_matches(self): + rows = [{"uuid": f"u-{i:02d}", "content": ("TODO " + "w" * 76) if i == 0 else f"TODO t{i}", + "marker": "TODO"} for i in range(12)] + r, _ = _status_run(rows, "--json") + out = json.loads(r.stderr) + assert out["reason"] == "ambiguous" + assert out["matches"] == [f"u-{i:02d}" for i in range(12)] + assert "refusing to guess which one to update" in out["error"] + assert out["error"].endswith("... and 2 more") + first = next(ln for ln in out["error"].split("\n") if ln.startswith(" u-00")) + assert first == " u-00 " + ("TODO " + "w" * 76)[:70] + + def test_an_id_wins_over_content(self): + api = MagicMock() + api.get_block.return_value = {"uuid": TARGET, "content": "TODO x", "marker": "TODO"} + with patch("logseq_cli.group.LogseqAPI", return_value=api), \ + patch("logseq_cli.commands.todos.find_blocks_by_content") as search: + r = split_runner().invoke(cli, ["set-todo-status", "--id", TARGET, "--content", "x", + "--page", "P", "--status", "DONE", "--json"]) + assert r.exit_code == 0, r.stderr + search.assert_not_called() + + @pytest.mark.parametrize("args", [["--status", "DONE"], ["--content", "x", "--status", "DONE"]], + ids=["nothing", "content without page"]) + def test_a_missing_selector_is_refused_before_any_request(self, args): + api = MagicMock() + with patch("logseq_cli.group.LogseqAPI", return_value=api): + r = split_runner().invoke(cli, ["set-todo-status", *args, "--json"]) + assert r.exit_code != 0 + out = json.loads(r.stderr) + assert out == {"error": "Specify either --id or both --content and --page.", + "reason": "missing_selector"} + assert api.method_calls == [] + + def test_plain_text_gets_the_error_prefix_and_leaves_stdout_empty(self): + for rows, text in (([], "Error: No block found matching 'x' on page"), + ([{"uuid": "a", "content": "TODO a", "marker": "TODO"}, + {"uuid": "b", "content": "TODO b", "marker": "TODO"}], + "Error: 2 blocks match 'x' on page")): + r, _ = _status_run(rows) + assert r.exit_code != 0 and r.stdout == "" + assert text in r.stderr + api = MagicMock() + with patch("logseq_cli.group.LogseqAPI", return_value=api): + r = split_runner().invoke(cli, ["set-todo-status", "--status", "DONE"]) + assert r.stderr.startswith("Error: Specify either --id or both --content and --page.") + + +class TestAHeadingTaskKeepsItsHeadingInContent: + def _content(self, *args): + api = MagicMock() + api.datascript_query.return_value = [ + [{"content": "## TODO ship it", "marker": "TODO", "uuid": "u1"}, + {"original-name": "Page A", "name": "page a"}]] + with patch("logseq_cli.group.LogseqAPI", return_value=api): + r = split_runner().invoke(cli, ["get-todos", "--no-follow-refs", "--json", *args]) + return json.loads(r.stdout) + + def test_the_marker_goes_the_heading_stays(self): + assert self._content()["todos"][0]["content"] == "## ship it" + + def test_match_sees_the_text_not_the_marker(self): + assert self._content("--match", "^## ship")["count"] == 1 + assert self._content("--match", "TODO ship")["count"] == 0 From 3aebe1fec87b405e8adf73211fe42b15bba4db32 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:01:00 +0200 Subject: [PATCH 05/15] Keep SCHEDULED, DEADLINE and LOGBOOK lines with the block above add-note-content --content $'TODO x\nSCHEDULED: <2026-09-25 Fri>' made two blocks: the SCHEDULED line became a block of its own and had no effect, and the dry run said "2 block(s)". A task with a date could not be written in one call. In Logseq's files these lines, and a :LOGBOOK: drawer, belong to the block above, as a key:: value line does. The outline parser now keeps them there, through the one rule in blocktext that says which lines belong to the block above, in every command that reads its text as an outline. A :LOGBOOK: that no :END: closes is text, not a drawer (measured with Logseq's parser, mldoc 1.5.7), so it hides nothing and swallows no bullet after it. Confirmed live on a probe page, 2026-10-06, Logseq 0.10.15: one block per task, Logseq sets the scheduled and deadline dates, the drawer stays in its block. Mutations tried, and the test that caught each: - a SCHEDULED line not attached: the scheduled case of the outline tests - an unclosed :LOGBOOK: read as a drawer: the mask case for an opener without a closer - a literal of its own in the outline module: the source check that the shared rule is the only one - the mask over the whole line list instead of the lines the loop reads: the bullet fence with a drawer inside - no block to join for a leading line: the leading planning line case Existing assertions changed, with the reason: none. A "- ```" line inside a code block that starts without a bullet no longer opens another code block, so the real closer is not cut out and a drawer behind it stays with its block. A bullet line ends the reach of a drawer opener: "- a", ":LOGBOOK:", "- b", ":END:" are four blocks and nothing is swallowed. A drawer that closes before the next bullet stays with its block, a blank line inside it still goes. Part of #92. --- AGENTS.md | 16 ++- CHANGELOG.md | 13 +++ logseq_cli/outlinetext.py | 48 ++++++++- tests/test_property_line_rule.py | 177 +++++++++++++++++++++++++++++++ tests/test_tasks.py | 6 +- 5 files changed, 253 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dde41cd..45e5761 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -265,9 +265,15 @@ logseq-cli add-journal-content \ --content "- ## Section\n\t- Child item\n\t\t- Grandchild" ``` -Each line is a block, with two exceptions that stay with the block they belong +Each line is a block, with four exceptions that stay with the block they belong to, as in Logseq's own files. A `key:: value` line without a bullet goes on the -block above. So does a code block: from a line starting with ```` ``` ```` or +block above. So does a `SCHEDULED: <…>` or `DEADLINE: <…>` line, indented or +not: `TODO x` with `SCHEDULED: <2026-09-25 Fri>` under it is one block, a task +with a date. So does a `:LOGBOOK:` drawer, every line from `:LOGBOOK:` to +`:END:`. A `:LOGBOOK:` that no `:END:` closes before the next bullet line is no +drawer but text, as a code fence nothing closes is, and the lines after it are +read as usual. So does a +code block: from a line starting with ```` ``` ```` or `~~~` to the next such line without a bullet, every line is code, `- ` lines included, and keeps its indentation. Put the fence on a bullet line to make the code block a block of its own: @@ -278,6 +284,12 @@ logseq-cli add-note-content --page "Notes" \ # -> "Example" with the code on it, then a block holding only the sh code ``` +```bash +logseq-cli add-note-content --page "Notes" \ + --content $'TODO Send the report\nSCHEDULED: <2026-09-25 Fri>' +# -> one task block with its date, not a task and a block that says SCHEDULED +``` + Text the CLI writes as ONE block must come back from the page file as that block. Logseq's file parser takes a `- ` or `#` line after the first (indented too) and a code fence nothing closes as block boundaries, and rebuilds the page diff --git a/CHANGELOG.md b/CHANGELOG.md index 32492af..94d39e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,6 +158,19 @@ file carries most of the reasoning behind the tool. ### Fixed +- A task with a date can be written in one call. `add-note-content --content + $'TODO x\nSCHEDULED: <2026-09-25 Fri>'` made two blocks: the `SCHEDULED:` line + became a block of its own and had no effect, and the dry run said "2 + block(s)". Found by reproducing it on a probe page. Logseq's own files keep + `SCHEDULED:` and `DEADLINE:` lines and a `:LOGBOOK:` drawer with the block + above, as they keep a `key:: value` line, and the outline parser now does + too, for every command that reads its text as an outline (`add-note-content`, + `add-journal-content`, `add-journal-block`, `insert-block` with an indented + outline, `--tree` given as text). A `:LOGBOOK:` that no `:END:` closes is + text, not a drawer (measured with Logseq's parser, mldoc 1.5.7), and so is + one whose `:END:` comes only after a bullet line, so it hides nothing and + swallows no bullet after it (#92). + - `set-todo-status` no longer breaks a heading task: `## TODO ship it` became `DONE ## TODO ship it`, a block Logseq reads no task in, because the command took the first word of the text for the marker. It now swaps the marker where diff --git a/logseq_cli/outlinetext.py b/logseq_cli/outlinetext.py index e592a83..4ceb641 100644 --- a/logseq_cli/outlinetext.py +++ b/logseq_cli/outlinetext.py @@ -13,7 +13,7 @@ import re -from logseq_cli.blocktext import PROPERTY_LINE_RE, code_block_lines, is_fence +from logseq_cli.blocktext import PROPERTY_LINE_RE, attached_line_mask, code_block_lines, is_fence from logseq_cli.notes import print_note @@ -178,11 +178,45 @@ def _dedent_code(raw_lines: list, opener: int, close: int, bulleted: bool) -> st return "".join("\n" + text for text in out) +def _attached_lines(lines: list, raw_lines: list) -> list: + """The shared mask of lines that belong to the block above, over the lines + the loop reads as text: the lines of a code block, started on a bullet line + or not, are the loop's own (it skips them), and code_block_lines does not + know a fence on a bullet line, so a drawer must not pair across one, and a + "- ```" line inside a code block is no opener. A bullet line also ends + the reach of a drawer opener.""" + skipped, index = set(), 0 + while index < len(lines): + stripped = lines[index].strip() + bare = stripped[2:] if stripped.startswith("- ") else stripped + if is_fence(bare): + close = _outline_fence_close(raw_lines, index) + if close is not None: + skipped.update(range(index, close + 1)) + index = close + 1 + continue + index += 1 + # A bullet line ends the reach of a drawer opener: the mask is made per + # stretch that starts at a bullet line, so an opener whose closer comes + # only behind the next bullet is text, as one that nothing closes is. + mask = [False] * len(lines) + stretch = [] + for i in [i for i in range(len(lines)) if i not in skipped] + [None]: + if stretch and (i is None or lines[i].strip().startswith("- ")): + for j, value in zip(stretch, attached_line_mask([lines[j] for j in stretch])): + mask[j] = value + stretch = [] + if i is not None: + stretch.append(i) + return mask + + def parse_hierarchical_content(content: str) -> list: """Parse indented content into a block tree. - Each line becomes a block, except a property line or a code block, which - go on the block they belong to (see below). Indentation (tab or 2 spaces) + Each line becomes a block, except a property line, a SCHEDULED/DEADLINE + line, a LOGBOOK drawer or a code block, which go on the block they belong + to (see below). Indentation (tab or 2 spaces) creates children. Leading '- ' is stripped from each line. Mixed tab/space indentation is normalized to tab-only first, so a node's leading whitespace can never carry the ``\\t \\t`` form that would break Logseq's outline. @@ -194,6 +228,7 @@ def parse_hierarchical_content(content: str) -> list: last_node = None last_indent = -1 skip_to = -1 + attached = _attached_lines(lines, raw_lines) for index, line in enumerate(lines): if index <= skip_to: @@ -243,6 +278,13 @@ def parse_hierarchical_content(content: str) -> list: continue stripped += code + # A SCHEDULED/DEADLINE line and a closed LOGBOOK drawer belong to the + # block above, indented or not: Logseq writes them under the bullet. + # The one rule is blocktext's; an opener nothing closes is text. + if attached[index] and last_node is not None: + last_node["content"] += "\n" + stripped + continue + # A property line without a bullet continues the block above it, as in # Logseq's files, so pasted outlines carrying collapsed:: true / id:: # ... keep their structure instead of gaining a bogus content block. diff --git a/tests/test_property_line_rule.py b/tests/test_property_line_rule.py index 2e5d8a3..bb86fd5 100644 --- a/tests/test_property_line_rule.py +++ b/tests/test_property_line_rule.py @@ -10,6 +10,7 @@ back. The characters that stop a line from being a property are exactly the ones #21 measured for the writer, except ``/``, which reads as a namespace. """ +import json from unittest.mock import MagicMock, patch import pytest @@ -19,6 +20,8 @@ from logseq_cli.blocktext import PROPERTY_LINE_RE from logseq_cli.outlinetext import parse_hierarchical_content from logseq_cli.render import is_properties_block +from tests.conftest import split_runner +from tests.logseq_http_double import LogseqHttpDouble READ_AS_PROPERTY = [ @@ -268,6 +271,180 @@ def test_drawer_lines_is_public(self): assert drawer_lines([":LOGBOOK:", "x"]) == set() +PLAN = "SCHEDULED: <2026-09-25 Fri>" +DUE = "DEADLINE: <2026-09-30 Wed>" +LOG = "CLOCK: [2026-09-25 Fri 10:00:00]--[2026-09-25 Fri 10:05:00] => 00:05:00" + + +def _contents(tree): + return [n["content"] for n in tree] + + +class TestOutlineKeepsAttachedLines: + """A SCHEDULED/DEADLINE line and a closed logbook drawer belong to the + block above, as in Logseq's files; the one rule is blocktext's + attached_line_mask.""" + + @pytest.mark.parametrize("text", [ + f"TODO x\n{PLAN}", f"TODO x\n{DUE}", f"TODO x\n{PLAN}\n{DUE}", + f"TODO x\n:LOGBOOK:\n{LOG}\n:END:", + f"TODO x\n{PLAN}\n:LOGBOOK:\n{LOG}\n:END:\nprio:: high", + ], ids=["scheduled", "deadline", "both", "drawer", "all three"]) + def test_the_lines_stay_with_their_task(self, text): + tree = parse_hierarchical_content(text) + assert [(n["content"], n["children"]) for n in tree] == [(text, [])] + + def test_an_indented_planning_line_under_a_bullet(self): + tree = parse_hierarchical_content(f"- TODO x\n {PLAN}\n {DUE}") + assert [(n["content"], n["children"]) for n in tree] == \ + [(f"TODO x\n{PLAN}\n{DUE}", [])] + + def test_a_planning_line_in_a_code_block_stays_code(self): + tree = parse_hierarchical_content(f"- note\n ```\n {PLAN}\n ```") + assert len(tree) == 1 + assert tree[0]["content"].count(PLAN) == 1 + + def test_a_bullet_fence_with_a_drawer_inside_stays_one_block(self): + fence = "```sh\n:LOGBOOK:\nx\n```" + tree = parse_hierarchical_content("- ```sh\n :LOGBOOK:\n x\n ```\n :END:\n- b") + assert tree == [{"content": fence, "children": [{"content": ":END:", "children": []}]}, + {"content": "b", "children": []}] + tree = parse_hierarchical_content("- ```sh\n :LOGBOOK:\n x\n ```\n- b") + assert tree == [{"content": fence, "children": []}, {"content": "b", "children": []}] + + @pytest.mark.parametrize("text", [ + PLAN, DUE, f":LOGBOOK:\n{LOG}\n:END:"], ids=["scheduled", "deadline", "drawer"]) + def test_a_leading_line_has_no_block_to_join(self, text): + tree = parse_hierarchical_content(text) + assert [(n["content"], n["children"]) for n in tree] == [(text, [])] + + def test_a_bullet_fence_line_inside_a_code_block_opens_nothing(self): + text = ("- TODO a\n```md\n- ```js\n```\n :LOGBOOK:\n" + " CLOCK: [2026-09-25 Fri 10:00:00]\n :END:\n- b\n```\nx\n```") + tree = parse_hierarchical_content(text) + assert [n["children"] for n in tree] == [[], []] + assert tree[0]["content"].endswith(":END:") + + def test_a_bullet_line_ends_a_drawer_that_nothing_closed_before_it(self): + # An opener whose closer comes only after a bullet line is text, like + # an opener nothing closes: no block is swallowed, every line is a block. + tree = parse_hierarchical_content("TODO x\n:LOGBOOK:\n- foo\n:END:") + assert _contents(tree) == ["TODO x", ":LOGBOOK:", "foo", ":END:"] + tree = parse_hierarchical_content("- a\n:LOGBOOK:\n- b\n:END:") + assert _contents(tree) == ["a", ":LOGBOOK:", "b", ":END:"] + tree = parse_hierarchical_content("- TODO a\n:LOGBOOK:\n- TODO b\n- c\n:END:") + assert _contents(tree) == ["TODO a", ":LOGBOOK:", "TODO b", "c", ":END:"] + + def test_a_drawer_closed_before_the_next_bullet_stays_with_its_block(self): + text = f"- TODO a\n :LOGBOOK:\n {LOG}\n :END:\n- TODO b" + assert _contents(parse_hierarchical_content(text)) == [ + f"TODO a\n:LOGBOOK:\n{LOG}\n:END:", "TODO b"] + + def test_a_blank_line_inside_a_drawer_goes_and_the_drawer_stays(self): + tree = parse_hierarchical_content(f"TODO x\n:LOGBOOK:\n\n{LOG}\n:END:") + assert _contents(tree) == [f"TODO x\n:LOGBOOK:\n{LOG}\n:END:"] + + def test_siblings_are_not_swallowed(self): + tree = parse_hierarchical_content(f"- TODO x\n {PLAN}\n- TODO y") + assert _contents(tree) == [f"TODO x\n{PLAN}", "TODO y"] + + +def _page_blocks(double, name="P"): + page = next(p for p in double.pages if p["name"] == name) + return [n["content"] for n in double._walk(page["blocks"])] + + +def _command(monkeypatch, args, *, page="P"): + double = LogseqHttpDouble().install(monkeypatch) + double.add_page(page, ["first"]) + result = split_runner().invoke(cli, ["--token", "t", *args]) + return result, double + + +class TestAnUnclosedLogbookIsText: + """An opener no closer ends is text, not a drawer (mldoc 1.5.7). The outline + parser makes a block of every line without a bullet, so the opener and what + follows it are blocks of their own: nothing is swallowed, nothing refused, + nothing said on stderr.""" + + def test_the_parser(self): + tree = parse_hierarchical_content("TODO x\n:LOGBOOK:\nno end") + assert _contents(tree) == ["TODO x", ":LOGBOOK:", "no end"] + tree = parse_hierarchical_content("TODO x\n:LOGBOOK:\nno end\n- TODO y") + assert _contents(tree) == ["TODO x", ":LOGBOOK:", "no end", "TODO y"] + + @pytest.mark.parametrize("text,count", [ + ("TODO x\n:LOGBOOK:\nno end", 3), + ("TODO x\n:LOGBOOK:\nno end\n- TODO y", 4), + (f"TODO x\n:LOGBOOK:\n{LOG}\n:END:", 1), + ]) + def test_the_command(self, monkeypatch, text, count): + result, double = _command(monkeypatch, ["add-note-content", "--page", "P", + "--content", text, "--dry-run", "--json"]) + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout)["blocks_added"] == count + assert result.stderr == "" + result, double = _command(monkeypatch, ["add-note-content", "--page", "P", + "--content", text, "--json"]) + assert result.exit_code == 0, result.stderr + assert result.stderr == "" + assert len(_page_blocks(double)) == 1 + count + if text.endswith("TODO y"): + assert _page_blocks(double)[-1] == "TODO y" + + +class TestTheCommandsKeepAScheduledTaskInOneBlock: + """What took two blocks, a task and a block holding only its date, is one.""" + + @pytest.mark.parametrize("text", [ + f"TODO x\n{PLAN}", f"TODO x\n{DUE}", f"TODO x\n:LOGBOOK:\n{LOG}\n:END:"], + ids=["scheduled", "deadline", "drawer"]) + def test_add_note_content(self, monkeypatch, text): + args = ["add-note-content", "--page", "P", "--content", text, "--json"] + result, double = _command(monkeypatch, [*args, "--dry-run"]) + assert json.loads(result.stdout)["blocks_added"] == 1 + result, double = _command(monkeypatch, args) + assert result.exit_code == 0, result.stderr + assert _page_blocks(double) == ["first", text] + + def test_other_parser_users_keep_the_scheduled_line(self, monkeypatch): + text = f"- TODO x\n {PLAN}" + result, double = _command(monkeypatch, ["add-journal-content", "--content", text, + "--date", "2026-09-25", "--dry-run", "--json"]) + assert json.loads(result.stdout)["blocks"] == 1 + result, double = _command(monkeypatch, ["add-journal-content", "--content", text, + "--date", "2026-09-25", "--json"]) + assert f"TODO x\n{PLAN}" in _page_blocks(double, "2026-09-25, Friday") + + child = f"- TODO x\n {PLAN}\n - child" + result, double = _command(monkeypatch, ["insert-block", "--page", "P", "--content", + child, "--dry-run", "--json"]) + assert json.loads(result.stdout)["blocks"] == 2 + result, double = _command(monkeypatch, ["insert-block", "--page", "P", "--content", + child, "--json"]) + assert _page_blocks(double) == ["first", f"TODO x\n{PLAN}", "child"] + + result, double = _command(monkeypatch, ["add-journal-block", "--content", child, + "--date", "2026-09-25", "--json"]) + assert json.loads(result.stdout)["blocks_added"] == 2 + assert f"TODO x\n{PLAN}" in _page_blocks(double, "2026-09-25, Friday") + + def test_a_content_file_and_a_text_tree(self, monkeypatch, tmp_path): + path = tmp_path / "in.txt" + path.write_text(f"TODO x\n{PLAN}", encoding="utf-8") + result, double = _command(monkeypatch, ["add-journal-block", "--content-file", str(path), + "--date", "2026-09-25", "--json"]) + assert json.loads(result.stdout)["blocks_added"] == 1 + double = LogseqHttpDouble().install(monkeypatch) + double.add_page("P", ["first"]) + result = split_runner().invoke(cli, ["--token", "t", "insert-block", "--child-of", + double.uuid_of("first"), "--tree", + f"TODO x\n{PLAN}", "--json"]) + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout)["blocks_added"] == 1 + assert f"TODO x\n{PLAN}" in _page_blocks(double) + + class TestOnlyThePageOwnPropertiesLoseTheirBullet: """Logseq writes the page's properties, its first block, without a bullet; every other block keeps one, properties-only or not (an empty diff --git a/tests/test_tasks.py b/tests/test_tasks.py index aad43b9..7f81250 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -656,7 +656,8 @@ class TestOrderAndStates: page, Logseq 0.10.15, STARTED included, which Logseq's own marker-pattern does not name). block-checkbox (components/block.cljs, 0.10.15) draws an empty box for NOW LATER DOING IN-PROGRESS TODO WAIT WAITING, a ticked one - for DONE, none for CANCELED/CANCELLED and none for STARTED either; that + for DONE, none for CANCELED/CANCELLED and none for STARTED either (common.css + strikes a cancelled task through instead); that STARTED is open here is this module's decision, not a drawing.""" def test_order(self): @@ -920,7 +921,8 @@ def _source_uses_the_shared_rule(path, function_name): class TestOneRuleForAttachedLines: - @pytest.mark.parametrize("filename,function", [("tasks.py", "task_text")]) + @pytest.mark.parametrize("filename,function", + [("tasks.py", "task_text"), ("outlinetext.py", None)]) def test_the_source_reads_the_shared_rule(self, filename, function): assert _source_uses_the_shared_rule(PACKAGE / filename, function) From 891648f2318a33df9566929cfff1b000f4159866 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:04:42 +0200 Subject: [PATCH 06/15] Select tasks by state in get-todos; default to every open marker get-todos took --status with a default of TODO, DOING, NOW and LATER, so a task marked WAIT, WAITING, IN-PROGRESS or STARTED never showed up unless the caller named it. Logseq draws the first three as open tasks, and its parser reads STARTED as a marker too, although its own interface does not offer it. --status also took any text: --status foo ran and found nothing. --state open|done|cancelled (repeatable, default open) now selects by state. --status takes the eleven markers Logseq reads, in any case, has no default and wins over --state, with a note when both are given. --include-done stays, hidden from --help, and adds DONE to either. Tasks sort by marker. The default changes under the same name and key; only open tasks are added, each carrying its marker, so nothing reads wrongly. The changelog names the old default and how to keep it. The two tests that compare the command reference and the skill with the registry now leave hidden options out. Mutations tried, and the test that caught each: - markers_in without WAIT: the open-markers case without a filter - the old sort table: the marker order case - --state winning over --status: the status-wins case - --status with its old default: the source scan for marker lists (no behaviour test can see it, since "given" is read from the source) - "given" read from the value, with the default back: the case of an explicit --state equal to the default, and the case without a filter - --state as free text or case sensitive, --status case sensitive: the case and usage error cases - the note on stdout: the case of the note under --json Existing assertions changed: none. The reference and skill tests only leave hidden options out, green before and after. Cases seen failing before the change: the thirteen cases of the state selection class, against the code as it was. The markers go into the query through tasks.marker_clause, which checks each of them against the eleven; get-todos and its reference query built that set by hand before. Part of #92. --- AGENTS.md | 5 +- CHANGELOG.md | 20 +++ README.md | 8 +- docs/commands.md | 2 +- examples/weekly-todos.sh | 2 +- logseq_cli/commands/todos.py | 52 ++++--- ...est_command_reference_documents_options.py | 4 + tests/test_get_todos.py | 139 ++++++++++++++++++ tests/test_marker_lists.py | 1 - tests/test_skill.py | 2 +- 10 files changed, 209 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 45e5761..86ee924 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,9 +163,12 @@ U=$(logseq-cli find-block --content "tag support" --page "Project Alpha" --exact # All open tasks logseq-cli get-todos -# Filter by status +# Filter by marker logseq-cli get-todos --status TODO --status DOING +# Filter by state: open (the default), done or cancelled +logseq-cli get-todos --state done + # Filter by page (substring) logseq-cli get-todos --page "Project Alpha" diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d39e7..54748ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ file carries most of the reasoning behind the tool. ### Added +- `get-todos --state open|done|cancelled` (repeatable, default `open`) selects + tasks by state, so `--state done` lists the finished ones and `--state + cancelled` both spellings of the cancelled marker (#92). + - `[graph] keep_empty_blocks_last = true` (or `LOGSEQ_CLI_KEEP_EMPTY_BLOCKS_LAST`) makes a write that ends a section go before the empty blocks that end it, so they stay last and no empty line is left in front of the entry (#110). Found by @@ -92,6 +96,22 @@ file carries most of the reasoning behind the tool. ### Changed +- `get-todos` without `--status` now lists every open task, not only TODO, + DOING, NOW and LATER: tasks marked WAIT, WAITING, IN-PROGRESS or STARTED come + with it. Logseq draws the first three as open tasks, and its parser reads + STARTED as a marker too, although its own interface does not offer it (#92). + The result is the same shape and every task carries its `marker`; only more + tasks appear. To keep the old selection, pass `--status TODO --status DOING + --status NOW --status LATER`. `--status` takes only the eleven markers Logseq + reads and does not mind the case (`--status todo` still works); + `get-todos --status foo` used to run and find nothing without a word, and is + now a usage error with a non-zero exit. `--status` has no default any more, + so `--state` decides unless `--status` is given, and a note says so when both + are. `--include-done` still works, adds DONE to either, and is left out of + `--help`. Tasks are ordered by marker, with IN-PROGRESS and STARTED among the + work in hand and WAIT and WAITING after LATER; they used to sort after DONE, + by page. + - `set-todo-status` refuses a block that is no task instead of making it one: `no marker` became `DONE no marker`, `todo x` became `DONE x`. Logseq reads no marker in such a block (`todo x`, `TODO:`, `TODO` followed directly by a diff --git a/README.md b/README.md index 515f623..35bdeec 100644 --- a/README.md +++ b/README.md @@ -70,17 +70,17 @@ files would not. more, not the pages they sit on. `logseq-cli get-backlinks --page "Alex" --with-context` - **Keep track of who owes what.** *"What am I waiting for from Alex?"* The - tasks marked WAITING or TODO that mention Alex, filtered before the agent + open tasks that mention Alex, WAITING ones included, filtered before the agent sees them. - `logseq-cli get-todos --status WAITING --status TODO --match "Alex"` + `logseq-cli get-todos --match "Alex"` - **Find where you wrote about something.** *"Where did I note the migration plan?"* The matching blocks, ten at most, with a note when there are more, instead of the agent opening page after page. `logseq-cli find-block --content "migration plan" --limit 10` - **Run it on a schedule.** A morning cron job running `examples/carry-todos-to-today.sh 7 --write` - ([the script](examples/carry-todos-to-today.sh)) carries every TODO, DOING, - NOW or LATER journal task older than a week into today's journal as a ref, with no agent and no + ([the script](examples/carry-todos-to-today.sh)) carries every open + journal task older than a week into today's journal as a ref, with no agent and no tokens involved (without `--write` it only previews). A scheduled agent run can start from an overview of the last month without its full text, and write a briefing or a summary of your mood into today's journal. diff --git a/docs/commands.md b/docs/commands.md index 6f25d21..ec1e0a6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -137,7 +137,7 @@ A write that ends a section (`add-journal-block`, `add-journal-content`, | Command | Description | |---------|-------------| -| `get-todos [--page NAME] [--status S] [--tag TAG] [--match REGEX] [--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 (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] [--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-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` | diff --git a/examples/weekly-todos.sh b/examples/weekly-todos.sh index 459c54a..cf945e4 100755 --- a/examples/weekly-todos.sh +++ b/examples/weekly-todos.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# List all open TODOs, grouped by page +# List the open TODO and DOING tasks, grouped by page # Usage: ./weekly-todos.sh [--status DOING] # Requires: LOGSEQ_TOKEN or --token diff --git a/logseq_cli/commands/todos.py b/logseq_cli/commands/todos.py index 01b1503..ec59b74 100644 --- a/logseq_cli/commands/todos.py +++ b/logseq_cli/commands/todos.py @@ -4,7 +4,6 @@ import click from logseq_cli.blocktext import pointer_target -from logseq_cli.datalog import edn_string from logseq_cli.dates import ( journal_day_to_date, next_occurrence, @@ -16,7 +15,9 @@ from logseq_cli.notes import print_note from logseq_cli.output import fail, follow_page, handle_connection_error, output from logseq_cli.safety import WriteCommand -from logseq_cli.tasks import marker_before_newline, marker_of, task_text, with_marker +from logseq_cli.tasks import (ORDER, STATES, marker_before_newline, marker_of, markers_in, + marker_clause, + task_text, with_marker) def follow_ref_chain(api, block: dict, as_json: bool): @@ -51,7 +52,7 @@ def follow_ref_chain(api, block: dict, as_json: bool): return target, followed -def _fetch_todo_references(api, markers_str: str) -> dict: +def _fetch_todo_references(api, markers) -> 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 @@ -69,7 +70,7 @@ def _fetch_todo_references(api, markers_str: str) -> dict: '[: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)] ' + + marker_clause("?m", sorted(markers)) + ' ' '[?ref :block/refs ?src] ' '[?ref :block/page ?refp]]' ) @@ -142,13 +143,22 @@ def _place_references(occurrences, date_start, date_end, limit: int): deduped = deduped[:limit] return deduped, withheld +def _given(ctx, name): + """Whether the option was typed on the command line: its source, not its + value, since --state open equals the default and is still given.""" + return ctx.get_parameter_source(name) == click.core.ParameterSource.COMMANDLINE + + @cli.command("get-todos", epilog="""\b Examples: logseq-cli --token TOKEN get-todos --status TODO --status DOING logseq-cli --token TOKEN get-todos --page "Projects" --tag urgent - logseq-cli --token TOKEN get-todos --from 2026-05-01 --to 2026-05-31 --include-done + 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 Notes: - --status repeatable. Default: TODO, DOING, NOW, LATER (no DONE). +""" f""" --state {'|'.join(STATES)} (repeatable, default open: {', '.join(markers_in(['open']))}) selects by state; + --status picks single markers and overrides --state. +""" """ --due-from today --due-to lists what is due in the next seven days. 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. Following refs costs one extra query for @@ -164,8 +174,12 @@ def _place_references(occurrences, date_start, date_end, limit: int): --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). """) -@click.option("--status", multiple=True, default=("TODO", "DOING", "NOW", "LATER"), - help="Task status to include (repeatable, default: TODO DOING NOW LATER)") +@click.option("--state", multiple=True, default=("open",), + type=click.Choice(STATES, case_sensitive=False), + help=f"Select by state (repeatable): {', '.join(STATES)}. Default: open.") +@click.option("--status", multiple=True, type=click.Choice(ORDER, case_sensitive=False), + 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("--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)") @@ -173,7 +187,7 @@ def _place_references(occurrences, date_start, date_end, limit: int): @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. A repeating task is placed by its next occurrence, derived from the date in its text; one whose interval cannot be read is left out and named on stderr") @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("--include-done", is_flag=True, hidden=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'; 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, @@ -181,12 +195,17 @@ def _place_references(occurrences, date_start, date_end, limit: int): @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, match, from_date, to_date, due_from, due_to, include_done, - refs_limit, no_follow_refs, as_json): +def get_todos(ctx, state, status, page, 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"] - markers = set(s.upper() for s in status) + if _given(ctx, "status"): + markers = set(status) + if _given(ctx, "state"): + print_note("Note: --state was not applied, because --status is given.") + else: + markers = set(markers_in(state)) if include_done: markers.add("DONE") @@ -197,12 +216,11 @@ def get_todos(ctx, status, page, tag, match, from_date, to_date, due_from, due_t except re.error as e: fail(f"--match is not a valid regular expression: {e}.", 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?]) ' '(pull ?p [:block/original-name :block/name :block/journal-day]) ' ':where [?b :block/marker ?m] ' - f'[(contains? #{{{markers_str}}} ?m)] ' + + marker_clause("?m", sorted(markers)) + ' ' '[?b :block/page ?p]]' ) results = api.datascript_query(query) @@ -210,7 +228,7 @@ def get_todos(ctx, status, page, tag, match, from_date, to_date, due_from, due_t # 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) + occurrences = {} if no_follow_refs else _fetch_todo_references(api, markers) todos = [] for block_data, page_data in results: @@ -382,8 +400,8 @@ def get_todos(ctx, status, page, tag, match, from_date, to_date, due_from, due_t t.pop("_journal_day", None) # Sort: DOING/NOW first, then by page - marker_order = {"DOING": 0, "NOW": 1, "TODO": 2, "LATER": 3, "DONE": 4} - todos.sort(key=lambda t: (marker_order.get(t["marker"], 9), t["page"].lower())) + rank = {m: i for i, m in enumerate(ORDER)} + todos.sort(key=lambda t: (rank.get(t["marker"], len(ORDER)), t["page"].lower())) if repeating_excluded: # Named, not just counted: a bare number would leave the caller unable diff --git a/tests/test_command_reference_documents_options.py b/tests/test_command_reference_documents_options.py index 20bbb4d..a7f05ba 100644 --- a/tests/test_command_reference_documents_options.py +++ b/tests/test_command_reference_documents_options.py @@ -40,7 +40,11 @@ def _documented_forms(param): ``--no-create``), and the reference lists the one a caller actually passes — which for a default-on flag is the negative. Either spelling counts as documented; requiring a specific one would document a flag nobody uses. + A hidden option is left out of ``--help`` on purpose, so the reference may + leave it out too. """ + if getattr(param, "hidden", False): + return [] forms = list(getattr(param, "opts", ())) + list(getattr(param, "secondary_opts", ())) return [f for f in forms if f.startswith("--") and f not in UBIQUITOUS] diff --git a/tests/test_get_todos.py b/tests/test_get_todos.py index 47f6026..8b041cb 100644 --- a/tests/test_get_todos.py +++ b/tests/test_get_todos.py @@ -1,11 +1,14 @@ """Tests for get-todos: page-name inline per TODO.""" import json as _json +import re from unittest.mock import MagicMock, patch +import pytest from click.testing import CliRunner from logseq_cli.cli import cli +from tests.conftest import split_runner def _mock_api_for_todos(todo_rows, ref_rows=None): @@ -578,3 +581,139 @@ def test_tag_filter_and_references_coexist(self): todos = _json.loads(result.output)["todos"] assert len(todos) == 1, todos assert todos[0]["references"] == ["Mar 19"], todos[0] + + +OPEN = {"NOW", "LATER", "TODO", "DOING", "IN-PROGRESS", "WAIT", "WAITING", "STARTED"} +ELEVEN = ["DOING", "NOW", "IN-PROGRESS", "STARTED", "TODO", "LATER", "WAIT", "WAITING", + "DONE", "CANCELED", "CANCELLED"] + + +def _marker_set(query): + """The markers the first set of a query asks for.""" + return set(re.findall(r'"([A-Z-]+)"', re.search(r"#\{([^}]*)\}", query).group(1))) + + +def _row(marker, page="Page A", uuid=None): + return ({"content": f"{marker} task", "marker": marker, "uuid": uuid or f"u-{marker}"}, + {"original-name": page, "name": page.lower()}) + + +def _get(*args, rows=(), runner=None): + api = _mock_api_for_todos(list(rows)) + with patch("logseq_cli.group.LogseqAPI", return_value=api): + result = (runner or CliRunner()).invoke(cli, ["get-todos", *args]) + return result, api + + +def _asked(api): + queries = [c.args[0] for c in api.datascript_query.call_args_list] + return [_marker_set(q) for q in queries] + + +class TestSelectByState: + """A task is selected by where it stands (open, done, cancelled), or by + single markers; the markers are the ones Logseq's parser reads, and the + mock here applies no marker rule of its own: what is asked for is read + from the query sent.""" + + def test_without_a_filter_every_open_marker_is_asked_for(self): + rows = [_row(m) for m in ("WAIT", "WAITING", "IN-PROGRESS", "STARTED")] + result, api = _get("--json", rows=rows) + assert result.exit_code == 0, result.output + assert _asked(api) == [OPEN, OPEN] + assert len(_json.loads(result.output)["todos"]) == 4 + + def test_state_done_alone_asks_for_done_and_says_nothing(self): + result, api = _get("--state", "done", runner=split_runner()) + assert _asked(api)[0] == {"DONE"} + assert "--state" not in result.stderr + + def test_state_cancelled_asks_for_both_spellings(self): + _, api = _get("--state", "cancelled") + assert _asked(api)[0] == {"CANCELED", "CANCELLED"} + + def test_state_is_repeatable(self): + _, api = _get("--state", "open", "--state", "done") + assert _asked(api)[0] == OPEN | {"DONE"} + + def test_include_done_adds_done_to_a_state(self): + assert _asked(_get("--include-done")[1])[0] == OPEN | {"DONE"} + assert _asked(_get("--state", "cancelled", "--include-done")[1])[0] == \ + {"CANCELED", "CANCELLED", "DONE"} + + def test_include_done_adds_done_to_a_status(self): + _, api = _get("--status", "TODO", "--include-done") + assert _asked(api)[0] == {"TODO", "DONE"} + + def test_status_wins_over_state_and_a_note_says_so(self): + result, api = _get("--status", "DOING", "--state", "done", runner=split_runner()) + assert _asked(api)[0] == {"DOING"} + assert "--state" in result.stderr and "--status" in result.stderr + + def test_an_explicit_state_equal_to_the_default_still_triggers_the_note(self): + result, api = _get("--state", "open", "--status", "DOING", runner=split_runner()) + assert _asked(api)[0] == {"DOING"} + assert "--state" in result.stderr and "--status" in result.stderr + + def test_status_alone_needs_no_note(self): + result, api = _get("--status", "DOING", runner=split_runner()) + assert _asked(api)[0] == {"DOING"} + assert "--state" not in result.stderr + + def test_status_is_case_insensitive_and_arrives_upper_case(self): + _, api = _get("--status", "todo") + query = api.datascript_query.call_args_list[0].args[0] + assert _marker_set(query) == {"TODO"} and '"todo"' not in query + _, api = _get("--status", "todo", "--status", "TODO") + assert api.datascript_query.call_args_list[0].args[0].count('"TODO"') == 1 + + def test_state_is_case_insensitive(self): + assert _asked(_get("--state", "OPEN")[1])[0] == OPEN + assert _asked(_get("--state", "Done")[1])[0] == {"DONE"} + + def test_an_unknown_state_is_a_usage_error(self): + result, api = _get("--state", "foo") + assert result.exit_code != 0 + assert "--state" in result.output + assert api.datascript_query.call_count == 0 + + def test_status_state_and_include_done_together(self): + result, api = _get("--status", "DOING", "--state", "done", "--include-done", + runner=split_runner()) + assert _asked(api)[0] == {"DOING", "DONE"} + assert "--state" in result.stderr and "--status" in result.stderr + + def test_the_note_under_json_follows_the_result(self): + result, _ = _get("--status", "DOING", "--state", "done", "--json", runner=split_runner()) + assert _json.loads(result.stdout)["count"] == 0 + assert "--state" in result.stderr + + @pytest.mark.parametrize("marker", ELEVEN) + @pytest.mark.parametrize("case", [str.upper, str.lower], ids=["upper", "lower"]) + def test_every_marker_is_accepted_by_status(self, marker, case): + result, api = _get("--status", case(marker)) + assert result.exit_code == 0, result.output + assert _asked(api)[0] == {marker} + + def test_an_unknown_status_is_a_usage_error(self): + result, _ = _get("--status", "foo") + assert result.exit_code != 0 + assert "--status" in result.output + + def test_include_done_is_not_in_the_help(self): + result, _ = _get("--help") + assert "--include-done" not in result.output + assert "--state open --state done" in result.output + + def test_sorted_by_marker_order_then_page(self): + # Each marker on a page named backwards, so that sorting by page alone fails. + pages = [f"Page {chr(ord('Z') - i)}" for i in range(len(ELEVEN))] + rows = [_row(m, page) for m, page in zip(ELEVEN, pages)] + rows += [_row("TODO", "b", "u-b"), _row("TODO", "A", "u-A")] + result, _ = _get("--include-done", "--no-follow-refs", "--json", rows=reversed(rows)) + todos = _json.loads(result.output)["todos"] + assert [t["marker"] for t in todos] == [ + "DOING", "NOW", "IN-PROGRESS", "STARTED", "TODO", "TODO", "TODO", "LATER", + "WAIT", "WAITING", "DONE", "CANCELED", "CANCELLED"] + todo_pages = [t["page"] for t in todos if t["marker"] == "TODO"] + assert todo_pages == ["A", "b", pages[4]] diff --git a/tests/test_marker_lists.py b/tests/test_marker_lists.py index 117be53..f1a00ab 100644 --- a/tests/test_marker_lists.py +++ b/tests/test_marker_lists.py @@ -36,7 +36,6 @@ # (file, symbol) -> places still holding a marker list of their own. EXCEPTIONS = { - ("commands/todos.py", "get_todos"): 4, ("commands/todos.py", "set_todo_status"): 2, ("commands/edit.py", "update_block"): 1, ("commands/query.py", "smart_query"): 1, diff --git a/tests/test_skill.py b/tests/test_skill.py index daf2f4b..253c731 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -32,7 +32,7 @@ def _parts(): TEXT, FIELDS, BODY = _parts() # click keeps --help out of ``params``; it is an option all the same. -OPTIONS = {name: {opt for param in command.params +OPTIONS = {name: {opt for param in command.params if not getattr(param, "hidden", False) for opt in [*getattr(param, "opts", ()), *getattr(param, "secondary_opts", ())] if opt.startswith("--")} | set(click.Context(command).help_option_names) for name, command in cli.commands.items()} From b4ddb9626a60a34c3b9827a1d128edf4b0648a9b Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:06:15 +0200 Subject: [PATCH 07/15] Offer every marker Logseq's frontend knows in set-todo-status --status took a fixed list of six markers, told upper case from lower case, and so refused WAIT, WAITING, IN-PROGRESS and CANCELLED, which Logseq sets itself, and --status done, although get-todos took --status todo. The choice is now tasks.FRONTEND_MARKERS: every marker Logseq's parser reads but STARTED, which its interface does not offer, so the CLI does not write a marker Logseq never writes. Case does not matter, and the help says why the option takes a marker and not a state: only the marker DOING tells DOING from TODO. The help lists are made from the constant, and the epilog of update-block no longer lists markers. Mutations tried, and the test that caught each: - the choice case sensitive: the lower-case WAITING case - the old list of six: the WAITING case - STARTED among the markers: the STARTED case - the marker hint left out of the help: the help case Existing assertions changed: none. Part of #92. --- AGENTS.md | 2 +- CHANGELOG.md | 7 +++++ docs/commands.md | 2 +- logseq_cli/commands/edit.py | 2 +- logseq_cli/commands/todos.py | 13 ++++---- tests/test_marker_lists.py | 2 -- tests/test_todo_marker_keeps_lines.py | 45 +++++++++++++++++++++++++++ 7 files changed, 62 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 86ee924..278c6cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -539,7 +539,7 @@ Every command with all its options: [docs/commands.md](docs/commands.md). | `insert-block` | Insert at specific position (after/before/child-of, `--first` for first child); `--keep-ids` preserves `id::` values in `--tree` or `--content` (also on `add-note-content`, `add-journal-block`, `add-journal-content`) | | `find-block` | Find blocks by content; `--limit N` caps the output (what is withheld goes to stderr); `--with-children` prints the subtree; `--uuid-only` prints bare uuids and fails on no match; `--exactly-one` fails unless exactly one block matches | | `update-block` | Change one block's content (by `--id` or `--where-content`); its properties are kept, a key `--content` sets takes the new value | -| `set-todo-status` | Change a TODO/DOING/DONE marker (never `replace-text`) | +| `set-todo-status` | Change a task's marker (never `replace-text`) | | `move-block` | Relocate a block, keeping its UUID and refs | | `copy-block` | Copy a block to another page (new UUID) | | `remove-block` | Delete a block by UUID | diff --git a/CHANGELOG.md b/CHANGELOG.md index 54748ed..6e0429d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,13 @@ file carries most of the reasoning behind the tool. ### Changed +- `set-todo-status --status` no longer tells upper case from lower case + (`--status done` is taken, as `--status todo` always was for `get-todos`), + and sets every marker Logseq's frontend knows: WAIT, WAITING, IN-PROGRESS and + CANCELLED are new. STARTED stays out: Logseq's parser reads it, but the + frontend does not offer it, so the CLI does not write a marker Logseq never + writes (#92). + - `get-todos` without `--status` now lists every open task, not only TODO, DOING, NOW and LATER: tasks marked WAIT, WAITING, IN-PROGRESS or STARTED come with it. Logseq draws the first three as open tasks, and its parser reads diff --git a/docs/commands.md b/docs/commands.md index ec1e0a6..f277951 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -123,7 +123,7 @@ A write that ends a section (`add-journal-block`, `add-journal-content`, | `update-block (--id UUID \| --where-content TEXT [--page NAME] [--regex]) (--content TEXT \| --content-file FILE) [--dry-run]` | Update block content. `--content` is ONE block and has no tree path: a `- ` or `# ` line after the first (indented too) or a code fence nothing closes is refused, since Logseq would read it as a block of its own: use `insert-block --child-of` for children. `--where-content` selects by text instead of UUID and aborts unless exactly one block matches. The block's properties are kept as written, values as their original text, unless `--content` sets the key itself; the one change is Logseq's own spelling of a key (`created_at` is stored and written back as `created-at`). The block's own `id::` line passes; one naming another uuid is dropped with a note, since it would become this block's uuid | | `remove-block --id UUID [--ignore-refs] [--dry-run]` | Delete a block and its children (alias: `delete-block`). `--dry-run` reports the descendant count. Refuses while `((block-refs))` from elsewhere point into the block or its children, and lists them; `--ignore-refs` removes anyway | | `add-block-ref --source-id UUID (--journal-date DATE \| --page NAME) [--under-heading "## X"] [--dry-run]` | Write a `((block-ref))` pointing at an existing block. Journal defaults to today, heading to `LOGSEQ_JOURNAL_HEADING`. A source uuid no block has is refused before anything is written, `--dry-run` included — a ref to it would render as nothing. A source without an `id::` line gets one, as Logseq's editor gives it; every command that writes a `((uuid))` does this | -| `set-todo-status (--id UUID \| --content TEXT --page NAME) --status DONE [--follow-refs] [--dry-run]` | Swap a TODO/DOING/DONE marker without retyping the line. `--follow-refs` updates the original when the block is just a `((ref))` or `{{embed ((ref))}}`, through a chain of them; a chain that loops or reaches a missing block is refused. Ambiguous `--content` aborts and lists candidates. A block Logseq reads no marker in (`todo x`, `TODO:`, `TODO` followed directly by a line break, a block that only holds a ref) is refused, nothing written; `--content` matching blocks without a task is refused too, unless `--follow-refs` is given. Each refusal carries a `reason` under `--json`. `--dry-run` shows the old and new marker | +| `set-todo-status (--id UUID \| --content TEXT --page NAME) --status DONE [--follow-refs] [--dry-run]` | Swap a task's marker without retyping the line. `--follow-refs` updates the original when the block is just a `((ref))` or `{{embed ((ref))}}`, through a chain of them; a chain that loops or reaches a missing block is refused. Ambiguous `--content` aborts and lists candidates. A block Logseq reads no marker in (`todo x`, `TODO:`, `TODO` followed directly by a line break, a block that only holds a ref) is refused, nothing written; `--content` matching blocks without a task is refused too, unless `--follow-refs` is given. Each refusal carries a `reason` under `--json`. `--dry-run` shows the old and new marker | | `replace-text --page NAME --find TEXT --replace TEXT` | Search & replace with regex and dry-run support. `--replace` is literal; with `--regex` it takes `\1` group references. A replacement that turns a line into an `id::` line is refused before any block is written | | `insert-block (--content TEXT \| --content-file FILE) (--page NAME \| --after UUID \| --before UUID \| --child-of UUID)` | Insert one block at a position: appended to a page, as a sibling after or before a block, or as a child. `--property K=V` sets properties on it, repeatable. Without indentation `--content` is one block, and a line Logseq would read as a block of its own is refused before anything is written: a `- ` or `# ` line after the first, or a code fence nothing closes (fine inside a closed code block). The same holds for every write of one block's text: a `--tree` node, `add-journal-block`, `update-block`, `create-page --content`. A quote followed by a blank line and a paragraph is written, with a `Note:` on stderr: Logseq ends the quote at the blank line | | `insert-block --tree "" [--quiet]` | `--quiet` prints only the confirmation line, not one uuid line per block | diff --git a/logseq_cli/commands/edit.py b/logseq_cli/commands/edit.py index 5248574..d2a9427 100644 --- a/logseq_cli/commands/edit.py +++ b/logseq_cli/commands/edit.py @@ -80,7 +80,7 @@ Existing block properties survive the update: they are read first and written back, so changing the text no longer drops them. A property line in --content is the new value of its key. - Use set-todo-status to change TODO/DOING/DONE markers. + Use set-todo-status to change a task's marker. --content is ONE block, so a line Logseq would read as a block of its own is refused: a "- " or "# " line after the first (indented too), or a code fence nothing closes. In a closed code block such lines are fine. Children go in diff --git a/logseq_cli/commands/todos.py b/logseq_cli/commands/todos.py index ec59b74..fa5658b 100644 --- a/logseq_cli/commands/todos.py +++ b/logseq_cli/commands/todos.py @@ -15,8 +15,8 @@ from logseq_cli.notes import print_note from logseq_cli.output import fail, follow_page, handle_connection_error, output from logseq_cli.safety import WriteCommand -from logseq_cli.tasks import (ORDER, STATES, marker_before_newline, marker_of, markers_in, - marker_clause, +from logseq_cli.tasks import (FRONTEND_MARKERS, ORDER, STATES, marker_before_newline, marker_of, + markers_in, marker_clause, task_text, with_marker) @@ -473,8 +473,8 @@ def _refuse_no_task(block: dict, content: str, as_json: bool): --page "Project Alpha" --status DONE logseq-cli --token TOKEN set-todo-status --id JOURNAL-UUID --status DONE --follow-refs Notes: - Status values: TODO, DOING, DONE, LATER, NOW, CANCELED. - --follow-refs: when the block holds only a ((uuid)) ref or {{embed ((uuid))}}, +""" f""" Status values: {', '.join(FRONTEND_MARKERS)}. +""" """ --follow-refs: when the block holds only a ((uuid)) ref or {{embed ((uuid))}}, follows it, and on through every such block, and updates the one at the end. A chain that loops or reaches a missing block is refused, nothing written. Prefer this over replace-text for marker changes — 1 call, deterministic. @@ -488,8 +488,9 @@ def _refuse_no_task(block: dict, content: str, as_json: bool): @click.option("--content", default=None, help="Content substring to find the block (used with --page)") @click.option("--page", "--name", default=None, help="Page to search in (used with --content)") @click.option("--status", required=True, - type=click.Choice(["TODO", "DOING", "DONE", "LATER", "NOW", "CANCELED"]), - help="New task status") + type=click.Choice(FRONTEND_MARKERS, case_sensitive=False), + help=f"Marker to set: {', '.join(FRONTEND_MARKERS)} (case does not matter). It takes " + "a marker, not a state, because only the marker DOING separates it from TODO.") @click.option("--follow-refs", is_flag=True, help="If the block holds only a ((uuid)) ref or an embed of one, follow it, " "through every such block, and update the block at the end instead.") diff --git a/tests/test_marker_lists.py b/tests/test_marker_lists.py index f1a00ab..32c4d5d 100644 --- a/tests/test_marker_lists.py +++ b/tests/test_marker_lists.py @@ -36,8 +36,6 @@ # (file, symbol) -> places still holding a marker list of their own. EXCEPTIONS = { - ("commands/todos.py", "set_todo_status"): 2, - ("commands/edit.py", "update_block"): 1, ("commands/query.py", "smart_query"): 1, ("commands/analysis.py", "analyze_graph"): 1, ("commands/analysis.py", "analyze_journal_patterns"): 2, diff --git a/tests/test_todo_marker_keeps_lines.py b/tests/test_todo_marker_keeps_lines.py index 6f5eaf9..0ee6490 100644 --- a/tests/test_todo_marker_keeps_lines.py +++ b/tests/test_todo_marker_keeps_lines.py @@ -132,3 +132,48 @@ def test_a_task_property_block_at_the_end_of_a_chain_is_refused(): r, _ = _set_status(_block("", None, host), "--follow-refs", "--json", api=api) assert json.loads(r.stderr)["reason"] == "not_a_task" api.update_block.assert_not_called() + + +FRONTEND = ["TODO", "DOING", "DONE", "LATER", "NOW", "CANCELED", "CANCELLED", "WAIT", "WAITING", + "IN-PROGRESS"] + + +def test_started_is_not_a_choice(): + r, api = _set_status(_block("TODO x", "TODO"), status="STARTED") + assert r.exit_code != 0 + assert "Invalid value" in r.stderr and "--status" in r.stderr + api.update_block.assert_not_called() + + +@pytest.mark.parametrize("value", ["WAITING", "waiting"]) +def test_waiting_is_a_choice(value): + r, api = _set_status(_block("TODO x", "TODO"), status=value) + assert r.exit_code == 0, r.stderr + assert api.update_block.call_args.args[1] == "WAITING x" + + +@pytest.mark.parametrize("marker", FRONTEND) +@pytest.mark.parametrize("case", [str.upper, str.lower], ids=["upper", "lower"]) +def test_every_frontend_marker_is_accepted(marker, case): + r, _ = _set_status(_block("TODO x", "TODO"), status=case(marker)) + assert r.exit_code == 0, r.stderr + + +def _help(*args): + r = split_runner().invoke(cli, [*args, "--help"]) + return " ".join(r.output.split()) + + +def test_status_help_says_it_takes_a_marker(): + text = _help("set-todo-status") + assert "takes a marker" in text and "DOING" in text + assert "WAITING" in text + assert "Status values: DOING, NOW, IN-PROGRESS, TODO, LATER, WAIT, WAITING, DONE, " \ + "CANCELED, CANCELLED." in text + assert "STARTED" not in text + + +def test_update_block_help_names_no_marker_list(): + text = _help("update-block") + assert "to change a task's marker" in text + assert "TODO/DOING/DONE" not in text From 152ff50a6de2c664b9705079942092d845d7bb07 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:08:57 +0200 Subject: [PATCH 08/15] Report next_due from the repeater of the timestamp it reports get-todos worked out next_due from the first repeater in a task's text, whichever timestamp it belonged to. A task with SCHEDULED <... .+1d> and DEADLINE <... +1w> reports the deadline, but next_due followed the daily repeater, so --due-from and --due-to could place it up to six days off. next_due now comes from the timestamp whose date is reported (DEADLINE before SCHEDULED, as before), with that timestamp's own repeater. A timestamp without a repeater does not move, as Logseq moves only timestamps that have one, so next_due is its date. dates.timestamps reads every timestamp of a line with its kind, date, time and repeater; dates.parse_timestamp reads the text between the angle brackets and does not check the weekday. Timestamps are read only from the SCHEDULED and DEADLINE lines of a block, outside code fences, as Logseq reads them; parse_repeater also found one in running text. parse_repeater is gone: nothing calls it any more, and keeping it would leave a second rule, "the first repeater in the text", next to the right one. Its seven cases are timestamps cases now, with the same inputs. Mutations tried, and the test that caught each: - timestamps reading only the first timestamp of a line: the case of both on one line - the kind taken from the wrong group, the bare + left out of the repeaters, .+ read as +: the timestamps table cases - no line rule and no code fence rule: the running text case - the time ignored: the case with 18:00 - the weekday checked, the hour read with two digits only: the parse_timestamp cases - next_due from the first repeater in the text, from the other kind, without a date for a timestamp without repeater, or with the date from the text: the cases of next_due following the reported timestamp Existing assertions changed: none; the cases of TestParseRepeater moved to the timestamps cases with the same inputs. Cases seen failing before the fix: the two-repeater case, the deadline without a repeater, and the repeater of the other timestamp not borrowed. timestamps returns early for a text without a SCHEDULED or DEADLINE word, so the code-block scan runs only for blocks that can hold a timestamp. Part of #92. --- CHANGELOG.md | 11 ++++ CONTRIBUTING.md | 2 +- logseq_cli/commands/todos.py | 27 +++++---- logseq_cli/dates.py | 91 +++++++++++++++++++++++------- tests/test_repeater.py | 101 ++++++++++++++++++++++++++-------- tests/test_todos_due_dates.py | 61 ++++++++++++++++++++ 6 files changed, 237 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0429d..5debc25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,6 +185,17 @@ file carries most of the reasoning behind the tool. ### Fixed +- `get-todos` worked out `next_due` of a repeating task from the first repeater + in its text, not from the timestamp whose date it reports. A task with + `SCHEDULED: <… .+1d>` and `DEADLINE: <… +1w>` reports the deadline, but + `next_due` followed the daily repeater, so `--due-from` and `--due-to` could + place it up to six days off. Found by working the date out by hand with + Logseq's formula. `next_due` now comes from the timestamp whose date is + reported, with that timestamp's own repeater; a timestamp without a repeater + does not move, so `next_due` is its date (#92). Timestamps are read only from + the SCHEDULED and DEADLINE lines of a block, as Logseq reads them; a + `SCHEDULED:` in running text or in a code fence is not one. + - A task with a date can be written in one call. `add-note-content --content $'TODO x\nSCHEDULED: <2026-09-25 Fri>'` made two blocks: the `SCHEDULED:` line became a block of its own and had no effect, and the dry run said "2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3799c40..874c131 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ logseq-cli/ │ ├── cliinput.py # --content, --content-file and --tree, taken from the command line │ ├── config.py # Config file discovery, loading and lookup │ ├── datalog.py # EDN/datalog query building (value quoting, keywords) -│ ├── dates.py # Date keywords, journal days, repeaters, journal title formats +│ ├── dates.py # Date keywords, journal days, SCHEDULED/DEADLINE timestamps, repeaters, journal title formats │ ├── group.py # The click group: global options, API client │ ├── headings.py # Compare, find and add a heading on a page; which empty block a heading goes before │ ├── ids.py # id:: lines in written text: dropped with a note, or kept by --keep-ids diff --git a/logseq_cli/commands/todos.py b/logseq_cli/commands/todos.py index fa5658b..c18a565 100644 --- a/logseq_cli/commands/todos.py +++ b/logseq_cli/commands/todos.py @@ -8,7 +8,7 @@ journal_day_to_date, next_occurrence, parse_date_keyword, - parse_repeater, + timestamps, ) from logseq_cli.group import cli from logseq_cli.lookup import find_blocks_by_content @@ -270,16 +270,23 @@ def get_todos(ctx, state, status, page, tag, match, from_date, to_date, due_from # Logseq stores the date as written, which is the next occurrence # only if the task was ticked off by its checkbox, so the next one # is derived with the source's own formula (see next_occurrence). A repeater whose interval cannot be read is - # left without next_due and reported rather than guessed at. - repeater = parse_repeater(content) + # left without next_due and reported rather than guessed at. The + # repeater is the one of the timestamp whose date is reported. + reported = "DEADLINE" if record.get("deadline") else "SCHEDULED" stored = record.get("deadline") or record.get("scheduled") - if repeater and stored: - try: - nxt = next_occurrence(datetime.date.fromisoformat(stored), repeater) - except (ValueError, TypeError): - nxt = None - if nxt: - record["next_due"] = str(nxt) + planned = next((ts for ts in timestamps(content) if ts.kind == reported), None) + if planned and stored: + if planned.repeater is None: + # Logseq moves only a timestamp with a repeater. + record["next_due"] = stored + else: + try: + nxt = next_occurrence(datetime.date.fromisoformat(stored), + planned.repeater) + except (ValueError, TypeError): + nxt = None + if nxt: + record["next_due"] = str(nxt) todos.append(record) # Filter by page if requested diff --git a/logseq_cli/dates.py b/logseq_cli/dates.py index e66c922..ae71425 100644 --- a/logseq_cli/dates.py +++ b/logseq_cli/dates.py @@ -1,8 +1,8 @@ -"""Dates as the CLI reads and writes them: keywords, journal days, repeaters. +"""Dates as the CLI reads and writes them: keywords, journal days, timestamps, repeaters. Parses what a caller types for a date (``today``, an ISO date, a range), turns a journal day into a date and a date into the title Logseq gives its Journal -Page, and works out when a repeating task falls due. Nothing here talks to +Page, reads the SCHEDULED/DEADLINE timestamps of a block, and works out when a repeating task falls due. Nothing here talks to Logseq, so all of it is tested without a mock; the layering tests keep it that way (ADR 0003). """ @@ -10,9 +10,12 @@ import re import calendar import datetime +from typing import NamedTuple, Optional import click +from logseq_cli.blocktext import PLANNING_LINE_RE, code_block_lines + # Locale-independent English day/month names (Logseq always uses English) _WEEKDAYS_FULL = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] @@ -88,6 +91,70 @@ def journal_day_to_date(jd: int) -> datetime.date: return datetime.date(int(s[:4]), int(s[4:6]), int(s[6:8])) +class Timestamp(NamedTuple): + """One SCHEDULED or DEADLINE of a block. ``kind`` is that word as written + (None where only the text between the angle brackets was read); the + weekday is neither read nor kept, so a text that names the wrong one reads + the same. ``repeater`` is ``(kind, num, unit)``: ``kind`` is ``+``, ``++`` or ``.+`` as + Logseq spells it, ``unit`` one of h d w m y.""" + kind: Optional[str] + date: datetime.date + time: Optional[datetime.time] + repeater: Optional[tuple] + + +_TIMESTAMP_RE = re.compile(r"(SCHEDULED|DEADLINE):\s*<([^>]*)>") +_TIMESTAMP_BODY_RE = re.compile( + r"^\s*(\d{4})-(\d{2})-(\d{2})(?:\s+[A-Za-z]+)?(?:\s+(\d{1,2}):(\d{2}))?(.*)$", re.S) +# ++ and .+ before +: the bare + would take them all. +_INTERVAL_RE = re.compile(r"(\+\+|\.\+|\+)(\d+)([hdwmy])") + + +def parse_timestamp(text: str): + """The text between ``<`` and ``>`` of a timestamp, as a Timestamp without + ``kind``, or None. Forgiving on purpose: a wrong or missing weekday, and + what is not a repeater (a warning time such as ``-2d``), are let go; the + date is not (a day that does not exist is None).""" + match = _TIMESTAMP_BODY_RE.match(text or "") + if not match: + return None + year, month, day, hour, minute, rest = match.groups() + try: + date = datetime.date(int(year), int(month), int(day)) + time = datetime.time(int(hour), int(minute)) if hour is not None else None + except ValueError: + return None + interval = _INTERVAL_RE.search(rest) + repeater = (interval.group(1), int(interval.group(2)), interval.group(3)) if interval else None + return Timestamp(None, date, time, repeater) + + +def timestamps(content: str) -> list: + """Every SCHEDULED/DEADLINE of a block's text, in the order of the text, + each with the repeater of its own. + + Only a line that starts with the word counts (blocktext.PLANNING_LINE_RE), + and not one inside a code block: Logseq's parser reads a date as a line of + the block, nothing else. A line may hold both. One that does not read as a + date is left out, never guessed. next_occurrence works in days, for "what + is due"; this tells what the text says, and the two answer different + questions. + """ + found = [] + if "SCHEDULED" not in (content or "") and "DEADLINE" not in (content or ""): + return found + lines = (content or "").split("\n") + inside, _ = code_block_lines(lines) + for line, in_code in zip(lines, inside): + if in_code or not PLANNING_LINE_RE.match(line): + continue + for match in _TIMESTAMP_RE.finditer(line): + stamp = parse_timestamp(match.group(2)) + if stamp: + found.append(stamp._replace(kind=match.group(1))) + return found + + # Repeating tasks # --------------- # Logseq stores a repeater's date as written in the text, and :block/scheduled @@ -103,24 +170,6 @@ def journal_day_to_date(jd: int) -> datetime.date: # weekday (repeat-until-future-timestamp) # ++ add delta once, but only if the written date is already past # + add delta once, unconditionally -_REPEATER_RE = re.compile( - r"(?:SCHEDULED|DEADLINE):\s*<[^>]*?(\+\+|\.\+|\+)(\d+)([hdwmy])[^>]*>" -) - - -def parse_repeater(content: str): - """Extract ``(kind, num, unit)`` from a SCHEDULED/DEADLINE line, or None. - - ``kind`` is ``"+"``, ``"++"`` or ``".+"`` exactly as Logseq spells it. The - order in the pattern matters: ``++`` and ``.+`` must be tried before the - bare ``+``, or every repeater would read as ``+``. - """ - if not content: - return None - match = _REPEATER_RE.search(content) - if not match: - return None - return match.group(1), int(match.group(2)), match.group(3) def _add_interval(start: datetime.date, num: int, unit: str): @@ -154,7 +203,7 @@ def _add_interval(start: datetime.date, num: int, unit: str): def next_occurrence(start: datetime.date, repeater, today: datetime.date = None): """Next due date of a repeating task, or None if it cannot be derived. - ``repeater`` is what :func:`parse_repeater` returns. ``today`` is injectable + ``repeater`` is the one of a :class:`Timestamp` from :func:`timestamps`. ``today`` is injectable so the rule can be tested against fixed dates instead of the clock. """ if not repeater or start is None: diff --git a/tests/test_repeater.py b/tests/test_repeater.py index c0c9a8f..7b460ec 100644 --- a/tests/test_repeater.py +++ b/tests/test_repeater.py @@ -24,32 +24,10 @@ """ import datetime +import pytest -from logseq_cli.dates import next_occurrence, parse_repeater - -class TestParseRepeater: - def test_double_plus_week(self): - assert parse_repeater("SCHEDULED: <2020-01-06 Mon ++1w>") == ("++", 1, "w") - - def test_dotted_day(self): - assert parse_repeater("SCHEDULED: <2024-03-01 Fri .+3d>") == (".+", 3, "d") - - def test_plain_plus_month(self): - assert parse_repeater("DEADLINE: <2024-03-01 Fri +2m>") == ("+", 2, "m") - - def test_with_a_time_before_the_repeater(self): - assert parse_repeater("SCHEDULED: <2022-12-01 Thu 18:00 ++1w>") == ("++", 1, "w") - - def test_no_repeater_is_none(self): - assert parse_repeater("SCHEDULED: <2024-03-01 Fri>") is None - - def test_no_timestamp_at_all_is_none(self): - assert parse_repeater("TODO just a task") is None - - def test_deadline_line_among_others(self): - content = "TODO pay rent\nDEADLINE: <2024-03-01 Fri ++1m>\n:LOGBOOK:\n:END:" - assert parse_repeater(content) == ("++", 1, "m") +from logseq_cli.dates import Timestamp, next_occurrence, parse_timestamp, timestamps class TestNextOccurrence: @@ -122,3 +100,78 @@ def test_day_interval_steps_past_today(self): def test_unknown_unit_is_none(self): """Not guessed: an unparseable repeater is reported, not invented.""" assert next_occurrence(datetime.date(2026, 5, 10), ("++", 1, "x"), self.TODAY) is None + + +D = datetime.date +T = datetime.time + + +class TestTimestamps: + """Every SCHEDULED/DEADLINE of a block, each with the repeater of its own.""" + + @pytest.mark.parametrize("content,expected", [ + ("SCHEDULED: <2020-01-06 Mon ++1w>", + [Timestamp("SCHEDULED", D(2020, 1, 6), None, ("++", 1, "w"))]), + ("SCHEDULED: <2024-03-01 Fri .+3d>", + [Timestamp("SCHEDULED", D(2024, 3, 1), None, (".+", 3, "d"))]), + ("DEADLINE: <2024-03-01 Fri +2m>", + [Timestamp("DEADLINE", D(2024, 3, 1), None, ("+", 2, "m"))]), + ("SCHEDULED: <2022-12-01 Thu 18:00 ++1w>", + [Timestamp("SCHEDULED", D(2022, 12, 1), T(18, 0), ("++", 1, "w"))]), + ("SCHEDULED: <2024-03-01 Fri>", [Timestamp("SCHEDULED", D(2024, 3, 1), None, None)]), + ("SCHEDULED: <2026-09-24 Thu 18:00>", + [Timestamp("SCHEDULED", D(2026, 9, 24), T(18, 0), None)]), + ("TODO just a task", []), + ("TODO pay rent\nDEADLINE: <2024-03-01 Fri ++1m>\n:LOGBOOK:\n:END:", + [Timestamp("DEADLINE", D(2024, 3, 1), None, ("++", 1, "m"))]), + ("SCHEDULED: <2026-09-24 Thu 10:00 .+2h>", + [Timestamp("SCHEDULED", D(2026, 9, 24), T(10, 0), (".+", 2, "h"))]), + ]) + def test_timestamps(self, content, expected): + assert timestamps(content) == expected + + def test_two_timestamps_on_two_lines_each_with_its_own_repeater(self): + got = timestamps("TODO x\nSCHEDULED: <2026-09-22 Tue .+1d>\nDEADLINE: <2026-09-23 Wed +1w>") + assert [(t.kind, t.repeater) for t in got] == [ + ("SCHEDULED", (".+", 1, "d")), ("DEADLINE", ("+", 1, "w"))] + + def test_the_order_of_the_text_is_kept(self): + got = timestamps("DEADLINE: <2026-09-23 Wed +1w>\nSCHEDULED: <2026-09-22 Tue .+1d>") + assert [t.kind for t in got] == ["DEADLINE", "SCHEDULED"] + + def test_both_on_one_line(self): + got = timestamps("SCHEDULED: <2026-09-22 Tue +1d> DEADLINE: <2026-09-23 Wed ++1w>") + assert [(t.kind, t.repeater) for t in got] == [ + ("SCHEDULED", ("+", 1, "d")), ("DEADLINE", ("++", 1, "w"))] + + def test_an_indented_line_is_read(self): + assert len(timestamps("TODO x\n SCHEDULED: <2026-09-22 Tue +1d>")) == 1 + + @pytest.mark.parametrize("content", [ + "TODO see SCHEDULED: <2026-09-22 Tue +1d> later", + "```\nSCHEDULED: <2026-09-22 Tue +1d>\n```", + "SCHEDULED: <2026-02-30 Fri +1d>", + "", None, + ], ids=["mid text", "code fence", "no such date", "empty", "none"]) + def test_no_timestamp(self, content): + assert timestamps(content) == [] + + +class TestParseTimestamp: + @pytest.mark.parametrize("text,expected", [ + ("2026-09-24 Thu .+1d", Timestamp(None, D(2026, 9, 24), None, (".+", 1, "d"))), + ("2026-09-24 Thu 18:00 ++1w", Timestamp(None, D(2026, 9, 24), T(18, 0), ("++", 1, "w"))), + ("2026-09-24 Thu 18:00", Timestamp(None, D(2026, 9, 24), T(18, 0), None)), + ("2026-09-24 Thu 9:05", Timestamp(None, D(2026, 9, 24), T(9, 5), None)), + ("2026-09-24 Thu +2m", Timestamp(None, D(2026, 9, 24), None, ("+", 2, "m"))), + ("2026-09-24", Timestamp(None, D(2026, 9, 24), None, None)), + ("2026-09-24 Mon", Timestamp(None, D(2026, 9, 24), None, None)), + ("2026-09-24 thu", Timestamp(None, D(2026, 9, 24), None, None)), + ("2026-09-24 Thu -2d", Timestamp(None, D(2026, 9, 24), None, None)), + ]) + def test_read(self, text, expected): + assert parse_timestamp(text) == expected + + @pytest.mark.parametrize("text", ["2026-02-30 Fri", "tomorrow", "", "2026-09-24 Thu 25:00"]) + def test_none(self, text): + assert parse_timestamp(text) is None diff --git a/tests/test_todos_due_dates.py b/tests/test_todos_due_dates.py index 678bdb8..f2ddaad 100644 --- a/tests/test_todos_due_dates.py +++ b/tests/test_todos_due_dates.py @@ -21,6 +21,7 @@ from unittest.mock import MagicMock, patch from logseq_cli.cli import cli +from logseq_cli.dates import next_occurrence from tests.conftest import split_runner @@ -192,3 +193,63 @@ def test_a_repeater_does_not_disturb_the_noted_on_range(self): result = _run(["get-todos", "--from", "2026-05-01", "--to", "2026-05-31", "--json"], rows) assert len(json.loads(result.stdout)["todos"]) == 1 + + +def _todo(args, rows): + """The one task a run reports, the JSON, and the spy on next_occurrence.""" + with patch("logseq_cli.commands.todos.next_occurrence", wraps=next_occurrence) as spy: + result = _run(["get-todos", "--no-follow-refs", "--json", *args], rows) + assert result.exit_code == 0, result.stderr + data = json.loads(result.stdout) + return (data["todos"][0] if data["todos"] else None), data, spy + + +class TestNextDueUsesTheReportedTimestamp: + """A task with two timestamps reports one date; its next_due follows that + timestamp's own repeater, not the first repeater in the text.""" + + TWO = _rows(( + "TODO pay\nSCHEDULED: <2020-01-06 Mon .+1d>\nDEADLINE: <2020-01-07 Tue +1w>", + "TODO", "Finance", None, 20200106, 20200107, True)) + + def test_next_due_comes_from_the_repeater_of_the_deadline(self): + todo, data, spy = _todo([], self.TWO) + assert spy.call_args.args[0] == datetime.date(2020, 1, 7) + assert spy.call_args.args[1] == ("+", 1, "w") + assert todo["deadline"] == "2020-01-07" + due = datetime.date.fromisoformat(todo["next_due"]) + assert due > datetime.date.today() and due.weekday() == 1 + for day, count in ((due, 1), (due + datetime.timedelta(days=1), 0)): + _, data, _ = _todo(["--due-from", str(day), "--due-to", str(day)], self.TWO) + assert data["count"] == count and "repeating_excluded" not in data + + def test_a_deadline_without_a_repeater_does_not_move(self): + rows = _rows(("TODO pay\nSCHEDULED: <2020-01-06 Mon .+1d>\nDEADLINE: <2020-01-07 Tue>", + "TODO", "Finance", None, 20200106, 20200107, True)) + todo, _, spy = _todo([], rows) + assert todo["next_due"] == "2020-01-07" and todo["repeating"] is True + assert spy.call_count == 0 + _, data, _ = _todo(["--due-from", "2020-01-01", "--due-to", "2020-01-31"], rows) + assert data["count"] == 1 + + def test_only_a_scheduled_repeater_reads_as_before(self): + rows = _rows(("TODO weekly\nSCHEDULED: <2020-01-06 Mon .+1d>", + "TODO", "Finance", None, 20200106, None, True)) + todo, _, spy = _todo([], rows) + assert spy.call_args.args[1] == (".+", 1, "d") + assert datetime.date.fromisoformat(todo["next_due"]) > datetime.date.today() + + def test_a_repeater_of_the_other_timestamp_is_not_borrowed(self): + rows = _rows(("TODO pay\nSCHEDULED: <2020-01-06 Mon .+1d>", + "TODO", "Finance", None, None, 20200107, True)) + todo, data, spy = _todo([], rows) + assert "next_due" not in todo and spy.call_count == 0 + _, data, _ = _todo(["--due-from", "2020-01-01", "--due-to", "2030-01-01"], rows) + assert data["repeating_excluded"] == 1 and data["count"] == 0 + + def test_the_reported_date_is_the_one_from_the_database(self): + rows = _rows(("TODO pay\nDEADLINE: <2020-01-14 Tue +1w>", + "TODO", "Finance", None, None, 20200107, True)) + todo, _, spy = _todo([], rows) + assert spy.call_args.args[0] == datetime.date(2020, 1, 7) + assert todo["deadline"] == "2020-01-07" From 33d012fc5ebaf07ccc2788d1be2220467cf8966a Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:15:22 +0200 Subject: [PATCH 09/15] Count analyze-graph tasks from the database, like get-todos analyze-graph counted tasks with a pattern over the page text, while get-todos counts blocks that carry a marker. On one graph the two disagreed: the pattern read TODO: , a lowercase todo, a block that starts with [ ] and a marker on a later line as tasks, none of which Logseq does. analyze-graph now asks for every block with one of the eleven markers Logseq reads and sorts them into open, done and cancelled by one classification, the same one get-todos uses. total_todos is replaced by tasks (open, done, cancelled, open_by_marker). A note on stderr says how many blocks start with [ ], which Logseq shows as text. --days limits only the Recently updated list, and its help says so. smart-query --request tasks asks for the same open markers, so it now also finds WAIT, WAITING, IN-PROGRESS and STARTED. The pattern is matched by every keyword it lists, so all of them are affected. The tree walker of meta.py is replaced by outlinetext.preorder_blocks, which the note uses per block too. Live check 2026-10-06, Logseq 0.10.15: 8 of 8 quantities equal on a probe page (and a deliberately shifted expectation was reported as unequal); the page was removed. Mutations tried and caught: - the query asking for the open markers only, the count taken per page: the test that the query names exactly the eleven markers, once - a checkbox looked for on every line, or behind a list character or a heading: the note case - the parenthesis kept at zero open tasks, the open markers sorted by the alphabet: the text line and the order cases - a missing tree not skipped, a marker outside the states not skipped, a query answer of none not read as empty: the three cases of that name - the empty-tree rule copied into a second place: the page text cases - the old list in the smart-query pattern, done also asking for CANCELED: the pattern cases, one per keyword - the tree walker in post-order, not descending, reading the children key unguarded: the walker cases Changed existing tests: the five total_todos cases of analyze-graph are replaced; the cases they held, prose, a logbook line, a marker on a later line, a bracket pair in prose, are marker and checkbox cases of the task rules now, and the marker cases are covered by the mock that answers the query. Two test gaps are closed with it: a checkbox block nested below the top level is counted, and --days limiting only the Recently updated list has a behaviour test. Part of #92. --- CHANGELOG.md | 21 +++ docs/commands.md | 8 +- docs/design.md | 4 +- examples/daily-todos.sh | 4 +- logseq_cli/commands/analysis.py | 54 +++--- logseq_cli/commands/meta.py | 9 +- logseq_cli/commands/query.py | 7 +- logseq_cli/lookup.py | 10 +- tests/test_marker_lists.py | 2 - tests/test_read_only_commands_smoke.py | 219 +++++++++++++++++++------ tests/test_tasks.py | 19 +++ tests/test_walk_blocks.py | 55 +++++++ 12 files changed, 324 insertions(+), 88 deletions(-) create mode 100644 tests/test_walk_blocks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5debc25..9c6a4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,21 @@ file carries most of the reasoning behind the tool. ### Changed +- `analyze-graph` counts tasks from the database, like `get-todos`: + `total_todos` is replaced by `tasks` (`open`, `done`, `cancelled`, + `open_by_marker`) and the text line reads `Tasks: N open (…), M done, K + cancelled`. Before, a pattern over the page text counted `TODO:`, a lowercase + `todo`, a block that starts with `[ ]` and a marker on a later line, so the + two commands disagreed on the same graph. A note says how many blocks start + with `[ ]`, which Logseq shows as text. `--days` limits only the Recently + updated list; its help now says so (#92). + +- `smart-query --request tasks` now also finds WAIT, WAITING, IN-PROGRESS and + STARTED tasks. The pattern is matched by every keyword it lists (`todo`, + `task`, `tasks`, `incomplete`, `pending`, `aufgaben`, `offene`, `offen`), not + only `tasks`, so all of them return more. The old list was TODO, LATER, NOW, + DOING; `--advanced` with your own query keeps it. `done` is unchanged (#92). + - `set-todo-status --status` no longer tells upper case from lower case (`--status done` is taken, as `--status todo` always was for `get-todos`), and sets every marker Logseq's frontend knows: WAIT, WAITING, IN-PROGRESS and @@ -183,6 +198,12 @@ file carries most of the reasoning behind the tool. where Logseq shows them. The body is still one `get-block` away. See [#98](https://github.com/muellerei/logseq-cli/issues/98). +### Removed + +- `analyze-graph` no longer reports `total_todos`; `tasks` replaces it (#92). + A key that changes its value never keeps its old name, so a reader of the + old key fails loudly. + ### Fixed - `get-todos` worked out `next_due` of a repeating task from the first repeater diff --git a/docs/commands.md b/docs/commands.md index f277951..84daed7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -94,13 +94,15 @@ graph. | `search-pages --query TEXT` | Case-insensitive name search | | `get-backlinks --page NAME [--with-context] [--limit N]` | Pages linking to NAME. `--with-context` also shows the blocks that do the linking — they arrive with the same API call, so it costs no extra read; `--limit` (default 3) caps the blocks per page and reports the remainder; `0` keeps all | | `get-journal-summary --range RANGE [--no-content]` | Journal summary (today, this week, last 30 days). `--no-content` drops the per-day bodies | -| `analyze-graph [--days N]` | Graph structure analysis. `--days N` adds the pages modified in the last N days | +| `analyze-graph [--days N]` | Graph structure analysis. `--days N` limits only the "Recently updated" list to the pages modified in the last N days; every other figure covers the whole graph. See "analyze-graph output" below the table | | `find-knowledge-gaps [--min-refs N] [--include-orphans/--no-include-orphans]` | Missing/underdeveloped/orphaned pages. `--min-refs` (default 2) is how many incoming references a short page needs before it counts as underdeveloped rather than unused | | `analyze-journal-patterns [--timeframe RANGE] [--mood/--no-mood] [--topics/--no-topics]` | Journal entry patterns over `--timeframe` (default "last 30 days"). `--no-mood` and `--no-topics` drop those sections | | `smart-query --request TEXT [--advanced] [--include-query]` | Datalog queries (natural language, or `--advanced` to pass raw Datalog through). `--include-query` prints the generated query alongside the result | | `suggest-connections [--min-confidence N] [--min-shared N] [--max-suggestions N] [--focus PAGE]` | Topic-based connection suggestions. `--min-shared` (default 3) is the real filter — it sets how many topics two pages must share before the pair counts at all; `--min-confidence` (default 0.3) then scores it. `--max-suggestions` (default 10) caps the list and the remainder is reported as withheld; `total_found` counts what the graph held, not what survived the cap. `--focus` restricts to one page | | `get-page-stats --page NAME` | Page statistics (blocks, words, in/outbound links) | +**analyze-graph output.** `--json` carries `tasks`: `{"open": N, "done": N, "cancelled": N, "open_by_marker": {"": N, ...}}`. A task is a block with a marker Logseq reads; `TODO:`, a lower-case `todo`, `[ ]` at the start of a block and `TODO` followed directly by a line break are not. `open` is the same number as the `count` of `get-todos --no-follow-refs`. The text output has the line `Tasks: 12 open (2 DOING, 10 TODO), 40 done, 1 cancelled`, open markers in the order of the list, and a note on stderr when blocks start with `[ ]`: `Note: N blocks start with "[ ]", which Logseq shows as text, not as a task or checkbox.` + ### Write (5) | Command | Description | @@ -324,6 +326,8 @@ logseq-cli smart-query --request "offene aufgaben" # German → finds open tas logseq-cli smart-query --request "open tasks" # English → same result ``` +The pattern for open tasks finds every open marker Logseq reads (the markers and their three states are in `CONTEXT.md`, under Task State); the pattern for done tasks finds `DONE` only. + Date formatting is locale-independent — weekday and month names are always English (as Logseq expects), regardless of system locale. ## Scripting Examples @@ -333,7 +337,7 @@ See `examples/` directory: - `backup-graph.sh` - Export all pages as a JSON backup - `carry-todos-to-today.sh` - Carry open tasks older than N days into today's journal as `((block-refs))`; dry run unless `--write` (suitable for a morning cronjob) - `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) +- `daily-todos.sh` - Daily open-task 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 - `morning-log.sh` - Add a timestamped log entry to today's journal diff --git a/docs/design.md b/docs/design.md index e800570..b1730c2 100644 --- a/docs/design.md +++ b/docs/design.md @@ -149,7 +149,9 @@ erfolgreich" as positive, 16% of positive hits in a 90-day sample; and by-products. None of that was caught by tests asserting that output exists — it took reading the numbers next to a graph whose real answer was known. A plausible number that gets believed is worse than an obvious failure, so these -commands now measure one defined thing each and agree with one another. +commands now measure one defined thing each, and the tasks that +`analyze-graph`, `get-todos` and `smart-query` report come from the same +classification of `:block/marker`. See [0.9.0, Fixed](../CHANGELOG.md#090---2026-09-14). ## Output is bounded because the consumer has a context limit diff --git a/examples/daily-todos.sh b/examples/daily-todos.sh index 65d9a0d..b5de418 100755 --- a/examples/daily-todos.sh +++ b/examples/daily-todos.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Daily TODO overview - suitable for cronjob +# Daily open-task overview - suitable for cronjob # Usage: ./daily-todos.sh # Cronjob: 0 9 * * * /path/to/daily-todos.sh >> ~/logseq-todos.log set -o pipefail -echo "=== TODO Overview $(date +%Y-%m-%d) ===" +echo "=== Open tasks $(date +%Y-%m-%d) ===" # With pipefail a failed call is not mistaken for an empty list. The error # goes to stderr, which cron mails rather than appending to the log, and the # script exits non-zero so the failure is not a quiet gap in the log. diff --git a/logseq_cli/commands/analysis.py b/logseq_cli/commands/analysis.py index 3a33818..a1edecd 100644 --- a/logseq_cli/commands/analysis.py +++ b/logseq_cli/commands/analysis.py @@ -6,7 +6,9 @@ from collections import Counter, defaultdict from logseq_cli.config import get, load_config from logseq_cli.dates import format_journal_date, journal_day_to_date, parse_date_range -from logseq_cli.lookup import get_page_content +from logseq_cli import tasks +from logseq_cli.lookup import get_page_content, page_text +from logseq_cli.outlinetext import preorder_blocks from logseq_cli.notes import print_note from logseq_cli.render import extract_topics from logseq_cli.output import fail, handle_connection_error, output @@ -69,7 +71,9 @@ def _word_pattern(words) -> "re.Pattern[str]": Note: Requires Logseq running — no filesystem fallback possible. """) -@click.option("--days", default=None, type=int, help="Limit to pages modified in last N days (1 or greater)") +@click.option("--days", default=None, type=int, + help="Limit the Recently updated list to pages modified in the last N days (1 or greater); " + "every other figure covers the whole graph") @click.option("--json", "as_json", is_flag=True, help="Output as JSON") @click.pass_context @handle_connection_error @@ -86,18 +90,6 @@ def analyze_graph(ctx, days, as_json): pages = api.get_all_pages() - # Open tasks only, and only where Logseq puts a marker: at the start of a - # block. Matching "todo" anywhere, case-insensitively, counted "Todo-Zettel" - # in prose and the "TODO" inside a DONE block's logbook line, so the number - # was neither the open tasks nor all of them. - todo_pattern = re.compile( - # The bullet may repeat: get_page_content prefixes each block with - # "- ", so a block that already starts with one arrives as "- - TODO". - # The checkbox needs its bullet for the same reason the markers need - # the line anchor: a bare "[ ]" occurs in code snippets, empty - # markdown links and table cells, none of which are tasks. - r"(?i:- \[ \])|^(?:\s*-\s*)*(?:TODO|DOING|NOW|LATER|WAITING|IN-PROGRESS)\b", - re.MULTILINE) link_pattern = re.compile(r"\[\[(.*?)\]\]") # Days filter: cutoff timestamp in milliseconds @@ -108,7 +100,7 @@ def analyze_graph(ctx, days, as_json): page_names = set() journal_count = 0 - total_todos = 0 + box_blocks = 0 reference_count = Counter() adjacency = defaultdict(set) recently_updated = [] @@ -128,11 +120,12 @@ def analyze_graph(ctx, days, as_json): updated_str = datetime.datetime.fromtimestamp(updated_at / 1000).strftime('%Y-%m-%d %H:%M') recently_updated.append({"page": name, "updated": updated_str, "updated_at": updated_at}) - content = get_page_content(api, name) - - # count TODOs - todos = todo_pattern.findall(content) - total_todos += len(todos) + # One read of the tree: its text for the links, its blocks for the + # ones that start with a checkbox Logseq shows as text. + blocks = api.get_page_blocks_tree(name) or [] + content = page_text(blocks) + box_blocks += sum(1 for b in preorder_blocks(blocks) + if tasks.starts_with_box(b.get("content") or "")) # extract links links = link_pattern.findall(content) @@ -167,11 +160,22 @@ def analyze_graph(ctx, days, as_json): top_referenced = reference_count.most_common(15) + # A task is a block Logseq gave a marker: one query for all of them, not + # one per page, and the page text takes no part in the count. + rows = api.datascript_query( + "[:find ?m (count ?b) :where [?b :block/marker ?m] " + + tasks.marker_clause("?m", tasks.ORDER) + "]") or [] + by_marker = {m: n for m, n in rows if m in tasks.STATE} + per_state = {state: sum(n for m, n in by_marker.items() if tasks.STATE[m] == state) + for state in tasks.STATES} + open_by_marker = {m: by_marker[m] for m in tasks.ORDER + if tasks.STATE[m] == "open" and by_marker.get(m)} + result = { "total_pages": len(pages), "journal_pages": journal_count, "non_journal_pages": len(pages) - journal_count, - "total_todos": total_todos, + "tasks": {**per_state, "open_by_marker": open_by_marker}, "top_referenced": [{"page": p, "refs": c} for p, c in top_referenced], "clusters": len(clusters), "largest_cluster": len(clusters[0]) if clusters else 0, @@ -186,7 +190,10 @@ def analyze_graph(ctx, days, as_json): click.echo(f"Total pages: {result['total_pages']}") click.echo(f"Journal pages: {result['journal_pages']}") click.echo(f"Content pages: {result['non_journal_pages']}") - click.echo(f"Open TODOs: {result['total_todos']}") + breakdown = ", ".join(f"{n} {m}" for m, n in open_by_marker.items()) + click.echo(f"Tasks: {per_state['open']} open" + + (f" ({breakdown})" if breakdown else "") + + f", {per_state['done']} done, {per_state['cancelled']} cancelled") click.echo(f"Clusters: {result['clusters']}") if clusters: click.echo(f"Largest cluster: {result['largest_cluster']} pages") @@ -197,6 +204,9 @@ def analyze_graph(ctx, days, as_json): click.echo("\nTop Referenced Pages:") for item in result["top_referenced"]: click.echo(f" {item['page']}: {item['refs']} refs") + if box_blocks: + print_note(f'Note: {box_blocks} blocks start with "[ ]", which Logseq shows as text, ' + 'not as a task or checkbox.') def _is_incidental_page(name: str) -> bool: """True for pages that exist as a side effect, not as knowledge. diff --git a/logseq_cli/commands/meta.py b/logseq_cli/commands/meta.py index 1675cd1..2e2ad38 100644 --- a/logseq_cli/commands/meta.py +++ b/logseq_cli/commands/meta.py @@ -17,6 +17,7 @@ ) from logseq_cli.group import cli, resolve_version from logseq_cli.headings import normalize_heading +from logseq_cli.outlinetext import preorder_blocks from logseq_cli.notes import print_note from logseq_cli.output import fail, handle_connection_error, output @@ -139,7 +140,7 @@ def init_config(ctx, out_path, days, force, dry_run, as_json): if not name: continue seen = set() - for block in _walk_blocks(api.get_page_blocks_tree(name) or []): + for block in preorder_blocks(api.get_page_blocks_tree(name) or []): text = (block.get("content") or "").strip() if text.startswith("#"): seen.add(normalize_heading(text)) @@ -198,12 +199,6 @@ def init_config(ctx, out_path, days, force, dry_run, as_json): click.echo(f"Wrote {target}") click.echo("Review it: these are counts from your graph, not certainties.") -def _walk_blocks(blocks): - """Yield every block in a tree, depth first.""" - for block in blocks: - yield block - yield from _walk_blocks(block.get("children") or []) - def _as_list(value): if value is None: return [] diff --git a/logseq_cli/commands/query.py b/logseq_cli/commands/query.py index f04d701..1a88d05 100644 --- a/logseq_cli/commands/query.py +++ b/logseq_cli/commands/query.py @@ -2,6 +2,7 @@ import click +from logseq_cli import tasks from logseq_cli.config import load_config, require from logseq_cli.datalog import edn_keyword, edn_string, page_name_literal from logseq_cli.group import cli @@ -112,12 +113,14 @@ def smart_query(ctx, request, include_query, advanced, as_json): }, "tasks": { "keywords": ["todo", "task", "tasks", "incomplete", "pending", "aufgaben", "offene", "offen"], - "query": '[:find (pull ?b [*]) :where [?b :block/marker ?m] [(contains? #{"TODO" "LATER" "NOW" "DOING"} ?m)]]', + "query": ("[:find (pull ?b [*]) :where [?b :block/marker ?m] " + + tasks.marker_clause("?m", tasks.markers_in(["open"])) + "]"), "description": "Open tasks", }, "done": { "keywords": ["done", "completed", "finished", "erledigt", "fertig", "abgeschlossen"], - "query": '[:find (pull ?b [*]) :where [?b :block/marker "DONE"]]', + "query": ("[:find (pull ?b [*]) :where [?b :block/marker ?m] " + + tasks.marker_clause("?m", tasks.markers_in(["done"])) + "]"), "description": "Completed tasks", }, "journal": { diff --git a/logseq_cli/lookup.py b/logseq_cli/lookup.py index 79c2340..48c26bb 100644 --- a/logseq_cli/lookup.py +++ b/logseq_cli/lookup.py @@ -20,14 +20,18 @@ def escape_regex(s: str) -> str: return re.escape(s) -def get_page_content(api, page_name: str) -> str: - """Fetch page blocks and return formatted text.""" - blocks = api.get_page_blocks_tree(page_name) +def page_text(blocks) -> str: + """The text of a page from its block tree, ``""`` for an empty or missing tree.""" if not blocks: return "" return process_blocks(blocks) +def get_page_content(api, page_name: str) -> str: + """Fetch page blocks and return formatted text.""" + return page_text(api.get_page_blocks_tree(page_name)) + + def find_backlinks(api, page_name: str) -> list: """Find all pages that link to page_name by scanning all page contents.""" escaped = escape_regex(page_name) diff --git a/tests/test_marker_lists.py b/tests/test_marker_lists.py index 32c4d5d..3d6a5f7 100644 --- a/tests/test_marker_lists.py +++ b/tests/test_marker_lists.py @@ -36,8 +36,6 @@ # (file, symbol) -> places still holding a marker list of their own. EXCEPTIONS = { - ("commands/query.py", "smart_query"): 1, - ("commands/analysis.py", "analyze_graph"): 1, ("commands/analysis.py", "analyze_journal_patterns"): 2, } diff --git a/tests/test_read_only_commands_smoke.py b/tests/test_read_only_commands_smoke.py index 7e2f327..282fb50 100644 --- a/tests/test_read_only_commands_smoke.py +++ b/tests/test_read_only_commands_smoke.py @@ -9,6 +9,8 @@ import json import os +import re +import time from unittest.mock import MagicMock, patch import pytest @@ -146,61 +148,184 @@ def test_ties_on_confidence_prefer_more_shared_topics(self, tmp_path): assert len(d["suggestions"][0]["shared_topics"]) == 6 +def run_result(api, tmp_path, *args): + cfg = tmp_path / "c.toml" + cfg.write_text("", encoding="utf-8") + with patch.dict(os.environ, {"LOGSEQ_CLI_CONFIG": str(cfg)}, clear=False), \ + patch("logseq_cli.group.LogseqAPI", return_value=api): + return split_runner().invoke(cli, ["--token", "X", *args]) + + +ELEVEN = {"NOW", "LATER", "TODO", "DOING", "IN-PROGRESS", "WAIT", "WAITING", "STARTED", + "DONE", "CANCELED", "CANCELLED"} +TREE = [ + "TODO a", "WAITING b", "STARTED c", "DONE d", "DONE e", "CANCELED f", + "note\n* [ ] a", "[ ] b", "TODO: c", "TODO\nnotes", "note\n[ ] c", "* [ ] d", "## [ ] e", +] +SIX_ROWS = [["TODO", 1], ["WAITING", 1], ["STARTED", 1], ["DONE", 2], ["CANCELED", 1]] + + class TestAnalyzeGraphCountsOpenTasks: - """total_todos matched "todo" anywhere, case-insensitively. + """Tasks are the blocks Logseq gives a marker, counted by one query. - That counted "Todo-Zettel" in prose and the TODO inside a DONE block's - logbook line, so the number was neither the open tasks nor all of them. + The count used to be a pattern over the page text, and it counted what only + looked like a task. The mock here answers two things apart: the page tree, + with the text of every block, and the answer of the task query, with the + blocks that carry a marker. It applies no marker rule of its own, so a + number that comes out right can only come from the answer of the query. """ - def _api(self, text): + def _api(self, trees=None, rows=SIX_ROWS): + trees = trees if trees is not None else {"P": TREE} api = MagicMock() - api.get_all_pages.return_value = [{"originalName": "P"}] - api.get_page_blocks_tree.return_value = [ - {"uuid": "1", "content": text, "children": []}] + api.get_all_pages.return_value = [{"originalName": n} for n in trees] + api.get_page_blocks_tree.side_effect = lambda n: ( + None if trees[n] is None else + [{"uuid": str(i), "content": c, "children": []} for i, c in enumerate(trees[n])]) api.get_page.return_value = {"name": "P"} + api.datascript_query.return_value = rows return api - def test_markers_count(self, tmp_path): - d = run_json(self._api("- TODO one\n- LATER two\n- [ ] three"), - tmp_path, "analyze-graph") - assert d["total_todos"] == 3 - - def test_prose_does_not_count(self, tmp_path): - d = run_json(self._api("- the Todo-Zettel is long\n- => todo later maybe"), - tmp_path, "analyze-graph") - assert d["total_todos"] == 0 - - def test_a_logbook_line_does_not_count(self, tmp_path): - """`State "DONE" from "TODO"` is history, not an open task.""" - d = run_json(self._api('- DONE shipped\n :LOGBOOK:\n * State "DONE" from "TODO"\n :END:'), - tmp_path, "analyze-graph") - assert d["total_todos"] == 0 - - def test_an_empty_bracket_pair_in_prose_does_not_count(self, tmp_path): - """A checkbox is `- [ ]` at the start of a block, not `[ ]` anywhere. - - The marker half of this pattern was anchored to the line but the - checkbox half was not, so an empty pair inside running text counted: - a code snippet (`tags = [ ]`), an empty markdown link, a table cell. - A graph with no tasks at all reported three of them. - - analyze-journal-patterns requires the bullet and was already right; - the two counters measure the same thing and must agree. - """ - text = ("- tags = [ ] means an empty list\n" - "- see [ ](https://example.com)\n" - "- a sentence about [ ] brackets") - d = run_json(self._api(text), tmp_path, "analyze-graph") - assert d["total_todos"] == 0 - - def test_a_marker_on_a_further_line_does_not_count(self, tmp_path): - """Logseq reads a marker only where a block starts. The page text - used to put a block's further lines at column 0, where the pattern - took one for a block start (#75).""" - d = run_json(self._api("a note\nTODO is only a word here"), - tmp_path, "analyze-graph") - assert d["total_todos"] == 0 + def test_counts_come_from_the_query_answer_not_from_text(self, tmp_path): + d = run_json(self._api(), tmp_path, "analyze-graph") + assert d["tasks"] == {"open": 3, "done": 2, "cancelled": 1, + "open_by_marker": {"STARTED": 1, "TODO": 1, "WAITING": 1}} + assert list(d["tasks"]["open_by_marker"]) == ["STARTED", "TODO", "WAITING"] + assert "total_todos" not in d and "checkboxes" not in d + + def test_the_query_names_exactly_the_eleven_markers(self, tmp_path): + api = self._api({"P": TREE, "Q": ["plain"]}) + run_json(api, tmp_path, "analyze-graph") + assert len(api.datascript_query.call_args_list) == 1 + query = api.datascript_query.call_args_list[0].args[0] + assert ":block/marker" in query and "(count ?b)" in query + asked = set(re.findall(r'"([A-Z-]+)"', re.search(r"#\{([^}]*)\}", query).group(1))) + assert asked == ELEVEN + + def test_the_note_counts_blocks_that_start_with_a_box(self, tmp_path): + result = run_result(self._api(), tmp_path, "analyze-graph", "--json") + assert ('Note: 1 blocks start with "[ ]", which Logseq shows as text, ' + 'not as a task or checkbox.') in result.stderr + none = self._api({"P": ["TODO a", "plain\n[ ] b", "* [ ] c"]}) + assert "Note:" not in run_result(none, tmp_path, "analyze-graph", "--json").stderr + many = self._api({"P": ["[x] a", "[X] b", " [ ] c", "[ ]d"]}) + assert 'Note: 4 blocks start with "[ ]"' in \ + run_result(many, tmp_path, "analyze-graph", "--json").stderr + + def test_text_line_shows_the_open_markers_in_order(self, tmp_path): + out = run_result(self._api(), tmp_path, "analyze-graph").stdout + assert "Tasks: 3 open (1 STARTED, 1 TODO, 1 WAITING), 2 done, 1 cancelled" in out + assert "Open TODOs:" not in out + + def test_no_open_tasks_drops_the_parenthesis(self, tmp_path): + api = self._api(rows=[["DONE", 2]]) + d = run_json(api, tmp_path, "analyze-graph") + assert d["tasks"]["open_by_marker"] == {} + out = run_result(api, tmp_path, "analyze-graph").stdout + assert "Tasks: 0 open, 2 done, 0 cancelled" in out + + def test_a_block_without_marker_does_not_count_whatever_the_text_says(self, tmp_path): + d = run_json(self._api(rows=[]), tmp_path, "analyze-graph") + assert d["tasks"] == {"open": 0, "done": 0, "cancelled": 0, "open_by_marker": {}} + + def test_open_tasks_equal_the_count_of_get_todos(self, tmp_path): + """Both commands make their number from the same three blocks. A mock + cannot show that the database answers both queries alike; the live runs + do.""" + def answer(query): + if "(pull ?b" in query: + return [({"content": f"TODO {c}", "marker": "TODO", "uuid": f"u{c}"}, + {"original-name": "P", "name": "p"}) for c in "abc"] + return [["TODO", 3]] + api = self._api() + api.datascript_query.side_effect = answer + assert run_json(api, tmp_path, "analyze-graph")["tasks"]["open"] == 3 + assert run_json(api, tmp_path, "get-todos", "--no-follow-refs")["count"] == 3 + + def test_days_help_says_what_it_limits(self): + out = split_runner().invoke(cli, ["analyze-graph", "--help"]).output + assert "Recently updated" in " ".join(out.split()) + + def test_open_markers_follow_tasks_order_not_the_alphabet(self, tmp_path): + api = self._api({"P": ["NOW a", "IN-PROGRESS b", "DOING c"]}, + rows=[["NOW", 1], ["IN-PROGRESS", 1], ["DOING", 1]]) + d = run_json(api, tmp_path, "analyze-graph") + assert list(d["tasks"]["open_by_marker"]) == ["DOING", "NOW", "IN-PROGRESS"] + out = run_result(api, tmp_path, "analyze-graph").stdout + assert "Tasks: 3 open (1 DOING, 1 NOW, 1 IN-PROGRESS), 0 done, 0 cancelled" in out + + def test_a_page_without_a_tree_is_skipped(self, tmp_path): + result = run_result(self._api({"P": None}, rows=[["TODO", 1]]), tmp_path, + "analyze-graph", "--json") + assert result.exit_code == 0, result.stderr + assert json.loads(result.stdout)["tasks"] == { + "open": 1, "done": 0, "cancelled": 0, "open_by_marker": {"TODO": 1}} + assert "Note:" not in result.stderr + + def test_a_box_block_under_a_parent_is_found(self, tmp_path): + api = self._api(rows=[]) + api.get_page_blocks_tree.side_effect = lambda n: [ + {"uuid": "1", "content": "parent", "children": [ + {"uuid": "2", "content": "[ ] nested", "children": []}]}] + result = run_result(api, tmp_path, "analyze-graph", "--json") + assert 'Note: 1 blocks start with "[ ]"' in result.stderr + + def test_days_limits_only_the_recently_updated_list(self, tmp_path): + now = int(time.time() * 1000) + api = self._api() + api.get_all_pages.return_value = [ + {"originalName": "P", "updatedAt": now}, + {"originalName": "Q", "updatedAt": now - 40 * 86400 * 1000}] + api.get_page_blocks_tree.side_effect = lambda n: [] + d = run_json(api, tmp_path, "analyze-graph", "--days", "30") + assert [r["page"] for r in d["recently_updated"]] == ["P"] + assert d["total_pages"] == 2 and d["tasks"]["open"] == 3 + + def test_a_marker_outside_state_is_skipped(self, tmp_path): + d = run_json(self._api(rows=[["TODO", 1], ["FOO", 7]]), tmp_path, "analyze-graph") + assert d["tasks"]["open"] == 1 and d["tasks"]["open_by_marker"] == {"TODO": 1} + assert "FOO" not in json.dumps(d) + + def test_a_query_answer_of_none_counts_zero(self, tmp_path): + api = self._api(rows=None) + assert run_json(api, tmp_path, "analyze-graph")["tasks"] == { + "open": 0, "done": 0, "cancelled": 0, "open_by_marker": {}} + assert "Tasks: 0 open, 0 done, 0 cancelled" in run_result(api, tmp_path, + "analyze-graph").stdout + + +def _sent_query(tmp_path, request): + api = empty_api() + run_result(api, tmp_path, "smart-query", "--request", request, "--json") + return api.datascript_query.call_args.args[0] + + +def _markers_in(query): + return set(re.findall(r'"([A-Z-]+)"', query)) + + +OPEN_MARKERS = {"NOW", "LATER", "TODO", "DOING", "IN-PROGRESS", "WAIT", "WAITING", "STARTED"} + + +class TestSmartQueryTaskPatterns: + """The patterns for open and done tasks ask for what Logseq reads as such, + not for a list of their own.""" + + def test_tasks_pattern_asks_for_every_open_marker(self, tmp_path): + query = _sent_query(tmp_path, "tasks") + assert ":block/marker" in query + assert _markers_in(query) == OPEN_MARKERS and "WAITING" in query + + # The keywords of the pattern are product data, German ones included: with + # an English stand-in the test would not show that every word of it works. + @pytest.mark.parametrize("word", ["todo", "task", "tasks", "incomplete", "pending", + "aufgaben", "offene", "offen"]) + def test_every_keyword_of_the_pattern_reaches_it(self, tmp_path, word): + assert _sent_query(tmp_path, word) == _sent_query(tmp_path, "tasks") + assert _markers_in(_sent_query(tmp_path, word)) == OPEN_MARKERS + + def test_done_pattern_asks_for_exactly_done(self, tmp_path): + assert _markers_in(_sent_query(tmp_path, "done")) == {"DONE"} class TestFindKnowledgeGapsIgnoresArtefacts: diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 7f81250..f87a148 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1079,3 +1079,22 @@ class TestTaskTextKeepsTheHeadingPrefix: ]) def test_task_text(self, content, marker, expected): assert tasks.task_text(content, marker) == expected + + +class TestFormerAnalysisCases: + """What the page-text counter of analyze-graph once got wrong, as the rule + it broke.""" + + @pytest.mark.parametrize("text", ["the Todo-Zettel is long", "=> todo later maybe"]) + def test_prose_is_no_marker(self, text): + # A lower-case word, a hyphenated one: Logseq reads no marker. + assert tasks.marker_of(text) is None + + def test_a_logbook_history_line_does_not_count(self): + # The TODO in a logbook line of a DONE block is history, not an open task. + assert tasks.marker_of('DONE shipped\n:LOGBOOK:\n* State "DONE" from "TODO"\n:END:') == "DONE" + + def test_a_marker_on_a_further_line_does_not_count(self): + # A page text put a block's further lines at column 0, where a pattern took + # one for a block start; Logseq reads a marker only where the block starts. + assert tasks.marker_of("a note\nTODO is only a word here") is None diff --git a/tests/test_walk_blocks.py b/tests/test_walk_blocks.py new file mode 100644 index 0000000..d398310 --- /dev/null +++ b/tests/test_walk_blocks.py @@ -0,0 +1,55 @@ +"""preorder_blocks: every block of a tree, depth first, the way getPageBlocksTree hands it back.""" +from unittest.mock import MagicMock + +from logseq_cli.lookup import get_page_content, page_text +from logseq_cli.outlinetext import preorder_blocks as walk_blocks +from logseq_cli.render import process_blocks + + +def _b(content, *children): + return {"content": content, "children": list(children)} + + +def _order(tree): + return [b["content"] for b in walk_blocks(tree)] + + +def test_yields_every_block_depth_first(): + tree = [_b("A", _b("B", _b("C")), _b("D")), _b("E")] + assert _order(tree) == ["A", "B", "C", "D", "E"] + + +def test_a_block_without_children_key_is_a_leaf(): + assert _order([{"content": "A"}]) == ["A"] + + +def test_children_none_is_a_leaf(): + assert _order([{"content": "A", "children": None}]) == ["A"] + + +def test_empty_tree_yields_nothing(): + assert walk_blocks([]) == [] + + +def test_walks_deeper_than_two_levels(): + tree = [_b("1", _b("2", _b("3", _b("4"))))] + assert _order(tree) == ["1", "2", "3", "4"] + + +def test_page_text_of_an_empty_or_missing_tree_is_empty(): + assert page_text([]) == "" and page_text(None) == "" + + +def test_page_text_is_what_process_blocks_makes_of_the_tree(): + tree = [{"uuid": "1", "content": "A", "children": [ + {"uuid": "2", "content": "B", "children": []}]}] + assert page_text(tree) == process_blocks(tree) != "" + + +def test_get_page_content_and_page_text_give_the_same_answer(): + """One source for "an empty tree is an empty text"; None tells a second copy apart.""" + tree = [{"uuid": "1", "content": "A", "children": []}] + for answer in (tree, [], None): + api = MagicMock() + api.get_page_blocks_tree.return_value = answer + assert get_page_content(api, "P") == page_text(answer) From 41f6f547ba8ea3e140ed2e7a35ea876964c4eb3c Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:16:07 +0200 Subject: [PATCH 10/15] Report the size of the largest cluster in analyze-graph largest_cluster was the size of the first cluster the search found, not of the largest one. The clusters come in the order of the pages, so a graph whose first linked group was small under-reported it: with a group of two pages before a group of three, the report said two. largest_cluster is now the largest of the sizes, 0 without a cluster. Mutations tried, and the test that caught each: - the first cluster again: the case of two groups of 2 and 3 pages and the text line case - the largest taken over the number of clusters, not their sizes: all four cases - no default for the largest of nothing: the case without a cluster The case with two groups was red against the code as it was (2 instead of 3). Part of #92. --- CHANGELOG.md | 6 ++++++ logseq_cli/commands/analysis.py | 2 +- tests/test_read_only_commands_smoke.py | 25 +++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6a4da..76fe61b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -206,6 +206,12 @@ file carries most of the reasoning behind the tool. ### Fixed +- `analyze-graph` reported the size of the first cluster it found as + `largest_cluster`, not the size of the largest one. The order of the clusters + follows the order of the pages, so a graph whose first linked group was small + under-reported it (found while reading the code next to the report; + reproduced with two groups of 2 and 3 pages) (#92). + - `get-todos` worked out `next_due` of a repeating task from the first repeater in its text, not from the timestamp whose date it reports. A task with `SCHEDULED: <… .+1d>` and `DEADLINE: <… +1w>` reports the deadline, but diff --git a/logseq_cli/commands/analysis.py b/logseq_cli/commands/analysis.py index a1edecd..6ef7014 100644 --- a/logseq_cli/commands/analysis.py +++ b/logseq_cli/commands/analysis.py @@ -178,7 +178,7 @@ def analyze_graph(ctx, days, as_json): "tasks": {**per_state, "open_by_marker": open_by_marker}, "top_referenced": [{"page": p, "refs": c} for p, c in top_referenced], "clusters": len(clusters), - "largest_cluster": len(clusters[0]) if clusters else 0, + "largest_cluster": max((len(c) for c in clusters), default=0), } if days is not None: result["recently_updated"] = [{"page": r["page"], "updated": r["updated"]} for r in recently_updated[:30]] diff --git a/tests/test_read_only_commands_smoke.py b/tests/test_read_only_commands_smoke.py index 282fb50..c9c0e90 100644 --- a/tests/test_read_only_commands_smoke.py +++ b/tests/test_read_only_commands_smoke.py @@ -328,6 +328,31 @@ def test_done_pattern_asks_for_exactly_done(self, tmp_path): assert _markers_in(_sent_query(tmp_path, "done")) == {"DONE"} +class TestAnalyzeGraphReportsTheLargestCluster: + """largest_cluster was the size of the first cluster found, and the order of + the clusters follows the order of the pages.""" + + TWO_GROUPS = {"A": ["B"], "C": ["D", "E"]} # A, the small group, comes first + + def test_the_largest_cluster_is_reported_not_the_first(self, tmp_path): + d = run_json(graph_with(self.TWO_GROUPS), tmp_path, "analyze-graph") + assert d["clusters"] == 2 and d["largest_cluster"] == 3 + + def test_text_line_names_the_largest_cluster(self, tmp_path): + out = run_result(graph_with(self.TWO_GROUPS), tmp_path, "analyze-graph").stdout + assert "Largest cluster: 3 pages" in out + + def test_no_cluster_reports_zero(self, tmp_path): + api = graph_with({"A": [], "B": []}) + d = run_json(api, tmp_path, "analyze-graph") + assert d["clusters"] == 0 and d["largest_cluster"] == 0 + assert "Largest cluster" not in run_result(api, tmp_path, "analyze-graph").stdout + + def test_equal_sized_clusters_report_that_size(self, tmp_path): + d = run_json(graph_with({"A": ["B"], "C": ["D"]}), tmp_path, "analyze-graph") + assert d["clusters"] == 2 and d["largest_cluster"] == 2 + + class TestFindKnowledgeGapsIgnoresArtefacts: """596 "orphans" in a real graph were almost all side effects. From fc5ecba1ad645e343a465c8815a2d29034a31c5f Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:19:47 +0200 Subject: [PATCH 11/15] Count journal pattern tasks from the database, one count per day analyze-journal-patterns counted tasks with two patterns over the text of each journal page. They counted Markdown checkboxes, which Logseq does not read as tasks, counted CANCELED and CANCELLED as done, and could be fooled by text that only looks like a task (TODO: x, TODO and a line break). The count now comes from one query for the blocks that carry one of the eleven markers, on the journal days of the time frame, sorted into open, done and cancelled by the same classification as get-todos. tasks holds open, done, cancelled and done_rate; the tasks of each entry hold open, done and cancelled. The rate is named done_rate and not completion_rate on purpose: its value changes (cancelled tasks do not count, checkbox lines drop out), and a key that keeps its name while it returns another number breaks quietly, whereas a missing one breaks loudly. tasks.total_complete, tasks.total_incomplete, tasks.completion_rate and the per-entry tasks_complete and tasks_incomplete are gone. Tasks are dated by the page their block is on, as before; get-todos --from/--to also counts the days a block ref carried a task to. Habit tracking still counts the text pattern "- [ ]" / "- [x]". Both are said in --help and in the text output now, not in the JSON. Mood, topics, projects and habit tracking are unchanged; habit tracking, which had no test, is pinned by one written before the change. Mutations tried, and the test that caught each: - the text counted as well, cancelled as done, rows counted instead of blocks: the cases of those names - the rate under its old name: the empty journal case (the key set) - the query asking for the open markers only, or for other days than the pages: the case of the query text - no guard for a row of fewer than three fields, for a marker outside the states, for an answer of none: one case each - the sentences left out of the help or the text output, or put into the JSON: the cases of the output Existing tests changed: the task cases of analyze-journal-patterns now give the database rows and the new keys; the checkbox case says that checkbox lines are no tasks; the doubled bullet case is gone (Logseq reads no marker behind a list character; a marker rule case holds it). The class that tested copies of the old patterns, not the code, is removed. Capitalised words opening a sentence are cases of the marker rule. The marker scan loses its last exception with this commit, so the list of exceptions goes away: a marker list outside the tasks module fails the test directly; only docstrings and the first argument of output.fail stay out of it, found by their position in the syntax tree. Checked against Logseq 0.10.15 on 2026-10-06: the counts of analyze-graph and get-todos --no-follow-refs equal Logseq's own task query for open, done and cancelled (6 of 6 pairs equal, nothing written to the graph). Mutations of the scan, each seen red and reverted: - a marker set added under commands/, the old marker list back in the smart-query pattern, an enumeration of markers in a help text: test_no_module_but_tasks_keeps_a_marker_list Part of #92. --- CHANGELOG.md | 19 +++ docs/commands.md | 4 +- logseq_cli/commands/analysis.py | 78 ++++----- tests/test_analyze_journal_patterns.py | 210 +++++++++++++++++++++---- tests/test_config_integration.py | 52 ------ tests/test_marker_lists.py | 17 +- tests/test_tasks.py | 6 + 7 files changed, 253 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fe61b..3752a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,21 @@ file carries most of the reasoning behind the tool. ### Changed +- `analyze-journal-patterns` counts tasks from the database, one count per + journal day. `tasks` holds `open`, `done`, `cancelled` and `done_rate`, and + the `tasks` of each entry holds `open`, `done` and `cancelled`. A task is a + block that carries a marker Logseq reads, so `TODO:`, a lowercase `todo`, + `TODO` followed by a line break and Markdown checkboxes (`- [ ]`) are no + longer counted as tasks, and CANCELED and CANCELLED no longer count as done. + The rate is named `done_rate`, not `completion_rate`, on purpose: its value + changes (cancelled tasks do not count, checkbox lines drop out), and a key + must not keep its name while it returns a different number, so a reader of the + old key now fails loudly instead of reading a wrong figure. `done_rate` is + `done / (open + done)` in percent with one decimal. Tasks are dated by the + page their block is on. `get-todos --from/--to` also counts the days a block + ref carried a task to. Habit tracking still counts the text pattern `- [ ]` / + `- [x]` and now says so in `--help` and in the text output (#92). + - `analyze-graph` counts tasks from the database, like `get-todos`: `total_todos` is replaced by `tasks` (`open`, `done`, `cancelled`, `open_by_marker`) and the text line reads `Tasks: N open (…), M done, K @@ -204,6 +219,10 @@ file carries most of the reasoning behind the tool. A key that changes its value never keeps its old name, so a reader of the old key fails loudly. +- `analyze-journal-patterns` no longer reports `tasks.total_complete`, + `tasks.total_incomplete` and `tasks.completion_rate`, and no longer reports + `tasks_complete` and `tasks_incomplete` per entry (#92). + ### Fixed - `analyze-graph` reported the size of the first cluster it found as diff --git a/docs/commands.md b/docs/commands.md index 84daed7..63f6d53 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -96,11 +96,13 @@ graph. | `get-journal-summary --range RANGE [--no-content]` | Journal summary (today, this week, last 30 days). `--no-content` drops the per-day bodies | | `analyze-graph [--days N]` | Graph structure analysis. `--days N` limits only the "Recently updated" list to the pages modified in the last N days; every other figure covers the whole graph. See "analyze-graph output" below the table | | `find-knowledge-gaps [--min-refs N] [--include-orphans/--no-include-orphans]` | Missing/underdeveloped/orphaned pages. `--min-refs` (default 2) is how many incoming references a short page needs before it counts as underdeveloped rather than unused | -| `analyze-journal-patterns [--timeframe RANGE] [--mood/--no-mood] [--topics/--no-topics]` | Journal entry patterns over `--timeframe` (default "last 30 days"). `--no-mood` and `--no-topics` drop those sections | +| `analyze-journal-patterns [--timeframe RANGE] [--mood/--no-mood] [--topics/--no-topics]` | Journal entry patterns over `--timeframe` (default "last 30 days"). `--no-mood` and `--no-topics` drop those sections. Tasks are dated by the page their block is on. `get-todos --from/--to` also counts the days a block ref carried a task to. See "analyze-journal-patterns output" below the table | | `smart-query --request TEXT [--advanced] [--include-query]` | Datalog queries (natural language, or `--advanced` to pass raw Datalog through). `--include-query` prints the generated query alongside the result | | `suggest-connections [--min-confidence N] [--min-shared N] [--max-suggestions N] [--focus PAGE]` | Topic-based connection suggestions. `--min-shared` (default 3) is the real filter — it sets how many topics two pages must share before the pair counts at all; `--min-confidence` (default 0.3) then scores it. `--max-suggestions` (default 10) caps the list and the remainder is reported as withheld; `total_found` counts what the graph held, not what survived the cap. `--focus` restricts to one page | | `get-page-stats --page NAME` | Page statistics (blocks, words, in/outbound links) | +**analyze-journal-patterns output.** `--json` carries `tasks`: `{"open": N, "done": N, "cancelled": N, "done_rate": X}`, and every entry in `entries` carries `tasks: {"open": N, "done": N, "cancelled": N}`. A task is a block with a marker Logseq reads (the states are in `CONTEXT.md`, under Task State); text that looks like one, such as `TODO:`, a lower-case `todo` or `- [ ]`, is not counted. `done_rate` is `done / (open + done)` in percent with one decimal, `0` without open and done tasks; cancelled tasks are not part of it. Tasks are dated by the page their block is on. `get-todos --from/--to` also counts the days a block ref carried a task to. Habit tracking counts the text pattern `- [ ]` / `- [x]`, not Logseq's checkboxes. + **analyze-graph output.** `--json` carries `tasks`: `{"open": N, "done": N, "cancelled": N, "open_by_marker": {"": N, ...}}`. A task is a block with a marker Logseq reads; `TODO:`, a lower-case `todo`, `[ ]` at the start of a block and `TODO` followed directly by a line break are not. `open` is the same number as the `count` of `get-todos --no-follow-refs`. The text output has the line `Tasks: 12 open (2 DOING, 10 TODO), 40 done, 1 cancelled`, open markers in the order of the list, and a note on stderr when blocks start with `[ ]`: `Note: N blocks start with "[ ]", which Logseq shows as text, not as a task or checkbox.` ### Write (5) diff --git a/logseq_cli/commands/analysis.py b/logseq_cli/commands/analysis.py index 6ef7014..cf79484 100644 --- a/logseq_cli/commands/analysis.py +++ b/logseq_cli/commands/analysis.py @@ -370,7 +370,18 @@ def find_knowledge_gaps(ctx, min_refs, include_orphans, as_json): for o in result["orphaned_pages"]: click.echo(f" {o}") -@cli.command("analyze-journal-patterns", epilog="""\b +# Said in the help and in the text output, from one place; the JSON carries no +# field for either (habit tracking goes away with its text pattern). +TASK_DATING_NOTE = ("Tasks are dated by the page their block is on. " + "get-todos --from/--to also counts the days a block ref carried a task to.") +HABIT_COUNT_NOTE = ('Habits are counted from the text pattern "- [ ]" / "- [x]", ' + "not from Logseq's checkboxes.") + + +@cli.command("analyze-journal-patterns", + help="Analyze patterns in journal entries.\n\n" + + TASK_DATING_NOTE + "\n\n" + HABIT_COUNT_NOTE, + epilog="""\b Example: logseq-cli --token TOKEN analyze-journal-patterns --timeframe "last 30 days" --mood --topics """) @@ -381,7 +392,6 @@ def find_knowledge_gaps(ctx, min_refs, include_orphans, as_json): @click.pass_context @handle_connection_error def analyze_journal_patterns(ctx, timeframe, mood, topics, as_json): - """Analyze patterns in journal entries.""" api = ctx.obj["api"] start, end = parse_date_range(timeframe) pages = api.get_all_pages() @@ -409,23 +419,6 @@ def analyze_journal_patterns(ctx, timeframe, mood, topics, as_json): mood_indicator_patterns = [f"{label}:" for label in mood_labels] + [ "\U0001f60a", "\U0001f614", "\U0001f620", "\U0001f60c", ] - # Two ways of writing a task. Logseq's own are the markers (TODO, DOING, - # DONE...); the markdown checkbox is what people paste in from elsewhere. - # Counting only the checkbox reported "0 complete, 0 incomplete" for a - # graph with over a thousand tasks — a number that reads like a - # measurement rather than a pattern that cannot match. - # The markers are upper-case in Logseq and only there, so they are matched - # case-sensitively: "Now that we finished" and "Later kam der Regen" - # open a sentence, not a task. The checkbox alternative keeps (?i), where - # "[X]" and "[x]" are both in the wild. - # The bullet may repeat: get_page_content prefixes each block with "- ", - # so a block already starting with one arrives as "- - TODO ...". - incomplete_task = re.compile( - r"(?i:- \[ \])|^(?:\s*-\s*)*(?:TODO|DOING|NOW|LATER|WAITING|IN-PROGRESS)\b", - re.MULTILINE) - complete_task = re.compile( - r"(?i:- \[x\])|^(?:\s*-\s*)*(?:DONE|CANCELED|CANCELLED)\b", - re.MULTILINE) link_pattern = re.compile(r"\[\[(.*?)\]\]") # Projects get named in more than one way. A namespace prefix covers both # the tag (#projects/alpha) and the link ([[projects/alpha]]), because a @@ -438,11 +431,28 @@ def analyze_journal_patterns(ctx, timeframe, mood, topics, as_json): # list, and treating every TODO as a habit would drown the real ones. habit_checkbox = re.compile(r"- \[[ x]\]", re.IGNORECASE) + # A task is a block Logseq gave a marker: one query for all of them, over the + # same days the pages are filtered by, and the page text takes no part. A + # day holds a set of blocks, so a block answered twice counts once. + rows = api.datascript_query( + "[:find ?b ?m ?d :where [?b :block/marker ?m] " + + tasks.marker_clause("?m", tasks.ORDER) + + " [?b :block/page ?p] [?p :block/journal-day ?d]" + + f" [(>= ?d {int(start.strftime('%Y%m%d'))})]" + + f" [(<= ?d {int(end.strftime('%Y%m%d'))})]]") or [] + per_day = {} + overall = {state: set() for state in tasks.STATES} + for row in rows: + if not (isinstance(row, (list, tuple)) and len(row) >= 3) or row[1] not in tasks.STATE: + continue + block, marker, day = row[:3] + state = tasks.STATE[marker] + per_day.setdefault(day, {}).setdefault(state, set()).add(block) + overall[state].add(block) + entries = [] topic_by_date = {} mood_entries = [] - total_incomplete = 0 - total_complete = 0 # Extended analysis collectors mood_patterns = [] # {date, mood, context} @@ -520,13 +530,9 @@ def analyze_journal_patterns(ctx, timeframe, mood, topics, as_json): }) break - # Habits / tasks - inc = len(incomplete_task.findall(content)) - comp = len(complete_task.findall(content)) - total_incomplete += inc - total_complete += comp - entry["tasks_incomplete"] = inc - entry["tasks_complete"] = comp + # Tasks of the day: the blocks the query answered for it. + day = per_day.get(int(jd), {}) + entry["tasks"] = {state: len(day.get(state, ())) for state in tasks.STATES} # Habit tracking - extract individual checkbox items for line in content.split("\n"): @@ -587,11 +593,11 @@ def analyze_journal_patterns(ctx, timeframe, mood, topics, as_json): "timeframe": timeframe, "entries_analyzed": len(entries), "tasks": { - "total_complete": total_complete, - "total_incomplete": total_incomplete, - "completion_rate": ( - round(total_complete / (total_complete + total_incomplete) * 100, 1) - if (total_complete + total_incomplete) > 0 + **{state: len(overall[state]) for state in tasks.STATES}, + # Cancelled tasks are neither done nor open. + "done_rate": ( + round(len(overall["done"]) / (len(overall["open"]) + len(overall["done"])) * 100, 1) + if (len(overall["open"]) + len(overall["done"])) > 0 else 0 ), }, @@ -620,8 +626,9 @@ def analyze_journal_patterns(ctx, timeframe, mood, topics, as_json): else: click.echo(f"=== Journal Patterns ({timeframe}) ===\n") click.echo(f"Entries analyzed: {len(entries)}") - click.echo(f"\nTasks: {total_complete} complete, {total_incomplete} incomplete " - f"({result['tasks']['completion_rate']}% rate)") + click.echo(f"\nTasks: {len(overall['open'])} open, {len(overall['done'])} done, " + f"{len(overall['cancelled'])} cancelled ({result['tasks']['done_rate']}% done)") + click.echo(TASK_DATING_NOTE) if topics and result.get("top_topics"): click.echo("\nTop Topics:") for t in result["top_topics"][:10]: @@ -646,6 +653,7 @@ def analyze_journal_patterns(ctx, timeframe, mood, topics, as_json): # Extended: Habit Tracking if habit_stats: click.echo("\nHabit Tracking:") + click.echo(f" {HABIT_COUNT_NOTE}") for habit_name, stats in sorted(habit_stats.items(), key=lambda x: x[1]["total"], reverse=True)[:10]: click.echo(f" {habit_name}:") click.echo(f" Completion: {stats['completion_rate']}% ({stats['done']}/{stats['total']})") diff --git a/tests/test_analyze_journal_patterns.py b/tests/test_analyze_journal_patterns.py index ff1c37f..6d686bb 100644 --- a/tests/test_analyze_journal_patterns.py +++ b/tests/test_analyze_journal_patterns.py @@ -1,25 +1,34 @@ -"""analyze-journal-patterns — the command whose patterns are now configurable. +"""analyze-journal-patterns — the command whose patterns are configurable. -Two of today's changes land here: the mood word lists and project markers come -from [analysis], and task counting learned Logseq's markers. Neither had a -test, and both fail the same quiet way — an empty result looks exactly like a -journal with nothing in it. +The mood word lists and project markers come from [analysis]; tasks are the +blocks Logseq gave a marker, counted by one query. Both fail quietly when +wrong: an empty result looks exactly like a journal with nothing in it. """ import json import os +import re from unittest.mock import MagicMock, patch from logseq_cli.cli import cli +from logseq_cli.dates import parse_date_range from tests.conftest import journal_day, split_runner JOURNAL = {"originalName": "J", "journalDay": journal_day(), "journal?": True} -def run(text, *args, config=None, tmp_path=None): +def run(text, *args, config=None, tmp_path=None, rows=(), pages=None, as_json=True): + """analyze-journal-patterns over journal pages that all read ``text``. + + ``rows`` answers the task query ([block, marker, day] rows, as the database + gives them; None for an answer of none). It is always set: a MagicMock + answers a query with something that iterates empty, and a test that says + nothing about tasks would count zero without anyone seeing it. + """ api = MagicMock() - api.get_all_pages.return_value = [JOURNAL] + api.get_all_pages.return_value = pages if pages is not None else [JOURNAL] + api.datascript_query.return_value = None if rows is None else list(rows) # An empty file rather than no variable at all: without it the developer's # own config is read, and a test for "the English defaults find nothing in # a German journal" passes or fails depending on whose machine it runs on. @@ -31,9 +40,11 @@ def run(text, *args, config=None, tmp_path=None): with patch.dict(os.environ, env, clear=False), \ patch("logseq_cli.group.LogseqAPI", return_value=api), \ patch("logseq_cli.commands.analysis.get_page_content", return_value=text): - return split_runner().invoke( + result = split_runner().invoke( cli, ["--token", "X", "analyze-journal-patterns", - "--timeframe", "last 30 days", "--json", *args]) + "--timeframe", "last 30 days", *(["--json"] if as_json else []), *args]) + result.api = api + return result def payload(result): @@ -41,36 +52,103 @@ def payload(result): return json.loads(result.stdout) +DAY = journal_day() + + +def _rows(*markers, day=DAY): + return [[i, m, day] for i, m in enumerate(markers, start=1)] + + class TestTaskCounting: + """A task is a block Logseq gave a marker, counted by one query; the text of + the page takes no part. The mock applies no marker rule: what comes out can + only come from the rows it answers.""" + def test_logseq_markers_are_counted(self, tmp_path): - d = payload(run("- TODO write it\n- DOING working\n- DONE shipped", tmp_path=tmp_path)) - assert d["tasks"]["total_incomplete"] == 2 - assert d["tasks"]["total_complete"] == 1 + d = payload(run("- text", tmp_path=tmp_path, rows=_rows("TODO", "DOING", "DONE"))) + assert d["tasks"]["open"] == 2 and d["tasks"]["done"] == 1 + assert d["entries"][0]["tasks"] == {"open": 2, "done": 1, "cancelled": 0} - def test_checkboxes_are_counted_too(self, tmp_path): + def test_checkbox_lines_are_not_tasks(self, tmp_path): + """Logseq reads "- [ ]" at the start of a block as text, not as a task.""" d = payload(run("- [ ] open\n- [x] closed\n- [X] also closed", tmp_path=tmp_path)) - assert d["tasks"]["total_incomplete"] == 1 - assert d["tasks"]["total_complete"] == 2 + assert d["tasks"]["open"] == 0 and d["tasks"]["done"] == 0 def test_marker_words_in_prose_are_not_tasks(self, tmp_path): - """The reason markers match case-sensitively and only at line start.""" - d = payload(run("- Now that we finished it\n- that is done\n- todo: later", tmp_path=tmp_path)) - assert d["tasks"]["total_incomplete"] == 0 - assert d["tasks"]["total_complete"] == 0 - - def test_a_doubled_bullet_still_counts(self, tmp_path): - """get_page_content prefixes every block with "- ", so a block that - already starts with one reaches the pattern as "- - TODO ...". - """ - d = payload(run("- - TODO nested bullet\n- - DONE also nested", + """No marker in the database, so no task, whatever the text says.""" + d = payload(run("- Now that we finished it\n- that is done\n- todo: later", tmp_path=tmp_path)) - assert d["tasks"]["total_incomplete"] == 1 - assert d["tasks"]["total_complete"] == 1 + assert d["tasks"]["open"] == 0 and d["tasks"]["done"] == 0 def test_an_empty_journal_reports_zero_without_dividing_by_zero(self, tmp_path): d = payload(run("- just some prose", tmp_path=tmp_path)) - assert d["tasks"] == {"total_complete": 0, "total_incomplete": 0, - "completion_rate": 0} + assert d["tasks"] == {"open": 0, "done": 0, "cancelled": 0, "done_rate": 0} + + def test_a_block_without_marker_does_not_count_whatever_the_text_says(self, tmp_path): + text = "- TODO: x\n- [ ] x\n- TODO\nnotes\n- Now that we finished it" + d = payload(run(text, tmp_path=tmp_path, rows=[])) + assert d["tasks"] == {"open": 0, "done": 0, "cancelled": 0, "done_rate": 0} + assert d["entries"][0]["tasks"] == {"open": 0, "done": 0, "cancelled": 0} + + def test_cancelled_is_not_done_and_not_in_the_rate(self, tmp_path): + d = payload(run("- x", tmp_path=tmp_path, + rows=_rows("TODO", "DONE", "DONE", "CANCELED"))) + assert d["tasks"] == {"open": 1, "done": 2, "cancelled": 1, "done_rate": 66.7} + d = payload(run("- x", tmp_path=tmp_path, rows=_rows("CANCELED", "CANCELLED"))) + assert d["tasks"] == {"open": 0, "done": 0, "cancelled": 2, "done_rate": 0} + + def test_the_old_keys_are_gone(self, tmp_path): + d = payload(run("- x", tmp_path=tmp_path, rows=_rows("TODO", "DONE"))) + assert set(d["tasks"]) == {"open", "done", "cancelled", "done_rate"} + for entry in d["entries"]: + assert set(entry["tasks"]) == {"open", "done", "cancelled"} + assert "tasks_complete" not in entry and "tasks_incomplete" not in entry + assert "completion_rate" not in d["tasks"] and "checkboxes" not in d + assert "checkboxes" not in d["entries"][0] + + def test_each_day_counts_the_tasks_of_its_page(self, tmp_path): + pages = [{"originalName": "J1", "journalDay": journal_day(1), "journal?": True}, + {"originalName": "J2", "journalDay": journal_day(2), "journal?": True}] + rows = ([[1, "TODO", journal_day(1)], [2, "DONE", journal_day(1)], + [3, "TODO", journal_day(2)], [3, "TODO", journal_day(2)]]) + d = payload(run("- x", tmp_path=tmp_path, rows=rows, pages=pages)) + by_page = {e["page"]: e["tasks"] for e in d["entries"]} + assert by_page["J1"] == {"open": 1, "done": 1, "cancelled": 0} + assert by_page["J2"] == {"open": 1, "done": 0, "cancelled": 0} # the same block twice + assert d["tasks"]["open"] == 2 and d["tasks"]["done"] == 1 + + def test_the_query_names_exactly_the_eleven_markers_and_the_range(self, tmp_path): + result = run("- x", tmp_path=tmp_path) + assert len(result.api.datascript_query.call_args_list) == 1 + query = result.api.datascript_query.call_args.args[0] + assert ":block/marker" in query and ":block/journal-day" in query + asked = set(re.findall(r'"([A-Z-]+)"', re.search(r"#\{([^}]*)\}", query).group(1))) + assert asked == {"NOW", "LATER", "TODO", "DOING", "IN-PROGRESS", "WAIT", "WAITING", + "STARTED", "DONE", "CANCELED", "CANCELLED"} + start, end = parse_date_range("last 30 days") + assert f"(>= ?d {int(start.strftime('%Y%m%d'))})" in query + assert f"(<= ?d {int(end.strftime('%Y%m%d'))})" in query + + def test_the_rate_uses_done_over_open_plus_done(self, tmp_path): + d = payload(run("- x", tmp_path=tmp_path, rows=_rows("TODO", "TODO", "DONE"))) + assert d["tasks"]["done_rate"] == 33.3 + + def test_the_text_line(self, tmp_path): + out = run("- x", tmp_path=tmp_path, as_json=False, + rows=_rows("TODO", "DOING", "DONE", "CANCELED")).stdout + assert "Tasks: 2 open, 1 done, 1 cancelled (33.3% done)" in out + + def test_a_row_with_fewer_than_three_fields_is_skipped(self, tmp_path): + d = payload(run("- x", tmp_path=tmp_path, rows=[[1, "TODO"], [2, "TODO", DAY]])) + assert d["tasks"]["open"] == 1 + + def test_a_marker_outside_state_is_skipped(self, tmp_path): + d = payload(run("- x", tmp_path=tmp_path, rows=[[1, "FOO", DAY], [2, "TODO", DAY]])) + assert (d["tasks"]["open"], d["tasks"]["done"], d["tasks"]["cancelled"]) == (1, 0, 0) + + def test_a_query_answer_of_none_counts_zero(self, tmp_path): + d = payload(run("- x", tmp_path=tmp_path, rows=None)) + assert d["tasks"] == {"open": 0, "done": 0, "cancelled": 0, "done_rate": 0} class TestMoodWordsComeFromConfig: @@ -126,5 +204,75 @@ def test_no_config_no_crash(self, tmp_path): assert d["entries_analyzed"] == 1 def test_flags_turn_sections_off(self, tmp_path): - d = payload(run("- TODO x", "--no-mood", "--no-topics", tmp_path=tmp_path)) - assert d["tasks"]["total_incomplete"] == 1 + d = payload(run("- TODO x", "--no-mood", "--no-topics", tmp_path=tmp_path, + rows=[[1, "TODO", DAY]])) + assert d["tasks"]["open"] == 1 + + +class TestHabitTrackingIsUnchanged: + """Habit tracking still counts the text pattern "- [ ]" / "- [x]"; it was + never tested, so it is pinned before the task count moves to the database.""" + + def test_habit_tracking_is_unchanged(self, tmp_path): + d = payload(run("- [ ] walk\n- [x] read", tmp_path=tmp_path)) + assert d["habit_tracking"] == { + "walk": {"total": 1, "done": 0, "completion_rate": 0.0, + "current_streak": 0, "longest_streak": 0}, + "read": {"total": 1, "done": 1, "completion_rate": 100.0, + "current_streak": 1, "longest_streak": 1}} + + def test_the_text_output_lists_the_habits(self, tmp_path): + out = run("- [ ] walk\n- [x] read", tmp_path=tmp_path, as_json=False).stdout + assert "Habit Tracking:" in out + assert "Completion: 0.0% (0/1)" in out and "Completion: 100.0% (1/1)" in out + assert "Current streak: 1 days" in out and "Longest streak: 1 days" in out + + +# The sentences stand here as literals of their own, not as imports of the +# constants: a test that imports them compares them with themselves. +DATING_SENTENCE = ("Tasks are dated by the page their block is on. " + "get-todos --from/--to also counts the days a block ref carried a task to.") +HABIT_SENTENCE = 'Habits are counted from the text pattern "- [ ]" / "- [x]", not from Logseq\'s checkboxes.' + + +def _help(): + out = split_runner().invoke(cli, ["analyze-journal-patterns", "--help"]).output + return " ".join(out.split()) + + +class TestTheOutputSaysWhatItCounts: + """Tasks are dated by the page their block lies on, habits come from a text + pattern: said in the help and in the text output, not in the JSON.""" + + def test_help_says_how_tasks_are_dated(self): + assert DATING_SENTENCE in _help() + + def test_help_says_what_habits_count(self): + assert HABIT_SENTENCE in _help() + + def test_text_output_says_how_tasks_are_dated(self, tmp_path): + out = run("- x", tmp_path=tmp_path, as_json=False, rows=_rows("TODO")).stdout + lines = out.splitlines() + assert out.count(DATING_SENTENCE) == 1 + i = next(n for n, line in enumerate(lines) if line.startswith("Tasks:")) + assert lines[i + 1] == DATING_SENTENCE + + def test_json_does_not_carry_the_sentences(self, tmp_path): + result = run("- [ ] walk", tmp_path=tmp_path, rows=_rows("TODO")) + assert DATING_SENTENCE not in result.stdout and HABIT_SENTENCE not in result.stdout + assert "dated_by" not in payload(result)["tasks"] + + def test_text_output_says_what_habits_count(self, tmp_path): + out = run("- [ ] walk", tmp_path=tmp_path, as_json=False).stdout + lines = out.splitlines() + assert out.count(HABIT_SENTENCE) == 1 + i = lines.index("Habit Tracking:") + assert lines[i + 1].strip() == HABIT_SENTENCE + assert "Completion: 0.0% (0/1)" in out + plain = run("- plain line", tmp_path=tmp_path, as_json=False).stdout + assert "Habit Tracking:" not in plain and HABIT_SENTENCE not in plain + + def test_json_habit_keys_are_unchanged(self, tmp_path): + d = payload(run("- [ ] walk", tmp_path=tmp_path)) + assert set(d["habit_tracking"]["walk"]) == { + "total", "done", "completion_rate", "current_streak", "longest_streak"} diff --git a/tests/test_config_integration.py b/tests/test_config_integration.py index 7bd9a8f..fe50121 100644 --- a/tests/test_config_integration.py +++ b/tests/test_config_integration.py @@ -16,7 +16,6 @@ import json import os -import re from unittest.mock import MagicMock, patch import pytest @@ -520,54 +519,3 @@ def test_project_prefix_is_escaped(self): assert pat.search("#axb/one") is None -class TestTaskCountingSeesLogseqMarkers: - """analyze-journal-patterns counted markdown checkboxes only. - - Logseq writes TODO/DOING/DONE markers, so a real graph reported - "0 complete, 0 incomplete (0% rate)" — a number that reads like a - measurement of a graph with no tasks, not like a counter that cannot - match. Measured against 120 real journals: 0/0 before, 284/30 after. - """ - - INCOMPLETE = re.compile( - r"(?i:- \[ \])|^(?:\s*-\s*)*(?:TODO|DOING|NOW|LATER|WAITING|IN-PROGRESS)\b", - re.MULTILINE) - COMPLETE = re.compile( - r"(?i:- \[x\])|^(?:\s*-\s*)*(?:DONE|CANCELED|CANCELLED)\b", - re.MULTILINE) - - def test_markers_count_as_tasks(self): - assert self.INCOMPLETE.search("- TODO write the ticket") - assert self.INCOMPLETE.search("- DOING in progress") - assert self.COMPLETE.search("- DONE shipped") - - def test_checkboxes_still_count(self): - """The old syntax must keep working for graphs that use it.""" - assert self.INCOMPLETE.search("- [ ] open") - assert self.COMPLETE.search("- [x] closed") - - def test_a_marker_word_inside_prose_is_not_a_task(self): - """Only at the start of a line, so 'nothing to do' stays prose.""" - assert not self.INCOMPLETE.search("we talked about the todo list") - assert not self.COMPLETE.search("that is done and dusted") - - def test_done_is_not_also_counted_as_incomplete(self): - assert not self.INCOMPLETE.search("- DONE shipped") - - def test_a_lowercase_marker_word_is_prose(self): - """Logseq markers are upper-case. "Now that..." opens a sentence. - - Reported against an English graph: with (?i) on the markers, every - block starting "Now", "Later", "Waiting" or "done" counted as a task. - """ - for line in ("- done", "- Now that we finished it", - "- Later kam der Regen", "- Waiting for the reply", - "- todo: still has to happen"): - assert not self.INCOMPLETE.search(line), line - assert not self.COMPLETE.search(line), line - - def test_a_checkbox_still_matches_either_case(self): - """[x] and [X] are both in the wild, unlike the markers.""" - assert self.COMPLETE.search("- [x] done") - assert self.COMPLETE.search("- [X] done") - diff --git a/tests/test_marker_lists.py b/tests/test_marker_lists.py index 3d6a5f7..d407b14 100644 --- a/tests/test_marker_lists.py +++ b/tests/test_marker_lists.py @@ -4,9 +4,7 @@ reads the source of every module but tasks.py and finds each place where two or more different marker words stand as whole words in one string literal or one collection (a set, list, tuple, the keys of a dict): Datalog strings and -regexes included. EXCEPTIONS names the places that are still to be replaced; -the test wants the counts equal, so a new list in a module with an old -exception does not pass as "one of the old ones". +regexes included. There is no list of exceptions: the answer is none. Prose is not a list. Docstrings and the first argument of ``fail`` are left out; in ``help=`` and ``epilog=`` only an enumeration counts (two markers in a @@ -34,12 +32,6 @@ ENUMERATION = re.compile( rf"(? places still holding a marker list of their own. -EXCEPTIONS = { - ("commands/analysis.py", "analyze_journal_patterns"): 2, -} - - def _words(text): return {m.group(1) for m in WORD.finditer(text)} @@ -129,11 +121,8 @@ def found_in_package(): def test_no_module_but_tasks_keeps_a_marker_list(): - found = collections.Counter(found_in_package()) - more = {k: n - EXCEPTIONS.get(k, 0) for k, n in found.items() if n > EXCEPTIONS.get(k, 0)} - fewer = {k: n - found.get(k, 0) for k, n in EXCEPTIONS.items() if n > found.get(k, 0)} - assert not more, f"more found than allowed, a new marker list; use tasks.ORDER/STATE: {more}" - assert not fewer, f"fewer found than allowed, an outdated entry; lower the count or drop it: {fewer}" + found = dict(found_in_package()) + assert not found, f"a marker list outside tasks.py; use tasks.ORDER/STATE: {found}" def _count(source): diff --git a/tests/test_tasks.py b/tests/test_tasks.py index f87a148..a6aeaea 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1098,3 +1098,9 @@ def test_a_marker_on_a_further_line_does_not_count(self): # A page text put a block's further lines at column 0, where a pattern took # one for a block start; Logseq reads a marker only where the block starts. assert tasks.marker_of("a note\nTODO is only a word here") is None + + + @pytest.mark.parametrize("text", ["Now that we finished it", "Waiting for the call", + "Later we will see"]) + def test_a_capitalised_word_opening_a_sentence_is_no_marker(self, text): + assert tasks.marker_of(text) is None From df1bde44861b93a817a1852379e3f59df1962487 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 04:15:38 +0200 Subject: [PATCH 12/15] Read a repeater no date can reach as one that cannot be placed get-todos stopped with a traceback and exit 1 on a block whose repeater holds a number no date can reach. Two sizes, two ways to fail: - Eleven digits or so (SCHEDULED: <2026-01-01 Thu +99999999999d>) made _add_interval raise OverflowError; 99999 months or years raised ValueError for a year past 9999. get_todos catches only (ValueError, TypeError) around next_occurrence, so the OverflowError ended the command. - More than 4300 digits made int() raise ValueError inside parse_timestamp, outside that try, so no caller could catch it. One such block, typed or pasted, made get-todos unusable for the whole graph. Logseq does not write such a number; it was already on main (parse_repeater, same two failures). parse_timestamp now reads a number of more than twelve digits as 10**12, a value no date lies away. _add_interval returns None for a result no date can hold, which is what next_occurrence already answers "cannot be derived" with. The task lists like one whose interval cannot be read: no next_due, left out of a due range, counted in repeating_excluded and named on stderr. Mutations tried, each caught: - int(digits) without the cap: the 5000-digit cases, in dates and through get-todos (13 tests) - _add_interval without the except: all 44 cases of the new class - only OverflowError caught: the 99999 months and years cases (6) - the cap from three digits on: the case that .+100y is not cut The cases were red before the change (44 failed), with the two exceptions above. --- CHANGELOG.md | 9 ++++++ logseq_cli/dates.py | 56 ++++++++++++++++++++++------------- tests/test_repeater.py | 30 +++++++++++++++++++ tests/test_todos_due_dates.py | 22 ++++++++++++++ 4 files changed, 97 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3752a55..4262604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,6 +225,15 @@ file carries most of the reasoning behind the tool. ### Fixed +- `get-todos` stopped with a traceback and exit 1 on a block whose repeater + holds a number no date can reach, such as `SCHEDULED: <2026-01-01 Thu + +99999999999d>`, or one of more than 4300 digits. A single such block, typed + or pasted, made the command unusable for the whole graph. The task now lists + like one whose interval cannot be read: it has no `next_due`, and a + `--due-from` or `--due-to` range leaves it out, counts it in + `repeating_excluded` and names it on stderr. Found by trying oversized numbers on every unit and form of repeater; + the crash was already on `main`. + - `analyze-graph` reported the size of the first cluster it found as `largest_cluster`, not the size of the largest one. The order of the clusters follows the order of the pages, so a graph whose first linked group was small diff --git a/logseq_cli/dates.py b/logseq_cli/dates.py index ae71425..66da8e8 100644 --- a/logseq_cli/dates.py +++ b/logseq_cli/dates.py @@ -110,6 +110,18 @@ class Timestamp(NamedTuple): _INTERVAL_RE = re.compile(r"(\+\+|\.\+|\+)(\d+)([hdwmy])") +# No date lies this many days, weeks, months or years away (the year stops at +# 9999), so every number past it means the same: the repeater cannot be placed. +_TOO_FAR = 10 ** 12 + + +def _interval_number(digits: str) -> int: + """The number of a repeater. Digits past what any date can hold are read as + _TOO_FAR, because int() refuses a string of more than 4300 digits and the + text is whatever a person typed or pasted.""" + return int(digits) if len(digits) <= 12 else _TOO_FAR + + def parse_timestamp(text: str): """The text between ``<`` and ``>`` of a timestamp, as a Timestamp without ``kind``, or None. Forgiving on purpose: a wrong or missing weekday, and @@ -125,7 +137,7 @@ def parse_timestamp(text: str): except ValueError: return None interval = _INTERVAL_RE.search(rest) - repeater = (interval.group(1), int(interval.group(2)), interval.group(3)) if interval else None + repeater = (interval.group(1), _interval_number(interval.group(2)), interval.group(3)) if interval else None return Timestamp(None, date, time, repeater) @@ -173,30 +185,34 @@ def timestamps(content: str) -> list: def _add_interval(start: datetime.date, num: int, unit: str): - """Add ``num`` units to ``start``. Returns None for an unknown unit. + """Add ``num`` units to ``start``. Returns None for an unknown unit, and + for a result no date can hold (a repeater typed as +99999999999d). Months and years are handled by arithmetic on the calendar fields rather than by a fixed day count, clamping the day to the target month's length (31 January plus one month is 28 or 29 February, as a calendar reads it). """ - if unit == "h": - # Hour repeats exist in the grammar; at date granularity the smallest - # step that can move the result is a day. - return start + datetime.timedelta(days=1) - if unit == "d": - return start + datetime.timedelta(days=num) - if unit == "w": - return start + datetime.timedelta(weeks=num) - if unit == "m": - month_index = start.month - 1 + num - year = start.year + month_index // 12 - month = month_index % 12 + 1 - day = min(start.day, calendar.monthrange(year, month)[1]) - return datetime.date(year, month, day) - if unit == "y": - year = start.year + num - day = min(start.day, calendar.monthrange(year, start.month)[1]) - return datetime.date(year, start.month, day) + try: + if unit == "h": + # Hour repeats exist in the grammar; at date granularity the smallest + # step that can move the result is a day. + return start + datetime.timedelta(days=1) + if unit == "d": + return start + datetime.timedelta(days=num) + if unit == "w": + return start + datetime.timedelta(weeks=num) + if unit == "m": + month_index = start.month - 1 + num + year = start.year + month_index // 12 + month = month_index % 12 + 1 + day = min(start.day, calendar.monthrange(year, month)[1]) + return datetime.date(year, month, day) + if unit == "y": + year = start.year + num + day = min(start.day, calendar.monthrange(year, start.month)[1]) + return datetime.date(year, start.month, day) + except (OverflowError, ValueError): + return None return None diff --git a/tests/test_repeater.py b/tests/test_repeater.py index 7b460ec..b6ef8ab 100644 --- a/tests/test_repeater.py +++ b/tests/test_repeater.py @@ -106,6 +106,36 @@ def test_unknown_unit_is_none(self): T = datetime.time +class TestAnIntervalNoDateCanHold: + """A repeater is text a person typed or pasted, so its number can be any + size. The date it would lead to does not exist then, and the answer is + "cannot be derived" (None), never an exception: one such block must not + take get-todos down for the whole graph. Four sizes, four ways it failed: + past the date range of a day count (11 digits), past the range of the year + (5 digits, unit y), past what a date takes at all, and past the 4300 digits + Python converts to an integer.""" + + TODAY = datetime.date(2026, 10, 6) + + # 99999 days or weeks still land on a date; 99999 months and years do not. + @pytest.mark.parametrize("digits,unit", [ + (digits, unit) + for digits in ("99999", "9" * 11, "9" * 13, "9" * 5000) + for unit in "dwmy" + if not (digits == "99999" and unit in "dw") + ], ids=lambda value: f"{len(value)}-digits" if value.startswith("9") else value) + @pytest.mark.parametrize("kind", ["+", "++", ".+"]) + def test_is_read_as_a_repeater_and_has_no_next_occurrence(self, kind, digits, unit): + stamp = parse_timestamp(f"2026-01-01 Thu {kind}{digits}{unit}") + assert stamp is not None and stamp.repeater is not None, "still a repeater" + assert (stamp.repeater[0], stamp.repeater[2]) == (kind, unit) + assert next_occurrence(stamp.date, stamp.repeater, self.TODAY) is None + + def test_a_large_interval_that_a_date_can_hold_is_not_cut(self): + stamp = parse_timestamp("2026-01-01 Thu .+100y") + assert next_occurrence(stamp.date, stamp.repeater, self.TODAY) == datetime.date(2126, 1, 1) + + class TestTimestamps: """Every SCHEDULED/DEADLINE of a block, each with the repeater of its own.""" diff --git a/tests/test_todos_due_dates.py b/tests/test_todos_due_dates.py index f2ddaad..ddfef63 100644 --- a/tests/test_todos_due_dates.py +++ b/tests/test_todos_due_dates.py @@ -20,6 +20,8 @@ from unittest.mock import MagicMock, patch +import pytest + from logseq_cli.cli import cli from logseq_cli.dates import next_occurrence from tests.conftest import split_runner @@ -180,6 +182,26 @@ def test_unreadable_repeat_is_reported_not_guessed(self): assert "opaque repeat" in result.stderr assert "next_due" not in json.dumps(payload) + # The number of a repeater is typed text: a date it leads to may not exist. + # One such block lists like an unreadable one, and the others still list. + @pytest.mark.parametrize("digits", ["9" * 11, "9" * 5000], ids=["11-digits", "5000-digits"]) + def test_an_interval_no_date_can_hold_does_not_stop_the_listing(self, digits): + rows = self.REPEATER + _rows( + ("TODO huge repeat\nSCHEDULED: <2020-01-06 Mon +" + digits + "d>", + "TODO", "Work", None, 20200106, None, True), + ) + result = _run(["get-todos", "--json"], rows) + assert result.exit_code == 0, result.output + todos = json.loads(result.stdout)["todos"] + assert len(todos) == 2 + huge = next(t for t in todos if "huge repeat" in t["content"]) + assert huge["repeating"] is True and "next_due" not in huge + ranged = _run(["get-todos", "--due-from", "2026-05-01", "--due-to", "2026-05-31", + "--json"], rows) + assert ranged.exit_code == 0, ranged.output + assert json.loads(ranged.stdout)["repeating_excluded"] == 1 + assert "huge repeat" in ranged.stderr + def test_repeater_appears_normally_without_a_due_range(self): result = _run(["get-todos", "--json"], self.REPEATER) assert len(json.loads(result.stdout)["todos"]) == 1 From 9965cf58266aa94733d7a74866f481604d6e5804 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:52:01 +0200 Subject: [PATCH 13/15] Keep a repeating task's date from today onward as its next occurrence next_occurrence stepped once past a stored date that was today or still ahead for "+" and ".+", so get-todos reported next_due one repeat too late and --due-from/--due-to left the task out. Only "++" kept such a date. --- CHANGELOG.md | 11 +++++++++++ logseq_cli/dates.py | 14 +++++++++----- tests/test_repeater.py | 16 +++++++++++++--- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4262604..0312f28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,6 +225,17 @@ file carries most of the reasoning behind the tool. ### Fixed +- `get-todos` reported `next_due` one repeat too late for a repeating task whose + date is today or still ahead. `SCHEDULED: <2026-10-11 Sun +1w>` with today + at 2026-10-06 reported 2026-10-18, and `.+1d` dated today reported tomorrow, + so `--due-from` and `--due-to` left the task out of the range that holds it. + That is the normal state of a task that was ticked off before, because + Logseq then writes the next date into the text. Only `++` kept such a date. + Found by trying every form with a stored date from today onward; the error + was already on `main`. A stored date that is not past is now the next + occurrence for `+`, `.+` and `++` alike. A date that is past is unchanged + (#92). + - `get-todos` stopped with a traceback and exit 1 on a block whose repeater holds a number no date can reach, such as `SCHEDULED: <2026-01-01 Thu +99999999999d>`, or one of more than 4300 digits. A single such block, typed diff --git a/logseq_cli/dates.py b/logseq_cli/dates.py index 66da8e8..e3a4c19 100644 --- a/logseq_cli/dates.py +++ b/logseq_cli/dates.py @@ -238,12 +238,16 @@ def next_occurrence(start: datetime.date, repeater, today: datetime.date = None) # ticked off, "+" and "++" return a date that is still in the past — useless # for "what is due", which has to look forward from today whatever the form. # - # So the single step is kept where it lands in the future, and otherwise the - # ".+" loop runs for every form. The interval is still Logseq's, and so is - # the weekday rule; only the starting point differs, because the question - # does. + # So a stored date that is not past is the occurrence itself: a task ticked + # off before has its next date written into the text, and one more step would + # skip it. Past that, the single step is kept where it lands in the future, + # and otherwise the ".+" loop runs for every form. The interval is still + # Logseq's, and so is the weekday rule; only the starting point differs, + # because the question does. + if start >= today: + return start if kind in ("+", "++"): - stepped = start if (kind == "++" and start > today) else _add_interval(start, num, unit) + stepped = _add_interval(start, num, unit) if stepped is None or stepped > today: return stepped diff --git a/tests/test_repeater.py b/tests/test_repeater.py index b6ef8ab..ebd6f9e 100644 --- a/tests/test_repeater.py +++ b/tests/test_repeater.py @@ -38,10 +38,20 @@ def test_double_plus_advances_once_when_past(self): start = datetime.date(2026, 5, 10) assert next_occurrence(start, ("++", 1, "w"), self.TODAY) == datetime.date(2026, 5, 17) - def test_double_plus_keeps_a_future_date(self): - """A date still ahead is already the next occurrence.""" + @pytest.mark.parametrize("kind", ["+", ".+", "++"]) + def test_a_date_ahead_is_the_next_occurrence_for_every_form(self, kind): + """A task ticked off before has its next date written into the text. + + That date is the occurrence that is due next; one more step would skip it + and put the task outside the range that holds it. + """ start = datetime.date(2026, 5, 20) - assert next_occurrence(start, ("++", 1, "w"), self.TODAY) == start + assert next_occurrence(start, (kind, 1, "w"), self.TODAY) == start + + @pytest.mark.parametrize("kind", ["+", ".+", "++"]) + def test_a_date_of_today_is_the_next_occurrence_for_every_form(self, kind): + """A task due today is due today, not tomorrow.""" + assert next_occurrence(self.TODAY, (kind, 1, "d"), self.TODAY) == self.TODAY def test_plus_keeps_stepping_until_it_reaches_the_future(self): """One step off a 2020 date is still 2020; the answer must be ahead. From 71c6d849c034f99af11651f2c7781759fe3ca262 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:12:47 +0200 Subject: [PATCH 14/15] Stop the next occurrence at today instead of the day after next_occurrence compared with "> today" in the single step and in the loop, so an overdue repeating task whose next date is today was reported for the occurrence after it, and --due-from today --due-to today missed it. --- CHANGELOG.md | 10 ++++++++++ logseq_cli/dates.py | 4 ++-- tests/test_repeater.py | 23 +++++++++++++++++------ tests/test_todos_due_dates.py | 4 ++-- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0312f28..57b73e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,6 +225,16 @@ file carries most of the reasoning behind the tool. ### Fixed +- `get-todos` skipped today when working out `next_due` of an overdue repeating + task whose next occurrence falls on today. With today at 2026-10-06, a task + last due 2026-09-29 with `+1w` reported 2026-10-13, and one due yesterday + with `+1d` reported tomorrow, so `--due-from today --due-to today` missed a + task that is due today. Found while checking the fix below against the case + one step before it; the error was already on `main`. The single step and the + loop now stop at a date that is today or later, as a stored date from today + onward already did. Three tests had written the old reading down as "after + today" and now say "on or after today" (#92). + - `get-todos` reported `next_due` one repeat too late for a repeating task whose date is today or still ahead. `SCHEDULED: <2026-10-11 Sun +1w>` with today at 2026-10-06 reported 2026-10-18, and `.+1d` dated today reported tomorrow, diff --git a/logseq_cli/dates.py b/logseq_cli/dates.py index e3a4c19..233ef13 100644 --- a/logseq_cli/dates.py +++ b/logseq_cli/dates.py @@ -248,7 +248,7 @@ def next_occurrence(start: datetime.date, repeater, today: datetime.date = None) return start if kind in ("+", "++"): stepped = _add_interval(start, num, unit) - if stepped is None or stepped > today: + if stepped is None or stepped >= today: return stepped current = start @@ -256,7 +256,7 @@ def next_occurrence(start: datetime.date, repeater, today: datetime.date = None) current = _add_interval(current, num, unit) if current is None: return None - if current > today: + if current >= today: break else: return None diff --git a/tests/test_repeater.py b/tests/test_repeater.py index ebd6f9e..9eb1e7d 100644 --- a/tests/test_repeater.py +++ b/tests/test_repeater.py @@ -15,9 +15,11 @@ "What is due" asks something else. Applied to a task that was never ticked off, `+` and `++` land on a date that is still in the past — a weekly task from 2020 -would answer 2020-01-13. So the single step is kept where it lands in the -future, and otherwise the `.+` loop runs for every form. Same interval, same -weekday rule, different starting point, because the question differs. +would answer 2020-01-13. So a stored date from today onward is the next +occurrence itself (a task ticked off before has its next date written into the +text), a past one keeps its single step where that lands on or after today, and +otherwise the `.+` loop runs for every form. Same interval, same weekday rule, +different starting point, because the question differs. Derived rather than stored: Logseq keeps no "next occurrence" anywhere, and :block/scheduled holds the date as written. @@ -53,6 +55,15 @@ def test_a_date_of_today_is_the_next_occurrence_for_every_form(self, kind): """A task due today is due today, not tomorrow.""" assert next_occurrence(self.TODAY, (kind, 1, "d"), self.TODAY) == self.TODAY + @pytest.mark.parametrize("kind", ["+", ".+", "++"]) + @pytest.mark.parametrize("start, repeater", [ + (datetime.date(2026, 5, 14), (1, "d")), + (datetime.date(2026, 5, 8), (1, "w")), + ]) + def test_an_occurrence_that_falls_on_today_is_not_skipped(self, kind, start, repeater): + """An overdue task whose next date is today is due today, not at the one after.""" + assert next_occurrence(start, (kind, *repeater), self.TODAY) == self.TODAY + def test_plus_keeps_stepping_until_it_reaches_the_future(self): """One step off a 2020 date is still 2020; the answer must be ahead. @@ -69,11 +80,11 @@ def test_plus_stops_at_one_step_when_that_is_already_future(self): assert next_occurrence(start, ("+", 1, "w"), self.TODAY) == datetime.date(2026, 5, 17) def test_dotted_repeats_until_future(self): - """.+ loops forward until it lands after today.""" + """.+ loops forward until it lands on or after today.""" start = datetime.date(2026, 5, 1) result = next_occurrence(start, (".+", 1, "w"), self.TODAY) - assert result > self.TODAY - assert result == datetime.date(2026, 5, 22) + assert result >= self.TODAY + assert result == datetime.date(2026, 5, 15) def test_dotted_over_years_still_terminates(self): """The 2020 weekly case from the reference graph.""" diff --git a/tests/test_todos_due_dates.py b/tests/test_todos_due_dates.py index ddfef63..5b7b5b0 100644 --- a/tests/test_todos_due_dates.py +++ b/tests/test_todos_due_dates.py @@ -240,7 +240,7 @@ def test_next_due_comes_from_the_repeater_of_the_deadline(self): assert spy.call_args.args[1] == ("+", 1, "w") assert todo["deadline"] == "2020-01-07" due = datetime.date.fromisoformat(todo["next_due"]) - assert due > datetime.date.today() and due.weekday() == 1 + assert due >= datetime.date.today() and due.weekday() == 1 for day, count in ((due, 1), (due + datetime.timedelta(days=1), 0)): _, data, _ = _todo(["--due-from", str(day), "--due-to", str(day)], self.TWO) assert data["count"] == count and "repeating_excluded" not in data @@ -259,7 +259,7 @@ def test_only_a_scheduled_repeater_reads_as_before(self): "TODO", "Finance", None, 20200106, None, True)) todo, _, spy = _todo([], rows) assert spy.call_args.args[1] == (".+", 1, "d") - assert datetime.date.fromisoformat(todo["next_due"]) > datetime.date.today() + assert datetime.date.fromisoformat(todo["next_due"]) >= datetime.date.today() def test_a_repeater_of_the_other_timestamp_is_not_borrowed(self): rows = _rows(("TODO pay\nSCHEDULED: <2020-01-06 Mon .+1d>", From 98607843d01112f9b60d688bc5091e816a3e7475 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:21:23 +0200 Subject: [PATCH 15/15] Refuse to replace a block's text when the block changed since it was read update_block reads the block again, past the cache, directly before a write that says what it replaces, and raises BlockChanged when the text is another. set-todo-status, replace-text, set-property and remove-property sent the whole text back and wrote over a change made in between, and the read-back passed because it compares with what was just sent. An id:: line is left out of the comparison: a ref written earlier in the same call stores the id of a block that call then replaces. --- AGENTS.md | 6 ++ CHANGELOG.md | 16 ++++ docs/design.md | 10 +++ logseq_cli/api.py | 27 +++++- logseq_cli/blocktext.py | 3 + logseq_cli/writerefused.py | 6 ++ skills/logseq-cli/SKILL.md | 4 +- tests/conftest.py | 13 +++ tests/test_id_contract_every_writer.py | 6 +- tests/test_one_block_round_trip.py | 7 +- tests/test_write_against_changed_block.py | 101 ++++++++++++++++++++++ tests/test_write_refused_output.py | 1 + 12 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 tests/test_write_against_changed_block.py diff --git a/AGENTS.md b/AGENTS.md index 278c6cd..8c63bf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -463,6 +463,7 @@ rollback), and the message ends with how many, or "Nothing was written.". | `logseq_error` | Logseq threw on the write and answered with an error object | `method`, `logseq_message` | | `page_exists` | `create-page` on a page that exists, whose properties Logseq would drop | `page` | | `rename_refused` | `rename-page` onto a name another page has (Logseq would merge the two) or an empty one | `old`, `new`, `why` (`exists` or `empty`) | +| `block_changed` | `set-todo-status`, `replace-text`, `set-property` and `remove-property` (every write that replaces a block's text) read the block again right before writing, and it no longer holds the text they read: someone changed it in between, and the write would have replaced that change. Nothing was written | `block` | | `write_not_verified` | The write does not show in Logseq: not written, or not all of it | `method`, `target`, `expected`, `got` | | `read_only` | Writes are switched off by `[safety] read_only`, `LOGSEQ_CLI_READ_ONLY` or `--read-only`; every command that writes refuses, `--dry-run` included, before its first request | `source` (list of `config`, `env`, `flag`), `config_path` (`null` when no config file was found) | | `config_error` | The config file cannot be used: it does not parse, `LOGSEQ_CLI_CONFIG` names a file that is not there, or `[safety]` does not check out: a key it does not know, a `[safety]` key outside `[safety]`, a value that is not a boolean, or a `[graph] keep_empty_blocks_last` that is not. A command that writes refuses rather than run without the limits the file may hold. Nothing was sent | none | @@ -506,6 +507,11 @@ write into X). A `--keep-ids` write is refused while any block is open. `write_not_verified` can be a false alarm when someone else wrote the block between the write and the read-back; read the block before retrying. +On `block_changed`: nothing was written to that block, and the change made +elsewhere stands. Read the block again and repeat the command if it still +makes sense. The check narrows the time between reading and writing to one +request; it does not close it. + `replace-text` writes the other blocks when one is refused, and names each refused block's reason in `failed_reasons` (`{id: reason}`) beside `failed`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b73e4..1cf8242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -225,6 +225,22 @@ file carries most of the reasoning behind the tool. ### Fixed +- A change made to a block while a command was changing it was written over + without a word. `set-todo-status`, `replace-text`, `set-property` and + `remove-property` read the block, build the new text from it and send the + whole text back, so an edit made in between (a parallel call of the CLI, a + sync, Logseq itself) was lost, and the read-back passed because it compares + with what was just sent. Found by an adversarial pass that asked how two calls + on one block could interleave; the gap was already on `main`. `update_block` + now reads the block again, past the cache, directly before the write when it + is told what it replaces, and refuses with the new reason `block_changed` + when the text is another, naming the block and writing nothing. An `id::` + line is left out of the comparison: a ref written earlier in the same + `replace-text` stores the id of a block it then replaces, and that is not a + change made elsewhere (measured, #95). The window is smaller, not closed: it + is now the requests between that read and the write, the same kind of gap the + editor check leaves (#92). + - `get-todos` skipped today when working out `next_due` of an overdue repeating task whose next occurrence falls on today. With today at 2026-10-06, a task last due 2026-09-29 with `+1w` reported 2026-10-13, and one due yesterday diff --git a/docs/design.md b/docs/design.md index b1730c2..e30400f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -41,6 +41,16 @@ write from elsewhere that lands between the CLI's write and its read makes a write that did land fail as `write_not_verified`; the error shows what was expected and what was read, so read the block before retrying. +The same gap exists before a write. A command that changes a block reads it, +builds the new text from what it read and sends the whole text, so a change +made to the block in between would be written over, and the read-back would +pass, because it compares with what was just sent. The API reads the block +again past the cache directly before such a write and refuses with +`block_changed` when the text is another. An `id::` line does not count: Logseq +adds it when a ref to the block is written earlier in the same call. This +shrinks the window to the requests between that read and the write; it does +not close it, as no read can. + The second half of the sentence answers a loss no read-back can catch. Measured on Logseq 0.10.15: a write to the block being edited replaced the editor content at once and dropped what had not been saved; Logseq answered diff --git a/logseq_cli/api.py b/logseq_cli/api.py index 74386ab..9091031 100644 --- a/logseq_cli/api.py +++ b/logseq_cli/api.py @@ -24,6 +24,7 @@ refuse_split_tree, stored_property_key, tree_texts, + without_block_ids, ) from logseq_cli.outlinetext import preorder_blocks, subtree_uuids from logseq_cli.pagenames import js_trim, page_name_to_create, title_as_created @@ -32,6 +33,7 @@ # from the API they call. They live in a leaf module, so that # strictinsert and output need not import the HTTP client for them. from logseq_cli.writerefused import ( # noqa: F401 re-exported + BlockChanged, EditorOpen, EditorStateUnknown, LogseqWriteError, @@ -1256,13 +1258,30 @@ def update_block(self, block_uuid: str, content: str, properties: dict = None, * ``replacing`` is the text this replaces, for a caller that changes a block rather than writing one: a line it already had passes the check - every write here goes through (#47). - - Raises WriteNotVerified unless the block then reads back with - ``content`` and ``properties`` (``_prove_text``). + every write here goes through (#47). It is also what the block must + still hold, bar its Id Line: a caller builds ``content`` from what it + read, and the whole text goes back, so a change made to the block since + would be written over, and the proof reads back what was just sent. + The block is read again past the cache directly before the write, and + BlockChanged is raised when its text is another. That shrinks the + window to the requests between this read and the write; it does not + close it, as the editor gate's does not (see ``_write``). + + Raises BlockChanged as above, and WriteNotVerified unless the block + then reads back with ``content`` and ``properties`` (``_prove_text``). """ refuse_split_block(content, command="logseq-cli", where="The text", replacing=replacing) refuse_id_lines(content, own=block_uuid, replacing=replacing) + if replacing is not None: + current = self.get_block(block_uuid, include_children=False, cached=False) + # An Id Line is Logseq's, not text anyone wrote: a ref written + # earlier in the same call stores the id of a block this call + # then replaces (#95), and that is not a change made elsewhere. + if without_block_ids((current or {}).get("content", "")) != without_block_ids(replacing): + raise BlockChanged( + f"Block {block_uuid[:8]}.. changed since it was read; nothing was " + "written over it. Read it again and repeat the command.", + block=block_uuid) # Properties carried along are written again, a ref among them too. texts = [content, *map(str, (properties or {}).values())] args = [block_uuid, content] diff --git a/logseq_cli/blocktext.py b/logseq_cli/blocktext.py index f47ba9f..c0cec1b 100644 --- a/logseq_cli/blocktext.py +++ b/logseq_cli/blocktext.py @@ -164,6 +164,9 @@ def without_block_ids(content: str) -> str: the line would name a uuid the block does not have, and a copy would put the original's id into the file, where the next parse finds two blocks claiming it. A line in a code block is code and stays. + + Also what ``LogseqAPI.update_block`` compares: an Id Line is Logseq's, not + text anyone wrote, so it does not make a block "changed". """ return "\n".join(line for line, value in id_lines(content) if not value) diff --git a/logseq_cli/writerefused.py b/logseq_cli/writerefused.py index d89463d..f0e8cf5 100644 --- a/logseq_cli/writerefused.py +++ b/logseq_cli/writerefused.py @@ -38,6 +38,12 @@ class EditorOpen(WriteRefused): reason = "open_in_editor" +class BlockChanged(WriteRefused): + """The block no longer holds the text the caller read and meant to replace. + Field: block.""" + reason = "block_changed" + + class EditorStateUnknown(WriteRefused): """checkEditing answered something not understood. Field: answer.""" reason = "editor_state_unknown" diff --git a/skills/logseq-cli/SKILL.md b/skills/logseq-cli/SKILL.md index e86a90a..bdf27fa 100644 --- a/skills/logseq-cli/SKILL.md +++ b/skills/logseq-cli/SKILL.md @@ -62,10 +62,10 @@ for example the token from Logseq's API settings, passed as `LOGSEQ_TOKEN`. - **Act on a refused write by its `reason`**: - `open_in_editor`: the user is typing in that block. The write did not happen; earlier writes of the same call may have, and the message says - how many. Ask the user to leave the block and retry once they have, - not in a loop. + how many. Ask the user to leave the block, then retry once (not in a loop). - `write_not_verified`: read the block before retrying. Someone writing there at the same time can make a write that landed look missing. + - `block_changed`: the block changed after the command read it; read it again. - `rename_refused`, `page_exists`: nothing was written. - `read_only`: writes are off on purpose. Do not retry or go around it; tell the user. diff --git a/tests/conftest.py b/tests/conftest.py index 79db1cb..b83d7c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -132,6 +132,19 @@ def journal_day(days_ago=1): return int(day.strftime("%Y%m%d")) +def holds_until_written(uuid, before, after): + """A ``call`` for a mocked API whose Logseq holds ``before`` until an + ``updateBlock`` is sent and ``after`` from then on: the read before a write + finds the old text, the proof read after it the new one.""" + state = {"written": False} + + def call(method, *args, **kwargs): + state["written"] = state["written"] or method == "logseq.Editor.updateBlock" + return {"uuid": uuid, "content": after if state["written"] else before, + "page": {"id": 1}} + return call + + def split_runner(): """CliRunner that captures stderr separately from stdout. diff --git a/tests/test_id_contract_every_writer.py b/tests/test_id_contract_every_writer.py index d666195..e45d610 100644 --- a/tests/test_id_contract_every_writer.py +++ b/tests/test_id_contract_every_writer.py @@ -25,7 +25,7 @@ from logseq_cli.api import LogseqAPI from logseq_cli.blocktext import IdLineError from logseq_cli.cli import cli -from tests.conftest import PageGraph, page_graph_api, split_runner +from tests.conftest import PageGraph, holds_until_written, page_graph_api, split_runner OWN = "6d0f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b" FOREIGN = "7e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a70" @@ -121,8 +121,10 @@ def test_an_update_may_keep_a_line_the_block_had(self): # A copy carries its source's line until the file is read again # (measured); replace-text and set-todo-status change another line. text = f"DONE x\nid:: {FOREIGN}" + old = f"TODO x\nid:: {FOREIGN}" api = self._updated(text) - api.update_block(TARGET, text, replacing=f"TODO x\nid:: {FOREIGN}") + api.call = MagicMock(side_effect=holds_until_written(TARGET, old, text)) + api.update_block(TARGET, text, replacing=old) assert self._updates_sent(api) == [("logseq.Editor.updateBlock", [TARGET, text])] def test_but_not_add_one(self): diff --git a/tests/test_one_block_round_trip.py b/tests/test_one_block_round_trip.py index 4212844..8ee3667 100644 --- a/tests/test_one_block_round_trip.py +++ b/tests/test_one_block_round_trip.py @@ -27,7 +27,7 @@ from logseq_cli.api import LogseqAPI from logseq_cli.cli import cli from logseq_cli.blocktext import SplitBlockError, block_boundaries -from tests.conftest import PageGraph, page_graph_api, split_runner +from tests.conftest import PageGraph, holds_until_written, page_graph_api, split_runner ANCHOR = "7e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a70" DATE = ["--date", "2026-01-05"] @@ -87,8 +87,9 @@ def test_refused(self, write): def test_an_update_may_keep_a_line_the_block_had(self): api = self._api() - # Logseq holds the text once written: the update reads it back. - api.call.return_value = {"uuid": ANCHOR, "content": "DONE x\n- b", "page": {"id": 1}} + # Logseq holds the old text until the write, the new one after: the + # re-read before the write finds the first, the proof the second. + api.call.side_effect = holds_until_written(ANCHOR, "TODO x\n- b", "DONE x\n- b") api.update_block(ANCHOR, "DONE x\n- b", replacing="TODO x\n- b") assert [c.args for c in api.call.call_args_list if c.args[0] == "logseq.Editor.updateBlock"] == [ diff --git a/tests/test_write_against_changed_block.py b/tests/test_write_against_changed_block.py new file mode 100644 index 0000000..ae1617f --- /dev/null +++ b/tests/test_write_against_changed_block.py @@ -0,0 +1,101 @@ +"""A write that replaces text checks the block still holds that text. + +A caller that changes a block reads it, builds the new text from what it read +and writes the whole text back. A change made to the block in between, by +another call of the CLI or by Logseq itself, would be written over, and the +proof afterwards reads back what was just sent, so it passes. ``update_block`` +reads the block again, past the cache, directly before the write when it is +told what it replaces (``replacing``), and refuses when the text is another. + +Same double, same real ``LogseqAPI`` as the other write tests: the change is +made by a request the double answers, between the reads. +""" +import json + +import pytest + +from logseq_cli.api import BlockChanged, LogseqAPI +from logseq_cli.cli import cli +from tests.conftest import split_runner +from tests.logseq_http_double import LogseqHttpDouble + +URL = "http://127.0.0.1:12315/api" +UUID = "00000000-0000-4000-8000-0000000000aa" +FOREIGN = "TODO x\nnote added elsewhere" + + +def _update(double, text): + """A change made from outside, as a request the double answers.""" + double.post(URL, json={"method": "logseq.Editor.updateBlock", "args": [UUID, text]}, + headers={"Authorization": "Bearer t"}, timeout=30) + + +def _content(double): + return double.post( + URL, json={"method": "logseq.Editor.getBlock", "args": [UUID, {}]}, + headers={"Authorization": "Bearer t"}, timeout=30).json()["content"] + + +@pytest.fixture +def double(monkeypatch): + return LogseqHttpDouble.installed(monkeypatch, { + "Probe Page": [{"content": "TODO x", "marker": "TODO", "uuid": UUID}]}) + + +@pytest.fixture +def api(double): + api = LogseqAPI(token="t") + api.cache_enabled = False + return api + + +def test_a_block_changed_since_it_was_read_is_refused(double, api): + _update(double, FOREIGN) + with pytest.raises(BlockChanged) as refused: + api.update_block(UUID, "DONE x", replacing="TODO x") + assert refused.value.reason == "block_changed" + assert refused.value.fields == {"block": UUID} + assert _content(double) == FOREIGN, "the change made elsewhere must stay" + assert len(double.sent("updateBlock")) == 1, "only the change made elsewhere was written" + + +def test_a_block_as_it_was_read_is_written(double, api): + api.update_block(UUID, "DONE x", replacing="TODO x") + assert _content(double) == "DONE x" + + +def test_an_empty_text_is_a_text_to_compare(double, api): + """The property-block path replaces "", which is not "no check".""" + with pytest.raises(BlockChanged): + api.update_block(UUID, "DONE x", replacing="") + assert _content(double) == "TODO x" + + +def test_a_write_that_replaces_nothing_is_not_compared(double, api): + api.update_block(UUID, "something else") + assert _content(double) == "something else" + + +def test_set_todo_status_does_not_write_over_a_change_between_its_reads(double, monkeypatch): + """The command reads the block, a change lands, then it writes. + + Through the command, cache on: the second read has to go past the cache + the first one filled. + """ + original = double.post + changed = [] + + def post(url, json=None, **kwargs): + answer = original(url, json=json, **kwargs) + if json["method"] == "logseq.Editor.getBlock" and not changed: + changed.append(True) + _update(double, FOREIGN) + return answer + + monkeypatch.setattr("logseq_cli.api.requests.post", post) + result = split_runner().invoke(cli, [ + "--token", "t", "set-todo-status", "--id", UUID, "--status", "DONE", "--json"]) + assert result.exit_code != 0 + assert json.loads(result.stderr)["reason"] == "block_changed" + assert _content(double) == FOREIGN + assert len(double.sent("updateBlock")) == 1 diff --git a/tests/test_write_refused_output.py b/tests/test_write_refused_output.py index febdb50..625a8e9 100644 --- a/tests/test_write_refused_output.py +++ b/tests/test_write_refused_output.py @@ -22,6 +22,7 @@ # (type, reason, fields): the contract --json callers rely on. TYPES = [ + ("BlockChanged", "block_changed", {"block": BLOCK}), ("EditorOpen", "open_in_editor", {"block": BLOCK, "page": "Probe Page"}), ("EditorStateUnknown", "editor_state_unknown", {"answer": '{"ok": 1}'}), ("LogseqWriteError", "logseq_error",