From a6bfe75632262e8dbe9ce918da501a9cce2885ef Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:40:24 +0200 Subject: [PATCH 01/25] Record why command registration is an explicit import list The import list in cli.py will look like a formality to anyone who reads it, and "why not scan the directory?" is the first idea a reader has when they see nine import lines. Both alternatives were considered and rejected for reasons that are not visible from the code itself. The working spec that weighs them is not part of this repository and is archived once the work is done, so the reasoning goes here instead. --- .../adr/0001-explicit-command-registration.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/adr/0001-explicit-command-registration.md diff --git a/docs/adr/0001-explicit-command-registration.md b/docs/adr/0001-explicit-command-registration.md new file mode 100644 index 0000000..d65844c --- /dev/null +++ b/docs/adr/0001-explicit-command-registration.md @@ -0,0 +1,28 @@ +# Commands are registered by an explicit import list + +`logseq_cli/cli.py` imports each command module by name, and +`logseq_cli/commands/__init__.py` stays empty. Importing a command module +registers its commands as a side effect, because each one decorates against the +group in `logseq_cli/group.py`; `group.py` imports nothing from `commands/`, so +the dependency runs one way and there is no cycle. + +## Considered Options + +**Discovering modules with `pkgutil.iter_modules`.** A new command module would +register itself, and the import list would never need editing. Rejected: a +module that appears by directory scan has no place where its existence is +written down, and a typo in a filename then presents as a missing command +rather than as an import error. The registry test +(`tests/test_command_registry.py`) would report the symptom and not the cause. + +**Keeping the group in `cli.py` and importing the command modules at the bottom +of the file.** This avoids editing the test suite, which patches the API client +by module path. Rejected: it introduces a circular import on purpose. A cycle +that has to be explained in a comment is worse than one mechanical edit across +the tests. + +## Consequences + +Adding a command module means adding one line to `cli.py`. Forgetting it means +the commands are absent, which is why the registry test asserts the full set of +command names rather than iterating whatever happens to be registered. From af5c849470cec54692b0e2fd5648164b33b4fa8f Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:10:23 +0200 Subject: [PATCH 02/25] Guard the command registry against a module nobody imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 001 moves every command into nine modules that cli.py imports by name. A module left out of that list registers nothing, and the CLI still starts — it is simply missing commands. The same holds for a second Command Name left behind when its Command moves to another module. The README counter test does go red on a missing module, but it reports a sum, not a name, and it excludes Command Names that are not canonical: with `delete-block` unregistered it stays green. Measured both ways before writing this: dropping the alias registration leaves the README tests passing and makes this one name `delete-block` in its failure. The set is literal rather than derived. Iterating cli.commands would assert that the registry equals itself, and a module that is never imported leaves nothing to iterate over; a count holds until something is added in the same commit and then does not say what went missing. --- tests/test_command_registry.py | 69 ++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_command_registry.py diff --git a/tests/test_command_registry.py b/tests/test_command_registry.py new file mode 100644 index 0000000..0bf4ebc --- /dev/null +++ b/tests/test_command_registry.py @@ -0,0 +1,69 @@ +"""The Registry holds every Command Name, and this file says which ones. + +Spec 001 splits `cli.py` into nine command modules that `cli.py` imports by +name. A module nobody imports registers nothing, and the failure is silent: +the Registry is simply short a few Command Names and the CLI starts fine. +The same is true of a second Command Name left behind when its Command moves. + +`test_counters_sum_to_the_number_of_commands` in +`tests/test_readme_documents_options.py` does go red on a missing module, but +it reports "counters sum to 37, registry has 29" — a number, from a test about +the README. This file names the Command Name that went missing, and it covers +`delete-block`, which that test excludes by design. + +The set is literal on purpose. Deriving it from `cli.commands` would assert +that the Registry equals itself: a module that is never imported leaves no +trace to iterate over. A count would hold as long as nothing is added in the +same commit, and when it failed it would not say which name went. + +The cost is a deliberate line here for every new Command Name, next to the +README row and the CHANGELOG entry that spec 007 already asks for. +""" +from logseq_cli.cli import cli + +# 38 Command Names for 37 Commands: `delete-block` is a second name for +# `remove-block`, not a Command of its own. +EXPECTED = { + "add-block-ref", + "add-journal-block", + "add-journal-content", + "add-journal-entry", + "add-note-content", + "analyze-graph", + "analyze-journal-patterns", + "copy-block", + "create-page", + "delete-block", + "delete-page", + "doctor", + "find-block", + "find-knowledge-gaps", + "get-all-pages", + "get-backlinks", + "get-block", + "get-journal-range", + "get-journal-summary", + "get-page", + "get-page-stats", + "get-properties", + "get-todos", + "init", + "insert-block", + "move-block", + "query-pages-by-property", + "remove-block", + "remove-property", + "rename-page", + "replace-text", + "search-pages", + "set-block-property", + "set-property", + "set-todo-status", + "smart-query", + "suggest-connections", + "update-block", +} + + +def test_every_command_name_is_registered(): + assert set(cli.commands) == EXPECTED From 82b87659816352812f4752213a0059bacbbe50d0 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:11:22 +0200 Subject: [PATCH 03/25] Build the connection-error wrapper with functools.wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper copied __name__ and __doc__ by hand. That is enough for the help text, and not enough for anything that asks where a callback came from: __module__ stays that of the module defining the decorator, and __wrapped__ is never set. Today decorator and commands share one file, so the two cannot be told apart. Spec 001 moves the decorator to output.py and the commands to nine modules, and tests/test_dry_run_coverage.py unwraps each callback and parses the module __module__ names to decide which commands write. Measured in a full simulation of that layout: without wraps the scan reports 0 writing commands instead of 18, silently, because every callback claims to live in the decorator's module. Probe, red before and green after: decorate a function defined in __main__ and read back __module__ and __wrapped__. Click does not inspect callback signatures, so the CLI is unchanged — both help baselines diff empty. --- logseq_cli/cli.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 13e13de..89a4d31 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -1,4 +1,5 @@ import datetime +import functools import json import os import re @@ -87,7 +88,15 @@ def handle_connection_error(func): ``as_json`` is read from the wrapped command's kwargs; Click passes every option by name, so it is there whenever the command declares the flag. + + ``functools.wraps`` carries ``__module__`` and ``__wrapped__`` across, not + only the name and the docstring. ``tests/test_dry_run_coverage.py`` unwraps + each callback and parses the module that ``__module__`` names; a wrapper + built by hand reports the module that defines *this* decorator instead, so + once the commands live elsewhere the scan would look in the wrong file and + find no writing command at all. """ + @functools.wraps(func) def wrapper(*args, **kwargs): as_json = bool(kwargs.get("as_json")) try: @@ -139,8 +148,6 @@ def wrapper(*args, **kwargs): as_json=as_json, reason="invalid_property_key", ) - wrapper.__name__ = func.__name__ - wrapper.__doc__ = func.__doc__ return wrapper From 8a9dd672417678c32a191d64627ce887d000eb10 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:13:12 +0200 Subject: [PATCH 04/25] Parse the command source instead of splitting one file on "def" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan that decides which commands write read cli.py as text and cut each body at the next top-level def. That puts the decorator lines of the following function at the end of the previous body: 73 of them carry foreign trailing text today. It changes nothing right now — regex and ast agree on the same 18 writers — but it holds only while the neighbours stay put, and spec 001 moves every command into one of nine modules. The failure would have been silent. The file's own guard checked five example names, so a scan that found fewer commands than it should would still pass, and --dry-run coverage across 18 commands would go unchecked. So: parse with ast, per module, keyed on the callback's __module__ after unwrapping; and assert the full set of writers as an equality, so a scan that shrinks and an inventory that shrinks both fail. Both directions probed and restored: removing --dry-run from create-page names it in the failure, and dropping a name from the known set fails too. --- tests/test_dry_run_coverage.py | 90 +++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/tests/test_dry_run_coverage.py b/tests/test_dry_run_coverage.py index 05be834..f43bcda 100644 --- a/tests/test_dry_run_coverage.py +++ b/tests/test_dry_run_coverage.py @@ -9,6 +9,9 @@ Validation must survive the preview too: a --dry-run that swallows "block not found" or "ambiguous selector" would report a write that could never succeed. """ +import ast +import functools +import importlib import json import pathlib from unittest.mock import MagicMock, patch @@ -547,25 +550,48 @@ class TestEveryWriteHasADryRun: ) @staticmethod - def _command_bodies(): + @functools.lru_cache(maxsize=None) + def _module_functions(module_name): + """{function name: source text} for one module, parsed rather than scanned.""" + module = importlib.import_module(module_name) + source = pathlib.Path(module.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + return { + node.name: ast.get_source_segment(source, node) + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + @classmethod + def _command_bodies(cls): """Map each command function name to its source text. - Read from the module file rather than via ``inspect``: every command is - wrapped by ``handle_connection_error``, and ``functools.wraps`` copies - enough metadata that ``getsource`` hands back the wrapper for all of - them. Splitting the file on top-level ``def`` keeps this independent of - the decorator stack. + Parsed rather than scanned, and per module rather than per file. + ``inspect.getsource`` is no use here: it hands back the wrapper that + ``handle_connection_error`` returns. + + The previous version split one file on top-level ``def``, which put the + decorator lines of the next function at the end of the previous one -- + 73 bodies carry foreign trailing text that way. Today that changes + nothing (both scans find the same 18 writers), but it only holds while + the neighbours stay put. Spec 001 moves every command into one of nine + modules, which reorders all of them. + + ``func.__module__`` after unwrapping names the file that defines the + command, which is why this survives the move -- and why + ``handle_connection_error`` has to build its wrapper with + ``functools.wraps``: a hand-built wrapper reports the decorator's own + module instead, and the scan would find nothing. """ - import re - import logseq_cli.cli as cli_module + from logseq_cli.cli import cli as root - source = pathlib.Path(cli_module.__file__).read_text(encoding="utf-8") bodies = {} - starts = [(m.start(), m.group(1)) - for m in re.finditer(r"^def ([a-z_][a-z0-9_]*)\(", source, re.M)] - for index, (offset, name) in enumerate(starts): - end = starts[index + 1][0] if index + 1 < len(starts) else len(source) - bodies[name] = source[offset:end] + for command in root.commands.values(): + func = command.callback + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + bodies[func.__name__] = cls._module_functions(func.__module__).get( + func.__name__, "") return bodies def _writing_commands(self): @@ -582,15 +608,35 @@ def _writing_commands(self): writing[name] = command return writing + # Every Command Name whose Command writes, measured 2026-09-16. Literal, + # because the thing guarded against is a scan that finds *fewer* commands + # than it should, and a sample of five cannot see that — nor can a set + # derived from the scan, which would assert that the scan equals itself. + _KNOWN_WRITERS = { + "add-block-ref", "add-journal-block", "add-journal-content", + "add-journal-entry", "add-note-content", "copy-block", "create-page", + "delete-block", "delete-page", "insert-block", "remove-block", + "remove-property", "rename-page", "replace-text", "set-block-property", + "set-property", "set-todo-status", "update-block", + } + def test_the_scan_finds_the_known_writers(self): - """Guards the guard: a scan that finds nothing would pass silently.""" - found = self._writing_commands() - for expected in ("create-page", "delete-page", "rename-page", - "update-block", "insert-block"): - assert expected in found, ( - f"{expected} writes but the scan missed it — the detection is " - f"broken, not the commands. Found: {sorted(found)}" - ) + """Guards the guard: a scan that finds nothing would pass silently. + + Asserted as equality, not containment. Containment catches a scan that + shrank, which is the danger, but it lets the inventory itself shrink + unnoticed — and a name dropped from the set here is how the scan would + be taught to miss a command later. Equality also makes a genuinely new + write command fail here, deliberately: it costs one line in this set, + next to the README row spec 007 already asks for. + """ + found = set(self._writing_commands()) + assert found == self._KNOWN_WRITERS, ( + f"missed by the scan: {sorted(self._KNOWN_WRITERS - found)}; " + f"not in the known set: {sorted(found - self._KNOWN_WRITERS)}. " + f"A command in the first list means the detection is broken, not " + f"the commands." + ) def test_every_writing_command_offers_dry_run(self): missing = [] From 14ee8ddf84d9917fc837ba6e50521349124d89c2 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:13:54 +0200 Subject: [PATCH 05/25] Drop an unused source scan from the cacheable-methods test The test reads every module except api.py into `code` and then asserts only against `api_src`. Verified with ast: the function stores `code` and never loads it. It came in with a5f2c19, which removed a cacheable method nobody called; the scan looks like it was meant to check call sites and then was not. Removed rather than repaired. Making it recursive and asserting against it would turn a dead line into a new assurance, which belongs with the _MUTATING_METHODS work the roadmap files alongside spec 010. Left as it is, it would get worse: src.glob("*.py") is not recursive, so once spec 001 moves the commands into logseq_cli/commands/ the line would look like it covered them while covering nothing. --- tests/test_api_cache.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_api_cache.py b/tests/test_api_cache.py index 653181d..ffea799 100644 --- a/tests/test_api_cache.py +++ b/tests/test_api_cache.py @@ -135,9 +135,6 @@ def test_every_cacheable_method_is_actually_called_somewhere(self): from logseq_cli.api import _CACHEABLE_METHODS src = Path(__file__).resolve().parent.parent / "logseq_cli" - code = "\n".join( - f.read_text(encoding="utf-8") for f in src.glob("*.py") if f.name != "api.py" - ) api_src = (src / "api.py").read_text(encoding="utf-8") for method in _CACHEABLE_METHODS: From bf1c7f5312b01398fe5cb7be03ffd7ce4b6c6900 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:15:08 +0200 Subject: [PATCH 06/25] Move the output and error helpers into logseq_cli/output.py First extraction of spec 001. handle_connection_error, output and fail go across unchanged; cli.py imports them back, so nothing that calls them moves yet. The imports the new module needs were read off the moved code with ast rather than guessed: click, requests, functools, json, sys, DatalogQueryError, ConfigError, InvalidKeywordError. Three of those then had no reader left in cli.py and are removed there. Three imports in cli.py are unused and stay: block_uuid_from_result, escape_regex and has_flush_newline_bullets were already dead before this commit and are not this commit's to clean up. Suite unchanged at 833, both help baselines diff empty, audit script exit 0. --- logseq_cli/cli.py | 105 +------------------------------------- logseq_cli/output.py | 117 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 103 deletions(-) create mode 100644 logseq_cli/output.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 89a4d31..fa4eb73 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -1,5 +1,4 @@ import datetime -import functools import json import os import re @@ -13,7 +12,7 @@ import click import requests -from logseq_cli.api import LogseqAPI, DatalogQueryError, InvalidPortError +from logseq_cli.api import LogseqAPI, InvalidPortError from logseq_cli.config import ( ConfigError, config_search_paths, @@ -23,7 +22,6 @@ resolve_heading, ) from logseq_cli.datalog import ( - InvalidKeywordError, edn_keyword, edn_string, page_name_literal, @@ -76,108 +74,9 @@ is_journal_date, count_blocks, ) +from logseq_cli.output import fail, handle_connection_error, output -def handle_connection_error(func): - """Catch transport-level errors and report them like every other failure. - - These two are what a caller hits first: Logseq not running, or a wrong - token. Reporting them as prose while ``--json`` was asked for would hand an - agent unparseable text exactly at first contact, so they go through - :func:`fail`, which honours ``--json`` and keeps errors on stderr. - - ``as_json`` is read from the wrapped command's kwargs; Click passes every - option by name, so it is there whenever the command declares the flag. - - ``functools.wraps`` carries ``__module__`` and ``__wrapped__`` across, not - only the name and the docstring. ``tests/test_dry_run_coverage.py`` unwraps - each callback and parses the module that ``__module__`` names; a wrapper - built by hand reports the module that defines *this* decorator instead, so - once the commands live elsewhere the scan would look in the wrong file and - find no writing command at all. - """ - @functools.wraps(func) - def wrapper(*args, **kwargs): - as_json = bool(kwargs.get("as_json")) - try: - return func(*args, **kwargs) - except requests.ConnectionError: - fail( - "Cannot connect to Logseq API. " - "Is Logseq running with the HTTP API enabled?", - as_json=as_json, - reason="connection_refused", - ) - except requests.HTTPError as e: - status = e.response.status_code - hint = ("Check --token: Logseq rejected it." if status in (401, 403) - else None) - fail( - f"HTTP {status} - {e.response.text}", - as_json=as_json, - reason="http_error", - status_code=status, - **({"hint": hint} if hint else {}), - ) - except DatalogQueryError as e: - # Not a transport error: the connection is healthy, Logseq rejected - # the query itself. A distinct reason keeps agents from running - # doctor (which reports OK) and falling back to the filesystem. - fail( - str(e), - as_json=as_json, - reason="datalog_query_failed", - query=e.query, - ) - except ConfigError as e: - # Nothing was sent and nothing is wrong with Logseq: a setting that - # describes the user's graph is missing or their config is broken. - # Its own reason keeps an agent from retrying or blaming the - # connection; the message names the setting and the file. - fail( - str(e), - as_json=as_json, - reason="config_error", - ) - except InvalidKeywordError as e: - # The connection is healthy and no query was sent; the input was - # rejected before building. A distinct reason keeps this out of the - # "connection down" path an agent would otherwise take. - fail( - str(e), - as_json=as_json, - reason="invalid_property_key", - ) - return wrapper - - -def output(data, as_json: bool, human_formatter=None): - """Output data as JSON or human-readable text.""" - if as_json: - click.echo(json.dumps(data, indent=2, default=str)) - elif human_formatter: - click.echo(human_formatter(data)) - else: - click.echo(data) - - -def fail(message: str, as_json: bool = False, exit_code: int = 1, **fields): - """Report an error and exit with ``exit_code`` (never returns). - - Errors always go to **stderr**, never stdout — stdout stays reserved for - payload, so a caller parsing stdout as JSON is never handed an error object - where data was expected. With ``--json`` the error is emitted as a JSON - object (``{"error": ..., ...fields}``) so agents can parse it structurally - instead of scraping prose; without it, a plain ``Error: ...`` line. - - ``fields`` adds context keys (e.g. ``id=...``, ``page=...``) to the JSON form. - """ - if as_json: - payload = {"error": message, **fields} - click.echo(json.dumps(payload, indent=2, default=str), err=True) - else: - click.echo(f"Error: {message}", err=True) - sys.exit(exit_code) def _project_pattern(tag_prefix: str, explicit_tags=None) -> "re.Pattern[str]": diff --git a/logseq_cli/output.py b/logseq_cli/output.py new file mode 100644 index 0000000..1693d62 --- /dev/null +++ b/logseq_cli/output.py @@ -0,0 +1,117 @@ +"""How every command speaks: results on stdout, failures on stderr. + +Kept apart from the commands so that one answer to "what does --json look +like" serves all of them, and so a command module cannot quietly grow its own. +""" +import functools +import json +import sys + +import click +import requests + +from logseq_cli.api import DatalogQueryError +from logseq_cli.config import ConfigError +from logseq_cli.datalog import InvalidKeywordError + + +def handle_connection_error(func): + """Catch transport-level errors and report them like every other failure. + + These two are what a caller hits first: Logseq not running, or a wrong + token. Reporting them as prose while ``--json`` was asked for would hand an + agent unparseable text exactly at first contact, so they go through + :func:`fail`, which honours ``--json`` and keeps errors on stderr. + + ``as_json`` is read from the wrapped command's kwargs; Click passes every + option by name, so it is there whenever the command declares the flag. + + ``functools.wraps`` carries ``__module__`` and ``__wrapped__`` across, not + only the name and the docstring. ``tests/test_dry_run_coverage.py`` unwraps + each callback and parses the module that ``__module__`` names; a wrapper + built by hand reports the module that defines *this* decorator instead, so + once the commands live elsewhere the scan would look in the wrong file and + find no writing command at all. + """ + @functools.wraps(func) + def wrapper(*args, **kwargs): + as_json = bool(kwargs.get("as_json")) + try: + return func(*args, **kwargs) + except requests.ConnectionError: + fail( + "Cannot connect to Logseq API. " + "Is Logseq running with the HTTP API enabled?", + as_json=as_json, + reason="connection_refused", + ) + except requests.HTTPError as e: + status = e.response.status_code + hint = ("Check --token: Logseq rejected it." if status in (401, 403) + else None) + fail( + f"HTTP {status} - {e.response.text}", + as_json=as_json, + reason="http_error", + status_code=status, + **({"hint": hint} if hint else {}), + ) + except DatalogQueryError as e: + # Not a transport error: the connection is healthy, Logseq rejected + # the query itself. A distinct reason keeps agents from running + # doctor (which reports OK) and falling back to the filesystem. + fail( + str(e), + as_json=as_json, + reason="datalog_query_failed", + query=e.query, + ) + except ConfigError as e: + # Nothing was sent and nothing is wrong with Logseq: a setting that + # describes the user's graph is missing or their config is broken. + # Its own reason keeps an agent from retrying or blaming the + # connection; the message names the setting and the file. + fail( + str(e), + as_json=as_json, + reason="config_error", + ) + except InvalidKeywordError as e: + # The connection is healthy and no query was sent; the input was + # rejected before building. A distinct reason keeps this out of the + # "connection down" path an agent would otherwise take. + fail( + str(e), + as_json=as_json, + reason="invalid_property_key", + ) + return wrapper + + +def output(data, as_json: bool, human_formatter=None): + """Output data as JSON or human-readable text.""" + if as_json: + click.echo(json.dumps(data, indent=2, default=str)) + elif human_formatter: + click.echo(human_formatter(data)) + else: + click.echo(data) + + +def fail(message: str, as_json: bool = False, exit_code: int = 1, **fields): + """Report an error and exit with ``exit_code`` (never returns). + + Errors always go to **stderr**, never stdout — stdout stays reserved for + payload, so a caller parsing stdout as JSON is never handed an error object + where data was expected. With ``--json`` the error is emitted as a JSON + object (``{"error": ..., ...fields}``) so agents can parse it structurally + instead of scraping prose; without it, a plain ``Error: ...`` line. + + ``fields`` adds context keys (e.g. ``id=...``, ``page=...``) to the JSON form. + """ + if as_json: + payload = {"error": message, **fields} + click.echo(json.dumps(payload, indent=2, default=str), err=True) + else: + click.echo(f"Error: {message}", err=True) + sys.exit(exit_code) From 0fa30c43407107340e8b0757df6368f96f97d2c5 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:15:56 +0200 Subject: [PATCH 07/25] Drop the underscore on the nine names other modules will import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 001 gives render.py, group.py and nine command modules their own files. These nine names are read from outside the module that defines them, and a private name imported from three other modules is not private — the underscore would misdescribe it. Nothing moves here. A rename and a move in one commit is two changes with one diff that shows neither: git log --follow and blame lose the thread, and a reviewer cannot tell a renamed line from a moved one. Keeping them apart is what lets every later commit in the series be a pure move. _resolve_single_ref keeps its underscore: only resolve_refs_in_blocks calls it, inside the same module. tests/test_version.py follows the rename in this commit because it imports the name; its module changes in the step that moves resolve_version to group.py. --- logseq_cli/cli.py | 72 ++++++++++++++++----------------- tests/test_backlinks_context.py | 2 +- tests/test_version.py | 6 +-- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index fa4eb73..6e1b1fe 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -132,7 +132,7 @@ def _word_pattern(words) -> "re.Pattern[str]": return re.compile("|".join(parts), re.IGNORECASE) -_BLOCK_REF_RE = re.compile(r'\(\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)\)') +BLOCK_REF_RE = re.compile(r'\(\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)\)') _TODO_MARKERS = {"TODO", "DOING", "DONE", "LATER", "NOW", "CANCELED", "WAIT", "WAITING"} # A text replacement must skip property lines: rewriting an id:: line breaks # every ((block-ref)) to that block, irreversibly. Regex shared via helpers. @@ -167,7 +167,7 @@ def _resolve_single_ref(api, uuid: str, dead: list = None) -> str: return f"(({uuid}))" -def _resolve_refs_in_blocks(api, blocks: list, dead: list = None) -> None: +def resolve_refs_in_blocks(api, blocks: list, dead: list = None) -> None: """Recursively resolve ((uuid)) references in block content, in-place. ``dead`` collects the uuids whose target could not be read, in first-seen @@ -176,12 +176,12 @@ def _resolve_refs_in_blocks(api, blocks: list, dead: list = None) -> None: for block in blocks: content = block.get("content", "") if content and "((" in content: - block["content"] = _BLOCK_REF_RE.sub( + block["content"] = BLOCK_REF_RE.sub( lambda m: _resolve_single_ref(api, m.group(1), dead), content ) children = block.get("children", []) if children: - _resolve_refs_in_blocks(api, children, dead) + resolve_refs_in_blocks(api, children, dead) def _swap_todo_marker(content: str, new_status: str) -> str: @@ -193,7 +193,7 @@ def _swap_todo_marker(content: str, new_status: str) -> str: return f"{new_status} {content}" -def _resolve_version() -> str: +def resolve_version() -> str: """Single source of truth for the CLI version. Reads pyproject.toml when running from a source checkout (the authoritative @@ -216,7 +216,7 @@ def _resolve_version() -> str: @click.group() -@click.version_option(version=_resolve_version(), prog_name="logseq-cli") +@click.version_option(version=resolve_version(), prog_name="logseq-cli") @click.option("--host", default=None, help="Logseq API host (default: 127.0.0.1)") @click.option("--port", default=None, help="Logseq API port (default: 12315)") @click.option("--token", default=None, help="Logseq API Bearer token") @@ -260,7 +260,7 @@ def get_all_pages(ctx, as_json): click.echo(name) -def _extract_backlink_names(refs) -> list: +def extract_backlink_names(refs) -> list: """Extract sorted page names from getPageLinkedReferences response. The native API returns a list of [page_dict, [block, ...]] pairs. @@ -284,7 +284,7 @@ def _extract_backlink_context(refs, limit: int) -> list: ``getPageLinkedReferences`` already answers ``[page, [block, ...]]`` pairs, so the blocks arrive with the same call that yields the names — no second - read. ``_extract_backlink_names`` keeps only the name; this keeps both. + read. ``extract_backlink_names`` keeps only the name; this keeps both. ``limit`` caps the blocks kept per page and the remainder is reported as ``withheld``, the same bargain the other reads make: a page mentioned fifty @@ -310,7 +310,7 @@ def _extract_backlink_context(refs, limit: int) -> list: content = (block.get("content") or "").strip() # A properties block is the linking page's own metadata; it holds no # mention and would read as context that is not there. - if not content or _is_properties_block(content): + if not content or is_properties_block(content): continue blocks.append({"uuid": block.get("uuid", ""), "content": content}) kept = blocks[:limit] if limit else blocks @@ -324,13 +324,13 @@ def _extract_backlink_context(refs, limit: int) -> list: return sorted(entries, key=lambda e: e["page"]) -def _is_properties_block(content: str) -> bool: +def is_properties_block(content: str) -> bool: """Check if block content is a Logseq properties block (key:: value lines).""" lines = content.strip().split("\n") return all(re.match(r"^[\w-]+::", line) for line in lines if line.strip()) -def _blocks_to_markdown(blocks, indent=0): +def blocks_to_markdown(blocks, indent=0): """Convert block tree to Logseq-compatible markdown. Properties blocks (top-level, all lines match 'key:: value') are rendered @@ -341,23 +341,23 @@ def _blocks_to_markdown(blocks, indent=0): for block in blocks: content = block.get("content", "") if content: - if indent == 0 and _is_properties_block(content): + if indent == 0 and is_properties_block(content): # Properties block: no bullet prefix, matches Logseq file format lines.append(content) else: lines.append(f"{prefix}- {content}") children = block.get("children", []) if children: - lines.append(_blocks_to_markdown(children, indent + 1)) + lines.append(blocks_to_markdown(children, indent + 1)) return "\n".join(lines) -def _blocks_with_ids(blocks, indent=0): +def blocks_with_ids(blocks, indent=0): """Render block tree as ``\\t\\t`` lines. Allows downstream tools to extract a block UUID without parsing JSON. Indentation is encoded as a run of tab characters whose length matches - the depth (matching ``_blocks_to_markdown``). + the depth (matching ``blocks_to_markdown``). """ lines = [] indent_str = "\t" * indent @@ -368,11 +368,11 @@ def _blocks_with_ids(blocks, indent=0): lines.append(f"{uuid}\t{indent_str}\t{content}") children = block.get("children", []) if children: - lines.append(_blocks_with_ids(children, indent + 1)) + lines.append(blocks_with_ids(children, indent + 1)) return "\n".join(lines) -def _extract_section(blocks, heading_text): +def extract_section(blocks, heading_text): """Return the block matching heading_text (with its children), searched recursively. Uses :func:`normalize_heading` so renderer macros (e.g. ``{{renderer :todomaster}}``) @@ -385,7 +385,7 @@ def _extract_section(blocks, heading_text): return [block] children = block.get("children", []) if children: - result = _extract_section(children, heading_text) + result = extract_section(children, heading_text) if result: return result return [] @@ -433,15 +433,15 @@ def _fetch_one(page_name): else: try: refs = api.get_page_linked_references(page_name) - backlinks = _extract_backlink_names(refs) + backlinks = extract_backlink_names(refs) except Exception: backlinks = find_backlinks(api, page_name) if heading and blocks: - blocks = _extract_section(blocks, heading) + blocks = extract_section(blocks, heading) if not blocks: click.echo(f"Warning: heading '{heading}' not found in '{page_name}'", err=True) if resolve_refs and blocks: - _resolve_refs_in_blocks(api, blocks, dead_refs) + resolve_refs_in_blocks(api, blocks, dead_refs) return {"page": page_name, "blocks": blocks, "backlinks": backlinks} results = [_fetch_one(p) for p in page] @@ -457,7 +457,7 @@ def _fetch_one(page_name): result["dead_refs"] = in_this if not resolve_refs: - total_refs = sum(_count_unresolved_refs(r.get("blocks") or []) for r in results) + total_refs = sum(count_unresolved_refs(r.get("blocks") or []) for r in results) if total_refs > 0: click.echo( f"⚠️ {total_refs} unresolved block-ref(s) in output — " @@ -486,9 +486,9 @@ def _fetch_one(page_name): placeholder = "(page does not exist)" if absent else "(empty page)" if with_ids: click.echo(f"=== {p} ===\n") - click.echo(_blocks_with_ids(blocks) if blocks else placeholder) + click.echo(blocks_with_ids(blocks) if blocks else placeholder) elif output_format == "markdown": - click.echo(_blocks_to_markdown(blocks) if blocks else placeholder) + click.echo(blocks_to_markdown(blocks) if blocks else placeholder) else: click.echo(f"=== {p} ===\n") click.echo(process_blocks(blocks) if blocks else placeholder) @@ -756,7 +756,7 @@ def _fetch_one(page_name): return [] if with_context: return _extract_backlink_context(refs, limit) - return _extract_backlink_names(refs) + return extract_backlink_names(refs) except (ConnectionError, requests.exceptions.ConnectionError, requests.exceptions.Timeout): click.echo("Native backlinks API unavailable, using brute-force scan...", err=True) return find_backlinks(api, page_name) @@ -877,16 +877,16 @@ def get_journal_summary(ctx, date_range, no_content, as_json): click.echo(f" {topic}: {count}") -def _count_unresolved_refs(blocks) -> int: +def count_unresolved_refs(blocks) -> int: """Recursively count ((uuid)) patterns in block content.""" count = 0 for block in blocks: content = block.get("content", "") if content and "((" in content: - count += len(_BLOCK_REF_RE.findall(content)) + count += len(BLOCK_REF_RE.findall(content)) children = block.get("children", []) if children: - count += _count_unresolved_refs(children) + count += count_unresolved_refs(children) return count @@ -984,9 +984,9 @@ def _fetch_one(target): try: blocks = api.get_page_blocks_tree(page_name) if heading and blocks: - blocks = _extract_section(blocks, heading) + blocks = extract_section(blocks, heading) if resolve_refs and blocks: - _resolve_refs_in_blocks(api, blocks) + resolve_refs_in_blocks(api, blocks) return { "date": d.strftime("%Y-%m-%d"), "page": page_name, @@ -1024,7 +1024,7 @@ def _fetch_one(target): ) if not resolve_refs: - total_refs = sum(_count_unresolved_refs(e.get("blocks", [])) for e in entries) + total_refs = sum(count_unresolved_refs(e.get("blocks", [])) for e in entries) if total_refs > 0: click.echo( f"⚠️ {total_refs} unresolved block-ref(s) in output — " @@ -1042,7 +1042,7 @@ def _fetch_one(target): if err: click.echo(f"!! ERROR: {err}\n") elif entry["blocks"]: - click.echo(_blocks_to_markdown(entry["blocks"])) + click.echo(blocks_to_markdown(entry["blocks"])) else: click.echo("(empty)\n") else: @@ -4014,7 +4014,7 @@ def set_todo_status(ctx, block_id, content, page, status, follow_refs, dry_run, # --follow-refs: if block content is just a ((uuid)) reference, update the referenced block if follow_refs: stripped = old_content.strip() - ref_match = _BLOCK_REF_RE.fullmatch(stripped) + ref_match = BLOCK_REF_RE.fullmatch(stripped) if ref_match: ref_uuid = ref_match.group(1) ref_block = api.get_block(ref_uuid, include_children=False) @@ -4346,7 +4346,7 @@ def rename_page(ctx, page, new_name, dry_run, as_json): referencing = None try: refs = api.get_page_linked_references(page) - referencing = _extract_backlink_names(refs) if refs else [] + referencing = extract_backlink_names(refs) if refs else [] except Exception as e: click.echo(f"Warning: could not read backlinks ({e}); " f"reference count unknown", err=True) @@ -4746,7 +4746,7 @@ def _collect(tree): # Inbound links via native API try: refs = api.get_page_linked_references(page) - inbound = _extract_backlink_names(refs) + inbound = extract_backlink_names(refs) except Exception: inbound = [] @@ -5259,7 +5259,7 @@ def add(name, ok, detail): result = { "healthy": healthy, "endpoint": api.base_url, - "version": _resolve_version(), + "version": resolve_version(), "checks": checks, } if graph: diff --git a/tests/test_backlinks_context.py b/tests/test_backlinks_context.py index 38484b8..239b35c 100644 --- a/tests/test_backlinks_context.py +++ b/tests/test_backlinks_context.py @@ -1,7 +1,7 @@ """get-backlinks --with-context: which block does the linking, not just which page. The linking blocks already arrive in the API response — getPageLinkedReferences -answers ``[page, [block, ...]]`` pairs — and ``_extract_backlink_names`` drops +answers ``[page, [block, ...]]`` pairs — and ``extract_backlink_names`` drops everything but the name. A caller that wants to know *why* a page links back has to fetch and search each page again, which is the read the response had already paid for. diff --git a/tests/test_version.py b/tests/test_version.py index ad011ae..726e88f 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -8,7 +8,7 @@ from click.testing import CliRunner -from logseq_cli.cli import cli, _resolve_version +from logseq_cli.cli import cli, resolve_version _PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" @@ -22,7 +22,7 @@ def _pyproject_version(): def test_resolve_version_matches_pyproject(): - assert _resolve_version() == _pyproject_version() + assert resolve_version() == _pyproject_version() def test_cli_version_flag_reports_pyproject_version(): @@ -32,4 +32,4 @@ def test_cli_version_flag_reports_pyproject_version(): def test_version_looks_like_semver(): - assert re.match(r"^\d+\.\d+\.\d+", _resolve_version()) + assert re.match(r"^\d+\.\d+\.\d+", resolve_version()) From 6d1bdb918dcdf8ea794202c6f2c8082d82389002 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:16:59 +0200 Subject: [PATCH 08/25] Move the block rendering helpers into logseq_cli/render.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine symbols, unchanged: the two reference resolvers, the four that render blocks to text or Markdown, the section extractor, the properties predicate and BLOCK_REF_RE. cli.py imports them back, so no caller moves yet. pages, journal and todos all read these, which is why they get their own module rather than living with whichever command module happens to use them most. _resolve_single_ref comes along and keeps its underscore — it is called only from resolve_refs_in_blocks, in the same file. The module needs exactly two imports, read off the moved code rather than guessed: re, and normalize_heading from helpers. Suite 833, both help baselines diff empty, audit script exit 0. --- logseq_cli/cli.py | 131 ++----------------------------------- logseq_cli/render.py | 151 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 126 deletions(-) create mode 100644 logseq_cli/render.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 6e1b1fe..e00ad55 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -75,6 +75,11 @@ count_blocks, ) from logseq_cli.output import fail, handle_connection_error, output +from logseq_cli.render import ( + BLOCK_REF_RE, blocks_to_markdown, blocks_with_ids, count_unresolved_refs, + extract_backlink_names, extract_section, is_properties_block, + resolve_refs_in_blocks, +) @@ -132,7 +137,6 @@ def _word_pattern(words) -> "re.Pattern[str]": return re.compile("|".join(parts), re.IGNORECASE) -BLOCK_REF_RE = re.compile(r'\(\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)\)') _TODO_MARKERS = {"TODO", "DOING", "DONE", "LATER", "NOW", "CANCELED", "WAIT", "WAITING"} # A text replacement must skip property lines: rewriting an id:: line breaks # every ((block-ref)) to that block, irreversibly. Regex shared via helpers. @@ -142,46 +146,8 @@ def _word_pattern(words) -> "re.Pattern[str]": FIND_BLOCK_CHILDREN_LIMIT = 25 -def _resolve_single_ref(api, uuid: str, dead: list = None) -> str: - """Resolve one block UUID to its content text. Returns UUID unchanged on failure. - - A failed lookup means the target is gone — Logseq answers ``null`` for a - deleted block. The fallback then renders the ref exactly as an unresolved - one, so two different things end up spelled the same way in the output. - ``dead`` collects those uuids so the caller can say which is which. - """ - try: - block = api.get_block(uuid, include_children=False) - if block: - ref_content = (block.get("content") or "").strip() - page_info = block.get("page") or {} - page_name = "" - if isinstance(page_info, dict): - page_name = page_info.get("originalName") or page_info.get("name") or "" - source = f" ↳ {page_name}" if page_name else "" - return f"{ref_content}{source}" - except Exception: - pass - if dead is not None and uuid not in dead: - dead.append(uuid) - return f"(({uuid}))" -def resolve_refs_in_blocks(api, blocks: list, dead: list = None) -> None: - """Recursively resolve ((uuid)) references in block content, in-place. - - ``dead`` collects the uuids whose target could not be read, in first-seen - order, so a caller can report them without walking the output again. - """ - for block in blocks: - content = block.get("content", "") - if content and "((" in content: - block["content"] = BLOCK_REF_RE.sub( - lambda m: _resolve_single_ref(api, m.group(1), dead), content - ) - children = block.get("children", []) - if children: - resolve_refs_in_blocks(api, children, dead) def _swap_todo_marker(content: str, new_status: str) -> str: @@ -260,23 +226,6 @@ def get_all_pages(ctx, as_json): click.echo(name) -def extract_backlink_names(refs) -> list: - """Extract sorted page names from getPageLinkedReferences response. - - The native API returns a list of [page_dict, [block, ...]] pairs. - We extract the page name from each pair and return a sorted list. - """ - if not refs or not isinstance(refs, list): - return [] - names = [] - for entry in refs: - if isinstance(entry, (list, tuple)) and len(entry) >= 1: - page_info = entry[0] - if isinstance(page_info, dict): - name = page_info.get("originalName") or page_info.get("name", "") - if name: - names.append(name) - return sorted(names) def _extract_backlink_context(refs, limit: int) -> list: @@ -324,71 +273,12 @@ def _extract_backlink_context(refs, limit: int) -> list: return sorted(entries, key=lambda e: e["page"]) -def is_properties_block(content: str) -> bool: - """Check if block content is a Logseq properties block (key:: value lines).""" - lines = content.strip().split("\n") - return all(re.match(r"^[\w-]+::", line) for line in lines if line.strip()) - - -def blocks_to_markdown(blocks, indent=0): - """Convert block tree to Logseq-compatible markdown. - - Properties blocks (top-level, all lines match 'key:: value') are rendered - without bullet prefix to match Logseq's on-disk format. - """ - lines = [] - prefix = "\t" * indent - for block in blocks: - content = block.get("content", "") - if content: - if indent == 0 and is_properties_block(content): - # Properties block: no bullet prefix, matches Logseq file format - lines.append(content) - else: - lines.append(f"{prefix}- {content}") - children = block.get("children", []) - if children: - lines.append(blocks_to_markdown(children, indent + 1)) - return "\n".join(lines) -def blocks_with_ids(blocks, indent=0): - """Render block tree as ``\\t\\t`` lines. - Allows downstream tools to extract a block UUID without parsing JSON. - Indentation is encoded as a run of tab characters whose length matches - the depth (matching ``blocks_to_markdown``). - """ - lines = [] - indent_str = "\t" * indent - for block in blocks: - uuid = block.get("uuid") or "" - content = block.get("content", "") - if content: - lines.append(f"{uuid}\t{indent_str}\t{content}") - children = block.get("children", []) - if children: - lines.append(blocks_with_ids(children, indent + 1)) - return "\n".join(lines) -def extract_section(blocks, heading_text): - """Return the block matching heading_text (with its children), searched recursively. - Uses :func:`normalize_heading` so renderer macros (e.g. ``{{renderer :todomaster}}``) - and whitespace variations on the stored block do not prevent a match. - """ - target = normalize_heading(heading_text) - for block in blocks: - content = block.get("content") or "" - if normalize_heading(content) == target: - return [block] - children = block.get("children", []) - if children: - result = extract_section(children, heading_text) - if result: - return result - return [] # --------------------------------------------------------------------------- @@ -877,17 +767,6 @@ def get_journal_summary(ctx, date_range, no_content, as_json): click.echo(f" {topic}: {count}") -def count_unresolved_refs(blocks) -> int: - """Recursively count ((uuid)) patterns in block content.""" - count = 0 - for block in blocks: - content = block.get("content", "") - if content and "((" in content: - count += len(BLOCK_REF_RE.findall(content)) - children = block.get("children", []) - if children: - count += count_unresolved_refs(children) - return count # --------------------------------------------------------------------------- diff --git a/logseq_cli/render.py b/logseq_cli/render.py new file mode 100644 index 0000000..5053d80 --- /dev/null +++ b/logseq_cli/render.py @@ -0,0 +1,151 @@ +"""Turning blocks into text, and resolving the references inside them. + +Six of these render blocks to text or Markdown; resolve_refs_in_blocks and +count_unresolved_refs do not — they resolve block references against the graph +and count the dead ones. BLOCK_REF_RE is here although todos.py uses it to +recognise a reference rather than to display one. The name is approximate and +kept: splitting along that line yields two files of about sixty lines plus an +edge between them, which is movement without the gain. + +They live apart from the commands because pages, journal and todos all read +them, and a helper three modules import is not private to any of them. +""" +import re + +from logseq_cli.helpers import normalize_heading + + +BLOCK_REF_RE = re.compile(r'\(\(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)\)') + +def _resolve_single_ref(api, uuid: str, dead: list = None) -> str: + """Resolve one block UUID to its content text. Returns UUID unchanged on failure. + + A failed lookup means the target is gone — Logseq answers ``null`` for a + deleted block. The fallback then renders the ref exactly as an unresolved + one, so two different things end up spelled the same way in the output. + ``dead`` collects those uuids so the caller can say which is which. + """ + try: + block = api.get_block(uuid, include_children=False) + if block: + ref_content = (block.get("content") or "").strip() + page_info = block.get("page") or {} + page_name = "" + if isinstance(page_info, dict): + page_name = page_info.get("originalName") or page_info.get("name") or "" + source = f" ↳ {page_name}" if page_name else "" + return f"{ref_content}{source}" + except Exception: + pass + if dead is not None and uuid not in dead: + dead.append(uuid) + return f"(({uuid}))" + +def resolve_refs_in_blocks(api, blocks: list, dead: list = None) -> None: + """Recursively resolve ((uuid)) references in block content, in-place. + + ``dead`` collects the uuids whose target could not be read, in first-seen + order, so a caller can report them without walking the output again. + """ + for block in blocks: + content = block.get("content", "") + if content and "((" in content: + block["content"] = BLOCK_REF_RE.sub( + lambda m: _resolve_single_ref(api, m.group(1), dead), content + ) + children = block.get("children", []) + if children: + resolve_refs_in_blocks(api, children, dead) + +def extract_backlink_names(refs) -> list: + """Extract sorted page names from getPageLinkedReferences response. + + The native API returns a list of [page_dict, [block, ...]] pairs. + We extract the page name from each pair and return a sorted list. + """ + if not refs or not isinstance(refs, list): + return [] + names = [] + for entry in refs: + if isinstance(entry, (list, tuple)) and len(entry) >= 1: + page_info = entry[0] + if isinstance(page_info, dict): + name = page_info.get("originalName") or page_info.get("name", "") + if name: + names.append(name) + return sorted(names) + +def is_properties_block(content: str) -> bool: + """Check if block content is a Logseq properties block (key:: value lines).""" + lines = content.strip().split("\n") + return all(re.match(r"^[\w-]+::", line) for line in lines if line.strip()) + +def blocks_to_markdown(blocks, indent=0): + """Convert block tree to Logseq-compatible markdown. + + Properties blocks (top-level, all lines match 'key:: value') are rendered + without bullet prefix to match Logseq's on-disk format. + """ + lines = [] + prefix = "\t" * indent + for block in blocks: + content = block.get("content", "") + if content: + if indent == 0 and is_properties_block(content): + # Properties block: no bullet prefix, matches Logseq file format + lines.append(content) + else: + lines.append(f"{prefix}- {content}") + children = block.get("children", []) + if children: + lines.append(blocks_to_markdown(children, indent + 1)) + return "\n".join(lines) + +def blocks_with_ids(blocks, indent=0): + """Render block tree as ``\\t\\t`` lines. + + Allows downstream tools to extract a block UUID without parsing JSON. + Indentation is encoded as a run of tab characters whose length matches + the depth (matching ``blocks_to_markdown``). + """ + lines = [] + indent_str = "\t" * indent + for block in blocks: + uuid = block.get("uuid") or "" + content = block.get("content", "") + if content: + lines.append(f"{uuid}\t{indent_str}\t{content}") + children = block.get("children", []) + if children: + lines.append(blocks_with_ids(children, indent + 1)) + return "\n".join(lines) + +def extract_section(blocks, heading_text): + """Return the block matching heading_text (with its children), searched recursively. + + Uses :func:`normalize_heading` so renderer macros (e.g. ``{{renderer :todomaster}}``) + and whitespace variations on the stored block do not prevent a match. + """ + target = normalize_heading(heading_text) + for block in blocks: + content = block.get("content") or "" + if normalize_heading(content) == target: + return [block] + children = block.get("children", []) + if children: + result = extract_section(children, heading_text) + if result: + return result + return [] + +def count_unresolved_refs(blocks) -> int: + """Recursively count ((uuid)) patterns in block content.""" + count = 0 + for block in blocks: + content = block.get("content", "") + if content and "((" in content: + count += len(BLOCK_REF_RE.findall(content)) + children = block.get("children", []) + if children: + count += count_unresolved_refs(children) + return count From 6643d6aadbf74c847007bda11143f1677818a80f Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:18:19 +0200 Subject: [PATCH 09/25] Move the click group into logseq_cli/group.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_version and the group callback go across unchanged. cli.py imports both back, so it still exposes `cli` and nothing that decorates against the group has to move yet. This is the commit that moves the API constructor out of cli.py, so the suite follows in the same commit: 220 occurrences of logseq_cli.cli.LogseqAPI across 38 files become logseq_cli.group.LogseqAPI. Counted before and after — a sed over 38 files reports nothing on its own — and both counts are 220 with no occurrence of the old target left. mock.patch replaces a name in the namespace it is given, so a patch left on cli.py would have replaced a re-exported name nobody calls and built a real client against a mock server. Probed rather than assumed: pointing one file's patches back at logseq_cli.cli turns 8 of its 12 tests red, so the new target is the one doing the work. tests/test_version.py changes module here (its rename happened in the previous commit): resolve_version now comes from logseq_cli.group. Left on cli.py it would have stayed green until the meta.py commit and then failed with an ImportError at a place no table names. resolve_version stays one level below the repository root because it finds pyproject.toml relative to its own file; group.py is that level. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 41 +-------------- logseq_cli/group.py | 66 ++++++++++++++++++++++++ tests/test_add_note_content_heading.py | 6 +-- tests/test_agent_contract.py | 16 +++--- tests/test_analyze_journal_patterns.py | 2 +- tests/test_backlinks_context.py | 4 +- tests/test_block_properties.py | 26 +++++----- tests/test_config_integration.py | 48 ++++++++--------- tests/test_content_file.py | 4 +- tests/test_create_page_exists.py | 2 +- tests/test_datalog_quoting.py | 22 ++++---- tests/test_dead_block_refs.py | 2 +- tests/test_destructive_dry_run.py | 2 +- tests/test_doctor.py | 2 +- tests/test_dry_run_coverage.py | 6 +-- tests/test_empty_content_guard.py | 16 +++--- tests/test_find_block_children.py | 16 +++--- tests/test_find_block_limit.py | 2 +- tests/test_flat_write_paths.py | 28 +++++----- tests/test_get_backlinks_batch.py | 2 +- tests/test_get_page_features.py | 28 +++++----- tests/test_get_page_missing.py | 2 +- tests/test_get_properties_lookup.py | 2 +- tests/test_get_todos.py | 62 +++++++++++----------- tests/test_init_config.py | 2 +- tests/test_insert_block_tree.py | 38 +++++++------- tests/test_journal_bounded_output.py | 2 +- tests/test_journal_range_parallel.py | 14 ++--- tests/test_journal_uuid_and_alias.py | 10 ++-- tests/test_keep_block_ids.py | 2 +- tests/test_move_block.py | 22 ++++---- tests/test_numeric_option_bounds.py | 2 +- tests/test_property_list_values.py | 4 +- tests/test_read_only_commands_smoke.py | 8 +-- tests/test_replace_text_properties.py | 8 +-- tests/test_search_pages_matching.py | 2 +- tests/test_silent_write_failure.py | 2 +- tests/test_suggest_connections_bounds.py | 2 +- tests/test_todos_due_dates.py | 2 +- tests/test_version.py | 3 +- tests/test_where_content.py | 20 +++---- 41 files changed, 289 insertions(+), 261 deletions(-) create mode 100644 logseq_cli/group.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index e00ad55..6f82c03 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -74,6 +74,7 @@ is_journal_date, count_blocks, ) +from logseq_cli.group import cli, resolve_version from logseq_cli.output import fail, handle_connection_error, output from logseq_cli.render import ( BLOCK_REF_RE, blocks_to_markdown, blocks_with_ids, count_unresolved_refs, @@ -159,48 +160,8 @@ def _swap_todo_marker(content: str, new_status: str) -> str: return f"{new_status} {content}" -def resolve_version() -> str: - """Single source of truth for the CLI version. - - Reads pyproject.toml when running from a source checkout (the authoritative - value during development), else falls back to the installed package metadata. - Avoids the stale hardcoded-version drift that previously made --version lie. - """ - pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" - try: - for line in pyproject.read_text(encoding="utf-8").splitlines(): - stripped = line.strip() - if stripped.startswith("version"): - # version = "0.5.0" - return stripped.split("=", 1)[1].strip().strip('"').strip("'") - except OSError: - pass - try: - return _pkg_version("logseq-cli") - except PackageNotFoundError: - return "unknown" -@click.group() -@click.version_option(version=resolve_version(), prog_name="logseq-cli") -@click.option("--host", default=None, help="Logseq API host (default: 127.0.0.1)") -@click.option("--port", default=None, help="Logseq API port (default: 12315)") -@click.option("--token", default=None, help="Logseq API Bearer token") -@click.option("--no-cache", "no_cache", is_flag=True, help="Bypass the in-memory read cache for this invocation") -@click.pass_context -def cli(ctx, host, port, token, no_cache): - """CLI for Logseq knowledge graph - pages, journals, blocks, search, and graph analysis.""" - ctx.ensure_object(dict) - try: - api = LogseqAPI(host=host, port=port, token=token) - except InvalidPortError as e: - # Raised before any request. A traceback here would be worse than the - # unchecked value was: the group callback runs ahead of every command, - # so this is the first thing a user sees, including under --json. - raise click.ClickException(str(e)) from None - if no_cache: - api.cache_enabled = False - ctx.obj["api"] = api # --------------------------------------------------------------------------- diff --git a/logseq_cli/group.py b/logseq_cli/group.py new file mode 100644 index 0000000..8d49d8d --- /dev/null +++ b/logseq_cli/group.py @@ -0,0 +1,66 @@ +"""The click group: global options, and the one place the API client is built. + +Every command module decorates against the `cli` group defined here, so this +module must not import from `commands/` — that is what keeps registration a +one-way edge and the imports free of cycles. + +It is also the only module that names LogseqAPI, which is why the test suite +patches `logseq_cli.group.LogseqAPI`: mock.patch replaces a name in the +namespace it is given, and this is the namespace the constructor is read from. + +resolve_version() stays at this level deliberately. It finds pyproject.toml +through `Path(__file__).resolve().parent.parent`, so one directory deeper it +would silently fall back to the installed metadata instead — and in an editable +install that agrees with pyproject until the next version bump, which is to say +the test would keep passing while the answer went stale. +""" +from importlib.metadata import version as _pkg_version, PackageNotFoundError +from pathlib import Path + +import click + +from logseq_cli.api import LogseqAPI, InvalidPortError + + +def resolve_version() -> str: + """Single source of truth for the CLI version. + + Reads pyproject.toml when running from a source checkout (the authoritative + value during development), else falls back to the installed package metadata. + Avoids the stale hardcoded-version drift that previously made --version lie. + """ + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + try: + for line in pyproject.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped.startswith("version"): + # version = "0.5.0" + return stripped.split("=", 1)[1].strip().strip('"').strip("'") + except OSError: + pass + try: + return _pkg_version("logseq-cli") + except PackageNotFoundError: + return "unknown" + + +@click.group() +@click.version_option(version=resolve_version(), prog_name="logseq-cli") +@click.option("--host", default=None, help="Logseq API host (default: 127.0.0.1)") +@click.option("--port", default=None, help="Logseq API port (default: 12315)") +@click.option("--token", default=None, help="Logseq API Bearer token") +@click.option("--no-cache", "no_cache", is_flag=True, help="Bypass the in-memory read cache for this invocation") +@click.pass_context +def cli(ctx, host, port, token, no_cache): + """CLI for Logseq knowledge graph - pages, journals, blocks, search, and graph analysis.""" + ctx.ensure_object(dict) + try: + api = LogseqAPI(host=host, port=port, token=token) + except InvalidPortError as e: + # Raised before any request. A traceback here would be worse than the + # unchecked value was: the group callback runs ahead of every command, + # so this is the first thing a user sees, including under --json. + raise click.ClickException(str(e)) from None + if no_cache: + api.cache_enabled = False + ctx.obj["api"] = api diff --git a/tests/test_add_note_content_heading.py b/tests/test_add_note_content_heading.py index 7fd24ca..39336fa 100644 --- a/tests/test_add_note_content_heading.py +++ b/tests/test_add_note_content_heading.py @@ -31,7 +31,7 @@ def test_existing_heading_appends_under_it(self): {"content": "## Notes", "uuid": "heading-uuid"}, ]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "add-note-content", "--page", "Foo", @@ -54,7 +54,7 @@ def test_missing_heading_creates_heading_then_appends(self): api.append_block_in_page.return_value = {"uuid": "new-heading"} api.insert_block.return_value = {"uuid": "child"} runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "add-note-content", "--page", "Foo", @@ -81,7 +81,7 @@ def test_hierarchical_content_under_heading(self): uuids=["parent-uuid", "child-uuid"], ) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "add-note-content", "--page", "Foo", diff --git a/tests/test_agent_contract.py b/tests/test_agent_contract.py index cea3bc6..ee477a3 100644 --- a/tests/test_agent_contract.py +++ b/tests/test_agent_contract.py @@ -31,7 +31,7 @@ class TestTransportErrorsAreStructured: def test_connection_error_is_json_with_reason(self): api = MagicMock() api.get_page.side_effect = requests.ConnectionError() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke(cli, ["get-page", "--name", "X", "--json"]) assert r.exit_code == 1 payload = json.loads(r.stderr) @@ -41,7 +41,7 @@ def test_connection_error_is_json_with_reason(self): def test_auth_error_names_the_token(self): api = MagicMock() api.get_page.side_effect = _http_error(401) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke(cli, ["get-page", "--name", "X", "--json"]) assert r.exit_code == 1 payload = json.loads(r.stderr) @@ -51,7 +51,7 @@ def test_auth_error_names_the_token(self): def test_without_json_the_message_stays_prose(self): api = MagicMock() api.get_page.side_effect = requests.ConnectionError() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke(cli, ["get-page", "--name", "X"]) assert r.exit_code == 1 assert r.stderr.startswith("Error: Cannot connect") @@ -61,7 +61,7 @@ def test_errors_never_reach_stdout(self): """stdout must stay parseable as payload, whatever went wrong.""" api = MagicMock() api.get_page.side_effect = _http_error(500) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke(cli, ["get-page", "--name", "X", "--json"]) assert r.stdout == "" @@ -70,7 +70,7 @@ class TestGetBlockNotFound: def test_unknown_uuid_fails_instead_of_printing_null(self): api = MagicMock() api.get_block.return_value = None - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke(cli, ["get-block", "--id", "nope", "--json"]) assert r.exit_code == 1 assert r.stdout.strip() != "null" @@ -80,7 +80,7 @@ def test_unknown_uuid_fails_instead_of_printing_null(self): def test_known_uuid_still_returns_the_block(self): api = MagicMock() api.get_block.return_value = {"uuid": "u-1", "content": "Text", "children": []} - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke(cli, ["get-block", "--id", "u-1", "--json"]) assert r.exit_code == 0, r.output assert json.loads(r.stdout)["content"] == "Text" @@ -88,7 +88,7 @@ def test_known_uuid_still_returns_the_block(self): def test_text_mode_also_fails(self): api = MagicMock() api.get_block.return_value = None - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke(cli, ["get-block", "--id", "nope"]) assert r.exit_code == 1 assert "not found" in r.stderr.lower() @@ -162,7 +162,7 @@ def test_get_todos_json_still_uses_those_keys(self): """Pins the other half: the example is only right while this holds.""" api = MagicMock() api.datascript_query.return_value = [] - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke(cli, ["get-todos", "--json"]) assert r.exit_code == 0, r.stdout assert set(json.loads(r.stdout)) == {"todos", "count"} diff --git a/tests/test_analyze_journal_patterns.py b/tests/test_analyze_journal_patterns.py index f9a3f86..95b3b5a 100644 --- a/tests/test_analyze_journal_patterns.py +++ b/tests/test_analyze_journal_patterns.py @@ -30,7 +30,7 @@ def run(text, *args, config=None, tmp_path=None): f.write_text(config, encoding="utf-8") env = {"LOGSEQ_CLI_CONFIG": str(f)} with patch.dict(os.environ, env, clear=False), \ - patch("logseq_cli.cli.LogseqAPI", return_value=api), \ + patch("logseq_cli.group.LogseqAPI", return_value=api), \ patch("logseq_cli.cli.get_page_content", return_value=text): return split_runner().invoke( cli, ["--token", "X", "analyze-journal-patterns", diff --git a/tests/test_backlinks_context.py b/tests/test_backlinks_context.py index 239b35c..c28a599 100644 --- a/tests/test_backlinks_context.py +++ b/tests/test_backlinks_context.py @@ -34,7 +34,7 @@ def _refs(page_name): def _run(args, api): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return CliRunner().invoke(cli, args) @@ -127,7 +127,7 @@ def test_negative_limit_is_rejected(self): def test_the_refusal_goes_to_stderr_and_stdout_stays_empty(self): """stdout is payload; a caller piping it into a parser gets nothing else.""" api = _api({"Alice": [("Journal", ["a [[Alice]]"])]}) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = split_runner().invoke(cli, ["get-backlinks", "--name", "Alice", "--with-context", "--limit", "-1"]) assert result.exit_code == 1 diff --git a/tests/test_block_properties.py b/tests/test_block_properties.py index c3351a8..82e240e 100644 --- a/tests/test_block_properties.py +++ b/tests/test_block_properties.py @@ -24,7 +24,7 @@ def test_property_under_heading_sets_on_root_and_returns_uuid(self): {"content": "## Collection", "uuid": "heading-uuid"}, ]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "add-note-content", "--page", "Foo", @@ -49,7 +49,7 @@ def test_property_under_heading_sets_on_root_and_returns_uuid(self): def test_property_append_path_uses_appended_uuid(self): api = _build_api() runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "add-note-content", "--page", "Foo", @@ -65,7 +65,7 @@ def test_property_append_path_uses_appended_uuid(self): def test_invalid_property_fails_before_any_write(self): api = _build_api() runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "add-note-content", "--page", "Foo", @@ -81,7 +81,7 @@ def test_invalid_property_fails_before_any_write(self): def test_numeric_value_is_coerced(self): api = _build_api() runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "add-note-content", "--page", "Foo", @@ -99,7 +99,7 @@ class TestInsertBlockProperties: def test_property_on_appended_block(self): api = _build_api() runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--page", "Foo", @@ -116,7 +116,7 @@ def test_property_on_appended_block(self): def test_invalid_property_fails_before_write(self): api = _build_api() runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--page", "Foo", @@ -144,7 +144,7 @@ def _api(self, properties): def test_properties_are_passed_back(self): api = self._api({"ticket": "ISSUE-42", "owner": ["Bob"]}) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "update-block", "--id", "u-1", "--content", "new"]) assert r.exit_code == 0, r.output @@ -153,7 +153,7 @@ def test_properties_are_passed_back(self): def test_block_without_properties_passes_none(self): api = self._api({}) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "update-block", "--id", "u-1", "--content", "new"]) assert r.exit_code == 0, r.output @@ -161,7 +161,7 @@ def test_block_without_properties_passes_none(self): def test_dry_run_names_what_it_keeps(self): api = self._api({"ticket": "ISSUE-42"}) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "update-block", "--id", "u-1", "--content", "new", "--dry-run"]) assert r.exit_code == 0, r.output @@ -186,7 +186,7 @@ class TestRemovePropertyById: def test_id_targets_that_block(self): api = MagicMock() api.get_block.return_value = {"uuid": "b-9", "content": "x"} - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "remove-property", "--id", "b-9", "--key", "prio"]) assert r.exit_code == 0, r.output @@ -196,7 +196,7 @@ def test_id_targets_that_block(self): def test_page_path_still_uses_the_first_block(self): api = MagicMock() api.get_page_blocks_tree.return_value = [{"uuid": "first"}] - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "remove-property", "--name", "P", "--key", "type"]) assert r.exit_code == 0, r.output @@ -204,7 +204,7 @@ def test_page_path_still_uses_the_first_block(self): def test_exactly_one_selector(self): api = MagicMock() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): both = CliRunner().invoke(cli, [ "remove-property", "--name", "P", "--id", "b", "--key", "k"]) neither = CliRunner().invoke(cli, ["remove-property", "--key", "k"]) @@ -216,7 +216,7 @@ def test_exactly_one_selector(self): def test_missing_block_aborts(self): api = MagicMock() api.get_block.return_value = None - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "remove-property", "--id", "nope", "--key", "k"]) assert r.exit_code == 1 diff --git a/tests/test_config_integration.py b/tests/test_config_integration.py index 2c38dee..f0cdd28 100644 --- a/tests/test_config_integration.py +++ b/tests/test_config_integration.py @@ -91,7 +91,7 @@ def test_configured_namespace_appears_lowercased(self, config_env): """ rec = QueryRecorder(result=[]) with config_env('[graph]\nprojects_namespace = "Projects/"\n'): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "projekte", "--json"]) assert r.exit_code == 0, r.output @@ -103,7 +103,7 @@ def test_configured_namespace_appears_lowercased(self, config_env): def test_english_keyword_uses_the_same_setting(self, config_env): rec = QueryRecorder(result=[]) with config_env('[graph]\nprojects_namespace = "Projects/"\n'): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "projects", "--json"]) assert r.exit_code == 0, r.output @@ -113,7 +113,7 @@ def test_a_different_namespace_is_not_hardcoded(self, config_env): """Guards against the setting being read but a default winning.""" rec = QueryRecorder(result=[]) with config_env('[graph]\nprojects_namespace = "Partners/"\n'): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): split_runner().invoke( cli, ["smart-query", "--request", "projekte", "--json"]) assert '"partners/"' in rec.queries[0] @@ -123,7 +123,7 @@ class TestProjectsWithoutConfigFailsLoud: def test_exit_nonzero_and_nothing_queried(self, config_env): rec = QueryRecorder(result=[]) with config_env(None): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "projekte"]) assert r.exit_code != 0 @@ -133,7 +133,7 @@ def test_exit_nonzero_and_nothing_queried(self, config_env): def test_message_names_the_setting_and_the_section(self, config_env): rec = QueryRecorder(result=[]) with config_env(None): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "projekte"]) msg = r.stderr @@ -145,7 +145,7 @@ def test_no_result_is_printed_on_stdout(self, config_env): "no projects found", which is exactly the confusion to avoid.""" rec = QueryRecorder(result=[]) with config_env(None): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "projekte"]) assert r.stdout == "" @@ -154,7 +154,7 @@ def test_an_empty_value_counts_as_missing(self, config_env): """An empty prefix matches every page, so it must not be accepted.""" rec = QueryRecorder(result=[]) with config_env('[graph]\nprojects_namespace = ""\n'): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "projekte"]) assert r.exit_code != 0 @@ -167,7 +167,7 @@ class TestPersonPropertyReachesTheQuery: def test_property_and_value_both_appear(self, config_env): rec = QueryRecorder(result=[]) with config_env(self.CONFIG): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "personen", "--json"]) assert r.exit_code == 0, r.output @@ -179,7 +179,7 @@ def test_property_and_value_both_appear(self, config_env): def test_a_graphs_own_convention_is_used_verbatim(self, config_env): rec = QueryRecorder(result=[]) with config_env('[graph]\nperson_property = "kind"\nperson_value = "Contact"\n'): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "people", "--json"]) assert r.exit_code == 0, r.output @@ -192,7 +192,7 @@ def test_a_graphs_own_convention_is_used_verbatim(self, config_env): def test_missing_property_fails_loud(self, config_env): rec = QueryRecorder(result=[]) with config_env(None): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "personen"]) assert r.exit_code != 0 @@ -206,7 +206,7 @@ def test_property_without_value_still_fails(self, config_env): query would match every page carrying the property at all.""" rec = QueryRecorder(result=[]) with config_env('[graph]\nperson_property = "type"\n'): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "personen"]) assert r.exit_code != 0 @@ -226,7 +226,7 @@ class TestConfigErrorAsJson: def test_reason_is_config_error(self, config_env, request_text): rec = QueryRecorder(result=[]) with config_env(None): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", request_text, "--json"]) assert r.exit_code != 0 @@ -237,7 +237,7 @@ def test_reason_is_config_error(self, config_env, request_text): def test_the_json_error_text_still_names_the_setting(self, config_env): rec = QueryRecorder(result=[]) with config_env(None): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "projekte", "--json"]) payload = json.loads(r.stderr) @@ -249,7 +249,7 @@ def test_a_broken_config_file_is_a_config_error_too(self, config_env): configure something.""" rec = QueryRecorder(result=[]) with config_env("[graph\nbroken"): - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["smart-query", "--request", "projekte", "--json"]) assert r.exit_code != 0 @@ -270,7 +270,7 @@ class TestHeadingShortcutEndToEnd: def test_shortcut_resolves_to_the_configured_heading(self, config_env): api = _journal_api() with config_env(self.CONFIG): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke( cli, ["add-journal-block", "--content", "Entry", "--under-heading", "tasks"]) @@ -289,7 +289,7 @@ def test_an_existing_heading_is_reused_not_recreated(self, config_env): {"content": "## Tasks", "uuid": "tasks-uuid"}, ] with config_env(self.CONFIG): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke( cli, ["add-journal-block", "--content", "Entry", "--under-heading", "tasks"]) @@ -304,7 +304,7 @@ def test_a_literal_heading_still_works(self, config_env): {"content": "## Notes", "uuid": "notes-uuid"}, ] with config_env(self.CONFIG): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke( cli, ["add-journal-block", "--content", "Entry", "--under-heading", "## Notes"]) @@ -317,7 +317,7 @@ def test_an_unknown_name_is_passed_through_unchanged(self, config_env): redirected write is not.""" api = _journal_api() with config_env(self.CONFIG): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke( cli, ["add-journal-block", "--content", "Entry", "--under-heading", "taskz"]) @@ -328,7 +328,7 @@ def test_an_unknown_name_is_passed_through_unchanged(self, config_env): def test_without_any_config_the_value_is_used_verbatim(self, config_env): api = _journal_api() with config_env(None): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke( cli, ["add-journal-block", "--content", "Entry", "--under-heading", "## Log"]) @@ -345,7 +345,7 @@ def test_env_var_beats_the_config_default(self, config_env): api = _journal_api() with config_env(self.CONFIG): with patch.dict(os.environ, {"LOGSEQ_JOURNAL_HEADING": "## FromEnv"}): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke( cli, ["add-journal-block", "--content", "Entry"]) assert r.exit_code == 0, r.output @@ -355,7 +355,7 @@ def test_env_var_beats_the_config_default(self, config_env): def test_config_default_applies_when_the_env_var_is_unset(self, config_env): api = _journal_api() with config_env(self.CONFIG): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke( cli, ["add-journal-block", "--content", "Entry"]) assert r.exit_code == 0, r.output @@ -365,7 +365,7 @@ def test_explicit_flag_beats_both(self, config_env): api = _journal_api() with config_env(self.CONFIG): with patch.dict(os.environ, {"LOGSEQ_JOURNAL_HEADING": "## FromEnv"}): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke( cli, ["add-journal-block", "--content", "Entry", "--under-heading", "## Explicit"]) @@ -376,7 +376,7 @@ def test_top_level_beats_the_config_default(self, config_env): """--top-level means top level, whatever the file says.""" api = _journal_api() with config_env(self.CONFIG): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke( cli, ["add-journal-block", "--content", "Entry", "--top-level"]) @@ -387,7 +387,7 @@ def test_top_level_beats_the_config_default(self, config_env): def test_nothing_configured_means_top_level(self, config_env): api = _journal_api() with config_env(None): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = split_runner().invoke( cli, ["add-journal-block", "--content", "Entry"]) assert r.exit_code == 0, r.output diff --git a/tests/test_content_file.py b/tests/test_content_file.py index 6cc2c2d..69da741 100644 --- a/tests/test_content_file.py +++ b/tests/test_content_file.py @@ -75,7 +75,7 @@ def api(monkeypatch): mock = fake_api([f"u{i}" for i in range(1, 40)]) mock.graph.children["head"] = [ {"uuid": "fl", "content": "### [[Carol]]", "children": []}] - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kwargs: mock) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kwargs: mock) mock.get_user_configs.return_value = {"preferredDateFormat": "yyyy-MM-dd"} mock.get_page.return_value = {"name": "journal"} mock.get_page_blocks_tree.return_value = [ @@ -527,7 +527,7 @@ def test_end_to_end_through_add_journal_block(self): api.get_page_blocks_tree.return_value = [] api.append_block_in_page.return_value = {"uuid": "u1"} from unittest.mock import patch - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = CliRunner().invoke( cli, ["add-journal-block", "--content-file", "-", "--dry-run"], input="piped entry\n") diff --git a/tests/test_create_page_exists.py b/tests/test_create_page_exists.py index 93471a4..ca3a5ae 100644 --- a/tests/test_create_page_exists.py +++ b/tests/test_create_page_exists.py @@ -24,7 +24,7 @@ def api(): mock = MagicMock() mock.create_page.return_value = {"id": 1, "name": "new page"} mock.append_block_in_page.return_value = {"uuid": "u1"} - with patch("logseq_cli.cli.LogseqAPI", return_value=mock): + with patch("logseq_cli.group.LogseqAPI", return_value=mock): yield mock diff --git a/tests/test_datalog_quoting.py b/tests/test_datalog_quoting.py index ca02087..26d648e 100644 --- a/tests/test_datalog_quoting.py +++ b/tests/test_datalog_quoting.py @@ -271,7 +271,7 @@ def test_get_todos_markers(self): # Markers are a fixed whitelist, but still routed through edn_string, # so the built query carries them as proper string literals. rec = QueryRecorder(result=[]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke(cli, ["get-todos", "--status", "TODO", "--json"]) assert r.exit_code == 0, r.output assert rec.queries, "no query was built" @@ -284,7 +284,7 @@ def test_get_todos_markers(self): def test_smart_query_content_search(self): rec = QueryRecorder(result=[]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): split_runner().invoke(cli, ["smart-query", "--request", INJECTION, "--json"]) assert rec.queries, "no query was built" q = rec.queries[0] @@ -293,7 +293,7 @@ def test_smart_query_content_search(self): def test_smart_query_links_to_lowercases(self): rec = QueryRecorder(result=[]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): split_runner().invoke(cli, ["smart-query", "--request", "links to Alice", "--json"]) assert rec.queries, "no query was built" # The case bug: :block/name is stored lowercased. Must query "alice". @@ -302,7 +302,7 @@ def test_smart_query_links_to_lowercases(self): def test_smart_query_tagged_wraps_hash_in_literal(self): rec = QueryRecorder(result=[]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): split_runner().invoke(cli, ["smart-query", "--request", "tagged foo", "--json"]) assert rec.queries, "no query was built" q = rec.queries[0] @@ -311,7 +311,7 @@ def test_smart_query_tagged_wraps_hash_in_literal(self): def test_query_pages_by_property_value(self): rec = QueryRecorder(result=[]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): split_runner().invoke( cli, ["query-pages-by-property", "--key", "type", @@ -346,7 +346,7 @@ def test_template_query_error_exits_nonzero(self): def test_rejected_property_key_has_its_own_reason(self): rec = QueryRecorder(result=[]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["query-pages-by-property", "--key", "type) ?v] [?p", "--json"]) @@ -359,7 +359,7 @@ def test_rejected_property_key_has_its_own_reason(self): def test_valid_property_key_still_works(self): rec = QueryRecorder(result=[]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["query-pages-by-property", "--key", "type", "--json"]) assert r.exit_code == 0, r.output @@ -374,7 +374,7 @@ class TestPropertyKeyCasing: def test_camelcase_key_query_contains_kebab_form(self): rec = QueryRecorder(result=[]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["query-pages-by-property", "--key", "excludeFromGraphView", "--json"]) @@ -386,7 +386,7 @@ def test_camelcase_key_query_contains_kebab_form(self): def test_kebab_key_still_works(self): rec = QueryRecorder(result=[]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["query-pages-by-property", "--key", "exclude-from-graph-view", "--json"]) @@ -397,7 +397,7 @@ def test_both_forms_are_tried_for_a_camelcase_key(self): """A camelCase key must match both spellings, since a foreign graph might store either. Both appear in the built query.""" rec = QueryRecorder(result=[]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): split_runner().invoke( cli, ["query-pages-by-property", "--key", "techStack", "--json"]) q = rec.queries[0] @@ -410,7 +410,7 @@ def test_value_is_read_despite_kebab_keys_in_pull(self): page = {"name": "P", "original-name": "P", "properties": {"tech-stack": "Python"}} rec = QueryRecorder(result=[[page]]) - with patch("logseq_cli.cli.LogseqAPI", return_value=rec): + with patch("logseq_cli.group.LogseqAPI", return_value=rec): r = split_runner().invoke( cli, ["query-pages-by-property", "--key", "techStack", "--json"]) assert r.exit_code == 0, r.output diff --git a/tests/test_dead_block_refs.py b/tests/test_dead_block_refs.py index 4908281..5984528 100644 --- a/tests/test_dead_block_refs.py +++ b/tests/test_dead_block_refs.py @@ -41,7 +41,7 @@ def get_block(uuid, include_children=True): return None # deleted block: Logseq answers null mock.get_block.side_effect = get_block - with patch("logseq_cli.cli.LogseqAPI", return_value=mock): + with patch("logseq_cli.group.LogseqAPI", return_value=mock): yield mock diff --git a/tests/test_destructive_dry_run.py b/tests/test_destructive_dry_run.py index 3ef153b..9753fcc 100644 --- a/tests/test_destructive_dry_run.py +++ b/tests/test_destructive_dry_run.py @@ -30,7 +30,7 @@ def _assert_no_mutation(api): def api(monkeypatch): """A MagicMock LogseqAPI injected into the CLI context.""" mock = MagicMock() - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kwargs: mock) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kwargs: mock) return mock diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 4abe9ef..ca854e6 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -33,7 +33,7 @@ def api(monkeypatch): mock.host, mock.port = "127.0.0.1", "12315" mock.base_url = "http://127.0.0.1:12315/api" mock.token = "tok" - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kwargs: mock) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kwargs: mock) return mock diff --git a/tests/test_dry_run_coverage.py b/tests/test_dry_run_coverage.py index f43bcda..bc96637 100644 --- a/tests/test_dry_run_coverage.py +++ b/tests/test_dry_run_coverage.py @@ -38,7 +38,7 @@ def _assert_no_mutation(api): def api(): """A MagicMock LogseqAPI injected into the CLI context.""" mock = MagicMock() - with patch("logseq_cli.cli.LogseqAPI", return_value=mock): + with patch("logseq_cli.group.LogseqAPI", return_value=mock): yield mock @@ -485,7 +485,7 @@ def _api(self): return api def _run(self, api, *args): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return split_runner().invoke(cli, ["--token", "X", "add-journal-block", *args]) def test_single_content_does_not_create_the_page(self): @@ -661,7 +661,7 @@ def test_add_journal_entry_dry_run_creates_no_page(self): api = MagicMock() api.get_user_configs.return_value = {"preferredDateFormat": "yyyy-MM-dd"} api.get_page.return_value = None # journal page not there yet - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = CliRunner().invoke( cli, ["add-journal-entry", "--content", "Entry", "--dry-run"]) assert result.exit_code == 0, result.output diff --git a/tests/test_empty_content_guard.py b/tests/test_empty_content_guard.py index e9e4362..541d06c 100644 --- a/tests/test_empty_content_guard.py +++ b/tests/test_empty_content_guard.py @@ -58,7 +58,7 @@ class TestWriteCommandsRejectBlank: @pytest.mark.parametrize("value", BLANK) def test_insert_block(self, monkeypatch, value): api = fake_api(["u1"]) - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kw: api) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kw: api) result = split_runner().invoke( cli, ["--token", "t", "insert-block", "--child-of", BLOCK, "--content", value] ) @@ -70,7 +70,7 @@ def test_insert_block(self, monkeypatch, value): @pytest.mark.parametrize("value", BLANK) def test_update_block_does_not_erase(self, monkeypatch, value): api = fake_api(["u1"]) - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kw: api) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kw: api) result = split_runner().invoke( cli, ["--token", "t", "update-block", "--id", BLOCK, "--content", value] ) @@ -81,7 +81,7 @@ def test_update_block_does_not_erase(self, monkeypatch, value): @pytest.mark.parametrize("value", BLANK) def test_add_journal_block(self, monkeypatch, value): api = fake_api(["u1"]) - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kw: api) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kw: api) result = split_runner().invoke( cli, ["--token", "t", "add-journal-block", "--content", value] ) @@ -91,7 +91,7 @@ def test_add_journal_block(self, monkeypatch, value): @pytest.mark.parametrize("value", BLANK) def test_add_journal_content(self, monkeypatch, value): api = fake_api(["u1"]) - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kw: api) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kw: api) result = split_runner().invoke( cli, ["--token", "t", "add-journal-content", "--content", value] ) @@ -103,7 +103,7 @@ def test_batch_rejects_when_one_value_is_blank(self, monkeypatch): # only some substitutions fail. One blank value must fail the call # rather than write the good ones and a blank alongside them. api = fake_api(["u1", "u2"]) - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kw: api) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kw: api) result = split_runner().invoke( cli, ["--token", "t", "add-journal-block", "--content", "real content", "--content", ""] @@ -116,7 +116,7 @@ def test_dry_run_also_rejects(self, monkeypatch, value): # --dry-run reports the plan; a plan to write a blank block is not one # worth previewing. api = fake_api(["u1"]) - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kw: api) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kw: api) result = split_runner().invoke( cli, ["--token", "t", "insert-block", "--child-of", BLOCK, "--content", value, "--dry-run"] @@ -130,7 +130,7 @@ class TestRealContentStillWrites: def test_insert_block_writes(self, monkeypatch): api = fake_api(["u1"]) - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kw: api) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kw: api) result = split_runner().invoke( cli, ["--token", "t", "insert-block", "--child-of", BLOCK, "--content", "**09:00** a real entry"] @@ -142,7 +142,7 @@ def test_update_block_writes(self, monkeypatch): api = fake_api(["u1"]) api.get_block.side_effect = None api.get_block.return_value = {"uuid": BLOCK, "content": "alt", "properties": {}} - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kw: api) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kw: api) result = split_runner().invoke( cli, ["--token", "t", "update-block", "--id", BLOCK, "--content", "neu"] ) diff --git a/tests/test_find_block_children.py b/tests/test_find_block_children.py index dd654b2..00ee1fa 100644 --- a/tests/test_find_block_children.py +++ b/tests/test_find_block_children.py @@ -43,7 +43,7 @@ def _get_block(uuid, include_children=True): class TestWithChildren: def test_children_are_printed_indented(self): api = _api([HIT], {"u-1": KIDS}) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "find-block", "--content", "14:22", "--with-children"]) assert r.exit_code == 0, r.output @@ -58,7 +58,7 @@ def indent_of(needle): def test_without_flag_no_extra_read_and_no_children(self): api = _api([HIT], {"u-1": KIDS}) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, ["find-block", "--content", "14:22"]) assert r.exit_code == 0, r.output assert "**Implementation:**" not in r.output @@ -68,7 +68,7 @@ def test_head_is_not_truncated_when_expanding(self): """The 80-char preview would cut the head of a subtree in half.""" long_hit = dict(HIT, content="X" * 200) api = _api([long_hit], {"u-1": []}) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "find-block", "--content", "X", "--with-children"]) assert "X" * 200 in r.output @@ -76,14 +76,14 @@ def test_head_is_not_truncated_when_expanding(self): def test_preview_still_truncates_without_the_flag(self): long_hit = dict(HIT, content="X" * 200) api = _api([long_hit]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, ["find-block", "--content", "X"]) assert "X" * 200 not in r.output assert "X" * 80 in r.output def test_json_output_carries_children(self): api = _api([HIT], {"u-1": KIDS}) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "find-block", "--content", "14:22", "--with-children", "--json"]) assert r.exit_code == 0, r.output @@ -94,7 +94,7 @@ def test_fanout_is_capped_and_the_remainder_named(self): n = FIND_BLOCK_CHILDREN_LIMIT + 7 hits = [dict(HIT, uuid=f"u-{i}") for i in range(n)] api = _api(hits, {f"u-{i}": [] for i in range(n)}) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "find-block", "--content", "x", "--with-children"]) assert r.exit_code == 0, r.output @@ -104,7 +104,7 @@ def test_fanout_is_capped_and_the_remainder_named(self): def test_first_limits_before_expanding(self): hits = [dict(HIT, uuid=f"u-{i}") for i in range(5)] api = _api(hits, {f"u-{i}": KIDS for i in range(5)}) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "find-block", "--content", "x", "--first", "--with-children"]) assert r.exit_code == 0, r.output @@ -112,7 +112,7 @@ def test_first_limits_before_expanding(self): def test_match_without_uuid_does_not_crash(self): api = _api([{"content": "no uuid here"}]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "find-block", "--content", "no uuid", "--with-children"]) assert r.exit_code == 0, r.output diff --git a/tests/test_find_block_limit.py b/tests/test_find_block_limit.py index b1e6ee4..5f15fa5 100644 --- a/tests/test_find_block_limit.py +++ b/tests/test_find_block_limit.py @@ -33,7 +33,7 @@ def _run(args, n_matches=50, split=False): api = MagicMock() api.datascript_query.return_value = [[b] for b in _blocks(n_matches)] runner = split_runner() if split else CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return runner.invoke(cli, ["--token", "T", "find-block"] + args) diff --git a/tests/test_flat_write_paths.py b/tests/test_flat_write_paths.py index 1f9c38d..8e86c99 100644 --- a/tests/test_flat_write_paths.py +++ b/tests/test_flat_write_paths.py @@ -46,7 +46,7 @@ class TestAddJournalBlockFlat: def test_under_heading_fails_loudly(self): api = _dead_api() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-journal-block", "--under-heading", "## Log", "--content", "**14:30** Entry"]) @@ -55,7 +55,7 @@ def test_under_heading_fails_loudly(self): def test_top_level_fails_loudly(self): api = _dead_api() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-journal-block", "--content", "**14:30** Entry", "--top-level"]) assert r.exit_code == 1 @@ -63,7 +63,7 @@ def test_top_level_fails_loudly(self): def test_json_mode_does_not_report_a_phantom_block(self): api = _dead_api() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-journal-block", "--under-heading", "## Log", "--content", "**14:30** X", "--json"]) @@ -71,7 +71,7 @@ def test_json_mode_does_not_report_a_phantom_block(self): def test_successful_write_still_reports(self): api = _live_api() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-journal-block", "--under-heading", "## Log", "--content", "**14:30** Entry"]) @@ -82,7 +82,7 @@ def test_successful_write_still_reports(self): class TestAddBlockRef: def test_failed_ref_is_not_reported_as_added(self): api = _dead_api() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-block-ref", "--source-id", "src", "--page", "P", "--under-heading", "## Log"]) @@ -91,7 +91,7 @@ def test_failed_ref_is_not_reported_as_added(self): def test_successful_ref_reports_its_uuid(self): api = _live_api("ref-uuid") - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-block-ref", "--source-id", "src", "--page", "P", "--under-heading", "## Log"]) @@ -104,7 +104,7 @@ def test_count_comes_from_writes_not_from_line_count(self): """One line lands, two do not: it must not claim three.""" api = _dead_api() api.append_block_in_page.side_effect = [{"uuid": "u1"}, None, None] - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-journal-entry", "--multi-block", "--content", "A\nB\nC"]) assert r.exit_code == 1 @@ -113,7 +113,7 @@ def test_count_comes_from_writes_not_from_line_count(self): def test_as_block_failure_aborts(self): api = _dead_api() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-journal-entry", "--content", "Text"]) assert r.exit_code == 1 @@ -122,7 +122,7 @@ def test_all_lines_land(self): api = _dead_api() api.append_block_in_page.side_effect = [ {"uuid": "u1"}, {"uuid": "u2"}, {"uuid": "u3"}] - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-journal-entry", "--multi-block", "--content", "A\nB\nC"]) assert r.exit_code == 0, r.output @@ -136,7 +136,7 @@ class TestCreatePageWithContent: def test_failed_content_write_aborts(self): api = _dead_api() api.get_page.return_value = None - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "create-page", "--name", "New", "--content", "Text"]) assert r.exit_code == 1 @@ -144,7 +144,7 @@ def test_failed_content_write_aborts(self): def test_page_without_content_is_unaffected(self): api = _dead_api() api.get_page.return_value = None - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, ["create-page", "--name", "New"]) assert r.exit_code == 0, r.output api.append_block_in_page.assert_not_called() @@ -162,7 +162,7 @@ def _api(self, after_content): def test_write_that_did_not_land_is_reported(self): api = self._api("old here") # unchanged: the update never took - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "replace-text", "--page", "P", "--find", "old", "--replace", "new"]) assert r.exit_code == 1 @@ -170,7 +170,7 @@ def test_write_that_did_not_land_is_reported(self): def test_write_that_landed_is_counted(self): api = self._api("new here") - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "replace-text", "--page", "P", "--find", "old", "--replace", "new"]) assert r.exit_code == 0, r.output @@ -178,7 +178,7 @@ def test_write_that_landed_is_counted(self): def test_dry_run_does_not_read_back_or_write(self): api = self._api("old here") - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "replace-text", "--page", "P", "--find", "old", "--replace", "new", "--dry-run"]) diff --git a/tests/test_get_backlinks_batch.py b/tests/test_get_backlinks_batch.py index edfe0db..2062d6c 100644 --- a/tests/test_get_backlinks_batch.py +++ b/tests/test_get_backlinks_batch.py @@ -24,7 +24,7 @@ def _refs(page_name): def _run(args, api): runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return runner.invoke(cli, args) diff --git a/tests/test_get_page_features.py b/tests/test_get_page_features.py index 5297c56..6ddcfcd 100644 --- a/tests/test_get_page_features.py +++ b/tests/test_get_page_features.py @@ -27,7 +27,7 @@ def test_without_flag_keeps_uuid_ref(self): ] api = _api_with_blocks(blocks) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-page", "--name", "Foo", "--no-backlinks"]) assert result.exit_code == 0, result.output assert "((11111111-2222-3333-4444-555555555555))" in result.output @@ -44,7 +44,7 @@ def test_with_flag_inlines_referenced_content(self): } api = _api_with_blocks(blocks, ref_block=ref_block) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-page", "--name", "Foo", "--no-backlinks", "--resolve-refs"] ) @@ -67,7 +67,7 @@ def test_resolve_refs_in_nested_children(self): } api = _api_with_blocks(blocks, ref_block=ref_block) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-page", "--name", "Foo", "--no-backlinks", "--resolve-refs"] ) @@ -87,7 +87,7 @@ def test_without_flag_no_uuid_prefix(self): ] api = _api_with_blocks(blocks) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-page", "--name", "Foo", "--no-backlinks"]) assert result.exit_code == 0, result.output assert "uuid-aaa" not in result.output @@ -102,7 +102,7 @@ def test_with_flag_prefixes_uuid(self): ] api = _api_with_blocks(blocks) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-page", "--name", "Foo", "--no-backlinks", "--with-ids"] ) @@ -125,7 +125,7 @@ def test_with_ids_combines_with_resolve_refs(self): } api = _api_with_blocks(blocks, ref_block=ref_block) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-page", "--name", "Foo", "--no-backlinks", "--with-ids", "--resolve-refs"] @@ -154,7 +154,7 @@ def test_heading_matches_despite_renderer_suffix(self): ] api = _api_with_blocks(blocks) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-page", "--name", "Journal", "--heading", "## Tasks"] ) @@ -171,7 +171,7 @@ def test_heading_matches_with_extra_whitespace(self): ] api = _api_with_blocks(blocks) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-page", "--name", "X", "--heading", "## Meeting"] ) @@ -182,7 +182,7 @@ def test_heading_not_found_emits_warning(self): blocks = [{"content": "## Other", "uuid": "h", "children": []}] api = _api_with_blocks(blocks) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-page", "--name", "X", "--heading", "## Tasks"] ) @@ -205,7 +205,7 @@ class TestGetPageUnresolvedRefWarning: def test_warns_on_stderr_when_flag_is_missing(self): blocks = [{"content": f"see (({self.UUID})) here", "uuid": "b1", "children": []}] api = _api_with_blocks(blocks) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = split_runner().invoke( cli, ["get-page", "--name", "Foo", "--no-backlinks"]) assert result.exit_code == 0, result.output @@ -218,7 +218,7 @@ def test_counts_refs_in_children_too(self): "children": [{"content": f"child (({self.UUID}))", "uuid": "b2", "children": []}], }] api = _api_with_blocks(blocks) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = split_runner().invoke( cli, ["get-page", "--name", "Foo", "--no-backlinks"]) assert result.exit_code == 0, result.output @@ -228,7 +228,7 @@ def test_silent_when_flag_resolves_them(self): blocks = [{"content": f"see (({self.UUID})) here", "uuid": "b1", "children": []}] ref = {"content": "the target", "page": {"originalName": "Src"}} api = _api_with_blocks(blocks, ref_block=ref) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = split_runner().invoke( cli, ["get-page", "--name", "Foo", "--no-backlinks", "--resolve-refs"]) assert result.exit_code == 0, result.output @@ -237,7 +237,7 @@ def test_silent_when_flag_resolves_them(self): def test_silent_when_page_has_no_refs(self): blocks = [{"content": "plain text", "uuid": "b1", "children": []}] api = _api_with_blocks(blocks) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = split_runner().invoke( cli, ["get-page", "--name", "Foo", "--no-backlinks"]) assert result.exit_code == 0, result.output @@ -248,7 +248,7 @@ def test_warning_does_not_pollute_json_payload(self): import json blocks = [{"content": f"see (({self.UUID})) here", "uuid": "b1", "children": []}] api = _api_with_blocks(blocks) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = split_runner().invoke( cli, ["get-page", "--name", "Foo", "--no-backlinks", "--json"]) assert result.exit_code == 0, result.output diff --git a/tests/test_get_page_missing.py b/tests/test_get_page_missing.py index 0992be9..61b7a90 100644 --- a/tests/test_get_page_missing.py +++ b/tests/test_get_page_missing.py @@ -21,7 +21,7 @@ @pytest.fixture def api(monkeypatch): mock = MagicMock() - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kwargs: mock) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kwargs: mock) mock.get_page_linked_references.return_value = [] return mock diff --git a/tests/test_get_properties_lookup.py b/tests/test_get_properties_lookup.py index 77af1fa..255b612 100644 --- a/tests/test_get_properties_lookup.py +++ b/tests/test_get_properties_lookup.py @@ -23,7 +23,7 @@ def _api_with_page(properties, text_values=None): def _invoke(api, key): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return split_runner().invoke( cli, ["get-properties", "--name", "Contents", "--property", key, "--json"]) diff --git a/tests/test_get_todos.py b/tests/test_get_todos.py index 64d49fb..bf7c3de 100644 --- a/tests/test_get_todos.py +++ b/tests/test_get_todos.py @@ -39,7 +39,7 @@ def test_plain_text_shows_page_per_todo(self): ] api = _mock_api_for_todos(rows) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos"]) assert result.exit_code == 0, result.output # Each TODO line should mention its page directly @@ -57,7 +57,7 @@ def test_json_includes_page_per_todo(self): ] api = _mock_api_for_todos(rows) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--json"]) assert result.exit_code == 0, result.output data = _json.loads(result.output) @@ -75,7 +75,7 @@ def test_filter_by_page_still_works(self): ] api = _mock_api_for_todos(rows) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--page", "Alpha", "--json"]) assert result.exit_code == 0, result.output data = _json.loads(result.output) @@ -87,7 +87,7 @@ def test_status_flag_passed_into_query(self): rows = [] api = _mock_api_for_todos(rows) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): runner.invoke(cli, ["get-todos", "--status", "DOING"]) # Verify the query string contained DOING. The todo query is the first # one; the reference query follows it. @@ -123,7 +123,7 @@ def test_impossible_range_returns_nothing(self): """A range that predates the graph must not return non-journal tasks.""" api = _mock_api_for_todos(self._rows()) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "1990-01-01", "--to", "1990-01-02", "--json"] ) @@ -134,7 +134,7 @@ def test_impossible_range_returns_nothing(self): def test_range_keeps_journal_task_and_drops_undated_one(self): api = _mock_api_for_todos(self._rows()) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "2026-05-01", "--to", "2026-05-31", "--json"] ) @@ -150,7 +150,7 @@ def test_without_range_everything_is_returned(self): """The filter only applies when asked for; the default is unchanged.""" api = _mock_api_for_todos(self._rows()) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--json"]) assert result.exit_code == 0, result.output todos = _json.loads(result.output)["todos"] @@ -160,7 +160,7 @@ def test_only_from_still_drops_undated(self): """One-sided ranges filter too — the bound is set, so it applies.""" api = _mock_api_for_todos(self._rows()) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--from", "2026-05-01", "--json"]) assert result.exit_code == 0, result.output contents = [t["content"] for t in _json.loads(result.output)["todos"]] @@ -193,7 +193,7 @@ def _ref(self, day, name=None): def test_todo_is_found_through_a_reference(self): api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) assert result.exit_code == 0, result.output @@ -205,7 +205,7 @@ def test_origin_fields_are_unchanged(self): """``page`` and ``uuid`` keep naming where the block lives.""" api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) todo = _json.loads(result.output)["todos"][0] @@ -218,7 +218,7 @@ def test_many_references_yield_one_row(self): refs = [self._ref(20260317 + i) for i in range(3)] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) data = _json.loads(result.output) @@ -232,7 +232,7 @@ def test_references_are_sorted_newest_first(self): self._ref(20260318, "Mar 18th, 2026")] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) refs_out = _json.loads(result.output)["todos"][0]["references"] @@ -243,7 +243,7 @@ def test_occurrences_outside_the_range_are_counted_not_listed(self): refs = [self._ref(20260319)] + [self._ref(20260101 + i) for i in range(5)] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) todo = _json.loads(result.output)["todos"][0] @@ -253,7 +253,7 @@ def test_occurrences_outside_the_range_are_counted_not_listed(self): def test_withheld_is_absent_when_nothing_was_withheld(self): api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) todo = _json.loads(result.output)["todos"][0] @@ -263,7 +263,7 @@ def test_refs_limit_caps_the_list_and_counts_the_rest(self): refs = [self._ref(20260301 + i) for i in range(5)] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--refs-limit", "2", "--json"]) todo = _json.loads(result.output)["todos"][0] assert len(todo["references"]) == 2, todo["references"] @@ -273,7 +273,7 @@ def test_refs_limit_zero_keeps_all(self): refs = [self._ref(20260301 + i) for i in range(5)] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--refs-limit", "0", "--json"]) todo = _json.loads(result.output)["todos"][0] assert len(todo["references"]) == 5, todo["references"] @@ -283,7 +283,7 @@ def test_no_follow_refs_restores_the_old_reading(self): """For callers who want where blocks live, not where they appear.""" api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--no-follow-refs", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) @@ -293,7 +293,7 @@ def test_no_follow_refs_restores_the_old_reading(self): def test_no_follow_refs_issues_no_second_query(self): api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319)]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): runner.invoke(cli, ["get-todos", "--no-follow-refs", "--json"]) assert api.datascript_query.call_count == 1, ( "--no-follow-refs must not pay for a read it does not use") @@ -308,7 +308,7 @@ def test_references_on_non_journal_pages_fall_out_of_a_range(self): refs = [({"uuid": "u-carried"}, {"original-name": "Project Alpha"})] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) data = _json.loads(result.output) @@ -320,7 +320,7 @@ def test_non_journal_reference_is_listed_without_a_range(self): refs = [({"uuid": "u-carried"}, {"original-name": "Project Alpha"})] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--json"]) todo = _json.loads(result.output)["todos"][0] assert todo["references"] == ["Project Alpha"], todo @@ -329,7 +329,7 @@ def test_a_todo_without_references_has_no_references_field(self): """Callers reading todos that are not carried see the payload they saw.""" api = _mock_api_for_todos([self._ORIGIN], []) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--json"]) todo = _json.loads(result.output)["todos"][0] assert "references" not in todo, todo @@ -344,7 +344,7 @@ def test_page_filter_matches_the_origin_not_the_reference(self): """ api = _mock_api_for_todos([self._ORIGIN], [self._ref(20260319, "Mar 19th, 2026")]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--page", "Mar 4th", "--json"]) todos = _json.loads(result.output)["todos"] assert len(todos) == 1, todos @@ -356,7 +356,7 @@ def test_a_reference_alone_does_not_invent_a_todo(self): "journal-day": 20260319})] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--json"]) uuids = [t["uuid"] for t in _json.loads(result.output)["todos"]] assert uuids == ["u-carried"], uuids @@ -367,7 +367,7 @@ def test_duplicate_reference_dates_are_collapsed(self): self._ref(20260319, "Mar 19th, 2026")] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--json"]) todo = _json.loads(result.output)["todos"][0] assert todo["references"] == ["Mar 19th, 2026"], todo @@ -377,7 +377,7 @@ def test_plain_text_names_the_occurrences(self): refs = [self._ref(20260319, "Mar 19th, 2026")] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19"]) assert result.exit_code == 0, result.output @@ -393,7 +393,7 @@ def test_plain_text_separates_occurrences_unambiguously(self): self._ref(20260914, "2026-09-14, Monday")] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos"]) line = next(l for l in result.output.splitlines() if "also on" in l) assert "Wednesday; 2026-09-14" in line, ( @@ -410,7 +410,7 @@ def test_lifting_the_cap_does_not_zero_the_withheld_count(self): refs = [self._ref(20260319)] + [self._ref(20260101 + i) for i in range(3)] api = _mock_api_for_todos([self._ORIGIN], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--refs-limit", "0", "--from", "2026-03-17", "--to", "2026-03-19", "--json"]) @@ -436,7 +436,7 @@ class TestGetTodosReferenceEdges: def _run(self, args, ref_rows): api = _mock_api_for_todos([self._ORIGIN], ref_rows) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return runner.invoke(cli, ["get-todos", *args, "--json"]), api def _refs(self, n, start=20260301): @@ -541,7 +541,7 @@ def test_plain_text_reports_a_count_when_no_date_survives(self): [({"content": "TODO carried", "marker": "TODO", "uuid": "u-carried"}, {"original-name": "Mar 18th, 2026", "journal-day": 20260318})], refs) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke( cli, ["get-todos", "--from", "2026-03-17", "--to", "2026-03-19"]) assert "1 other page" in result.output, result.output @@ -558,7 +558,7 @@ def test_a_null_reference_result_does_not_kill_the_command(self): api.datascript_query.side_effect = lambda q: ( None if ":block/refs" in q else [self._ORIGIN]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--json"]) assert result.exit_code == 0, result.output todo = _json.loads(result.output)["todos"][0] @@ -572,7 +572,7 @@ def test_tag_filter_and_references_coexist(self): [({"uuid": "u-carried"}, {"original-name": "Mar 19", "journal-day": 20260319})]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, ["get-todos", "--tag", "urgent", "--json"]) todos = _json.loads(result.output)["todos"] assert len(todos) == 1, todos diff --git a/tests/test_init_config.py b/tests/test_init_config.py index e3a32b3..2dbf6aa 100644 --- a/tests/test_init_config.py +++ b/tests/test_init_config.py @@ -44,7 +44,7 @@ def tree(name): def run(api, *args): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return split_runner().invoke(cli, ["--token", "X", "init", *args]) diff --git a/tests/test_insert_block_tree.py b/tests/test_insert_block_tree.py index ba27397..58487c8 100644 --- a/tests/test_insert_block_tree.py +++ b/tests/test_insert_block_tree.py @@ -138,7 +138,7 @@ class TestInsertBlockCLITreeFlag: def test_tree_under_child_of_returns_uuids(self): api = _make_api_with_uuid_sequence(["u1", "u2", "u3"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "parent-uuid", @@ -156,7 +156,7 @@ def test_tree_with_json_input(self): {"content": "alpha", "children": [{"content": "beta"}]}, ]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "parent", @@ -170,7 +170,7 @@ def test_tree_with_json_input(self): def test_tree_with_page_top_level(self): api = _make_api_with_uuid_sequence(["p1", "p2"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--page", "MyPage", @@ -185,7 +185,7 @@ def test_tree_with_page_top_level(self): def test_tree_only_root_block(self): api = _make_api_with_uuid_sequence(["solo"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "p", @@ -199,7 +199,7 @@ def test_tree_only_root_block(self): def test_tree_empty_input_is_error(self): api = _make_api_with_uuid_sequence([]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "p", @@ -211,7 +211,7 @@ def test_tree_empty_input_is_error(self): def test_tree_plain_text_output_lists_uuids(self): api = _make_api_with_uuid_sequence(["u1", "u2"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "parent", @@ -257,7 +257,7 @@ def test_strict_aborts_on_null_result(self): def test_cli_after_tree_returns_uuids(self): api = _make_api_with_uuid_sequence(["a1", "a2", "a3"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--after", "anchor-uuid", @@ -272,7 +272,7 @@ def test_cli_after_tree_returns_uuids(self): def test_cli_before_tree_works(self): api = _make_api_with_uuid_sequence(["b1", "b2"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--before", "anchor-uuid", @@ -290,7 +290,7 @@ class TestInsertBlockDryRun: def test_dry_run_after_tree_counts_without_writing(self): api = MagicMock() runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--after", "anchor", @@ -308,7 +308,7 @@ def test_dry_run_after_tree_counts_without_writing(self): def test_dry_run_child_of_flat_content(self): api = MagicMock() runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "parent", @@ -323,7 +323,7 @@ def test_dry_run_child_of_flat_content(self): def test_dry_run_after_hierarchical_content(self): api = MagicMock() runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--after", "anchor", @@ -355,7 +355,7 @@ def test_cli_after_flat_null_result_exits_nonzero(self): api = MagicMock() api.insert_block.return_value = None # Logseq returns null for bad anchor runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--after", "00000000-0000-0000-0000-000000000000", @@ -404,7 +404,7 @@ def _insert(parent_uuid, content, opts=None): def test_single_content_uses_before_true(self): api, calls = self._api_recording(["f1"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "parent", "--first", "--content", "head block", "--json", @@ -415,7 +415,7 @@ def test_single_content_uses_before_true(self): def test_without_first_appends_last(self): api, calls = self._api_recording(["l1"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "parent", "--content", "tail block", "--json", @@ -427,7 +427,7 @@ def test_tree_first_root_leads_rest_chain_as_siblings(self): """Order must be preserved: a, b, c, not reversed by repeated before=True.""" api, calls = self._api_recording(["u-a", "u-b", "u-c"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "parent", "--first", "--tree", "- a\n- b\n- c", "--json", @@ -443,7 +443,7 @@ def test_tree_first_root_leads_rest_chain_as_siblings(self): def test_tree_first_nests_children_under_head(self): api, calls = self._api_recording(["u-root", "u-kid"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "parent", "--first", "--tree", "- root\n\t- kid", "--json", @@ -455,7 +455,7 @@ def test_tree_first_nests_children_under_head(self): def test_first_without_child_of_is_rejected(self): api, _ = self._api_recording(["nope"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--page", "SomePage", "--first", "--content", "x", @@ -469,7 +469,7 @@ def test_silent_write_failure_aborts(self): api = MagicMock() api.insert_block.return_value = None runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "parent", "--first", "--content", "vanishes", @@ -480,7 +480,7 @@ def test_silent_write_failure_aborts(self): def test_dry_run_reports_first_child_and_writes_nothing(self): api, calls = self._api_recording(["never"]) runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "insert-block", "--child-of", "parent", "--first", "--content", "planned", "--dry-run", diff --git a/tests/test_journal_bounded_output.py b/tests/test_journal_bounded_output.py index 177a6bb..6ddc67c 100644 --- a/tests/test_journal_bounded_output.py +++ b/tests/test_journal_bounded_output.py @@ -27,7 +27,7 @@ def _journal_pages(days): @pytest.fixture def api(monkeypatch): mock = MagicMock() - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kwargs: mock) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kwargs: mock) mock.get_all_pages.return_value = _journal_pages(range(1, 11)) # 1..10 Aug mock.get_page_blocks_tree.return_value = [ {"uuid": "h", "content": "## Log", diff --git a/tests/test_journal_range_parallel.py b/tests/test_journal_range_parallel.py index 59475ea..232f48e 100644 --- a/tests/test_journal_range_parallel.py +++ b/tests/test_journal_range_parallel.py @@ -43,7 +43,7 @@ def slow_blocks(page_name): api.get_page_blocks_tree.side_effect = slow_blocks runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "get-journal-range", "--from", "2026-04-20", @@ -63,7 +63,7 @@ def test_single_day_range(self): {"content": "x", "uuid": "u1", "children": []} ] runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "get-journal-range", "--from", "2026-04-20", @@ -89,7 +89,7 @@ def maybe_fail(page_name): api.get_page_blocks_tree.side_effect = maybe_fail runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "get-journal-range", "--from", "2026-04-20", @@ -114,7 +114,7 @@ def test_workers_env_respected(self, monkeypatch): api.get_page_blocks_tree.return_value = [] runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "get-journal-range", "--from", "2026-04-20", @@ -131,7 +131,7 @@ def test_ten_day_range_order(self): api.get_all_pages.return_value = _make_journal_pages(dates) api.get_page_blocks_tree.return_value = [] runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "get-journal-range", "--from", "2026-04-10", @@ -151,7 +151,7 @@ def test_keyword_today_yesterday_resolves(self): api.get_all_pages.return_value = _make_journal_pages(dates) api.get_page_blocks_tree.return_value = [] runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = runner.invoke(cli, [ "get-journal-range", "--from", "yesterday", @@ -164,7 +164,7 @@ def test_keyword_today_yesterday_resolves(self): def test_keyword_invalid_date_rejected(self): runner = CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=MagicMock()): + with patch("logseq_cli.group.LogseqAPI", return_value=MagicMock()): result = runner.invoke(cli, [ "get-journal-range", "--from", "tomorrowww", diff --git a/tests/test_journal_uuid_and_alias.py b/tests/test_journal_uuid_and_alias.py index 9151ad8..2256ed9 100644 --- a/tests/test_journal_uuid_and_alias.py +++ b/tests/test_journal_uuid_and_alias.py @@ -21,7 +21,7 @@ def _japi(): class TestJournalUuidReturn: def test_add_journal_content_returns_uuid(self): api = _japi() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-journal-content", "--content", "- entry", "--top-level", "--date", "2026-06-04", "--json", @@ -33,7 +33,7 @@ def test_add_journal_content_returns_uuid(self): def test_add_journal_block_single_returns_uuid(self): api = _japi() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-journal-block", "--content", "entry", "--top-level", "--date", "2026-06-04", "--json", @@ -45,7 +45,7 @@ def test_add_journal_block_single_returns_uuid(self): def test_add_journal_block_batch_returns_uuids(self): api = _japi() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "add-journal-block", "--content", "a", "--content", "b", "--top-level", "--date", "2026-06-04", "--json", @@ -62,7 +62,7 @@ class TestNameAlias: def test_find_block_accepts_name(self): api = MagicMock() api.datascript_query.return_value = [] - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "find-block", "--content", "x", "--name", "SomePage", "--json", ]) @@ -71,7 +71,7 @@ def test_find_block_accepts_name(self): def test_insert_block_accepts_name(self): api = MagicMock() api.append_block_in_page.return_value = {"uuid": "ib"} - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "insert-block", "--name", "SomePage", "--content", "x", "--json", ]) diff --git a/tests/test_keep_block_ids.py b/tests/test_keep_block_ids.py index d4168de..d8b514d 100644 --- a/tests/test_keep_block_ids.py +++ b/tests/test_keep_block_ids.py @@ -116,7 +116,7 @@ def test_keep_ids_sets_keepuuid_on_the_batch(self): # ---------- the command ---------------------------------------------------- def _run(args, api): - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return CliRunner().invoke(cli, ["--token", "T"] + args) diff --git a/tests/test_move_block.py b/tests/test_move_block.py index 66aede5..d4ccf7f 100644 --- a/tests/test_move_block.py +++ b/tests/test_move_block.py @@ -52,7 +52,7 @@ def _get_block(uuid, include_children=True): class TestMoveBlock: def test_under_moves_and_reports(self): api = _api(children_after=[{"uuid": SRC}]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "move-block", "--id", SRC, "--under", TGT]) assert r.exit_code == 0, r.output @@ -61,7 +61,7 @@ def test_under_moves_and_reports(self): def test_before_moves_as_sibling(self): api = _api(sibling_order=[SRC, TGT]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "move-block", "--id", SRC, "--before", TGT]) assert r.exit_code == 0, r.output @@ -71,7 +71,7 @@ def test_before_moves_as_sibling(self): def test_before_requires_source_directly_in_front(self): """Same parent is not enough: a move that did nothing must not pass.""" api = _api(sibling_order=[TGT, SRC]) # source lands AFTER the target - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "move-block", "--id", SRC, "--before", TGT]) assert r.exit_code == 1 @@ -80,7 +80,7 @@ def test_before_requires_source_directly_in_front(self): def test_silent_refusal_is_reported(self): """Moving into the block's own subtree: Logseq just does nothing.""" api = _api(children_after=[]) # source never shows up under the target - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "move-block", "--id", SRC, "--under", TGT]) assert r.exit_code == 1 @@ -91,7 +91,7 @@ def test_missing_target_aborts_before_moving(self): api = _api() api.get_block.side_effect = lambda uuid, include_children=True: ( {"uuid": SRC, "content": "x", "children": []} if uuid == SRC else None) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "move-block", "--id", SRC, "--under", "nope"]) assert r.exit_code == 1 @@ -100,7 +100,7 @@ def test_missing_target_aborts_before_moving(self): def test_same_block_is_rejected(self): api = _api() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "move-block", "--id", SRC, "--before", SRC]) assert r.exit_code == 1 @@ -109,7 +109,7 @@ def test_same_block_is_rejected(self): def test_exactly_one_position_flag(self): api = _api() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): both = CliRunner().invoke(cli, [ "move-block", "--id", SRC, "--under", TGT, "--before", TGT]) neither = CliRunner().invoke(cli, ["move-block", "--id", SRC]) @@ -120,7 +120,7 @@ def test_exactly_one_position_flag(self): def test_dry_run_writes_nothing(self): api = _api(children_after=[{"uuid": SRC}]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "move-block", "--id", SRC, "--under", TGT, "--dry-run"]) assert r.exit_code == 0, r.output @@ -135,7 +135,7 @@ def test_failed_copy_does_not_remove_source(self): api = MagicMock() api.get_block.return_value = {"uuid": SRC, "content": "WICHTIG", "children": []} api.append_block_in_page.return_value = None # silent write failure - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "copy-block", "--id", SRC, "--to-page", "Target", "--remove"]) assert r.exit_code == 1 @@ -150,7 +150,7 @@ def test_failed_child_copy_does_not_remove_source(self): "children": [{"content": "Child", "children": []}]} api.append_block_in_page.return_value = {"uuid": "new-root"} api.insert_block.return_value = None - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "copy-block", "--id", SRC, "--to-page", "Target", "--remove"]) assert r.exit_code == 1 @@ -160,7 +160,7 @@ def test_successful_copy_still_removes(self): api = MagicMock() api.get_block.return_value = {"uuid": SRC, "content": "Head", "children": []} api.append_block_in_page.return_value = {"uuid": "new-root"} - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "copy-block", "--id", SRC, "--to-page", "Target", "--remove"]) assert r.exit_code == 0, r.output diff --git a/tests/test_numeric_option_bounds.py b/tests/test_numeric_option_bounds.py index a655ced..9bd0030 100644 --- a/tests/test_numeric_option_bounds.py +++ b/tests/test_numeric_option_bounds.py @@ -78,7 +78,7 @@ def _run(args): api.get_page_linked_references.return_value = [] api.datascript_query.return_value = [] api.get_page_blocks_tree.return_value = [] - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return split_runner().invoke(cli, args), api diff --git a/tests/test_property_list_values.py b/tests/test_property_list_values.py index 90a4a18..32803d8 100644 --- a/tests/test_property_list_values.py +++ b/tests/test_property_list_values.py @@ -26,7 +26,7 @@ def _api(rows): def _run(args, rows): api = _api(rows) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): result = CliRunner().invoke(cli, ["--token", "T"] + args) return result, api @@ -52,7 +52,7 @@ def test_smart_query_asks_for_both_too(self, tmp_path): cfg.write_text('[graph]\nperson_property = "type"\nperson_value = "Person"\n', encoding="utf-8") api = _api([]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): CliRunner().invoke( cli, ["--token", "T", "smart-query", "--request", "personen"], env={"LOGSEQ_CLI_CONFIG": str(cfg)}) diff --git a/tests/test_read_only_commands_smoke.py b/tests/test_read_only_commands_smoke.py index 3e22f37..f0891a9 100644 --- a/tests/test_read_only_commands_smoke.py +++ b/tests/test_read_only_commands_smoke.py @@ -58,7 +58,7 @@ def test_runs_and_emits_json(command, args, tmp_path): 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.cli.LogseqAPI", return_value=api_with_content()): + patch("logseq_cli.group.LogseqAPI", return_value=api_with_content()): result = split_runner().invoke(cli, ["--token", "X", command, *args, "--json"]) assert result.exit_code == 0, result.stderr or result.stdout json.loads(result.stdout) @@ -70,7 +70,7 @@ def test_survives_an_empty_graph(command, args, tmp_path): 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.cli.LogseqAPI", return_value=empty_api()): + patch("logseq_cli.group.LogseqAPI", return_value=empty_api()): result = split_runner().invoke(cli, ["--token", "X", command, *args, "--json"]) assert result.exception is None or isinstance(result.exception, SystemExit), \ f"{command} raised {result.exception!r}" @@ -95,7 +95,7 @@ def run_json(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.cli.LogseqAPI", return_value=api): + patch("logseq_cli.group.LogseqAPI", return_value=api): result = split_runner().invoke(cli, ["--token", "X", *args, "--json"]) assert result.exit_code == 0, result.stderr or result.stdout return json.loads(result.stdout) @@ -283,7 +283,7 @@ def _run(self, text, tmp_path): api.get_all_pages.return_value = [ {"originalName": "J", "journalDay": 20260910, "journal?": True}] with patch.dict(os.environ, {"LOGSEQ_CLI_CONFIG": str(cfg)}, clear=False), \ - patch("logseq_cli.cli.LogseqAPI", return_value=api), \ + patch("logseq_cli.group.LogseqAPI", return_value=api), \ patch("logseq_cli.cli.get_page_content", return_value=text): r = split_runner().invoke( cli, ["--token", "X", "analyze-journal-patterns", diff --git a/tests/test_replace_text_properties.py b/tests/test_replace_text_properties.py index 91c85c1..dcef36c 100644 --- a/tests/test_replace_text_properties.py +++ b/tests/test_replace_text_properties.py @@ -41,7 +41,7 @@ def test_id_line_is_not_touched_when_find_matches_the_uuid(self): uuid = "abcdef12-3456-7890-abcd-ef1234567890" content = f"DONE Service updaten 6e10 fixen\nid:: {uuid}" api = _api(content) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "replace-text", "--page", "P", "--find", "6e10", "--replace", "XXXX"]) assert r.exit_code == 0, r.output @@ -55,7 +55,7 @@ def test_property_line_survives_even_with_text_after_it(self): "id:: abcdef12-3456-7890-abcd-ef1234567890\n" "=> Service via alt route") api = _api(content) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "replace-text", "--page", "P", "--find", "Service", "--replace", "Host"]) assert r.exit_code == 0, r.output @@ -68,7 +68,7 @@ def test_property_line_survives_even_with_text_after_it(self): def test_soft_property_line_is_not_replaced(self): content = "TODO Task A\nprio:: A\ncollapsed:: true" api = _api(content) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "replace-text", "--page", "P", "--find", "A", "--replace", "Z"]) assert r.exit_code == 0, r.output @@ -79,7 +79,7 @@ def test_soft_property_line_is_not_replaced(self): def test_plain_text_block_still_replaced(self): content = "old here" api = _api(content) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "replace-text", "--page", "P", "--find", "old", "--replace", "new"]) assert r.exit_code == 0, r.output diff --git a/tests/test_search_pages_matching.py b/tests/test_search_pages_matching.py index 0567142..7e5a55e 100644 --- a/tests/test_search_pages_matching.py +++ b/tests/test_search_pages_matching.py @@ -29,7 +29,7 @@ def _run(query): api = MagicMock() api.get_all_pages.return_value = PAGES - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return CliRunner().invoke(cli, ["--token", "T", "search-pages", "--query", query]) diff --git a/tests/test_silent_write_failure.py b/tests/test_silent_write_failure.py index e5c330a..8a5d483 100644 --- a/tests/test_silent_write_failure.py +++ b/tests/test_silent_write_failure.py @@ -83,7 +83,7 @@ def api(monkeypatch): which a bare MagicMock cannot answer meaningfully. """ mock = fake_api([f"u{i}" for i in range(1, 40)]) - monkeypatch.setattr("logseq_cli.cli.LogseqAPI", lambda **kwargs: mock) + monkeypatch.setattr("logseq_cli.group.LogseqAPI", lambda **kwargs: mock) mock.get_user_configs.return_value = {"preferredDateFormat": "yyyy-MM-dd"} mock.get_page.return_value = {"name": "journal"} mock.get_page_blocks_tree.return_value = [ diff --git a/tests/test_suggest_connections_bounds.py b/tests/test_suggest_connections_bounds.py index 636fd04..b07c016 100644 --- a/tests/test_suggest_connections_bounds.py +++ b/tests/test_suggest_connections_bounds.py @@ -31,7 +31,7 @@ def _api(page_count=3, topics=("alpha", "beta", "gamma", "delta")): def _run(args, api, split=False): runner = split_runner() if split else CliRunner() - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): return runner.invoke(cli, args) diff --git a/tests/test_todos_due_dates.py b/tests/test_todos_due_dates.py index 30f5cac..5a0efcf 100644 --- a/tests/test_todos_due_dates.py +++ b/tests/test_todos_due_dates.py @@ -51,7 +51,7 @@ def _api(rows): def _run(args, rows): - with patch("logseq_cli.cli.LogseqAPI", return_value=_api(rows)): + with patch("logseq_cli.group.LogseqAPI", return_value=_api(rows)): return split_runner().invoke(cli, args) diff --git a/tests/test_version.py b/tests/test_version.py index 726e88f..4dc2f74 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -8,7 +8,8 @@ from click.testing import CliRunner -from logseq_cli.cli import cli, resolve_version +from logseq_cli.cli import cli +from logseq_cli.group import resolve_version _PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" diff --git a/tests/test_where_content.py b/tests/test_where_content.py index 13c3fbb..dc6d031 100644 --- a/tests/test_where_content.py +++ b/tests/test_where_content.py @@ -61,7 +61,7 @@ def test_long_ambiguity_is_truncated_but_counted(self): class TestUpdateBlockWhereContent: def test_updates_the_single_match(self): api = _api(ONE) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "update-block", "--where-content", "14:22", "--content", "new"]) assert r.exit_code == 0, r.output @@ -69,7 +69,7 @@ def test_updates_the_single_match(self): def test_ambiguous_writes_nothing(self): api = _api(TWO) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "update-block", "--where-content", "Duplicate", "--content", "new"]) assert r.exit_code == 1 @@ -77,7 +77,7 @@ def test_ambiguous_writes_nothing(self): def test_no_match_writes_nothing(self): api = _api([]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "update-block", "--where-content", "nope", "--content", "new"]) assert r.exit_code == 1 @@ -85,7 +85,7 @@ def test_no_match_writes_nothing(self): def test_exactly_one_selector(self): api = _api(ONE) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): both = CliRunner().invoke(cli, [ "update-block", "--id", "u-1", "--where-content", "x", "--content", "n"]) neither = CliRunner().invoke(cli, ["update-block", "--content", "n"]) @@ -96,7 +96,7 @@ def test_exactly_one_selector(self): def test_dry_run_writes_nothing(self): api = _api(ONE) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "update-block", "--where-content", "14:22", "--content", "new", "--dry-run"]) @@ -106,7 +106,7 @@ def test_dry_run_writes_nothing(self): def test_id_path_still_works(self): api = _api([]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "update-block", "--id", "u-1", "--content", "new"]) assert r.exit_code == 0, r.output @@ -120,7 +120,7 @@ class TestSetTodoStatusAmbiguity: def test_two_matching_todos_abort(self): api = _api([{"uuid": "u-A", "content": "TODO Report (A)"}, {"uuid": "u-B", "content": "TODO Report (B)"}]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "set-todo-status", "--content", "Report", "--page", "X", "--status", "DONE"]) @@ -132,7 +132,7 @@ 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"}, {"uuid": "u-B", "content": "see Write report above"}]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): r = CliRunner().invoke(cli, [ "set-todo-status", "--content", "Report", "--page", "X", "--status", "DONE"]) @@ -145,11 +145,11 @@ class TestInsertBlockQuiet: def test_quiet_drops_the_uuid_list_but_keeps_the_confirmation(self): from tests.conftest import fake_api api = fake_api(["u1", "u2", "u3"]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api): + with patch("logseq_cli.group.LogseqAPI", return_value=api): loud = CliRunner().invoke(cli, [ "insert-block", "--child-of", "p", "--tree", "- a\n- b\n- c"]) api2 = fake_api(["u1", "u2", "u3"]) - with patch("logseq_cli.cli.LogseqAPI", return_value=api2): + with patch("logseq_cli.group.LogseqAPI", return_value=api2): quiet = CliRunner().invoke(cli, [ "insert-block", "--child-of", "p", "--tree", "- a\n- b\n- c", "--quiet"]) From d2471a797f2c5648c10fb10af127c63b44a90ab4 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:19:31 +0200 Subject: [PATCH 10/25] Move get-block and find-block into logseq_cli/commands/blocks.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of the nine command modules, and the one that creates the subpackage — smallest and most isolated first, so the pattern is proven before the large ones. Registration happens as a side effect of the import in cli.py; the commands themselves are unchanged. The packaging line belongs in this commit, not after the series: setuptools does not infer subpackages from an explicit `packages` list, so from here on every wheel without it would ship a CLI that installs, starts, and has no commands. `pip install -e .` hides this completely, and so does `pip wheel` reading a cached build — measured. Checked with --no-cache-dir against the source tree: 11 modules in the wheel, 11 in source. tests/test_find_block_children.py imports FIND_BLOCK_CHILDREN_LIMIT and moves with it. Its import is the combined form, so the whole line is replaced rather than the symbol. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 162 +--------------------------- logseq_cli/commands/__init__.py | 0 logseq_cli/commands/blocks.py | 173 ++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_find_block_children.py | 3 +- 5 files changed, 177 insertions(+), 163 deletions(-) create mode 100644 logseq_cli/commands/__init__.py create mode 100644 logseq_cli/commands/blocks.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 6f82c03..a6c81f2 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -75,6 +75,7 @@ count_blocks, ) from logseq_cli.group import cli, resolve_version +from logseq_cli.commands import blocks # noqa: F401 imported for registration from logseq_cli.output import fail, handle_connection_error, output from logseq_cli.render import ( BLOCK_REF_RE, blocks_to_markdown, blocks_with_ids, count_unresolved_refs, @@ -144,7 +145,6 @@ def _word_pattern(words) -> "re.Pattern[str]": # find-block --with-children costs one extra read per match (the datalog pull # carries no children), so the fan-out is capped and the remainder reported. -FIND_BLOCK_CHILDREN_LIMIT = 25 @@ -367,171 +367,11 @@ def _fetch_one(page_name): # --------------------------------------------------------------------------- # 3. get-block # --------------------------------------------------------------------------- -@cli.command("get-block", epilog="""\b -Example: - logseq-cli --token TOKEN get-block --id 12345678-90ab-cdef-1234-567890abcdef -Note: - UUID accepts "((uuid))" or bare uuid form. Use get-page --resolve-refs for bulk. -""") -@click.option("--id", "block_id", required=True, help="Block UUID (with or without (()))") -@click.option("--no-children", is_flag=True, help="Exclude child blocks") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def get_block(ctx, block_id, no_children, as_json): - """Get a block by UUID.""" - api = ctx.obj["api"] - # Strip (( )) if present - block_id = block_id.strip("()") - include_children = not no_children - block = api.get_block(block_id, include_children=include_children) - - if not block: - # The API answers an unknown UUID with null, so returning that verbatim - # on stdout with exit 0 reads as a successful empty block. Report it the - # way get-page reports a missing page: non-zero exit, error on stderr. - fail(f"Block not found: {block_id}", as_json=as_json, - id=block_id, exists=False) - - if as_json: - output(block, True) - else: - # Metadata - page_info = block.get("page") - if isinstance(page_info, dict): - click.echo(f"Page: {page_info.get('name') or page_info.get('id', '?')}") - elif page_info is not None: - click.echo(f"Page: {page_info}") - parent_info = block.get("parent") - if isinstance(parent_info, dict): - click.echo(f"Parent: {parent_info.get('name') or parent_info.get('id', '?')}") - elif parent_info is not None: - click.echo(f"Parent: {parent_info}") - created = block.get("createdAt") or block.get("created-at") - updated = block.get("updatedAt") or block.get("updated-at") - if created: - click.echo(f"Created: {datetime.datetime.fromtimestamp(created / 1000).strftime('%Y-%m-%d %H:%M')}") - if updated: - click.echo(f"Updated: {datetime.datetime.fromtimestamp(updated / 1000).strftime('%Y-%m-%d %H:%M')}") - click.echo() - - content = block.get("content", "") - click.echo(content) - children = block.get("children", []) - if children: - click.echo(process_blocks(children, indent=1)) # --------------------------------------------------------------------------- # 3b. find-block # --------------------------------------------------------------------------- -@cli.command("find-block", epilog="""\b -Examples: - logseq-cli --token TOKEN find-block --content "tag support" --page "Project Alpha" --first - logseq-cli --token TOKEN find-block --content "^### " --page "X" --regex - logseq-cli --token TOKEN find-block --content "14:57" --page "2026-07-22, tuesday" --with-children -Note: - Output gives uuid + page + content preview. Use --first to disambiguate; pipe to - insert-block --child-of, update-block, remove-block downstream. - --with-children prints each match with its sub-blocks indented, instead of - guessing a line count with `get-page | grep -A`. - A common word matches thousands of blocks: --limit N caps the output, and - whatever is withheld is reported on stderr. --first is --limit 1 with the - same notice. -""") -@click.option("--content", required=True, help="Content text (substring match or regex with --regex)") -@click.option("--page", "--name", default=None, help="Restrict search to this page name") -@click.option("--regex", "use_regex", is_flag=True, help="Interpret --content as regex pattern") -@click.option("--first", "first_only", is_flag=True, help="Output only the first match") -@click.option("--limit", "limit", type=int, default=None, help="Print at most N matches (1 or greater); the number withheld is reported on stderr") -@click.option("--with-children", "with_children", is_flag=True, help="Print each match with its sub-blocks (one extra API read per match)") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def find_block(ctx, content, page, use_regex, first_only, limit, with_children, as_json): - """Find blocks by content substring or regex.""" - api = ctx.obj["api"] - - # Before the query, not after it: the whole result set arrives either way - # (measured: 71ms, 473KB for 1382 matches), and a value that will be - # refused must not cost that read first. - if first_only and limit is not None: - fail("Specify either --first or --limit, not both.", as_json) - if limit is not None and limit < 1: - fail("--limit must be 1 or greater.", as_json) - - matches = find_blocks_by_content(api, content, page=page, use_regex=use_regex) - - # A common word matches thousands of blocks, and printing all of them is - # the unbounded-output failure the journal paths fixed in 0.6.0: the caller - # hits its response cap and reasons on a fragment without being told. The - # cut cannot move into the query - DataScript ignores a :limit clause, and - # the whole result set arrives either way (measured: 71ms, 473KB for 1382 - # matches) - so it happens here, and what was withheld is always named. - withheld = 0 - if first_only: - withheld = max(len(matches) - 1, 0) - matches = matches[:1] - elif limit is not None and len(matches) > limit: - withheld = len(matches) - limit - matches = matches[:limit] - - # The datalog pull returns no children, so each subtree costs one extra - # read. Bounded so a broad --content cannot fan out into hundreds of calls; - # what was skipped is stated rather than silently dropped. - truncated = 0 - if with_children and matches: - if len(matches) > FIND_BLOCK_CHILDREN_LIMIT: - truncated = len(matches) - FIND_BLOCK_CHILDREN_LIMIT - matches = matches[:FIND_BLOCK_CHILDREN_LIMIT] - for block in matches: - uuid = block.get("uuid") - if not uuid: - continue - full = api.get_block(uuid, include_children=True) - if full: - block["children"] = full.get("children") or [] - - # stdout stays pure payload in both forms, so the notice goes to stderr - # whether or not --json is set; a caller parsing stdout must still learn - # that it is holding part of an answer. - if withheld: - shown = len(matches) - click.echo( - f"showing {shown} of {shown + withheld} match(es) ... {withheld} omitted " - "(raise --limit, or narrow --content/--page)", err=True) - - if as_json: - output(matches, True) - else: - if not matches: - click.echo("No blocks found.") - else: - click.echo(f"Found {len(matches)} block(s):") - for block in matches: - uuid = block.get("uuid") or "?" - page_info = block.get("page") - page_name = "" - if isinstance(page_info, dict): - page_name = page_info.get("original-name") or page_info.get("name") or "" - click.echo(f" uuid: {uuid}") - if page_name: - click.echo(f" page: {page_name}") - if with_children: - # full content, not a preview: truncating the head of a - # subtree would defeat the point of asking for its children - click.echo(f" content: {block.get('content') or ''}") - children = block.get("children") or [] - if children: - click.echo(process_blocks(children, indent=2)) - else: - preview = (block.get("content") or "")[:80].replace("\n", " ") - click.echo(f" content: {preview}") - click.echo() - if truncated: - click.echo( - f"({truncated} further match(es) not expanded; narrow --content " - "or --page, or use --first)", err=True) # --------------------------------------------------------------------------- diff --git a/logseq_cli/commands/__init__.py b/logseq_cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/logseq_cli/commands/blocks.py b/logseq_cli/commands/blocks.py new file mode 100644 index 0000000..5a84f19 --- /dev/null +++ b/logseq_cli/commands/blocks.py @@ -0,0 +1,173 @@ +import datetime + +import click + +from logseq_cli.group import cli +from logseq_cli.helpers import find_blocks_by_content, process_blocks +from logseq_cli.output import fail, handle_connection_error, output + + +FIND_BLOCK_CHILDREN_LIMIT = 25 + + +@cli.command("get-block", epilog="""\b +Example: + logseq-cli --token TOKEN get-block --id 12345678-90ab-cdef-1234-567890abcdef +Note: + UUID accepts "((uuid))" or bare uuid form. Use get-page --resolve-refs for bulk. +""") +@click.option("--id", "block_id", required=True, help="Block UUID (with or without (()))") +@click.option("--no-children", is_flag=True, help="Exclude child blocks") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def get_block(ctx, block_id, no_children, as_json): + """Get a block by UUID.""" + api = ctx.obj["api"] + # Strip (( )) if present + block_id = block_id.strip("()") + include_children = not no_children + block = api.get_block(block_id, include_children=include_children) + + if not block: + # The API answers an unknown UUID with null, so returning that verbatim + # on stdout with exit 0 reads as a successful empty block. Report it the + # way get-page reports a missing page: non-zero exit, error on stderr. + fail(f"Block not found: {block_id}", as_json=as_json, + id=block_id, exists=False) + + if as_json: + output(block, True) + else: + # Metadata + page_info = block.get("page") + if isinstance(page_info, dict): + click.echo(f"Page: {page_info.get('name') or page_info.get('id', '?')}") + elif page_info is not None: + click.echo(f"Page: {page_info}") + parent_info = block.get("parent") + if isinstance(parent_info, dict): + click.echo(f"Parent: {parent_info.get('name') or parent_info.get('id', '?')}") + elif parent_info is not None: + click.echo(f"Parent: {parent_info}") + created = block.get("createdAt") or block.get("created-at") + updated = block.get("updatedAt") or block.get("updated-at") + if created: + click.echo(f"Created: {datetime.datetime.fromtimestamp(created / 1000).strftime('%Y-%m-%d %H:%M')}") + if updated: + click.echo(f"Updated: {datetime.datetime.fromtimestamp(updated / 1000).strftime('%Y-%m-%d %H:%M')}") + click.echo() + + content = block.get("content", "") + click.echo(content) + children = block.get("children", []) + if children: + click.echo(process_blocks(children, indent=1)) + +@cli.command("find-block", epilog="""\b +Examples: + logseq-cli --token TOKEN find-block --content "tag support" --page "Project Alpha" --first + logseq-cli --token TOKEN find-block --content "^### " --page "X" --regex + logseq-cli --token TOKEN find-block --content "14:57" --page "2026-07-22, tuesday" --with-children +Note: + Output gives uuid + page + content preview. Use --first to disambiguate; pipe to + insert-block --child-of, update-block, remove-block downstream. + --with-children prints each match with its sub-blocks indented, instead of + guessing a line count with `get-page | grep -A`. + A common word matches thousands of blocks: --limit N caps the output, and + whatever is withheld is reported on stderr. --first is --limit 1 with the + same notice. +""") +@click.option("--content", required=True, help="Content text (substring match or regex with --regex)") +@click.option("--page", "--name", default=None, help="Restrict search to this page name") +@click.option("--regex", "use_regex", is_flag=True, help="Interpret --content as regex pattern") +@click.option("--first", "first_only", is_flag=True, help="Output only the first match") +@click.option("--limit", "limit", type=int, default=None, help="Print at most N matches (1 or greater); the number withheld is reported on stderr") +@click.option("--with-children", "with_children", is_flag=True, help="Print each match with its sub-blocks (one extra API read per match)") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def find_block(ctx, content, page, use_regex, first_only, limit, with_children, as_json): + """Find blocks by content substring or regex.""" + api = ctx.obj["api"] + + # Before the query, not after it: the whole result set arrives either way + # (measured: 71ms, 473KB for 1382 matches), and a value that will be + # refused must not cost that read first. + if first_only and limit is not None: + fail("Specify either --first or --limit, not both.", as_json) + if limit is not None and limit < 1: + fail("--limit must be 1 or greater.", as_json) + + matches = find_blocks_by_content(api, content, page=page, use_regex=use_regex) + + # A common word matches thousands of blocks, and printing all of them is + # the unbounded-output failure the journal paths fixed in 0.6.0: the caller + # hits its response cap and reasons on a fragment without being told. The + # cut cannot move into the query - DataScript ignores a :limit clause, and + # the whole result set arrives either way (measured: 71ms, 473KB for 1382 + # matches) - so it happens here, and what was withheld is always named. + withheld = 0 + if first_only: + withheld = max(len(matches) - 1, 0) + matches = matches[:1] + elif limit is not None and len(matches) > limit: + withheld = len(matches) - limit + matches = matches[:limit] + + # The datalog pull returns no children, so each subtree costs one extra + # read. Bounded so a broad --content cannot fan out into hundreds of calls; + # what was skipped is stated rather than silently dropped. + truncated = 0 + if with_children and matches: + if len(matches) > FIND_BLOCK_CHILDREN_LIMIT: + truncated = len(matches) - FIND_BLOCK_CHILDREN_LIMIT + matches = matches[:FIND_BLOCK_CHILDREN_LIMIT] + for block in matches: + uuid = block.get("uuid") + if not uuid: + continue + full = api.get_block(uuid, include_children=True) + if full: + block["children"] = full.get("children") or [] + + # stdout stays pure payload in both forms, so the notice goes to stderr + # whether or not --json is set; a caller parsing stdout must still learn + # that it is holding part of an answer. + if withheld: + shown = len(matches) + click.echo( + f"showing {shown} of {shown + withheld} match(es) ... {withheld} omitted " + "(raise --limit, or narrow --content/--page)", err=True) + + if as_json: + output(matches, True) + else: + if not matches: + click.echo("No blocks found.") + else: + click.echo(f"Found {len(matches)} block(s):") + for block in matches: + uuid = block.get("uuid") or "?" + page_info = block.get("page") + page_name = "" + if isinstance(page_info, dict): + page_name = page_info.get("original-name") or page_info.get("name") or "" + click.echo(f" uuid: {uuid}") + if page_name: + click.echo(f" page: {page_name}") + if with_children: + # full content, not a preview: truncating the head of a + # subtree would defeat the point of asking for its children + click.echo(f" content: {block.get('content') or ''}") + children = block.get("children") or [] + if children: + click.echo(process_blocks(children, indent=2)) + else: + preview = (block.get("content") or "")[:80].replace("\n", " ") + click.echo(f" content: {preview}") + click.echo() + if truncated: + click.echo( + f"({truncated} further match(es) not expanded; narrow --content " + "or --page, or use --first)", err=True) diff --git a/pyproject.toml b/pyproject.toml index 3aef9c0..8746af6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,4 +29,4 @@ Issues = "https://github.com/muellerei/logseq-cli/issues" logseq-cli = "logseq_cli.cli:cli" [tool.setuptools] -packages = ["logseq_cli"] +packages = ["logseq_cli", "logseq_cli.commands"] diff --git a/tests/test_find_block_children.py b/tests/test_find_block_children.py index 00ee1fa..6c529cf 100644 --- a/tests/test_find_block_children.py +++ b/tests/test_find_block_children.py @@ -12,7 +12,8 @@ from click.testing import CliRunner -from logseq_cli.cli import cli, FIND_BLOCK_CHILDREN_LIMIT +from logseq_cli.cli import cli +from logseq_cli.commands.blocks import FIND_BLOCK_CHILDREN_LIMIT def _api(matches, children_by_uuid=None): From 7337e5794fe9e107807b7b3ade1ad393a14c2e33 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:20:07 +0200 Subject: [PATCH 11/25] Move get-todos and set-todo-status into logseq_cli/commands/todos.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six symbols: the two commands, the marker table and the three helpers that only they use. set-todo-status matches block references, so this module imports BLOCK_REF_RE from render.py — one of the two cross-module edges the map records, and the reason that regex is not private to either side. No test pointer moves with it. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 502 +-------------------------------- logseq_cli/commands/todos.py | 525 +++++++++++++++++++++++++++++++++++ 2 files changed, 526 insertions(+), 501 deletions(-) create mode 100644 logseq_cli/commands/todos.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index a6c81f2..1b72015 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -75,6 +75,7 @@ count_blocks, ) from logseq_cli.group import cli, resolve_version +from logseq_cli.commands import todos # noqa: F401 imported for registration from logseq_cli.commands import blocks # noqa: F401 imported for registration from logseq_cli.output import fail, handle_connection_error, output from logseq_cli.render import ( @@ -139,7 +140,6 @@ def _word_pattern(words) -> "re.Pattern[str]": return re.compile("|".join(parts), re.IGNORECASE) -_TODO_MARKERS = {"TODO", "DOING", "DONE", "LATER", "NOW", "CANCELED", "WAIT", "WAITING"} # A text replacement must skip property lines: rewriting an id:: line breaks # every ((block-ref)) to that block, irreversibly. Regex shared via helpers. @@ -151,13 +151,6 @@ def _word_pattern(words) -> "re.Pattern[str]": -def _swap_todo_marker(content: str, new_status: str) -> str: - """Replace the leading TODO-marker in content with new_status.""" - parts = content.split(None, 1) - if parts and parts[0].upper() in _TODO_MARKERS: - rest = parts[1] if len(parts) > 1 else "" - return f"{new_status} {rest}".strip() - return f"{new_status} {content}" @@ -3235,511 +3228,18 @@ def add_block_ref(ctx, source_id, journal_date, page, under_heading, dry_run, as click.echo(f" uuid: {new_uuid}") -def _fetch_todo_references(api, markers_str: str) -> dict: - """Map each referenced todo's uuid to the pages its references sit on. - In Logseq a block reference is not a copy, it is the same block appearing in - a second place: checking off a reference checks off the original. Carrying an - open task forward by ``((uuid))`` is therefore the ordinary way to keep it - alive, and the later journals hold references rather than blocks of their - own. ``:block/refs`` is a real relation, so this needs no string matching on - the ``((uuid))`` form. - Answers ``{uuid: [(journal_day_or_None, page_name), ...]}``, unordered and - with duplicates intact — the caller decides what a date range keeps and how - the rest is counted, which it cannot do once entries are dropped here. - """ - query = ( - '[:find (pull ?src [:block/uuid]) ' - '(pull ?refp [:block/original-name :block/name :block/journal-day]) ' - ':where [?src :block/marker ?m] ' - f'[(contains? #{{{markers_str}}} ?m)] ' - '[?ref :block/refs ?src] ' - '[?ref :block/page ?refp]]' - ) - occurrences = {} - for row in api.datascript_query(query) or []: - if not (isinstance(row, (list, tuple)) and len(row) >= 2): - continue - src, refp = row[0], row[1] - # A pull answers None, not {}, for an entity carrying none of the - # requested attributes — seen on a live graph, and it is the reference - # pages without a name that hit this. - if not isinstance(src, dict) or not isinstance(refp, dict): - continue - uuid = src.get("uuid") - name = refp.get("original-name") or refp.get("name", "") - if not uuid or not name: - continue - jd = refp.get("journal-day") or refp.get("journalDay") - occurrences.setdefault(uuid, []).append((jd, name)) - return occurrences - - -def _place_references(occurrences, date_start, date_end, limit: int): - """Pick the occurrences to report and count the ones left out. - - Answers ``(names, withheld)``. ``names`` is sorted newest first, because - Datalog guarantees no result order and because the most recent occurrence is - the one a caller reaches for first — the origin is already in ``page``. - - Two things fall out rather than being listed. An occurrence outside a given - range is not an answer to the question asked; and an occurrence on a page - with no ``journal-day`` cannot be shown to fall inside a range at all, the - same rule the origin page already follows. Both are counted in ``withheld`` - instead of vanishing: that a task has been carried for months is worth - knowing even when the dates themselves are not asked for. - """ - dated, undated = [], [] - for jd, name in occurrences: - if jd is None: - undated.append(name) - continue - try: - dated.append((journal_day_to_date(jd), name)) - except (ValueError, TypeError): - # An unparseable journal-day places an occurrence no better than a - # missing one does. - undated.append(name) - - if date_start or date_end: - in_range, out_of_range = [], len(undated) - for d, name in dated: - dt = datetime.datetime.combine(d, datetime.time()) - if (date_start and dt < date_start) or (date_end and dt > date_end): - out_of_range += 1 - else: - in_range.append((d, name)) - kept = [name for _, name in sorted(in_range, key=lambda e: e[0], reverse=True)] - withheld = out_of_range - else: - kept = [name for _, name in sorted(dated, key=lambda e: e[0], reverse=True)] - kept += sorted(undated) - withheld = 0 - - # Two references written on the same day are one occurrence of that day: - # the field names where a task stood, not how often it was typed. - deduped = list(dict.fromkeys(kept)) - withheld += len(kept) - len(deduped) - - if limit and len(deduped) > limit: - withheld += len(deduped) - limit - deduped = deduped[:limit] - return deduped, withheld # --------------------------------------------------------------------------- # 21. get-todos # --------------------------------------------------------------------------- -@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 -Notes: - --status repeatable. Default: TODO, DOING, NOW, LATER (no DONE). - A task carried forward by a block-ref ((uuid)) is found on the day it stands, - and reported once: "page" and "uuid" stay the original block, "references" - names the other pages it appears on. Following refs costs one extra query for - the whole command, not one per task. --no-follow-refs restores the old reading. - Plain-text output: "MARKER [Page] preview" — page name inline, no grouping needed. -""") -@click.option("--status", multiple=True, default=("TODO", "DOING", "NOW", "LATER"), - help="Task status to include (repeatable, default: TODO DOING NOW LATER)") -@click.option("--page", "--name", default=None, help="Filter by page name (substring, case-insensitive)") -@click.option("--tag", default=None, help="Filter by hashtag (e.g. 'urgent', without #)") -@click.option("--from", "from_date", default=None, help="Only TODOs on or after this date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow'). Dates come from the journal pages a task stands on — the one its block lives on and the ones it was carried into by ((block-ref)) — so tasks found only on ordinary pages are excluded whenever a range is given.") -@click.option("--to", "to_date", default=None, help="Only TODOs on or before this date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow'). Same page rule as --from.") -@click.option("--due-from", "due_from", default=None, help="Only tasks due on or after this date, by SCHEDULED/DEADLINE rather than by the journal page they sit on. Repeating tasks are excluded and reported — Logseq stores their first occurrence, not the next") -@click.option("--due-to", "due_to", default=None, help="Only tasks due on or before this date. Same rule as --due-from") -@click.option("--include-done", is_flag=True, help="Also include DONE tasks") -@click.option("--refs-limit", "refs_limit", type=int, default=10, show_default=True, - help="Occurrences kept per task in 'references'; 0 lifts the cap. references_withheld counts everything left out, which with --from/--to also includes occurrences outside the range and on pages with no journal-day — so 0 does not make it zero") -@click.option("--no-follow-refs", "no_follow_refs", is_flag=True, - help="Do not resolve block-refs: report only where task blocks live, not where they appear. Saves one read") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, include_done, - refs_limit, no_follow_refs, as_json): - """List all TODOs/tasks in the graph.""" - api = ctx.obj["api"] - - markers = set(s.upper() for s in status) - if include_done: - markers.add("DONE") - - if refs_limit < 0: - fail("--refs-limit must be 0 or greater (0 lifts the cap).", as_json) - - markers_str = " ".join(edn_string(m) for m in sorted(markers)) - query = ( - '[: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)] ' - '[?b :block/page ?p]]' - ) - results = api.datascript_query(query) - - # One extra read for the whole command, not one per task: the relation is - # queried in bulk and joined below. --no-follow-refs skips it entirely - # rather than fetching what it will not use. - occurrences = {} if no_follow_refs else _fetch_todo_references(api, markers_str) - - todos = [] - for block_data, page_data in results: - content = block_data.get("content", "") - marker = block_data.get("marker", "") - uuid = block_data.get("uuid", "") - 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 - for line in content.split("\n"): - stripped = line.strip() - if stripped == ":LOGBOOK:": - in_logbook = True - continue - if stripped == ":END:": - in_logbook = False - continue - if in_logbook: - continue - if re.match(r"^\w[\w-]*::\s", line): - continue - if re.match(r"^\s*(SCHEDULED|DEADLINE):\s*<", line): - continue - content_lines.append(line) - clean_content = "\n".join(content_lines).strip() - # Strip leading marker from content (e.g. "TODO some task" -> "some task") - clean_content = re.sub(r"^(TODO|DOING|DONE|NOW|LATER|WAITING|CANCELLED)\s+", "", clean_content) - - record = { - "marker": marker, - "content": clean_content, - "page": page_name, - "uuid": uuid, - "_journal_day": journal_day, - } - # scheduled/deadline are YYYYMMDD integers, the same shape as - # journal-day (verified against a live graph), so the existing - # conversion applies. Absent keys stay absent: a graph that does not - # use these fields must see the payload it saw before. - for field in ("scheduled", "deadline"): - raw = block_data.get(field) - if raw: - try: - record[field] = str(journal_day_to_date(raw)) - except (ValueError, TypeError): - pass - if block_data.get("repeated?"): - record["repeating"] = True - # Logseq stores the date as written, never the next occurrence, 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) - 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) - todos.append(record) - - # Filter by page if requested - if page: - page_lower = page.lower() - todos = [t for t in todos if page_lower in t["page"].lower()] - - # Filter by tag if requested - if tag: - tag_pattern = re.compile(rf"#\b{re.escape(tag)}\b", re.IGNORECASE) - todos = [t for t in todos if tag_pattern.search(t["content"])] - - # Resolve block references. A task carried forward by ((uuid)) stands on the - # later day as much as on the day it was written, so its occurrences are - # attached here — before the date filter, which reads them. - date_start = ( - datetime.datetime.combine(parse_date_keyword(from_date), datetime.time()) - if from_date else None - ) - date_end = ( - datetime.datetime.combine(parse_date_keyword(to_date), datetime.time()) - if to_date else None - ) - for t in todos: - refs = occurrences.get(t["uuid"]) - if not refs: - continue - names, withheld = _place_references(refs, date_start, date_end, refs_limit) - # A task with no occurrence left to report carries no field: a caller - # reading tasks that are not carried forward sees the payload it saw - # before this command learned to follow references. - if names: - t["references"] = names - if withheld: - t["references_withheld"] = withheld - - # Filter by date range. A task counts as inside the range if the journal - # page it sits on is, or if it appears inside it through a reference — - # checking off a reference checks off the original, so both are the same - # task standing on that day. - # - # A page carrying no journal-day cannot be shown to fall inside the range, - # so it falls out of it, and the same rule governs reference pages: 44 of - # 248 reference occurrences measured on a live graph sit on ordinary pages. - # Letting either pass made the filter apply to the journal subset only and - # stay silent about the rest: a range predating the graph still returned - # every task on an ordinary page, and no caller could tell which part had - # been filtered. - if from_date or to_date: - filtered = [] - for t in todos: - if t.get("references"): - filtered.append(t) - continue - jd = t.get("_journal_day") - if jd is None: - continue - try: - d = journal_day_to_date(jd) - dt = datetime.datetime.combine(d, datetime.time()) - if date_start and dt < date_start: - continue - if date_end and dt > date_end: - continue - filtered.append(t) - except (ValueError, TypeError): - # An unparseable journal-day is no more inside the range than a - # missing one; keeping it here would reintroduce the same - # silent pass-through for a rarer input. - continue - todos = filtered - - # Filter by due date. Separate from --from/--to on purpose: those date a - # task by the journal page it sits on, which is when it was written down. - # - # Repeating tasks are excluded rather than placed. Measured against a live - # graph: :block/scheduled holds the date written in the text, not the next - # occurrence, so a weekly task created in 2020 still reads 20200106. Logseq - # does not store the next date anywhere, and computing it here would put a - # second answer beside the graph's own - and could not be done at all for - # the `.+` form, which repeats from completion. They are reported instead. - repeating_excluded = [] - if due_from or due_to: - due_start = parse_date_keyword(due_from) if due_from else None - due_end = parse_date_keyword(due_to) if due_to else None - kept = [] - for t in todos: - # A deadline is the commitment; a schedule is when work starts. A - # task carrying both is placed by its deadline. - # A deadline is the commitment; a schedule is when work starts. A - # task carrying both is placed by its deadline. For a repeater the - # derived next occurrence replaces the stored date, which is its - # first one — filtering on that would place a live weekly task in - # the year it was created. - due = t.get("next_due") or t.get("deadline") or t.get("scheduled") - if not due: - continue - if t.get("repeating") and not t.get("next_due"): - repeating_excluded.append(t) - continue - try: - d = datetime.date.fromisoformat(due) - except (ValueError, TypeError): - continue - if due_start and d < due_start: - continue - if due_end and d > due_end: - continue - kept.append(t) - todos = kept - - # Strip internal _journal_day before output - for t in todos: - 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())) - - if repeating_excluded: - # Named, not just counted: a bare number would leave the caller unable - # to tell which commitments were left out of the answer. - click.echo( - f"⚠️ {len(repeating_excluded)} repeating task(s) excluded from the " - f"due range — their repeat interval could not be read, so the next " - f"occurrence cannot be derived:", - err=True) - for t in repeating_excluded: - click.echo(f" {t['content'][:70]} ({t['page']})", err=True) - - if as_json: - payload = {"todos": todos, "count": len(todos)} - if repeating_excluded: - payload["repeating_excluded"] = len(repeating_excluded) - output(payload, True) - else: - if not todos: - click.echo("No tasks found.") - else: - click.echo(f"Tasks ({len(todos)}):\n") - for t in todos: - preview = t["content"][:100] + ("..." if len(t["content"]) > 100 else "") - click.echo(f" {t['marker']} [{t['page']}] {preview}") - # Named here too, not only in JSON: the gap this closes was - # just as invisible in plain text, and "[Mar 4th]" alone still - # reads as though the task had not been touched since. - refs = t.get("references") - if refs: - withheld = t.get("references_withheld") - more = f" (+{withheld} more)" if withheld else "" - # Semicolons, not commas: a journal page is named - # "2026-09-16, Wednesday", so a comma-separated list of - # them reads as twice as many entries as it holds. - click.echo(f" also on: {'; '.join(refs)}{more}") - elif t.get("references_withheld"): - click.echo(f" also on {t['references_withheld']} other page(s)") # --------------------------------------------------------------------------- # 21b. set-todo-status # --------------------------------------------------------------------------- -@cli.command("set-todo-status", epilog="""\b -Examples: - logseq-cli --token TOKEN set-todo-status --id UUID --status DONE - logseq-cli --token TOKEN set-todo-status --content "ship the parser" \\ - --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 block is a ((uuid)) ref to a project page, updates the original. - Prefer this over replace-text for marker changes — 1 call, deterministic. -""") -@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)") -@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") -@click.option("--follow-refs", is_flag=True, - help="If the block content is a ((uuid)) reference, follow it and update the original block instead.") -@click.option("--dry-run", "dry_run", is_flag=True, help="Show the marker change, without writing") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def set_todo_status(ctx, block_id, content, page, status, follow_refs, dry_run, as_json): - """Update the status of a TODO block (e.g. TODO → DONE). - - Identify the block either by UUID (--id) or by content substring + page (--content + --page). - Use --follow-refs when the block is a ((uuid)) reference in a journal and the original block - lives on a project page. - - Examples: - logseq-cli set-todo-status --id UUID --status DONE - logseq-cli set-todo-status --content "ship the parser" --page "Project Alpha" --status DONE - logseq-cli set-todo-status --id JOURNAL-REF-UUID --status DONE --follow-refs - """ - 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) - - # Resolve UUID via content search if needed - if not block_id: - 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 - if not candidates: - click.echo(f"No block found matching '{content}' on page '{page}'.", err=True) - sys.exit(1) - 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 - # command overwrites content, so an ambiguous selector must stop. - listing = "\n".join( - f" {m.get('uuid')} {(m.get('content') or '')[:70]}" - 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) - block_id = candidates[0].get("uuid") - old_content = candidates[0].get("content", "") - else: - block_id = block_id.strip("()") - block = api.get_block(block_id, include_children=False) - if not block: - click.echo(f"Block {block_id} not found.", err=True) - sys.exit(1) - old_content = block.get("content", "") - - # --follow-refs: if block content is just a ((uuid)) reference, update the referenced block - if follow_refs: - stripped = old_content.strip() - ref_match = BLOCK_REF_RE.fullmatch(stripped) - if ref_match: - ref_uuid = ref_match.group(1) - ref_block = api.get_block(ref_uuid, include_children=False) - if ref_block: - block_id = ref_uuid - old_content = ref_block.get("content", "") - else: - click.echo(f"Warning: referenced block {ref_uuid} not found, updating original.", err=True) - - new_content = _swap_todo_marker(old_content, status) - if new_content == old_content: - if as_json: - output({"uuid": block_id, "status": "unchanged", "content": old_content}, True) - else: - click.echo(f"No change (block already has status or no marker found).") - 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 - # 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 = "" - - if dry_run: - if as_json: - output({"uuid": block_id, "old_marker": old_marker, "new_marker": status, - "old": old_content, "new": new_content, "status": status, - "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would set status on block {block_id}") - click.echo(f" marker: {old_marker or '(none)'} -> {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 "") - click.echo(f" now: {preview}") - return - - api.update_block(block_id, new_content) - - if as_json: - output({"uuid": block_id, "old": old_content, "new": new_content, "status": status}, True) - else: - click.echo(f"Updated: {old_content[:60]}{'...' if len(old_content) > 60 else ''}") - click.echo(f" → {new_content[:60]}{'...' if len(new_content) > 60 else ''}") # --------------------------------------------------------------------------- diff --git a/logseq_cli/commands/todos.py b/logseq_cli/commands/todos.py new file mode 100644 index 0000000..b401a17 --- /dev/null +++ b/logseq_cli/commands/todos.py @@ -0,0 +1,525 @@ +import datetime +import re +import sys + +import click + +from logseq_cli.datalog import edn_string +from logseq_cli.group import cli +from logseq_cli.helpers import ( + find_blocks_by_content, + journal_day_to_date, + next_occurrence, + parse_date_keyword, + parse_repeater, +) +from logseq_cli.output import fail, handle_connection_error, output +from logseq_cli.render import BLOCK_REF_RE + + +_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.""" + parts = content.split(None, 1) + if parts and parts[0].upper() in _TODO_MARKERS: + rest = parts[1] if len(parts) > 1 else "" + return f"{new_status} {rest}".strip() + return f"{new_status} {content}" + +def _fetch_todo_references(api, markers_str: str) -> dict: + """Map each referenced todo's uuid to the pages its references sit on. + + In Logseq a block reference is not a copy, it is the same block appearing in + a second place: checking off a reference checks off the original. Carrying an + open task forward by ``((uuid))`` is therefore the ordinary way to keep it + alive, and the later journals hold references rather than blocks of their + own. ``:block/refs`` is a real relation, so this needs no string matching on + the ``((uuid))`` form. + + Answers ``{uuid: [(journal_day_or_None, page_name), ...]}``, unordered and + with duplicates intact — the caller decides what a date range keeps and how + the rest is counted, which it cannot do once entries are dropped here. + """ + query = ( + '[:find (pull ?src [:block/uuid]) ' + '(pull ?refp [:block/original-name :block/name :block/journal-day]) ' + ':where [?src :block/marker ?m] ' + f'[(contains? #{{{markers_str}}} ?m)] ' + '[?ref :block/refs ?src] ' + '[?ref :block/page ?refp]]' + ) + occurrences = {} + for row in api.datascript_query(query) or []: + if not (isinstance(row, (list, tuple)) and len(row) >= 2): + continue + src, refp = row[0], row[1] + # A pull answers None, not {}, for an entity carrying none of the + # requested attributes — seen on a live graph, and it is the reference + # pages without a name that hit this. + if not isinstance(src, dict) or not isinstance(refp, dict): + continue + uuid = src.get("uuid") + name = refp.get("original-name") or refp.get("name", "") + if not uuid or not name: + continue + jd = refp.get("journal-day") or refp.get("journalDay") + occurrences.setdefault(uuid, []).append((jd, name)) + return occurrences + +def _place_references(occurrences, date_start, date_end, limit: int): + """Pick the occurrences to report and count the ones left out. + + Answers ``(names, withheld)``. ``names`` is sorted newest first, because + Datalog guarantees no result order and because the most recent occurrence is + the one a caller reaches for first — the origin is already in ``page``. + + Two things fall out rather than being listed. An occurrence outside a given + range is not an answer to the question asked; and an occurrence on a page + with no ``journal-day`` cannot be shown to fall inside a range at all, the + same rule the origin page already follows. Both are counted in ``withheld`` + instead of vanishing: that a task has been carried for months is worth + knowing even when the dates themselves are not asked for. + """ + dated, undated = [], [] + for jd, name in occurrences: + if jd is None: + undated.append(name) + continue + try: + dated.append((journal_day_to_date(jd), name)) + except (ValueError, TypeError): + # An unparseable journal-day places an occurrence no better than a + # missing one does. + undated.append(name) + + if date_start or date_end: + in_range, out_of_range = [], len(undated) + for d, name in dated: + dt = datetime.datetime.combine(d, datetime.time()) + if (date_start and dt < date_start) or (date_end and dt > date_end): + out_of_range += 1 + else: + in_range.append((d, name)) + kept = [name for _, name in sorted(in_range, key=lambda e: e[0], reverse=True)] + withheld = out_of_range + else: + kept = [name for _, name in sorted(dated, key=lambda e: e[0], reverse=True)] + kept += sorted(undated) + withheld = 0 + + # Two references written on the same day are one occurrence of that day: + # the field names where a task stood, not how often it was typed. + deduped = list(dict.fromkeys(kept)) + withheld += len(kept) - len(deduped) + + if limit and len(deduped) > limit: + withheld += len(deduped) - limit + deduped = deduped[:limit] + return deduped, withheld + +@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 +Notes: + --status repeatable. Default: TODO, DOING, NOW, LATER (no DONE). + A task carried forward by a block-ref ((uuid)) is found on the day it stands, + and reported once: "page" and "uuid" stay the original block, "references" + names the other pages it appears on. Following refs costs one extra query for + the whole command, not one per task. --no-follow-refs restores the old reading. + Plain-text output: "MARKER [Page] preview" — page name inline, no grouping needed. +""") +@click.option("--status", multiple=True, default=("TODO", "DOING", "NOW", "LATER"), + help="Task status to include (repeatable, default: TODO DOING NOW LATER)") +@click.option("--page", "--name", default=None, help="Filter by page name (substring, case-insensitive)") +@click.option("--tag", default=None, help="Filter by hashtag (e.g. 'urgent', without #)") +@click.option("--from", "from_date", default=None, help="Only TODOs on or after this date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow'). Dates come from the journal pages a task stands on — the one its block lives on and the ones it was carried into by ((block-ref)) — so tasks found only on ordinary pages are excluded whenever a range is given.") +@click.option("--to", "to_date", default=None, help="Only TODOs on or before this date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow'). Same page rule as --from.") +@click.option("--due-from", "due_from", default=None, help="Only tasks due on or after this date, by SCHEDULED/DEADLINE rather than by the journal page they sit on. Repeating tasks are excluded and reported — Logseq stores their first occurrence, not the next") +@click.option("--due-to", "due_to", default=None, help="Only tasks due on or before this date. Same rule as --due-from") +@click.option("--include-done", is_flag=True, help="Also include DONE tasks") +@click.option("--refs-limit", "refs_limit", type=int, default=10, show_default=True, + help="Occurrences kept per task in 'references'; 0 lifts the cap. references_withheld counts everything left out, which with --from/--to also includes occurrences outside the range and on pages with no journal-day — so 0 does not make it zero") +@click.option("--no-follow-refs", "no_follow_refs", is_flag=True, + help="Do not resolve block-refs: report only where task blocks live, not where they appear. Saves one read") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def get_todos(ctx, status, page, tag, from_date, to_date, due_from, due_to, include_done, + refs_limit, no_follow_refs, as_json): + """List all TODOs/tasks in the graph.""" + api = ctx.obj["api"] + + markers = set(s.upper() for s in status) + if include_done: + markers.add("DONE") + + if refs_limit < 0: + fail("--refs-limit must be 0 or greater (0 lifts the cap).", as_json) + + markers_str = " ".join(edn_string(m) for m in sorted(markers)) + query = ( + '[: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)] ' + '[?b :block/page ?p]]' + ) + results = api.datascript_query(query) + + # One extra read for the whole command, not one per task: the relation is + # queried in bulk and joined below. --no-follow-refs skips it entirely + # rather than fetching what it will not use. + occurrences = {} if no_follow_refs else _fetch_todo_references(api, markers_str) + + todos = [] + for block_data, page_data in results: + content = block_data.get("content", "") + marker = block_data.get("marker", "") + uuid = block_data.get("uuid", "") + 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 + for line in content.split("\n"): + stripped = line.strip() + if stripped == ":LOGBOOK:": + in_logbook = True + continue + if stripped == ":END:": + in_logbook = False + continue + if in_logbook: + continue + if re.match(r"^\w[\w-]*::\s", line): + continue + if re.match(r"^\s*(SCHEDULED|DEADLINE):\s*<", line): + continue + content_lines.append(line) + clean_content = "\n".join(content_lines).strip() + # Strip leading marker from content (e.g. "TODO some task" -> "some task") + clean_content = re.sub(r"^(TODO|DOING|DONE|NOW|LATER|WAITING|CANCELLED)\s+", "", clean_content) + + record = { + "marker": marker, + "content": clean_content, + "page": page_name, + "uuid": uuid, + "_journal_day": journal_day, + } + # scheduled/deadline are YYYYMMDD integers, the same shape as + # journal-day (verified against a live graph), so the existing + # conversion applies. Absent keys stay absent: a graph that does not + # use these fields must see the payload it saw before. + for field in ("scheduled", "deadline"): + raw = block_data.get(field) + if raw: + try: + record[field] = str(journal_day_to_date(raw)) + except (ValueError, TypeError): + pass + if block_data.get("repeated?"): + record["repeating"] = True + # Logseq stores the date as written, never the next occurrence, 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) + 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) + todos.append(record) + + # Filter by page if requested + if page: + page_lower = page.lower() + todos = [t for t in todos if page_lower in t["page"].lower()] + + # Filter by tag if requested + if tag: + tag_pattern = re.compile(rf"#\b{re.escape(tag)}\b", re.IGNORECASE) + todos = [t for t in todos if tag_pattern.search(t["content"])] + + # Resolve block references. A task carried forward by ((uuid)) stands on the + # later day as much as on the day it was written, so its occurrences are + # attached here — before the date filter, which reads them. + date_start = ( + datetime.datetime.combine(parse_date_keyword(from_date), datetime.time()) + if from_date else None + ) + date_end = ( + datetime.datetime.combine(parse_date_keyword(to_date), datetime.time()) + if to_date else None + ) + for t in todos: + refs = occurrences.get(t["uuid"]) + if not refs: + continue + names, withheld = _place_references(refs, date_start, date_end, refs_limit) + # A task with no occurrence left to report carries no field: a caller + # reading tasks that are not carried forward sees the payload it saw + # before this command learned to follow references. + if names: + t["references"] = names + if withheld: + t["references_withheld"] = withheld + + # Filter by date range. A task counts as inside the range if the journal + # page it sits on is, or if it appears inside it through a reference — + # checking off a reference checks off the original, so both are the same + # task standing on that day. + # + # A page carrying no journal-day cannot be shown to fall inside the range, + # so it falls out of it, and the same rule governs reference pages: 44 of + # 248 reference occurrences measured on a live graph sit on ordinary pages. + # Letting either pass made the filter apply to the journal subset only and + # stay silent about the rest: a range predating the graph still returned + # every task on an ordinary page, and no caller could tell which part had + # been filtered. + if from_date or to_date: + filtered = [] + for t in todos: + if t.get("references"): + filtered.append(t) + continue + jd = t.get("_journal_day") + if jd is None: + continue + try: + d = journal_day_to_date(jd) + dt = datetime.datetime.combine(d, datetime.time()) + if date_start and dt < date_start: + continue + if date_end and dt > date_end: + continue + filtered.append(t) + except (ValueError, TypeError): + # An unparseable journal-day is no more inside the range than a + # missing one; keeping it here would reintroduce the same + # silent pass-through for a rarer input. + continue + todos = filtered + + # Filter by due date. Separate from --from/--to on purpose: those date a + # task by the journal page it sits on, which is when it was written down. + # + # Repeating tasks are excluded rather than placed. Measured against a live + # graph: :block/scheduled holds the date written in the text, not the next + # occurrence, so a weekly task created in 2020 still reads 20200106. Logseq + # does not store the next date anywhere, and computing it here would put a + # second answer beside the graph's own - and could not be done at all for + # the `.+` form, which repeats from completion. They are reported instead. + repeating_excluded = [] + if due_from or due_to: + due_start = parse_date_keyword(due_from) if due_from else None + due_end = parse_date_keyword(due_to) if due_to else None + kept = [] + for t in todos: + # A deadline is the commitment; a schedule is when work starts. A + # task carrying both is placed by its deadline. + # A deadline is the commitment; a schedule is when work starts. A + # task carrying both is placed by its deadline. For a repeater the + # derived next occurrence replaces the stored date, which is its + # first one — filtering on that would place a live weekly task in + # the year it was created. + due = t.get("next_due") or t.get("deadline") or t.get("scheduled") + if not due: + continue + if t.get("repeating") and not t.get("next_due"): + repeating_excluded.append(t) + continue + try: + d = datetime.date.fromisoformat(due) + except (ValueError, TypeError): + continue + if due_start and d < due_start: + continue + if due_end and d > due_end: + continue + kept.append(t) + todos = kept + + # Strip internal _journal_day before output + for t in todos: + 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())) + + if repeating_excluded: + # Named, not just counted: a bare number would leave the caller unable + # to tell which commitments were left out of the answer. + click.echo( + f"⚠️ {len(repeating_excluded)} repeating task(s) excluded from the " + f"due range — their repeat interval could not be read, so the next " + f"occurrence cannot be derived:", + err=True) + for t in repeating_excluded: + click.echo(f" {t['content'][:70]} ({t['page']})", err=True) + + if as_json: + payload = {"todos": todos, "count": len(todos)} + if repeating_excluded: + payload["repeating_excluded"] = len(repeating_excluded) + output(payload, True) + else: + if not todos: + click.echo("No tasks found.") + else: + click.echo(f"Tasks ({len(todos)}):\n") + for t in todos: + preview = t["content"][:100] + ("..." if len(t["content"]) > 100 else "") + click.echo(f" {t['marker']} [{t['page']}] {preview}") + # Named here too, not only in JSON: the gap this closes was + # just as invisible in plain text, and "[Mar 4th]" alone still + # reads as though the task had not been touched since. + refs = t.get("references") + if refs: + withheld = t.get("references_withheld") + more = f" (+{withheld} more)" if withheld else "" + # Semicolons, not commas: a journal page is named + # "2026-09-16, Wednesday", so a comma-separated list of + # them reads as twice as many entries as it holds. + click.echo(f" also on: {'; '.join(refs)}{more}") + elif t.get("references_withheld"): + click.echo(f" also on {t['references_withheld']} other page(s)") + +@cli.command("set-todo-status", epilog="""\b +Examples: + logseq-cli --token TOKEN set-todo-status --id UUID --status DONE + logseq-cli --token TOKEN set-todo-status --content "ship the parser" \\ + --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 block is a ((uuid)) ref to a project page, updates the original. + Prefer this over replace-text for marker changes — 1 call, deterministic. +""") +@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)") +@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") +@click.option("--follow-refs", is_flag=True, + help="If the block content is a ((uuid)) reference, follow it and update the original block instead.") +@click.option("--dry-run", "dry_run", is_flag=True, help="Show the marker change, without writing") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def set_todo_status(ctx, block_id, content, page, status, follow_refs, dry_run, as_json): + """Update the status of a TODO block (e.g. TODO → DONE). + + Identify the block either by UUID (--id) or by content substring + page (--content + --page). + Use --follow-refs when the block is a ((uuid)) reference in a journal and the original block + lives on a project page. + + Examples: + logseq-cli set-todo-status --id UUID --status DONE + logseq-cli set-todo-status --content "ship the parser" --page "Project Alpha" --status DONE + logseq-cli set-todo-status --id JOURNAL-REF-UUID --status DONE --follow-refs + """ + 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) + + # Resolve UUID via content search if needed + if not block_id: + 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 + if not candidates: + click.echo(f"No block found matching '{content}' on page '{page}'.", err=True) + sys.exit(1) + 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 + # command overwrites content, so an ambiguous selector must stop. + listing = "\n".join( + f" {m.get('uuid')} {(m.get('content') or '')[:70]}" + 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) + block_id = candidates[0].get("uuid") + old_content = candidates[0].get("content", "") + else: + block_id = block_id.strip("()") + block = api.get_block(block_id, include_children=False) + if not block: + click.echo(f"Block {block_id} not found.", err=True) + sys.exit(1) + old_content = block.get("content", "") + + # --follow-refs: if block content is just a ((uuid)) reference, update the referenced block + if follow_refs: + stripped = old_content.strip() + ref_match = BLOCK_REF_RE.fullmatch(stripped) + if ref_match: + ref_uuid = ref_match.group(1) + ref_block = api.get_block(ref_uuid, include_children=False) + if ref_block: + block_id = ref_uuid + old_content = ref_block.get("content", "") + else: + click.echo(f"Warning: referenced block {ref_uuid} not found, updating original.", err=True) + + new_content = _swap_todo_marker(old_content, status) + if new_content == old_content: + if as_json: + output({"uuid": block_id, "status": "unchanged", "content": old_content}, True) + else: + click.echo(f"No change (block already has status or no marker found).") + 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 + # 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 = "" + + if dry_run: + if as_json: + output({"uuid": block_id, "old_marker": old_marker, "new_marker": status, + "old": old_content, "new": new_content, "status": status, + "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would set status on block {block_id}") + click.echo(f" marker: {old_marker or '(none)'} -> {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 "") + click.echo(f" now: {preview}") + return + + api.update_block(block_id, new_content) + + if as_json: + output({"uuid": block_id, "old": old_content, "new": new_content, "status": status}, True) + else: + click.echo(f"Updated: {old_content[:60]}{'...' if len(old_content) > 60 else ''}") + click.echo(f" → {new_content[:60]}{'...' if len(new_content) > 60 else ''}") From 348d46ad30696ddb2142460cf5f1797c308eac80 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:20:50 +0200 Subject: [PATCH 12/25] Move the property commands into logseq_cli/commands/properties.py Five commands and the four helpers only they use. tests/test_property_list_values.py imports _format_property_value and moves with it; its import is the combined form, so the whole line is replaced. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 369 +-------------------------- logseq_cli/commands/properties.py | 386 +++++++++++++++++++++++++++++ tests/test_property_list_values.py | 3 +- 3 files changed, 389 insertions(+), 369 deletions(-) create mode 100644 logseq_cli/commands/properties.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 1b72015..e4f48f9 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -75,6 +75,7 @@ count_blocks, ) from logseq_cli.group import cli, resolve_version +from logseq_cli.commands import properties # noqa: F401 imported for registration from logseq_cli.commands import todos # noqa: F401 imported for registration from logseq_cli.commands import blocks # noqa: F401 imported for registration from logseq_cli.output import fail, handle_connection_error, output @@ -1365,58 +1366,12 @@ def _extract_param_from_request(req_lower, keywords, original_request): return result if result else None -def _property_key_spellings(key: str): - """Return the datalog spellings to try for a property key. - - Logseq stores property keys kebab-cased in datalog but shows them - camelCased. A camelCase key gets its kebab form added so either spelling - the user types finds the page; the camelCase form is kept too, in case a - foreign graph stored it that way. Order preserved, duplicates dropped. - """ - kebab = re.sub(r"([A-Z])", lambda m: "-" + m.group(1).lower(), key) - forms = [key] - if kebab != key: - forms.append(kebab) - return forms -def _find_stored_property_key(props: dict, key: str): - """Find the stored spelling of a user-typed property key. - - The API returns camelCase keys (excludeFromGraphView), datalog and habit - spell them kebab-cased; a plain .lower() matches neither. Compare with - dashes stripped and case folded so every spelling finds the stored key. - """ - want = key.replace("-", "").lower() - for stored in props: - if stored.replace("-", "").lower() == want: - return stored - return None -def _format_property_value(value) -> str: - """A property value as the page writes it, not as Python prints it. - - A collection rendered with ``str()`` comes out as ``['Core']`` - Python - syntax for something the page spells ``Core``, or ``Core, Edge`` - when it carries several values. - """ - if isinstance(value, (list, tuple)): - return ", ".join(str(v) for v in value) - return str(value) - -def _read_property_value(props: dict, key: str): - """Read a property value trying every spelling of the key. - The datalog pull returns kebab-cased keys, so a user who typed the - camelCase form would otherwise read an empty value off a page the query - did find. Try each spelling, first hit wins. - """ - for form in _property_key_spellings(key): - if form in props: - return props[form] - return "" def _print_results(results): @@ -3245,252 +3200,21 @@ def add_block_ref(ctx, source_id, journal_date, page, under_heading, dry_run, as # --------------------------------------------------------------------------- # 22. get-properties # --------------------------------------------------------------------------- -@cli.command("get-properties", epilog="""\b -Examples: - logseq-cli --token TOKEN get-properties --name "Alice" - logseq-cli --token TOKEN get-properties --name "Alice" --property "team" -""") -@click.option("--page", "--name", required=True, help="Page name") -@click.option("--property", "prop_name", default=None, help="Get a specific property by name") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def get_properties(ctx, page, prop_name, as_json): - """Get properties of a page.""" - api = ctx.obj["api"] - page_data = api.get_page(page) - - if not page_data: - fail(f"Page '{page}' not found.", as_json=as_json, page=page) - - properties = page_data.get("properties") or {} - text_values = page_data.get("propertiesTextValues") or {} - page_name = page_data.get("originalName") or page_data.get("name", page) - - # Logseq does not always expose page properties on the page object itself: - # for pages written via set-property they live on the first block instead - # (the property block). Without this fallback the command reported - # "No properties" for pages whose properties were perfectly intact on disk, - # which is what made set-property look like it had silently failed. - if not properties: - try: - blocks = api.get_page_blocks_tree(page) or [] - except Exception: - blocks = [] - if blocks: - first = blocks[0] or {} - block_props = first.get("properties") or {} - if block_props: - properties = block_props - text_values = first.get("propertiesTextValues") or text_values - - if prop_name: - stored_key = _find_stored_property_key(properties, prop_name) - if stored_key is None: - fail(f"Property '{prop_name}' not found on '{page_name}'.", - as_json=as_json, page=page_name, property=prop_name) - value = properties.get(stored_key) - text_key = _find_stored_property_key(text_values, prop_name) - text_value = text_values.get(text_key) if text_key else None - - if as_json: - output({"page": page_name, "property": stored_key, "value": value, "text": text_value}, True) - else: - click.echo(text_value or value) - else: - if as_json: - output({"page": page_name, "properties": properties, "text_values": text_values}, True) - else: - if not properties: - click.echo(f"No properties on '{page_name}'.") - else: - click.echo(f"Properties of '{page_name}':\n") - for key in sorted(properties.keys()): - display = text_values.get(key, properties[key]) - click.echo(f" {key}:: {display}") # --------------------------------------------------------------------------- # 23. set-property # --------------------------------------------------------------------------- -@cli.command("set-property", epilog="""\b -Examples: - logseq-cli --token TOKEN set-property --name "Alice" --key "team" --value "[[Platform]]" - logseq-cli --token TOKEN set-property --name "X" --key "type" --value "Person" -Note: - Properties land at page-top (above first block). NEVER use update-block for - properties — that creates a text-block, not a real property. - Verify with: get-properties --name X -""") -@click.option("--page", "--name", required=True, help="Page name") -@click.option("--key", required=True, help="Property key (e.g. 'type', 'team', 'role')") -@click.option("--value", required=True, help="Property value") -@click.option("--dry-run", "dry_run", is_flag=True, help="Show the property change, without writing") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def set_property(ctx, page, key, value, dry_run, as_json): - """Set or update a property on a page's first block.""" - api = ctx.obj["api"] - - # Get page blocks to find the first block (properties block) - blocks = api.get_page_blocks_tree(page) - if not blocks: - fail(f"Page '{page}' not found or has no blocks", as_json=as_json, page=page) - - first_block = blocks[0] - block_uuid = first_block.get("uuid") - if not block_uuid: - fail("Could not find block UUID", as_json=as_json, page=page) - - # Auto-detect value type (shared with set-block-property / --property) - value = coerce_property_value(value) - - if dry_run: - # Whether this creates or overwrites is the fact worth previewing: the - # command is called "set" either way, and an unnoticed overwrite loses - # the old value with no trace. It is read off the block already fetched. - existing = first_block.get("properties") or {} - had = key in existing - old_value = existing.get(key) - if as_json: - output({"page": page, "property": key, "old_value": old_value, - "value": value, "existed": had, "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would set '{key}::' on page '{page}'") - if had: - click.echo(f" was: {old_value}") - else: - click.echo(f" was: (not set)") - click.echo(f" now: {value}") - return - - api.upsert_block_property(str(block_uuid), key, value) - - result = {"page": page, "property": key, "value": value, "status": "updated"} - if as_json: - output(result, True) - else: - click.echo(f"Set '{key}:: {value}' on page '{page}'") # --------------------------------------------------------------------------- # 24. remove-property # --------------------------------------------------------------------------- -@cli.command("remove-property", epilog="""\b -Examples: - logseq-cli --token TOKEN remove-property --name "X" --key "deprecated_key" - logseq-cli --token TOKEN remove-property --id UUID --key "prio" -Note: - --name removes a PAGE property (stored on the page's first block). - --id removes the property from that one block, wherever it sits. -""") -@click.option("--page", "--name", default=None, help="Page name (removes a page property)") -@click.option("--id", "block_id", default=None, help="Block UUID (removes the property from that block)") -@click.option("--key", required=True, help="Property key to remove") -@click.option("--dry-run", "dry_run", is_flag=True, help="Show which property would be removed, without writing") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def remove_property(ctx, page, block_id, key, dry_run, as_json): - """Remove a property from a page or from a single block.""" - api = ctx.obj["api"] - if bool(page) == bool(block_id): - fail("Specify exactly one of: --name, --id.", as_json=as_json) - - if block_id: - # A page property is just a property on the page's first block, so the - # API call is the same; only the way the block is found differs. - block_uuid = block_id.strip().replace("((", "").replace("))", "") - block = api.get_block(block_uuid, include_children=False) - if not block: - fail(f"Block not found: {block_uuid}", as_json=as_json, id=block_uuid) - existing = (block.get("properties") if isinstance(block, dict) else None) or {} - target = f"block '{block_uuid}'" - result = {"id": block_uuid, "property": key, "status": "removed"} - else: - blocks = api.get_page_blocks_tree(page) - if not blocks: - fail(f"Page '{page}' not found or has no blocks", as_json=as_json, page=page) - block_uuid = blocks[0].get("uuid") - if not block_uuid: - fail("Could not find block UUID", as_json=as_json, page=page) - existing = blocks[0].get("properties") or {} - target = f"page '{page}'" - result = {"page": page, "property": key, "status": "removed"} - - if dry_run: - # "Property not there" is the outcome worth knowing before the write: - # the real call succeeds silently either way, so a caller who misspelled - # the key would otherwise see "Removed" and believe it. - present = key in existing - if as_json: - output({**result, "status": "would_remove" if present else "not_present", - "value": existing.get(key), "present": present, - "dry_run": True}, True) - elif present: - click.echo(f"[DRY RUN] Would remove '{key}' from {target}") - click.echo(f" value: {existing[key]}") - else: - click.echo(f"[DRY RUN] '{key}' is not set on {target}; nothing would be removed") - return - - api.remove_block_property(str(block_uuid), key) - - if as_json: - output(result, True) - else: - click.echo(f"Removed '{key}' from {target}") # --------------------------------------------------------------------------- # 25. set-block-property # --------------------------------------------------------------------------- -@cli.command("set-block-property", epilog="""\b -Example: - logseq-cli --token TOKEN set-block-property --id UUID --key "id" --value "abc-123" -""") -@click.option("--id", "block_id", required=True, help="Block UUID") -@click.option("--key", required=True, help="Property key") -@click.option("--value", required=True, help="Property value") -@click.option("--dry-run", "dry_run", is_flag=True, help="Show the property change, without writing") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def set_block_property(ctx, block_id, key, value, dry_run, as_json): - """Set or update a property on a specific block.""" - api = ctx.obj["api"] - - # Auto-detect value type (shared coercion with the inline --property option) - value = coerce_property_value(value) - - if dry_run: - # The write path sets the property blind — upsert needs no prior read. - # The preview does need one: without it there is no old value to show, - # and it also turns a mistyped UUID into an error instead of a silent - # no-op. One extra read, only on this path. - block = api.get_block(block_id, include_children=False) - if not block: - fail(f"Block not found: {block_id}", as_json=as_json, id=block_id) - existing = (block.get("properties") if isinstance(block, dict) else None) or {} - had = key in existing - old_value = existing.get(key) - if as_json: - output({"block": block_id, "property": key, "old_value": old_value, - "value": value, "existed": had, "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would set '{key}::' on block '{block_id}'") - click.echo(f" was: {old_value if had else '(not set)'}") - click.echo(f" now: {value}") - return - - api.upsert_block_property(block_id, key, value) - - result = {"block": block_id, "property": key, "value": value, "status": "updated"} - if as_json: - output(result, True) - else: - click.echo(f"Set '{key}:: {value}' on block '{block_id}'") # --------------------------------------------------------------------------- @@ -3648,97 +3372,6 @@ def delete_page(ctx, page, force, dry_run, as_json): # --------------------------------------------------------------------------- # 28. query-pages-by-property # --------------------------------------------------------------------------- -@cli.command("query-pages-by-property", epilog="""\b -Examples: - logseq-cli --token TOKEN query-pages-by-property --key "type" --value "Person" - logseq-cli --token TOKEN query-pages-by-property --key "team" -Note: - Without --value: lists all pages that have the key (with their values). - With --value: matches the whole value, and also a page whose value is a - collection containing it — Logseq stores `team:: Core` as "Core" on one - page and ["Core"] on another, and the page does not show which. -""") -@click.option("--key", required=True, help="Property key to filter by (e.g. 'type', 'team', 'role')") -@click.option("--value", default=None, help="Property value to match (omit to find all pages with this key)") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def query_pages_by_property(ctx, key, value, as_json): - """Find pages by property key/value (e.g. --key type --value Person).""" - api = ctx.obj["api"] - - # Property keys have two spellings for the same data: Logseq displays - # camelCase (excludeFromGraphView), datalog stores kebab-case - # (exclude-from-graph-view). Querying the user's spelling as-is finds - # nothing when they typed the displayed form. Try both, so either works; - # a foreign graph might store either. Both are whitelisted before use. - key_forms = _property_key_spellings(key) - key_get = " ".join( - f"[(get ?props :{edn_keyword(k)}) ?v]" for k in key_forms - ) - key_clause = key_get if len(key_forms) == 1 else f"(or {key_get})" - if value: - # Logseq stores a property value either as a scalar or as a collection, - # and which one is not visible from the page: on one real graph `team` - # was "Core" on two pages and ["Core"] on ten others. Equality alone - # matched the two and silently dropped the rest. `contains?` covers the - # collection form; neither `coll?` nor `set` is available as a predicate - # here, so the two shapes are tried side by side rather than normalised. - literal = edn_string(value) - value_clause = f"(or [(= ?v {literal})] [(contains? ?v {literal})])" - query = f'''[:find (pull ?p [:block/name :block/original-name :block/properties]) - :where - [?p :block/name] - [?p :block/properties ?props] - {key_clause} - {value_clause}]''' - else: - # Query pages that have this property key (any value) - query = f'''[:find (pull ?p [:block/name :block/original-name :block/properties]) - :where - [?p :block/name] - [?p :block/properties ?props] - {key_clause}]''' - - # The former full-scan fallback is gone: it existed for a malformed key, - # which edn_keyword now rejects before any query is built, and a silent - # scan over ~1900 pages is no good answer even on success. A rejected key - # is a usage error with a clear message, not a reason to fall back. - results = api.datascript_query(query) - - # Extract page names from results - pages_found = [] - for item in results: - if isinstance(item, list) and len(item) > 0: - page = item[0] - if isinstance(page, dict): - name = page.get("original-name") or page.get("name", "?") - prop_value = _read_property_value(page.get("properties", {}), key) - pages_found.append({"name": name, "value": _format_property_value(prop_value)}) - elif isinstance(item, dict): - name = item.get("original-name") or item.get("name", "?") - prop_value = _read_property_value(item.get("properties", {}), key) - pages_found.append({"name": name, "value": _format_property_value(prop_value)}) - - pages_found.sort(key=lambda x: x["name"].lower()) - - result_data = { - "key": key, - "value": value, - "count": len(pages_found), - "pages": pages_found, - } - - if as_json: - output(result_data, True) - else: - filter_desc = f"{key}:: {value}" if value else f"{key}:: *" - click.echo(f"Pages with {filter_desc} ({len(pages_found)}):\n") - for p in pages_found: - if value: - click.echo(f" {p['name']}") - else: - click.echo(f" {p['name']} ({key}:: {p['value']})") # --------------------------------------------------------------------------- diff --git a/logseq_cli/commands/properties.py b/logseq_cli/commands/properties.py new file mode 100644 index 0000000..3289f66 --- /dev/null +++ b/logseq_cli/commands/properties.py @@ -0,0 +1,386 @@ +import re + +import click + +from logseq_cli.group import cli +from logseq_cli.datalog import edn_keyword, edn_string +from logseq_cli.helpers import coerce_property_value +from logseq_cli.output import fail, handle_connection_error, output + + +def _property_key_spellings(key: str): + """Return the datalog spellings to try for a property key. + + Logseq stores property keys kebab-cased in datalog but shows them + camelCased. A camelCase key gets its kebab form added so either spelling + the user types finds the page; the camelCase form is kept too, in case a + foreign graph stored it that way. Order preserved, duplicates dropped. + """ + kebab = re.sub(r"([A-Z])", lambda m: "-" + m.group(1).lower(), key) + forms = [key] + if kebab != key: + forms.append(kebab) + return forms + +def _find_stored_property_key(props: dict, key: str): + """Find the stored spelling of a user-typed property key. + + The API returns camelCase keys (excludeFromGraphView), datalog and habit + spell them kebab-cased; a plain .lower() matches neither. Compare with + dashes stripped and case folded so every spelling finds the stored key. + """ + want = key.replace("-", "").lower() + for stored in props: + if stored.replace("-", "").lower() == want: + return stored + return None + +def _format_property_value(value) -> str: + """A property value as the page writes it, not as Python prints it. + + A collection rendered with ``str()`` comes out as ``['Core']`` - Python + syntax for something the page spells ``Core``, or ``Core, Edge`` + when it carries several values. + """ + if isinstance(value, (list, tuple)): + return ", ".join(str(v) for v in value) + return str(value) + +def _read_property_value(props: dict, key: str): + """Read a property value trying every spelling of the key. + + The datalog pull returns kebab-cased keys, so a user who typed the + camelCase form would otherwise read an empty value off a page the query + did find. Try each spelling, first hit wins. + """ + for form in _property_key_spellings(key): + if form in props: + return props[form] + return "" + +@cli.command("get-properties", epilog="""\b +Examples: + logseq-cli --token TOKEN get-properties --name "Alice" + logseq-cli --token TOKEN get-properties --name "Alice" --property "team" +""") +@click.option("--page", "--name", required=True, help="Page name") +@click.option("--property", "prop_name", default=None, help="Get a specific property by name") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def get_properties(ctx, page, prop_name, as_json): + """Get properties of a page.""" + api = ctx.obj["api"] + page_data = api.get_page(page) + + if not page_data: + fail(f"Page '{page}' not found.", as_json=as_json, page=page) + + properties = page_data.get("properties") or {} + text_values = page_data.get("propertiesTextValues") or {} + page_name = page_data.get("originalName") or page_data.get("name", page) + + # Logseq does not always expose page properties on the page object itself: + # for pages written via set-property they live on the first block instead + # (the property block). Without this fallback the command reported + # "No properties" for pages whose properties were perfectly intact on disk, + # which is what made set-property look like it had silently failed. + if not properties: + try: + blocks = api.get_page_blocks_tree(page) or [] + except Exception: + blocks = [] + if blocks: + first = blocks[0] or {} + block_props = first.get("properties") or {} + if block_props: + properties = block_props + text_values = first.get("propertiesTextValues") or text_values + + if prop_name: + stored_key = _find_stored_property_key(properties, prop_name) + if stored_key is None: + fail(f"Property '{prop_name}' not found on '{page_name}'.", + as_json=as_json, page=page_name, property=prop_name) + value = properties.get(stored_key) + text_key = _find_stored_property_key(text_values, prop_name) + text_value = text_values.get(text_key) if text_key else None + + if as_json: + output({"page": page_name, "property": stored_key, "value": value, "text": text_value}, True) + else: + click.echo(text_value or value) + else: + if as_json: + output({"page": page_name, "properties": properties, "text_values": text_values}, True) + else: + if not properties: + click.echo(f"No properties on '{page_name}'.") + else: + click.echo(f"Properties of '{page_name}':\n") + for key in sorted(properties.keys()): + display = text_values.get(key, properties[key]) + click.echo(f" {key}:: {display}") + +@cli.command("set-property", epilog="""\b +Examples: + logseq-cli --token TOKEN set-property --name "Alice" --key "team" --value "[[Platform]]" + logseq-cli --token TOKEN set-property --name "X" --key "type" --value "Person" +Note: + Properties land at page-top (above first block). NEVER use update-block for + properties — that creates a text-block, not a real property. + Verify with: get-properties --name X +""") +@click.option("--page", "--name", required=True, help="Page name") +@click.option("--key", required=True, help="Property key (e.g. 'type', 'team', 'role')") +@click.option("--value", required=True, help="Property value") +@click.option("--dry-run", "dry_run", is_flag=True, help="Show the property change, without writing") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def set_property(ctx, page, key, value, dry_run, as_json): + """Set or update a property on a page's first block.""" + api = ctx.obj["api"] + + # Get page blocks to find the first block (properties block) + blocks = api.get_page_blocks_tree(page) + if not blocks: + fail(f"Page '{page}' not found or has no blocks", as_json=as_json, page=page) + + first_block = blocks[0] + block_uuid = first_block.get("uuid") + if not block_uuid: + fail("Could not find block UUID", as_json=as_json, page=page) + + # Auto-detect value type (shared with set-block-property / --property) + value = coerce_property_value(value) + + if dry_run: + # Whether this creates or overwrites is the fact worth previewing: the + # command is called "set" either way, and an unnoticed overwrite loses + # the old value with no trace. It is read off the block already fetched. + existing = first_block.get("properties") or {} + had = key in existing + old_value = existing.get(key) + if as_json: + output({"page": page, "property": key, "old_value": old_value, + "value": value, "existed": had, "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would set '{key}::' on page '{page}'") + if had: + click.echo(f" was: {old_value}") + else: + click.echo(f" was: (not set)") + click.echo(f" now: {value}") + return + + api.upsert_block_property(str(block_uuid), key, value) + + result = {"page": page, "property": key, "value": value, "status": "updated"} + if as_json: + output(result, True) + else: + click.echo(f"Set '{key}:: {value}' on page '{page}'") + +@cli.command("remove-property", epilog="""\b +Examples: + logseq-cli --token TOKEN remove-property --name "X" --key "deprecated_key" + logseq-cli --token TOKEN remove-property --id UUID --key "prio" +Note: + --name removes a PAGE property (stored on the page's first block). + --id removes the property from that one block, wherever it sits. +""") +@click.option("--page", "--name", default=None, help="Page name (removes a page property)") +@click.option("--id", "block_id", default=None, help="Block UUID (removes the property from that block)") +@click.option("--key", required=True, help="Property key to remove") +@click.option("--dry-run", "dry_run", is_flag=True, help="Show which property would be removed, without writing") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def remove_property(ctx, page, block_id, key, dry_run, as_json): + """Remove a property from a page or from a single block.""" + api = ctx.obj["api"] + if bool(page) == bool(block_id): + fail("Specify exactly one of: --name, --id.", as_json=as_json) + + if block_id: + # A page property is just a property on the page's first block, so the + # API call is the same; only the way the block is found differs. + block_uuid = block_id.strip().replace("((", "").replace("))", "") + block = api.get_block(block_uuid, include_children=False) + if not block: + fail(f"Block not found: {block_uuid}", as_json=as_json, id=block_uuid) + existing = (block.get("properties") if isinstance(block, dict) else None) or {} + target = f"block '{block_uuid}'" + result = {"id": block_uuid, "property": key, "status": "removed"} + else: + blocks = api.get_page_blocks_tree(page) + if not blocks: + fail(f"Page '{page}' not found or has no blocks", as_json=as_json, page=page) + block_uuid = blocks[0].get("uuid") + if not block_uuid: + fail("Could not find block UUID", as_json=as_json, page=page) + existing = blocks[0].get("properties") or {} + target = f"page '{page}'" + result = {"page": page, "property": key, "status": "removed"} + + if dry_run: + # "Property not there" is the outcome worth knowing before the write: + # the real call succeeds silently either way, so a caller who misspelled + # the key would otherwise see "Removed" and believe it. + present = key in existing + if as_json: + output({**result, "status": "would_remove" if present else "not_present", + "value": existing.get(key), "present": present, + "dry_run": True}, True) + elif present: + click.echo(f"[DRY RUN] Would remove '{key}' from {target}") + click.echo(f" value: {existing[key]}") + else: + click.echo(f"[DRY RUN] '{key}' is not set on {target}; nothing would be removed") + return + + api.remove_block_property(str(block_uuid), key) + + if as_json: + output(result, True) + else: + click.echo(f"Removed '{key}' from {target}") + +@cli.command("set-block-property", epilog="""\b +Example: + logseq-cli --token TOKEN set-block-property --id UUID --key "id" --value "abc-123" +""") +@click.option("--id", "block_id", required=True, help="Block UUID") +@click.option("--key", required=True, help="Property key") +@click.option("--value", required=True, help="Property value") +@click.option("--dry-run", "dry_run", is_flag=True, help="Show the property change, without writing") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def set_block_property(ctx, block_id, key, value, dry_run, as_json): + """Set or update a property on a specific block.""" + api = ctx.obj["api"] + + # Auto-detect value type (shared coercion with the inline --property option) + value = coerce_property_value(value) + + if dry_run: + # The write path sets the property blind — upsert needs no prior read. + # The preview does need one: without it there is no old value to show, + # and it also turns a mistyped UUID into an error instead of a silent + # no-op. One extra read, only on this path. + block = api.get_block(block_id, include_children=False) + if not block: + fail(f"Block not found: {block_id}", as_json=as_json, id=block_id) + existing = (block.get("properties") if isinstance(block, dict) else None) or {} + had = key in existing + old_value = existing.get(key) + if as_json: + output({"block": block_id, "property": key, "old_value": old_value, + "value": value, "existed": had, "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would set '{key}::' on block '{block_id}'") + click.echo(f" was: {old_value if had else '(not set)'}") + click.echo(f" now: {value}") + return + + api.upsert_block_property(block_id, key, value) + + result = {"block": block_id, "property": key, "value": value, "status": "updated"} + if as_json: + output(result, True) + else: + click.echo(f"Set '{key}:: {value}' on block '{block_id}'") + +@cli.command("query-pages-by-property", epilog="""\b +Examples: + logseq-cli --token TOKEN query-pages-by-property --key "type" --value "Person" + logseq-cli --token TOKEN query-pages-by-property --key "team" +Note: + Without --value: lists all pages that have the key (with their values). + With --value: matches the whole value, and also a page whose value is a + collection containing it — Logseq stores `team:: Core` as "Core" on one + page and ["Core"] on another, and the page does not show which. +""") +@click.option("--key", required=True, help="Property key to filter by (e.g. 'type', 'team', 'role')") +@click.option("--value", default=None, help="Property value to match (omit to find all pages with this key)") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def query_pages_by_property(ctx, key, value, as_json): + """Find pages by property key/value (e.g. --key type --value Person).""" + api = ctx.obj["api"] + + # Property keys have two spellings for the same data: Logseq displays + # camelCase (excludeFromGraphView), datalog stores kebab-case + # (exclude-from-graph-view). Querying the user's spelling as-is finds + # nothing when they typed the displayed form. Try both, so either works; + # a foreign graph might store either. Both are whitelisted before use. + key_forms = _property_key_spellings(key) + key_get = " ".join( + f"[(get ?props :{edn_keyword(k)}) ?v]" for k in key_forms + ) + key_clause = key_get if len(key_forms) == 1 else f"(or {key_get})" + if value: + # Logseq stores a property value either as a scalar or as a collection, + # and which one is not visible from the page: on one real graph `team` + # was "Core" on two pages and ["Core"] on ten others. Equality alone + # matched the two and silently dropped the rest. `contains?` covers the + # collection form; neither `coll?` nor `set` is available as a predicate + # here, so the two shapes are tried side by side rather than normalised. + literal = edn_string(value) + value_clause = f"(or [(= ?v {literal})] [(contains? ?v {literal})])" + query = f'''[:find (pull ?p [:block/name :block/original-name :block/properties]) + :where + [?p :block/name] + [?p :block/properties ?props] + {key_clause} + {value_clause}]''' + else: + # Query pages that have this property key (any value) + query = f'''[:find (pull ?p [:block/name :block/original-name :block/properties]) + :where + [?p :block/name] + [?p :block/properties ?props] + {key_clause}]''' + + # The former full-scan fallback is gone: it existed for a malformed key, + # which edn_keyword now rejects before any query is built, and a silent + # scan over ~1900 pages is no good answer even on success. A rejected key + # is a usage error with a clear message, not a reason to fall back. + results = api.datascript_query(query) + + # Extract page names from results + pages_found = [] + for item in results: + if isinstance(item, list) and len(item) > 0: + page = item[0] + if isinstance(page, dict): + name = page.get("original-name") or page.get("name", "?") + prop_value = _read_property_value(page.get("properties", {}), key) + pages_found.append({"name": name, "value": _format_property_value(prop_value)}) + elif isinstance(item, dict): + name = item.get("original-name") or item.get("name", "?") + prop_value = _read_property_value(item.get("properties", {}), key) + pages_found.append({"name": name, "value": _format_property_value(prop_value)}) + + pages_found.sort(key=lambda x: x["name"].lower()) + + result_data = { + "key": key, + "value": value, + "count": len(pages_found), + "pages": pages_found, + } + + if as_json: + output(result_data, True) + else: + filter_desc = f"{key}:: {value}" if value else f"{key}:: *" + click.echo(f"Pages with {filter_desc} ({len(pages_found)}):\n") + for p in pages_found: + if value: + click.echo(f" {p['name']}") + else: + click.echo(f" {p['name']} ({key}:: {p['value']})") diff --git a/tests/test_property_list_values.py b/tests/test_property_list_values.py index 32803d8..0533128 100644 --- a/tests/test_property_list_values.py +++ b/tests/test_property_list_values.py @@ -15,7 +15,8 @@ from click.testing import CliRunner -from logseq_cli.cli import cli, _format_property_value +from logseq_cli.cli import cli +from logseq_cli.commands.properties import _format_property_value def _api(rows): From 030af099eec5fdf7b840ec9b2400f9510adf51cb Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:21:11 +0200 Subject: [PATCH 13/25] Move smart-query into logseq_cli/commands/query.py The command and the two helpers only it uses. No test pointer moves with it. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 314 +-------------------------------- logseq_cli/commands/query.py | 325 +++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 313 deletions(-) create mode 100644 logseq_cli/commands/query.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index e4f48f9..a0a58a1 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -75,6 +75,7 @@ count_blocks, ) from logseq_cli.group import cli, resolve_version +from logseq_cli.commands import query # noqa: F401 imported for registration from logseq_cli.commands import properties # noqa: F401 imported for registration from logseq_cli.commands import todos # noqa: F401 imported for registration from logseq_cli.commands import blocks # noqa: F401 imported for registration @@ -1348,22 +1349,6 @@ def analyze_journal_patterns(ctx, timeframe, mood, topics, as_json): click.echo(f" {month}: {', '.join(month_topics[:15])}") -def _extract_param_from_request(req_lower, keywords, original_request): - """Extract a dynamic parameter value from a natural language request. - - Removes matched keywords from the request to isolate the parameter value. - Example: "links to Alice" with keyword "links to" -> "Alice" - """ - remaining = original_request.strip() - remaining_lower = req_lower.strip() - # Remove matched keywords (longest first to avoid partial removal) - for kw in sorted(keywords, key=len, reverse=True): - idx = remaining_lower.find(kw) - if idx != -1: - remaining = remaining[:idx] + remaining[idx + len(kw):] - remaining_lower = remaining_lower[:idx] + remaining_lower[idx + len(kw):] - result = remaining.strip().strip('"').strip("'").strip() - return result if result else None @@ -1374,308 +1359,11 @@ def _extract_param_from_request(req_lower, keywords, original_request): -def _print_results(results): - """Print query results in human-readable format (max 20 items).""" - if not isinstance(results, list): - return - for i, item in enumerate(results[:20]): - if isinstance(item, list) and len(item) > 0: - block = item[0] - if isinstance(block, dict): - name = block.get("name") or block.get("original-name") or block.get("content", "")[:80] - click.echo(f" {i+1}. {name}") - else: - click.echo(f" {i+1}. {block}") - elif isinstance(item, dict): - name = item.get("name") or item.get("originalName") or item.get("content", "")[:80] - click.echo(f" {i+1}. {name}") - else: - click.echo(f" {i+1}. {item}") # --------------------------------------------------------------------------- # 10. smart-query # --------------------------------------------------------------------------- -@cli.command("smart-query", epilog="""\b -Examples: - logseq-cli --token TOKEN smart-query --request "offene aufgaben" - logseq-cli --token TOKEN smart-query --request '[:find ?n :where [?p :block/name ?n]]' --advanced -Note: - Without --advanced: keyword-template match (fragile for complex queries). - With --advanced: raw Datalog passes through untouched. -""") -@click.option("--request", required=True, help="Natural language query request (or raw Datalog with --advanced)") -@click.option("--include-query", is_flag=True, help="Include the generated Datalog query in output") -@click.option("--advanced", is_flag=True, help="Pass --request as raw Datalog query (bypass template matching)") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def smart_query(ctx, request, include_query, advanced, as_json): - """Run smart Datalog queries via pattern matching on request keywords. - - Use --advanced to pass a raw Datalog query string directly via --request, - bypassing all template matching. Without --advanced, natural language in - --request is matched against pre-built query templates. - """ - api = ctx.obj["api"] - req_lower = request.lower() - - # --advanced mode: pass raw Datalog query directly to datascript_query. - # This is the one place that does NOT go through the datalog build layer, - # and that is correct: --advanced is the documented raw pass-through for - # arbitrary Datalog. Do not "fix" it to route through edn_string. - if advanced: - query_str = request - description = "Advanced (raw Datalog query)" - # A rejected query raises DatalogQueryError, caught by the decorator: a - # query that never ran must exit non-zero, not report an empty result. - results = api.datascript_query(query_str) - - result_data = { - "request": request, - "matched_template": "advanced", - "description": description, - "results_count": len(results) if isinstance(results, list) else 0, - "results": results, - } - if include_query: - result_data["query"] = query_str - - if as_json: - output(result_data, True) - else: - click.echo(f"Query: {description}") - if include_query: - click.echo(f"Datalog: {query_str}") - click.echo(f"Results: {result_data['results_count']}\n") - _print_results(results) - return - - # Pre-built query templates - query_templates = { - "recent": { - "keywords": ["recent", "latest", "new", "last modified", "updated", "kürzlich", "zuletzt", "letzte", "neueste"], - "query": '[:find (pull ?p [*]) :where [?p :block/name] [?p :block/updated-at ?u] [(> ?u {timestamp})]]', - "description": "Recently modified pages", - }, - "referenced": { - "keywords": ["most referenced", "popular", "top pages", "most linked"], - "query": '[:find ?name (count ?b) :where [?b :block/content ?c] [?p :block/name ?name] [(clojure.string/includes? ?c ?name)]]', - "description": "Most referenced pages", - }, - "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)]]', - "description": "Open tasks", - }, - "done": { - "keywords": ["done", "completed", "finished", "erledigt", "fertig", "abgeschlossen"], - "query": '[:find (pull ?b [*]) :where [?b :block/marker "DONE"]]', - "description": "Completed tasks", - }, - "journal": { - "keywords": ["journal", "diary", "daily", "tagebuch"], - "query": '[:find (pull ?p [*]) :where [?p :block/journal? true]]', - "description": "Journal pages", - }, - "properties": { - "keywords": ["property", "properties", "type"], - "query": '[:find (pull ?b [*]) :where [?b :block/properties ?p] [(not-empty ?p)]]', - "description": "Blocks with properties", - }, - "scheduled": { - "keywords": ["scheduled", "deadline", "due", "geplant", "fällig", "termin"], - "query": '[:find (pull ?b [*]) :where (or [?b :block/scheduled ?d] [?b :block/deadline ?d])]', - "description": "Scheduled/deadline blocks", - }, - "empty": { - "keywords": ["empty", "blank", "no content", "leer", "ohne inhalt"], - "query": '[:find (pull ?p [*]) :where [?p :block/name ?n] (not [?b :block/page ?p] [?b :block/content ?c] [(not= ?c "")])]', - "description": "Empty pages", - }, - "links-to": { - "keywords": ["links to", "references", "mentions", "verlinkt", "referenziert"], - "query": ( - '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' - ' :where [?b :block/refs ?target] [?target :block/name {page_name}]]' - ), - "description": "Blocks linking to {page_name}", - "extract_param": "page_name", - }, - "created-today": { - "keywords": ["created today", "today", "heute erstellt"], - "query": ( - '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' - ' :where [?b :block/created-at ?c] [(> ?c {today_start})]]' - ), - "description": "Blocks created today", - }, - "tagged": { - "keywords": ["tagged", "tag", "hashtag", "getaggt", "markiert"], - "query": ( - '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' - ' :where [?b :block/content ?c] [(clojure.string/includes? ?c {tag_name})]]' - ), - "description": "Blocks tagged with #{tag_name}", - "extract_param": "tag_name", - }, - "long-content": { - "keywords": ["long", "detailed", "ausfuehrlich", "ausführlich"], - "query": ( - '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' - ' :where [?b :block/content ?c] [(count ?c) ?len] [(> ?len 300)]]' - ), - "description": "Blocks with long content (>300 chars)", - }, - "persons": { - "keywords": ["person", "persons", "people", "personen", "kollegen"], - # Built from config: which property marks a person page is the - # user's own convention, not something the CLI can know. - "query": None, - "needs_config": ("graph", "person_property"), - "description": "All person pages", - }, - "projects": { - "keywords": ["project", "projects", "projekte"], - # Built from config: the namespace that marks project pages differs - # per graph, so there is no default to fall back on. - "query": None, - "needs_config": ("graph", "projects_namespace"), - "description": "All project pages", - }, - } - - # Match query template - best_match = None - best_score = 0 - - for key, template in query_templates.items(): - score = sum(1 for kw in template["keywords"] if kw in req_lower) - if score > best_score: - best_score = score - best_match = key - - if not best_match: - # Default: content search across all blocks, then fall back to page name search - best_match = "content-search" - - if best_match == "content-search": - # Improved fallback: search block content, then page names as last resort - search_term = request.strip() - content_query = ( - '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' - ' :where [?b :block/content ?c]' - f' [(clojure.string/includes? ?c {edn_string(search_term)})]]' - ) - # A real error (connection down, rejected query) must surface via the - # decorator, not be turned into a page-name search: that would answer a - # different question with exit 0. The fallback is for the fachliche - # case only, no content hits, so it keys off an empty result. - results = api.datascript_query(content_query) - if results: - query_used = content_query - description = f"Content search for '{search_term}'" - else: - # No content hits: try page names as a last resort. - pages = api.get_all_pages() - results = [ - p for p in pages - if req_lower in (p.get("name") or "").lower() - ] - query_used = f"(page name search for '{request}')" - description = "Page name search (no content match)" - else: - template = query_templates[best_match] - query_str = template["query"] - - # Templates that describe the user's own graph carry no query of their - # own: it is built here from config. A missing setting raises and exits - # non-zero rather than querying for a guessed namespace, which would - # return an empty list that looks exactly like "no projects". - if query_str is None: - section, key = template["needs_config"] - cfg = load_config() - if key == "projects_namespace": - prefix = require(cfg, section, key, - f"smart-query --request {request!r}") - query_str = ( - '[:find (pull ?p [*]) :where [?p :block/name ?n] ' - f'[(clojure.string/starts-with? ?n {edn_string(str(prefix).lower())})]]' - ) - else: - prop = require(cfg, section, key, - f"smart-query --request {request!r}") - value = require(cfg, section, "person_value", - f"smart-query --request {request!r}") - # Same shape as query-pages-by-property: the value may be - # stored as a scalar or inside a collection. Not a live defect - # while person_property points at a scalar-valued key, but it - # is configurable - aim it at one Logseq stores as a list and - # the query would quietly return too few. - literal = edn_string(str(value)) - query_str = ( - '[:find (pull ?p [*]) :where [?p :block/name] ' - '[?p :block/properties ?props] ' - f'[(get ?props :{edn_keyword(str(prop))}) ?t] ' - f'(or [(= ?t {literal})] [(contains? ?t {literal})])]' - ) - - # Handle timestamp placeholder - if "{timestamp}" in query_str: - ts = int((datetime.datetime.now() - datetime.timedelta(days=7)).timestamp() * 1000) - query_str = query_str.replace("{timestamp}", str(ts)) - - # Handle today_start placeholder - if "{today_start}" in query_str: - today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - ts = int(today.timestamp() * 1000) - query_str = query_str.replace("{today_start}", str(ts)) - - # Handle dynamic parameter extraction (page_name, tag_name) - extract_param = template.get("extract_param") - if extract_param and f"{{{extract_param}}}" in query_str: - param_value = _extract_param_from_request(req_lower, template["keywords"], request) - if not param_value: - param_value = request.strip() - # Each template's placeholder needs the build function that matches - # its query position, and the two known ones differ on purpose: - # links-to queries :block/name (stored lowercased), tagged queries - # :block/content (user spelling). Sending links-to through - # edn_string would keep the case bug that lost 1569 backlinks. - if best_match == "links-to": - literal = page_name_literal(param_value) - elif best_match == "tagged": - literal = edn_string("#" + param_value) - else: - literal = edn_string(param_value) - query_str = query_str.replace(f"{{{extract_param}}}", literal) - description = template["description"].replace(f"{{{extract_param}}}", param_value) - else: - description = template["description"] - - # A rejected query raises DatalogQueryError (caught by the decorator): - # a query that never ran must fail loud, not report zero hits. - results = api.datascript_query(query_str) - query_used = query_str - - result_data = { - "request": request, - "matched_template": best_match, - "description": description, - "results_count": len(results) if isinstance(results, list) else 0, - "results": results, - } - if include_query: - result_data["query"] = query_used - - if as_json: - output(result_data, True) - else: - click.echo(f"Query: {description}") - if include_query: - click.echo(f"Datalog: {query_used}") - click.echo(f"Results: {result_data['results_count']}\n") - _print_results(results) # --------------------------------------------------------------------------- diff --git a/logseq_cli/commands/query.py b/logseq_cli/commands/query.py new file mode 100644 index 0000000..f04d701 --- /dev/null +++ b/logseq_cli/commands/query.py @@ -0,0 +1,325 @@ +import datetime + +import click + +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 +from logseq_cli.output import handle_connection_error, output + + +def _extract_param_from_request(req_lower, keywords, original_request): + """Extract a dynamic parameter value from a natural language request. + + Removes matched keywords from the request to isolate the parameter value. + Example: "links to Alice" with keyword "links to" -> "Alice" + """ + remaining = original_request.strip() + remaining_lower = req_lower.strip() + # Remove matched keywords (longest first to avoid partial removal) + for kw in sorted(keywords, key=len, reverse=True): + idx = remaining_lower.find(kw) + if idx != -1: + remaining = remaining[:idx] + remaining[idx + len(kw):] + remaining_lower = remaining_lower[:idx] + remaining_lower[idx + len(kw):] + result = remaining.strip().strip('"').strip("'").strip() + return result if result else None + +def _print_results(results): + """Print query results in human-readable format (max 20 items).""" + if not isinstance(results, list): + return + for i, item in enumerate(results[:20]): + if isinstance(item, list) and len(item) > 0: + block = item[0] + if isinstance(block, dict): + name = block.get("name") or block.get("original-name") or block.get("content", "")[:80] + click.echo(f" {i+1}. {name}") + else: + click.echo(f" {i+1}. {block}") + elif isinstance(item, dict): + name = item.get("name") or item.get("originalName") or item.get("content", "")[:80] + click.echo(f" {i+1}. {name}") + else: + click.echo(f" {i+1}. {item}") + +@cli.command("smart-query", epilog="""\b +Examples: + logseq-cli --token TOKEN smart-query --request "offene aufgaben" + logseq-cli --token TOKEN smart-query --request '[:find ?n :where [?p :block/name ?n]]' --advanced +Note: + Without --advanced: keyword-template match (fragile for complex queries). + With --advanced: raw Datalog passes through untouched. +""") +@click.option("--request", required=True, help="Natural language query request (or raw Datalog with --advanced)") +@click.option("--include-query", is_flag=True, help="Include the generated Datalog query in output") +@click.option("--advanced", is_flag=True, help="Pass --request as raw Datalog query (bypass template matching)") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def smart_query(ctx, request, include_query, advanced, as_json): + """Run smart Datalog queries via pattern matching on request keywords. + + Use --advanced to pass a raw Datalog query string directly via --request, + bypassing all template matching. Without --advanced, natural language in + --request is matched against pre-built query templates. + """ + api = ctx.obj["api"] + req_lower = request.lower() + + # --advanced mode: pass raw Datalog query directly to datascript_query. + # This is the one place that does NOT go through the datalog build layer, + # and that is correct: --advanced is the documented raw pass-through for + # arbitrary Datalog. Do not "fix" it to route through edn_string. + if advanced: + query_str = request + description = "Advanced (raw Datalog query)" + # A rejected query raises DatalogQueryError, caught by the decorator: a + # query that never ran must exit non-zero, not report an empty result. + results = api.datascript_query(query_str) + + result_data = { + "request": request, + "matched_template": "advanced", + "description": description, + "results_count": len(results) if isinstance(results, list) else 0, + "results": results, + } + if include_query: + result_data["query"] = query_str + + if as_json: + output(result_data, True) + else: + click.echo(f"Query: {description}") + if include_query: + click.echo(f"Datalog: {query_str}") + click.echo(f"Results: {result_data['results_count']}\n") + _print_results(results) + return + + # Pre-built query templates + query_templates = { + "recent": { + "keywords": ["recent", "latest", "new", "last modified", "updated", "kürzlich", "zuletzt", "letzte", "neueste"], + "query": '[:find (pull ?p [*]) :where [?p :block/name] [?p :block/updated-at ?u] [(> ?u {timestamp})]]', + "description": "Recently modified pages", + }, + "referenced": { + "keywords": ["most referenced", "popular", "top pages", "most linked"], + "query": '[:find ?name (count ?b) :where [?b :block/content ?c] [?p :block/name ?name] [(clojure.string/includes? ?c ?name)]]', + "description": "Most referenced pages", + }, + "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)]]', + "description": "Open tasks", + }, + "done": { + "keywords": ["done", "completed", "finished", "erledigt", "fertig", "abgeschlossen"], + "query": '[:find (pull ?b [*]) :where [?b :block/marker "DONE"]]', + "description": "Completed tasks", + }, + "journal": { + "keywords": ["journal", "diary", "daily", "tagebuch"], + "query": '[:find (pull ?p [*]) :where [?p :block/journal? true]]', + "description": "Journal pages", + }, + "properties": { + "keywords": ["property", "properties", "type"], + "query": '[:find (pull ?b [*]) :where [?b :block/properties ?p] [(not-empty ?p)]]', + "description": "Blocks with properties", + }, + "scheduled": { + "keywords": ["scheduled", "deadline", "due", "geplant", "fällig", "termin"], + "query": '[:find (pull ?b [*]) :where (or [?b :block/scheduled ?d] [?b :block/deadline ?d])]', + "description": "Scheduled/deadline blocks", + }, + "empty": { + "keywords": ["empty", "blank", "no content", "leer", "ohne inhalt"], + "query": '[:find (pull ?p [*]) :where [?p :block/name ?n] (not [?b :block/page ?p] [?b :block/content ?c] [(not= ?c "")])]', + "description": "Empty pages", + }, + "links-to": { + "keywords": ["links to", "references", "mentions", "verlinkt", "referenziert"], + "query": ( + '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' + ' :where [?b :block/refs ?target] [?target :block/name {page_name}]]' + ), + "description": "Blocks linking to {page_name}", + "extract_param": "page_name", + }, + "created-today": { + "keywords": ["created today", "today", "heute erstellt"], + "query": ( + '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' + ' :where [?b :block/created-at ?c] [(> ?c {today_start})]]' + ), + "description": "Blocks created today", + }, + "tagged": { + "keywords": ["tagged", "tag", "hashtag", "getaggt", "markiert"], + "query": ( + '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' + ' :where [?b :block/content ?c] [(clojure.string/includes? ?c {tag_name})]]' + ), + "description": "Blocks tagged with #{tag_name}", + "extract_param": "tag_name", + }, + "long-content": { + "keywords": ["long", "detailed", "ausfuehrlich", "ausführlich"], + "query": ( + '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' + ' :where [?b :block/content ?c] [(count ?c) ?len] [(> ?len 300)]]' + ), + "description": "Blocks with long content (>300 chars)", + }, + "persons": { + "keywords": ["person", "persons", "people", "personen", "kollegen"], + # Built from config: which property marks a person page is the + # user's own convention, not something the CLI can know. + "query": None, + "needs_config": ("graph", "person_property"), + "description": "All person pages", + }, + "projects": { + "keywords": ["project", "projects", "projekte"], + # Built from config: the namespace that marks project pages differs + # per graph, so there is no default to fall back on. + "query": None, + "needs_config": ("graph", "projects_namespace"), + "description": "All project pages", + }, + } + + # Match query template + best_match = None + best_score = 0 + + for key, template in query_templates.items(): + score = sum(1 for kw in template["keywords"] if kw in req_lower) + if score > best_score: + best_score = score + best_match = key + + if not best_match: + # Default: content search across all blocks, then fall back to page name search + best_match = "content-search" + + if best_match == "content-search": + # Improved fallback: search block content, then page names as last resort + search_term = request.strip() + content_query = ( + '[:find (pull ?b [:block/content :block/uuid {:block/page [:block/original-name :block/name]}])' + ' :where [?b :block/content ?c]' + f' [(clojure.string/includes? ?c {edn_string(search_term)})]]' + ) + # A real error (connection down, rejected query) must surface via the + # decorator, not be turned into a page-name search: that would answer a + # different question with exit 0. The fallback is for the fachliche + # case only, no content hits, so it keys off an empty result. + results = api.datascript_query(content_query) + if results: + query_used = content_query + description = f"Content search for '{search_term}'" + else: + # No content hits: try page names as a last resort. + pages = api.get_all_pages() + results = [ + p for p in pages + if req_lower in (p.get("name") or "").lower() + ] + query_used = f"(page name search for '{request}')" + description = "Page name search (no content match)" + else: + template = query_templates[best_match] + query_str = template["query"] + + # Templates that describe the user's own graph carry no query of their + # own: it is built here from config. A missing setting raises and exits + # non-zero rather than querying for a guessed namespace, which would + # return an empty list that looks exactly like "no projects". + if query_str is None: + section, key = template["needs_config"] + cfg = load_config() + if key == "projects_namespace": + prefix = require(cfg, section, key, + f"smart-query --request {request!r}") + query_str = ( + '[:find (pull ?p [*]) :where [?p :block/name ?n] ' + f'[(clojure.string/starts-with? ?n {edn_string(str(prefix).lower())})]]' + ) + else: + prop = require(cfg, section, key, + f"smart-query --request {request!r}") + value = require(cfg, section, "person_value", + f"smart-query --request {request!r}") + # Same shape as query-pages-by-property: the value may be + # stored as a scalar or inside a collection. Not a live defect + # while person_property points at a scalar-valued key, but it + # is configurable - aim it at one Logseq stores as a list and + # the query would quietly return too few. + literal = edn_string(str(value)) + query_str = ( + '[:find (pull ?p [*]) :where [?p :block/name] ' + '[?p :block/properties ?props] ' + f'[(get ?props :{edn_keyword(str(prop))}) ?t] ' + f'(or [(= ?t {literal})] [(contains? ?t {literal})])]' + ) + + # Handle timestamp placeholder + if "{timestamp}" in query_str: + ts = int((datetime.datetime.now() - datetime.timedelta(days=7)).timestamp() * 1000) + query_str = query_str.replace("{timestamp}", str(ts)) + + # Handle today_start placeholder + if "{today_start}" in query_str: + today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + ts = int(today.timestamp() * 1000) + query_str = query_str.replace("{today_start}", str(ts)) + + # Handle dynamic parameter extraction (page_name, tag_name) + extract_param = template.get("extract_param") + if extract_param and f"{{{extract_param}}}" in query_str: + param_value = _extract_param_from_request(req_lower, template["keywords"], request) + if not param_value: + param_value = request.strip() + # Each template's placeholder needs the build function that matches + # its query position, and the two known ones differ on purpose: + # links-to queries :block/name (stored lowercased), tagged queries + # :block/content (user spelling). Sending links-to through + # edn_string would keep the case bug that lost 1569 backlinks. + if best_match == "links-to": + literal = page_name_literal(param_value) + elif best_match == "tagged": + literal = edn_string("#" + param_value) + else: + literal = edn_string(param_value) + query_str = query_str.replace(f"{{{extract_param}}}", literal) + description = template["description"].replace(f"{{{extract_param}}}", param_value) + else: + description = template["description"] + + # A rejected query raises DatalogQueryError (caught by the decorator): + # a query that never ran must fail loud, not report zero hits. + results = api.datascript_query(query_str) + query_used = query_str + + result_data = { + "request": request, + "matched_template": best_match, + "description": description, + "results_count": len(results) if isinstance(results, list) else 0, + "results": results, + } + if include_query: + result_data["query"] = query_used + + if as_json: + output(result_data, True) + else: + click.echo(f"Query: {description}") + if include_query: + click.echo(f"Datalog: {query_used}") + click.echo(f"Results: {result_data['results_count']}\n") + _print_results(results) From fc9929fdbfe4d76dd809dd0ed8192ce6433b5d53 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:22:09 +0200 Subject: [PATCH 14/25] Move init and doctor into logseq_cli/commands/meta.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve symbols: the two commands, the config renderer and the helpers only they use. doctor reports the version, so this module imports resolve_version from group.py — the second of the two cross-module edges the map records. Three test pointers move with it. Two are dotted patch targets; the third is tests/test_doctor.py taking the module object itself to replace import_module. That third one does not fail the way the spec predicted. Pointing it back at logseq_cli.cli does not raise AttributeError — cli.py still imports import_module for its own use, so monkeypatch.setattr succeeds and patches a name doctor no longer reads. The test fails on its assertion instead, which is loud enough here only because it asserts the patched behaviour directly. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 469 +--------------------------------- logseq_cli/commands/meta.py | 495 ++++++++++++++++++++++++++++++++++++ tests/test_doctor.py | 6 +- 3 files changed, 499 insertions(+), 471 deletions(-) create mode 100644 logseq_cli/commands/meta.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index a0a58a1..e581921 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -75,6 +75,7 @@ count_blocks, ) from logseq_cli.group import cli, resolve_version +from logseq_cli.commands import meta # noqa: F401 imported for registration from logseq_cli.commands import query # noqa: F401 imported for registration from logseq_cli.commands import properties # noqa: F401 imported for registration from logseq_cli.commands import todos # noqa: F401 imported for registration @@ -3299,494 +3300,26 @@ def _collect(tree): # Rejected as signals, measured against a 1845-page graph: `file` is set on # 962 pages and `format` on 22, so neither separates the two kinds — they # only look like they would. -_DB_GRAPH_PREFIX = "logseq_db_" -_FILE_GRAPH_PREFIX = "logseq_local_" -def _graph_kind(graph_url): - """Classify the current graph from its url. - Returns ``(kind, ok, detail)`` where kind is ``"db"``, ``"file"`` or - ``None``. An unrecognised or absent url yields ``ok=None``: a wrong - "file graph, all good" is worse than no answer, because it rules out the - one cause the reader should be looking at. - """ - if not isinstance(graph_url, str) or not graph_url: - return None, None, "could not be determined (no graph url in the API answer)" - if graph_url.startswith(_DB_GRAPH_PREFIX): - return "db", False, "Logseq 2.x (DB/SQLite) — not supported by this CLI" - if graph_url.startswith(_FILE_GRAPH_PREFIX): - return "file", True, "file-based (Markdown) graph — supported" - return None, None, f"could not be determined from {graph_url!r}" - - -def _port_has_listener(host: str, port: str, timeout: float = 2.0) -> bool: - """True if something accepts TCP connections on host:port.""" - import socket - try: - with socket.create_connection((host, int(port)), timeout=timeout): - return True - except (OSError, ValueError): - return False - - -def _logseq_process_running() -> "bool | None": - """True/False if a Logseq desktop process is detectable, None if unknown. - - Best-effort and platform-dependent: used only to tell "app not running" from - "app running but its HTTP API is off", which is the distinction that costs - the most time to work out by hand. - """ - import shutil - import subprocess - if not shutil.which("pgrep"): - return None - try: - for pattern in ("Logseq", "logseq"): - res = subprocess.run(["pgrep", "-x", pattern], - capture_output=True, timeout=5) - if res.returncode == 0: - return True - return False - except (OSError, subprocess.SubprocessError): - return None - - -@cli.command("init", epilog="""\b -Examples: - logseq-cli --token TOKEN init --dry-run - logseq-cli --token TOKEN init - logseq-cli --token TOKEN init --output ./config.toml --force -Note: - Reads the graph, never writes to it. Suggestions are counted, not guessed: - each one comes with how many of the recent journals actually use it, so a - section you abandoned years ago does not end up in your config. -""") -@click.option("--output", "out_path", default=None, - help="Where to write (default: the first config search path)") -@click.option("--days", default=120, show_default=True, type=int, - help="How many of the most recent journals to look at (1 or greater)") -@click.option("--force", is_flag=True, help="Overwrite an existing config file") -@click.option("--dry-run", "dry_run", is_flag=True, help="Print what would be written") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def init_config(ctx, out_path, days, force, dry_run, as_json): - """Suggest a config file from what your graph actually contains.""" - api = ctx.obj["api"] - - # Sliced off the front of the journals, so a negative value drops the - # oldest one instead of limiting the sample: the suggestion would rest on - # a quietly different set of journals than the one asked for. 0 is refused - # rather than allowed, because looking at no journals still writes a config - # - one built on no evidence, under the message "No journals found - is the - # right graph open?", which blames the graph for what the flag did. - if days < 1: - fail("--days must be 1 or greater.", as_json) - target = Path(out_path).expanduser() if out_path else config_search_paths()[0] - if target.exists() and not (force or dry_run): - fail(f"{target} already exists. Pass --force to overwrite it, " - "or --dry-run to see what would be written.", - as_json=as_json, reason="config_exists") - - pages = api.get_all_pages() or [] - journals = [p for p in pages - if p.get("journalDay") or p.get("journal-day") or p.get("journal?")] - # Most recent first: a section abandoned years ago must not outvote the one - # in use now, which counting the whole history would let it do. - journals.sort(key=lambda p: p.get("journalDay") or p.get("journal-day") or 0, - reverse=True) - journals = journals[:days] - - heading_counts = Counter() - for page in journals: - name = page.get("originalName") or page.get("original-name") or page.get("name") - if not name: - continue - seen = set() - for block in _walk_blocks(api.get_page_blocks_tree(name) or []): - text = (block.get("content") or "").strip() - if text.startswith("#"): - seen.add(normalize_heading(text)) - heading_counts.update(seen) - - namespaces = Counter() - prop_values = Counter() - for page in pages: - name = (page.get("originalName") or page.get("original-name") - or page.get("name") or "") - if "/" in name: - namespaces[name.split("/", 1)[0] + "/"] += 1 - props = page.get("properties") or {} - if isinstance(props, dict): - for value in _as_list(props.get("type")): - prop_values[str(value)] += 1 - - total = len(journals) - suggestions = { - "journals_examined": total, - "headings": heading_counts.most_common(8), - "namespaces": namespaces.most_common(5), - "person_values": prop_values.most_common(5), - } - toml_text = _render_config(heading_counts, namespaces, prop_values, total) - - if as_json: - output({"target": str(target), "written": False if dry_run else None, - "suggestions": suggestions, "config": toml_text}, True) - if dry_run: - return - else: - click.echo(f"Looked at {total} journal page(s).") - if not total: - click.echo(" No journals found — is the right graph open?") - for heading, count in heading_counts.most_common(8): - click.echo(f" {count:4}/{total} {heading}") - for ns, count in namespaces.most_common(5): - click.echo(f" {count:4} pages under {ns}") - for value, count in prop_values.most_common(5): - click.echo(f" {count:4} pages with type:: {value}") - click.echo() - - if dry_run: - if not as_json: - click.echo(f"[DRY RUN] Would write {target}:\n") - click.echo(toml_text) - return - - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(toml_text, encoding="utf-8") - if not 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 [] - return value if isinstance(value, list) else [value] -def _slug(heading: str) -> str: - """A short name for a heading, usable as a TOML key.""" - text = re.sub(r"^#+\s*", "", heading) - text = re.sub(r"\[\[([^\]]*)\]\]", r"\1", text) - text = re.sub(r"[^0-9A-Za-z]+", "_", text).strip("_").lower() - return text or "section" -def _tie_note(counts, what: str) -> list: - """Name the runners-up when the count cannot separate them. - `Counter.most_common` breaks a tie by insertion order, so whichever page - the API happened to return first would decide — and the comment written - next to the winner ("10 pages live under this prefix") reads as evidence - while hiding that something else scored exactly the same. In the graph - this was found in, two namespaces had ten pages each and the wrong one - was picked, after which `smart-query` returned ten confident non-results. - Returns comment lines, or nothing when there is a clear winner. - """ - ranked = counts.most_common() - if not ranked: - return [] - top_count = ranked[0][1] - rivals = [name for name, count in ranked[1:] if count == top_count] - if not rivals: - return [] - return [f"# just as common, and possibly the {what} you want: " - + ", ".join(str(r) for r in rivals), - "# counting cannot tell them apart — pick the right one yourself"] - - -def _render_config(headings, namespaces, prop_values, total) -> str: - """Build the config text, commenting out anything that is a guess.""" - lines = [ - "# Written by `logseq-cli init` from the graph it found.", - "# The counts say how many of the recent journals use each heading;", - "# check them, they are evidence rather than certainty.", - "", - "[journal]", - ] - ranked = headings.most_common(8) - # Several sections can appear in every journal, and then the count alone - # does not say which one prose goes under. Prefer a plain top-level - # heading: one that is not a link to a page ("## [[Meeting]]" collects - # meetings) and not a sub-heading, which is where notes usually live. - def _is_plain_top_level(h: str) -> bool: - return h.startswith("## ") and not h.startswith("### ") and "[[" not in h - - default_pick = next( - ((h, c) for h, c in ranked if _is_plain_top_level(h)), - ranked[0] if ranked else None, - ) - if default_pick: - top, count = default_pick - lines.append(f'# in {count} of {total} journals') - lines += _tie_note( - Counter({h: c for h, c in ranked if _is_plain_top_level(h)}), - "section") - lines.append(f'default_heading = "{top}"') - else: - lines.append('# No headings found; journal writes go in at top level.') - lines.append('# default_heading = "## Log"') - - lines += ["", "[journal.headings]", - "# The key is yours to choose; the value must match the graph exactly."] - used = set() - for heading, count in ranked: - key = _slug(heading) - while key in used: - key += "_" - used.add(key) - lines.append(f'{key} = "{heading}" # {count}/{total}') - - lines += ["", "[graph]"] - if namespaces: - ns, count = namespaces.most_common(1)[0] - lines.append(f"# {count} pages live under this prefix") - lines += _tie_note(namespaces, "namespace") - lines.append(f'projects_namespace = "{ns}"') - else: - lines.append("# No namespaced pages found. Without this setting,") - lines.append('# `smart-query --request "projects"` reports it as missing.') - lines.append('# projects_namespace = "projects/"') - - if prop_values: - value, count = prop_values.most_common(1)[0] - lines.append(f"# {count} pages carry type:: {value}") - lines += _tie_note(prop_values, "type:: value") - lines.append('person_property = "type"') - lines.append(f'person_value = "{value}"') - else: - lines.append("# No type:: properties found.") - lines.append('# person_property = "type"') - lines.append('# person_value = "Person"') - - lines += [ - "", - "# [analysis] is not guessed: which words carry mood in your journal is", - "# not something a count can tell. The defaults are English; see", - "# docs/configuration.md and config.example.toml.", - "", - ] - return "\n".join(lines) -@cli.command("doctor", epilog="""\b -Examples: - logseq-cli --token TOKEN doctor - logseq-cli --token TOKEN doctor --json -Note: - Read-only. Exit 0 = ready to read and write, 1 = something is wrong. - Distinguishes "Logseq not running" from "running but HTTP API off" and - from "API up but token rejected" - each needs a different fix. -""") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -def doctor(ctx, as_json): - """Check connectivity, auth and graph access in one call.""" - api = ctx.obj["api"] - checks = [] - remedy = None - def add(name, ok, detail): - checks.append({"check": name, "ok": ok, "detail": detail}) - # 0. The runtime itself. Everything below assumes the CLI is installed - # correctly; when it is not, the failure surfaces later as something - # unrelated (an ImportError mid-command, a config that never loads). - py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" - py_ok = sys.version_info >= (3, 10) - add("python", py_ok, - py + ("" if py_ok else " (3.10 or newer required)")) - missing = [] - versions = [] - for mod, label in (("click", "click"), ("requests", "requests")): - try: - import_module(mod) - except Exception: # noqa: BLE001 - any import failure means "not usable" - missing.append(label) - continue - # Ask the installed metadata rather than the module: click deprecated - # its __version__ attribute and drops it in 9.1, and a doctor that - # warns about the library it is checking is not much of a doctor. - try: - versions.append(f"{label} {_pkg_version(mod)}") - except PackageNotFoundError: # pragma: no cover - importable but no dist - versions.append(label) - # The TOML parser is stdlib from 3.11 and the tomli backport before that; - # either is fine, only having neither is a problem, and only for configs. - try: - import_module("tomllib") - versions.append("tomllib (stdlib)") - except ModuleNotFoundError: - try: - versions.append(f"tomli {import_module('tomli').__version__}") - except Exception: # noqa: BLE001 - missing.append("tomli (needed on Python 3.10 to read a config file)") - add("packages", not missing, - ", ".join(versions) if not missing else "missing: " + ", ".join(missing)) - if missing: - remedy = remedy or 'Reinstall the package: pip install -e ".[dev]"' - - # 1. Is anything listening? Separates "app closed" from "API disabled", - # the exact ambiguity that turned a real outage into a manual hunt. - listener = _port_has_listener(api.host, api.port) - add("port", listener, - f"{api.host}:{api.port} " + ("accepting connections" if listener else "no listener")) - - if not listener: - proc = _logseq_process_running() - if proc is True: - add("process", False, - "Logseq is running but nothing listens on the API port") - remedy = ("Logseq runs, but its HTTP API is off or bound elsewhere. " - "Enable it in Logseq: Settings -> Features -> HTTP APIs Server, " - "then start the server and confirm the port.") - elif proc is False: - add("process", False, "no Logseq process found") - remedy = "Logseq is not running. Start it, then enable the HTTP API server." - else: - add("process", None, "process state unknown (pgrep unavailable)") - remedy = (f"Nothing listens on {api.host}:{api.port}. Check that Logseq runs " - "and its HTTP API server is enabled.") - - # 2. Token: only meaningful once the port answers. - token_set = bool(api.token) - if listener: - add("token", token_set, - "token provided" if token_set else "no token (--token or LOGSEQ_TOKEN)") - - # 3. Live API call. This is what actually proves usability. - graph = None - if listener: - try: - configs = api.call("logseq.App.getUserConfigs") - add("api", True, "API responded") - if isinstance(configs, dict): - graph = configs.get("currentGraph") or configs.get("preferredWorkflow") - except requests.HTTPError as e: - code = e.response.status_code if e.response is not None else "?" - add("api", False, f"HTTP {code}") - if code == 401: - # Distinguish "none supplied" from "supplied but wrong": the - # first is a missing flag, the second a wrong value. - remedy = ( - "No token was supplied. Pass the value from Logseq's API " - "settings via --token or the LOGSEQ_TOKEN env var." - if not token_set else - "The API rejected the token. Check that it matches the value " - "in Logseq: Settings -> Features -> HTTP APIs Server." - ) - else: - remedy = f"API answered HTTP {code}. Check the Logseq API settings." - except requests.RequestException as e: - add("api", False, f"{type(e).__name__}: {e}") - remedy = "Port is open but the API did not answer. Is another service on that port?" - except Exception as e: # noqa: BLE001 - doctor must never crash - add("api", False, f"{type(e).__name__}: {e}") - remedy = "Unexpected error talking to the API." - - # 3b. Graph kind. A 2.x (DB) graph answers this same API, so reachability - # proves nothing about whether the reads below will mean anything: it keeps - # a different data model, and the fields these commands ask for are simply - # absent. That surfaces as empty names and empty lists — the exact shape an - # empty graph has, which sends people looking at their own notes for a - # cause that is one version number away. - if graph is not None or any(c["check"] == "api" and c["ok"] for c in checks): - kind, kind_ok, kind_detail = _graph_kind(graph) - add("graph kind", kind_ok, kind_detail) - if kind == "db": - remedy = ( - "This is a Logseq 2.x (DB) graph, which this CLI does not " - "support: it stores the graph in SQLite under a different data " - "model, so reads return nothing rather than failing. Use a " - "file-based (Markdown) graph on the 0.10.x line." - ) - # 4. Graph read: proves a graph is actually loaded, not just the API alive. - if any(c["check"] == "api" and c["ok"] for c in checks): - try: - pages = api.get_all_pages() - count = len(pages) if isinstance(pages, list) else 0 - add("graph", count > 0, f"{count} page(s) visible") - if count == 0: - remedy = "API works but no pages are visible. Is a graph open in Logseq?" - except Exception as e: # noqa: BLE001 - add("graph", False, f"{type(e).__name__}: {e}") - remedy = "API works but the graph could not be read." - - # Config last: it says nothing about whether Logseq is reachable, so it is - # reported with ok=None and cannot turn a working setup into a failed one. - # Without it most commands are fine; the point is to name the few that are - # not, before the user hits one and wonders why it found nothing. - try: - cfg = load_config() - configured = [ - key for section, key in ( - ("graph", "projects_namespace"), - ("graph", "person_property"), - ) - if get(cfg, section, key) - ] - if not cfg: - add("config", None, - "no config file; commands that need one will say so " - "(see docs/configuration.md)") - elif configured: - add("config", True, f"{cfg['_path']} ({', '.join(configured)})") - else: - add("config", None, - f"{cfg['_path']} carries no [graph] settings; " - "smart-query for projects or people will report them missing") - except ConfigError as e: - # A broken config is worth failing on: the user meant to configure - # something and it is not being applied. - add("config", False, str(e).split("\n")[0]) - remedy = remedy or "Fix the config file, or remove it to run without one." - healthy = all(c["ok"] for c in checks if c["ok"] is not None) - result = { - "healthy": healthy, - "endpoint": api.base_url, - "version": resolve_version(), - "checks": checks, - } - if graph: - result["graph"] = graph - if remedy: - result["remedy"] = remedy - if as_json: - output(result, True) - else: - click.echo(f"logseq-cli {result['version']} -> {api.base_url}") - for c in checks: - mark = "ok " if c["ok"] else ("?? " if c["ok"] is None else "FAIL") - click.echo(f" [{mark}] {c['check']}: {c['detail']}") - if graph: - click.echo(f" graph: {graph}") - click.echo() - if healthy: - click.echo("Ready: reads and writes should work.") - else: - click.echo("Not ready.") - if remedy: - click.echo(f" {remedy}") - if not healthy: - sys.exit(1) def main(): diff --git a/logseq_cli/commands/meta.py b/logseq_cli/commands/meta.py new file mode 100644 index 0000000..ce5c7f6 --- /dev/null +++ b/logseq_cli/commands/meta.py @@ -0,0 +1,495 @@ +import re +import sys +from collections import Counter +from importlib import import_module +from importlib.metadata import PackageNotFoundError, version as _pkg_version +from pathlib import Path + +import click +import requests + +from logseq_cli.config import ConfigError, config_search_paths, get, load_config +from logseq_cli.group import cli, resolve_version +from logseq_cli.helpers import normalize_heading +from logseq_cli.output import fail, handle_connection_error, output + + +_DB_GRAPH_PREFIX = "logseq_db_" + +_FILE_GRAPH_PREFIX = "logseq_local_" + +def _graph_kind(graph_url): + """Classify the current graph from its url. + + Returns ``(kind, ok, detail)`` where kind is ``"db"``, ``"file"`` or + ``None``. An unrecognised or absent url yields ``ok=None``: a wrong + "file graph, all good" is worse than no answer, because it rules out the + one cause the reader should be looking at. + """ + if not isinstance(graph_url, str) or not graph_url: + return None, None, "could not be determined (no graph url in the API answer)" + if graph_url.startswith(_DB_GRAPH_PREFIX): + return "db", False, "Logseq 2.x (DB/SQLite) — not supported by this CLI" + if graph_url.startswith(_FILE_GRAPH_PREFIX): + return "file", True, "file-based (Markdown) graph — supported" + return None, None, f"could not be determined from {graph_url!r}" + +def _port_has_listener(host: str, port: str, timeout: float = 2.0) -> bool: + """True if something accepts TCP connections on host:port.""" + import socket + try: + with socket.create_connection((host, int(port)), timeout=timeout): + return True + except (OSError, ValueError): + return False + +def _logseq_process_running() -> "bool | None": + """True/False if a Logseq desktop process is detectable, None if unknown. + + Best-effort and platform-dependent: used only to tell "app not running" from + "app running but its HTTP API is off", which is the distinction that costs + the most time to work out by hand. + """ + import shutil + import subprocess + if not shutil.which("pgrep"): + return None + try: + for pattern in ("Logseq", "logseq"): + res = subprocess.run(["pgrep", "-x", pattern], + capture_output=True, timeout=5) + if res.returncode == 0: + return True + return False + except (OSError, subprocess.SubprocessError): + return None + +@cli.command("init", epilog="""\b +Examples: + logseq-cli --token TOKEN init --dry-run + logseq-cli --token TOKEN init + logseq-cli --token TOKEN init --output ./config.toml --force +Note: + Reads the graph, never writes to it. Suggestions are counted, not guessed: + each one comes with how many of the recent journals actually use it, so a + section you abandoned years ago does not end up in your config. +""") +@click.option("--output", "out_path", default=None, + help="Where to write (default: the first config search path)") +@click.option("--days", default=120, show_default=True, type=int, + help="How many of the most recent journals to look at (1 or greater)") +@click.option("--force", is_flag=True, help="Overwrite an existing config file") +@click.option("--dry-run", "dry_run", is_flag=True, help="Print what would be written") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def init_config(ctx, out_path, days, force, dry_run, as_json): + """Suggest a config file from what your graph actually contains.""" + api = ctx.obj["api"] + + # Sliced off the front of the journals, so a negative value drops the + # oldest one instead of limiting the sample: the suggestion would rest on + # a quietly different set of journals than the one asked for. 0 is refused + # rather than allowed, because looking at no journals still writes a config + # - one built on no evidence, under the message "No journals found - is the + # right graph open?", which blames the graph for what the flag did. + if days < 1: + fail("--days must be 1 or greater.", as_json) + + target = Path(out_path).expanduser() if out_path else config_search_paths()[0] + if target.exists() and not (force or dry_run): + fail(f"{target} already exists. Pass --force to overwrite it, " + "or --dry-run to see what would be written.", + as_json=as_json, reason="config_exists") + + pages = api.get_all_pages() or [] + journals = [p for p in pages + if p.get("journalDay") or p.get("journal-day") or p.get("journal?")] + # Most recent first: a section abandoned years ago must not outvote the one + # in use now, which counting the whole history would let it do. + journals.sort(key=lambda p: p.get("journalDay") or p.get("journal-day") or 0, + reverse=True) + journals = journals[:days] + + heading_counts = Counter() + for page in journals: + name = page.get("originalName") or page.get("original-name") or page.get("name") + if not name: + continue + seen = set() + for block in _walk_blocks(api.get_page_blocks_tree(name) or []): + text = (block.get("content") or "").strip() + if text.startswith("#"): + seen.add(normalize_heading(text)) + heading_counts.update(seen) + + namespaces = Counter() + prop_values = Counter() + for page in pages: + name = (page.get("originalName") or page.get("original-name") + or page.get("name") or "") + if "/" in name: + namespaces[name.split("/", 1)[0] + "/"] += 1 + props = page.get("properties") or {} + if isinstance(props, dict): + for value in _as_list(props.get("type")): + prop_values[str(value)] += 1 + + total = len(journals) + suggestions = { + "journals_examined": total, + "headings": heading_counts.most_common(8), + "namespaces": namespaces.most_common(5), + "person_values": prop_values.most_common(5), + } + toml_text = _render_config(heading_counts, namespaces, prop_values, total) + + if as_json: + output({"target": str(target), "written": False if dry_run else None, + "suggestions": suggestions, "config": toml_text}, True) + if dry_run: + return + else: + click.echo(f"Looked at {total} journal page(s).") + if not total: + click.echo(" No journals found — is the right graph open?") + for heading, count in heading_counts.most_common(8): + click.echo(f" {count:4}/{total} {heading}") + for ns, count in namespaces.most_common(5): + click.echo(f" {count:4} pages under {ns}") + for value, count in prop_values.most_common(5): + click.echo(f" {count:4} pages with type:: {value}") + click.echo() + + if dry_run: + if not as_json: + click.echo(f"[DRY RUN] Would write {target}:\n") + click.echo(toml_text) + return + + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(toml_text, encoding="utf-8") + if not 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 [] + return value if isinstance(value, list) else [value] + +def _slug(heading: str) -> str: + """A short name for a heading, usable as a TOML key.""" + text = re.sub(r"^#+\s*", "", heading) + text = re.sub(r"\[\[([^\]]*)\]\]", r"\1", text) + text = re.sub(r"[^0-9A-Za-z]+", "_", text).strip("_").lower() + return text or "section" + +def _tie_note(counts, what: str) -> list: + """Name the runners-up when the count cannot separate them. + + `Counter.most_common` breaks a tie by insertion order, so whichever page + the API happened to return first would decide — and the comment written + next to the winner ("10 pages live under this prefix") reads as evidence + while hiding that something else scored exactly the same. In the graph + this was found in, two namespaces had ten pages each and the wrong one + was picked, after which `smart-query` returned ten confident non-results. + + Returns comment lines, or nothing when there is a clear winner. + """ + ranked = counts.most_common() + if not ranked: + return [] + top_count = ranked[0][1] + rivals = [name for name, count in ranked[1:] if count == top_count] + if not rivals: + return [] + return [f"# just as common, and possibly the {what} you want: " + + ", ".join(str(r) for r in rivals), + "# counting cannot tell them apart — pick the right one yourself"] + +def _render_config(headings, namespaces, prop_values, total) -> str: + """Build the config text, commenting out anything that is a guess.""" + lines = [ + "# Written by `logseq-cli init` from the graph it found.", + "# The counts say how many of the recent journals use each heading;", + "# check them, they are evidence rather than certainty.", + "", + "[journal]", + ] + ranked = headings.most_common(8) + # Several sections can appear in every journal, and then the count alone + # does not say which one prose goes under. Prefer a plain top-level + # heading: one that is not a link to a page ("## [[Meeting]]" collects + # meetings) and not a sub-heading, which is where notes usually live. + def _is_plain_top_level(h: str) -> bool: + return h.startswith("## ") and not h.startswith("### ") and "[[" not in h + + default_pick = next( + ((h, c) for h, c in ranked if _is_plain_top_level(h)), + ranked[0] if ranked else None, + ) + if default_pick: + top, count = default_pick + lines.append(f'# in {count} of {total} journals') + lines += _tie_note( + Counter({h: c for h, c in ranked if _is_plain_top_level(h)}), + "section") + lines.append(f'default_heading = "{top}"') + else: + lines.append('# No headings found; journal writes go in at top level.') + lines.append('# default_heading = "## Log"') + + lines += ["", "[journal.headings]", + "# The key is yours to choose; the value must match the graph exactly."] + used = set() + for heading, count in ranked: + key = _slug(heading) + while key in used: + key += "_" + used.add(key) + lines.append(f'{key} = "{heading}" # {count}/{total}') + + lines += ["", "[graph]"] + if namespaces: + ns, count = namespaces.most_common(1)[0] + lines.append(f"# {count} pages live under this prefix") + lines += _tie_note(namespaces, "namespace") + lines.append(f'projects_namespace = "{ns}"') + else: + lines.append("# No namespaced pages found. Without this setting,") + lines.append('# `smart-query --request "projects"` reports it as missing.') + lines.append('# projects_namespace = "projects/"') + + if prop_values: + value, count = prop_values.most_common(1)[0] + lines.append(f"# {count} pages carry type:: {value}") + lines += _tie_note(prop_values, "type:: value") + lines.append('person_property = "type"') + lines.append(f'person_value = "{value}"') + else: + lines.append("# No type:: properties found.") + lines.append('# person_property = "type"') + lines.append('# person_value = "Person"') + + lines += [ + "", + "# [analysis] is not guessed: which words carry mood in your journal is", + "# not something a count can tell. The defaults are English; see", + "# docs/configuration.md and config.example.toml.", + "", + ] + return "\n".join(lines) + +@cli.command("doctor", epilog="""\b +Examples: + logseq-cli --token TOKEN doctor + logseq-cli --token TOKEN doctor --json +Note: + Read-only. Exit 0 = ready to read and write, 1 = something is wrong. + Distinguishes "Logseq not running" from "running but HTTP API off" and + from "API up but token rejected" - each needs a different fix. +""") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +def doctor(ctx, as_json): + """Check connectivity, auth and graph access in one call.""" + api = ctx.obj["api"] + checks = [] + remedy = None + + def add(name, ok, detail): + checks.append({"check": name, "ok": ok, "detail": detail}) + + # 0. The runtime itself. Everything below assumes the CLI is installed + # correctly; when it is not, the failure surfaces later as something + # unrelated (an ImportError mid-command, a config that never loads). + py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + py_ok = sys.version_info >= (3, 10) + add("python", py_ok, + py + ("" if py_ok else " (3.10 or newer required)")) + + missing = [] + versions = [] + for mod, label in (("click", "click"), ("requests", "requests")): + try: + import_module(mod) + except Exception: # noqa: BLE001 - any import failure means "not usable" + missing.append(label) + continue + # Ask the installed metadata rather than the module: click deprecated + # its __version__ attribute and drops it in 9.1, and a doctor that + # warns about the library it is checking is not much of a doctor. + try: + versions.append(f"{label} {_pkg_version(mod)}") + except PackageNotFoundError: # pragma: no cover - importable but no dist + versions.append(label) + # The TOML parser is stdlib from 3.11 and the tomli backport before that; + # either is fine, only having neither is a problem, and only for configs. + try: + import_module("tomllib") + versions.append("tomllib (stdlib)") + except ModuleNotFoundError: + try: + versions.append(f"tomli {import_module('tomli').__version__}") + except Exception: # noqa: BLE001 + missing.append("tomli (needed on Python 3.10 to read a config file)") + add("packages", not missing, + ", ".join(versions) if not missing else "missing: " + ", ".join(missing)) + if missing: + remedy = remedy or 'Reinstall the package: pip install -e ".[dev]"' + + # 1. Is anything listening? Separates "app closed" from "API disabled", + # the exact ambiguity that turned a real outage into a manual hunt. + listener = _port_has_listener(api.host, api.port) + add("port", listener, + f"{api.host}:{api.port} " + ("accepting connections" if listener else "no listener")) + + if not listener: + proc = _logseq_process_running() + if proc is True: + add("process", False, + "Logseq is running but nothing listens on the API port") + remedy = ("Logseq runs, but its HTTP API is off or bound elsewhere. " + "Enable it in Logseq: Settings -> Features -> HTTP APIs Server, " + "then start the server and confirm the port.") + elif proc is False: + add("process", False, "no Logseq process found") + remedy = "Logseq is not running. Start it, then enable the HTTP API server." + else: + add("process", None, "process state unknown (pgrep unavailable)") + remedy = (f"Nothing listens on {api.host}:{api.port}. Check that Logseq runs " + "and its HTTP API server is enabled.") + + # 2. Token: only meaningful once the port answers. + token_set = bool(api.token) + if listener: + add("token", token_set, + "token provided" if token_set else "no token (--token or LOGSEQ_TOKEN)") + + # 3. Live API call. This is what actually proves usability. + graph = None + if listener: + try: + configs = api.call("logseq.App.getUserConfigs") + add("api", True, "API responded") + if isinstance(configs, dict): + graph = configs.get("currentGraph") or configs.get("preferredWorkflow") + except requests.HTTPError as e: + code = e.response.status_code if e.response is not None else "?" + add("api", False, f"HTTP {code}") + if code == 401: + # Distinguish "none supplied" from "supplied but wrong": the + # first is a missing flag, the second a wrong value. + remedy = ( + "No token was supplied. Pass the value from Logseq's API " + "settings via --token or the LOGSEQ_TOKEN env var." + if not token_set else + "The API rejected the token. Check that it matches the value " + "in Logseq: Settings -> Features -> HTTP APIs Server." + ) + else: + remedy = f"API answered HTTP {code}. Check the Logseq API settings." + except requests.RequestException as e: + add("api", False, f"{type(e).__name__}: {e}") + remedy = "Port is open but the API did not answer. Is another service on that port?" + except Exception as e: # noqa: BLE001 - doctor must never crash + add("api", False, f"{type(e).__name__}: {e}") + remedy = "Unexpected error talking to the API." + + # 3b. Graph kind. A 2.x (DB) graph answers this same API, so reachability + # proves nothing about whether the reads below will mean anything: it keeps + # a different data model, and the fields these commands ask for are simply + # absent. That surfaces as empty names and empty lists — the exact shape an + # empty graph has, which sends people looking at their own notes for a + # cause that is one version number away. + if graph is not None or any(c["check"] == "api" and c["ok"] for c in checks): + kind, kind_ok, kind_detail = _graph_kind(graph) + add("graph kind", kind_ok, kind_detail) + if kind == "db": + remedy = ( + "This is a Logseq 2.x (DB) graph, which this CLI does not " + "support: it stores the graph in SQLite under a different data " + "model, so reads return nothing rather than failing. Use a " + "file-based (Markdown) graph on the 0.10.x line." + ) + + # 4. Graph read: proves a graph is actually loaded, not just the API alive. + if any(c["check"] == "api" and c["ok"] for c in checks): + try: + pages = api.get_all_pages() + count = len(pages) if isinstance(pages, list) else 0 + add("graph", count > 0, f"{count} page(s) visible") + if count == 0: + remedy = "API works but no pages are visible. Is a graph open in Logseq?" + except Exception as e: # noqa: BLE001 + add("graph", False, f"{type(e).__name__}: {e}") + remedy = "API works but the graph could not be read." + + # Config last: it says nothing about whether Logseq is reachable, so it is + # reported with ok=None and cannot turn a working setup into a failed one. + # Without it most commands are fine; the point is to name the few that are + # not, before the user hits one and wonders why it found nothing. + try: + cfg = load_config() + configured = [ + key for section, key in ( + ("graph", "projects_namespace"), + ("graph", "person_property"), + ) + if get(cfg, section, key) + ] + if not cfg: + add("config", None, + "no config file; commands that need one will say so " + "(see docs/configuration.md)") + elif configured: + add("config", True, f"{cfg['_path']} ({', '.join(configured)})") + else: + add("config", None, + f"{cfg['_path']} carries no [graph] settings; " + "smart-query for projects or people will report them missing") + except ConfigError as e: + # A broken config is worth failing on: the user meant to configure + # something and it is not being applied. + add("config", False, str(e).split("\n")[0]) + remedy = remedy or "Fix the config file, or remove it to run without one." + + healthy = all(c["ok"] for c in checks if c["ok"] is not None) + + result = { + "healthy": healthy, + "endpoint": api.base_url, + "version": resolve_version(), + "checks": checks, + } + if graph: + result["graph"] = graph + if remedy: + result["remedy"] = remedy + + if as_json: + output(result, True) + else: + click.echo(f"logseq-cli {result['version']} -> {api.base_url}") + for c in checks: + mark = "ok " if c["ok"] else ("?? " if c["ok"] is None else "FAIL") + click.echo(f" [{mark}] {c['check']}: {c['detail']}") + if graph: + click.echo(f" graph: {graph}") + click.echo() + if healthy: + click.echo("Ready: reads and writes should work.") + else: + click.echo("Not ready.") + if remedy: + click.echo(f" {remedy}") + + if not healthy: + sys.exit(1) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index ca854e6..612ab06 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -41,7 +41,7 @@ def api(monkeypatch): def listener(monkeypatch): """Control whether the port appears open.""" def set_state(open_): - monkeypatch.setattr("logseq_cli.cli._port_has_listener", + monkeypatch.setattr("logseq_cli.commands.meta._port_has_listener", lambda *a, **k: open_) return set_state @@ -49,7 +49,7 @@ def set_state(open_): @pytest.fixture def process(monkeypatch): def set_state(running): - monkeypatch.setattr("logseq_cli.cli._logseq_process_running", + monkeypatch.setattr("logseq_cli.commands.meta._logseq_process_running", lambda: running) return set_state @@ -165,7 +165,7 @@ def test_python_and_packages_are_reported(self, api, listener): def test_missing_package_fails_with_a_remedy(self, api, listener, monkeypatch): """A broken install must not look like a healthy one.""" - import logseq_cli.cli as cli_mod + import logseq_cli.commands.meta as cli_mod real = cli_mod.import_module From b499b2ed34f6efc9833a9625302d6d6487fce88c Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:23:27 +0200 Subject: [PATCH 15/25] Move the graph analysis commands into logseq_cli/commands/analysis.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four commands and the four helpers only they use. Four test pointers move with it, two of each kind. The imports of _project_pattern, _word_pattern and _is_incidental_page fail loudly if left behind; the two get_page_content patches do not — the helper is defined in helpers.py and read here, so a patch left on logseq_cli.cli would replace a name nobody calls and let the real helper run against the mock API. Patching it at its definition does not work either, and an earlier revision of the spec prescribed exactly that: pointing this file at logseq_cli.helpers.get_page_content turns 7 of its 13 tests red. mock.patch replaces a name in the namespace it is given, and the namespace that reads it is this module. tests/test_config_integration.py used the combined import form and takes `cli` from logseq_cli.cli, not from the command module — a test takes the group from the entry point, so that the registry it sees is the full one. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 777 +----------------------- logseq_cli/commands/analysis.py | 800 +++++++++++++++++++++++++ tests/test_analyze_journal_patterns.py | 2 +- tests/test_config_integration.py | 3 +- tests/test_read_only_commands_smoke.py | 10 +- 5 files changed, 809 insertions(+), 783 deletions(-) create mode 100644 logseq_cli/commands/analysis.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index e581921..38a5f31 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -75,6 +75,7 @@ count_blocks, ) from logseq_cli.group import cli, resolve_version +from logseq_cli.commands import analysis # noqa: F401 imported for registration from logseq_cli.commands import meta # noqa: F401 imported for registration from logseq_cli.commands import query # noqa: F401 imported for registration from logseq_cli.commands import properties # noqa: F401 imported for registration @@ -90,57 +91,8 @@ -def _project_pattern(tag_prefix: str, explicit_tags=None) -> "re.Pattern[str]": - """Match project mentions, as a tag or as a page link. - ``#projects/alpha`` and ``[[projects/alpha]]`` name the same project, and a - graph that namespaces project pages tends to contain both, so matching only - the tag form undercounts. Group 1 is the project name either way. - ``explicit_tags`` is for graphs that do not namespace at all: those names - cannot be inferred from a prefix, so they are listed. They match as a tag - and as a link for the same reason the namespaced form does — a graph that - writes ``[[Alpha]]`` in its journals and ``#Alpha`` in passing means the - same project both times, and counting only one of them undercounts. In the - journal this was measured against, the flat link outnumbered the flat tag - by two orders of magnitude, so tags alone would have found nothing. - """ - prefix = tag_prefix.lstrip("#") - alts = [ - r"#" + re.escape(prefix) + r"(\S+)", - r"\[\[" + re.escape(prefix) + r"([^\]]+)\]\]", - ] - for raw in explicit_tags or []: - name = str(raw).strip().lstrip("#") - if name: - escaped = re.escape(name) - alts.append(r"#(" + escaped + r")\b") - alts.append(r"\[\[(" + escaped + r")\]\]") - return re.compile("|".join(alts), re.IGNORECASE) - - -def _word_pattern(words) -> "re.Pattern[str]": - """Case-insensitive whole-word alternation over a list of words. - - Words come from config, so they are escaped: a user writing "c++" or a - stray "(" must not turn into a broken or surprising pattern. \\b around a - word that starts or ends with a non-word character would never match, so - the boundary is applied per word only where it can bite. - """ - parts = [] - for raw in words: - w = str(raw).strip() - if not w: - continue - esc = re.escape(w) - left = r"\b" if w[0].isalnum() or w[0] == "_" else "" - right = r"\b" if w[-1].isalnum() or w[-1] == "_" else "" - parts.append(f"{left}{esc}{right}") - if not parts: - # Matches nothing, rather than an empty alternation that matches - # everywhere and would report every block as a mood hit. - return re.compile(r"(?!)") - return re.compile("|".join(parts), re.IGNORECASE) # A text replacement must skip property lines: rewriting an id:: line breaks @@ -735,619 +687,20 @@ def _fetch_one(target): # --------------------------------------------------------------------------- # 7. analyze-graph # --------------------------------------------------------------------------- -@cli.command("analyze-graph", epilog="""\b -Example: - logseq-cli --token TOKEN analyze-graph --days 30 -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("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def analyze_graph(ctx, days, as_json): - """Analyze the knowledge graph structure.""" - api = ctx.obj["api"] - - # A window, not a cap: a negative value moves the cutoff into the future, - # so "recently updated" silently empties and the report answers a question - # nobody asked. 0 puts the cutoff at this moment and is refused for the - # same reason - it can only ever report pages edited in the future. - if days is not None and days < 1: - fail("--days must be 1 or greater.", 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-Liste" - # 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 - cutoff_ms = None - if days is not None: - cutoff_dt = datetime.datetime.now() - datetime.timedelta(days=days) - cutoff_ms = int(cutoff_dt.timestamp() * 1000) - - page_names = set() - journal_count = 0 - total_todos = 0 - reference_count = Counter() - adjacency = defaultdict(set) - recently_updated = [] - - for page in pages: - name = page.get("originalName") or page.get("name", "") - page_names.add(name.lower()) - if page.get("journalDay") or page.get("journal-day") or page.get("journal?"): - journal_count += 1 - - for page in pages: - name = page.get("originalName") or page.get("name", "") - updated_at = page.get("updatedAt") or page.get("updated-at") or 0 - - # Track recently updated pages when --days is set - if cutoff_ms and updated_at >= cutoff_ms: - 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}) - - try: - content = get_page_content(api, name) - except Exception: - content = "" - - # count TODOs - todos = todo_pattern.findall(content) - total_todos += len(todos) - - # extract links - links = link_pattern.findall(content) - for link in links: - reference_count[link] += 1 - adjacency[name.lower()].add(link.lower()) - adjacency[link.lower()].add(name.lower()) - - # Sort recently updated by timestamp descending - recently_updated.sort(key=lambda x: x["updated_at"], reverse=True) - - # BFS clusters - visited = set() - clusters = [] - - for node in adjacency: - if node in visited: - continue - cluster = set() - queue = [node] - while queue: - current = queue.pop(0) - if current in visited: - continue - visited.add(current) - cluster.add(current) - for neighbor in adjacency.get(current, []): - if neighbor not in visited: - queue.append(neighbor) - if len(cluster) > 1: - clusters.append(sorted(cluster)) - - top_referenced = reference_count.most_common(15) - - result = { - "total_pages": len(pages), - "journal_pages": journal_count, - "non_journal_pages": len(pages) - journal_count, - "total_todos": total_todos, - "top_referenced": [{"page": p, "refs": c} for p, c in top_referenced], - "clusters": len(clusters), - "largest_cluster": len(clusters[0]) if clusters else 0, - } - if days is not None: - result["recently_updated"] = [{"page": r["page"], "updated": r["updated"]} for r in recently_updated[:30]] - - if as_json: - output(result, True) - else: - click.echo("=== Graph Analysis ===\n") - 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']}") - click.echo(f"Clusters: {result['clusters']}") - if clusters: - click.echo(f"Largest cluster: {result['largest_cluster']} pages") - if days is not None and recently_updated: - click.echo(f"\nRecently Updated (last {days} days): {len(recently_updated)} pages") - for r in recently_updated[:30]: - click.echo(f" {r['page']} ({r['updated']})") - click.echo(f"\nTop Referenced Pages:") - for item in result["top_referenced"]: - click.echo(f" {item['page']}: {item['refs']} refs") # --------------------------------------------------------------------------- # 8. find-knowledge-gaps # --------------------------------------------------------------------------- -def _is_incidental_page(name: str) -> bool: - """True for pages that exist as a side effect, not as knowledge. - - Logseq turns `#272` in a sentence into a page called "272", and a stray - bracket or dash into a page of its own. Those are real pages with no - incoming links, so they answer "orphaned" truthfully and drown the answer: - in the graph this was measured against, 596 orphans were almost entirely - of this kind. Dates written in file-name form are the same story from the - other side — they look like missing pages but are journals under another - spelling. - """ - stripped = name.strip() - if len(stripped) < 3: - return True - if not any(c.isalpha() for c in stripped): - return True - if re.fullmatch(r"[\W_]+", stripped): - return True - # A name opening with punctuation is a tag that swallowed one: "#-AI" - if not (stripped[0].isalnum() or stripped[0] in "_@"): - return True - # 2025_10_10, 2025-10-10, 2025/10/10 — a journal, not a gap - if re.fullmatch(r"\d{4}[-_/]\d{1,2}[-_/]\d{1,2}", stripped): - return True - # An unclosed bracket dragged in from prose: "#Active)", "3b82f6)". - if stripped.endswith(")") and "(" not in stripped: - return True - # A ticket number that took the next word with it: "#272-Designentscheidung" - # comes from "#272-Designentscheidung" in a sentence. Three digits or more, - # so that "2-Faktor-Auth" and "4-Level-Struktur" — real terms — survive. - if re.match(r"\d{3,}-", stripped): - return True - return False - - -def _has_richer_namesake(name_lower: str, page_names: dict, content_of) -> bool: - """True if some namespaced page shares this name and actually has content. - - An empty `Alpha` with 185 references sits next to - `projects/Alpha` with 8806 words: the bare page is an anchor for - the name, not a gap in the notes. Reporting it as underdeveloped sends the - reader to write something that is already written next door. - """ - for other_lower, other_original in page_names.items(): - if other_lower == name_lower: - continue - tail = other_lower.rsplit("/", 1)[-1] - if tail != name_lower: - continue - try: - if len((content_of(other_original) or "").strip()) > 200: - return True - except Exception: # noqa: BLE001 - unreadable page proves nothing - continue - return False -@cli.command("find-knowledge-gaps", epilog="""\b -Example: - logseq-cli --token TOKEN find-knowledge-gaps --min-refs 3 --include-orphans -""") -@click.option("--min-refs", default=2, type=int, help="Min references for underdeveloped detection") -@click.option("--include-orphans/--no-orphans", default=True, help="Include orphaned pages") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def find_knowledge_gaps(ctx, min_refs, include_orphans, as_json): - """Find missing, underdeveloped, and orphaned pages.""" - api = ctx.obj["api"] - pages = api.get_all_pages() - link_pattern = re.compile(r"\[\[(.*?)\]\]") - page_names = {} # lowercase -> original name - incoming_refs = Counter() - page_content_lengths = {} - for page in pages: - name = page.get("originalName") or page.get("name", "") - page_names[name.lower()] = name - - # Pass 1: collect all references and content lengths - for page in pages: - name = page.get("originalName") or page.get("name", "") - try: - content = get_page_content(api, name) - except Exception: - content = "" - page_content_lengths[name.lower()] = len(content) - links = link_pattern.findall(content) - for link in links: - incoming_refs[link.lower()] += 1 - - # Missing pages: referenced but don't exist - missing = [] - for ref_lower, count in incoming_refs.items(): - if ref_lower in page_names: - continue - if _is_incidental_page(ref_lower): - continue - missing.append({"page": ref_lower, "references": count}) - missing.sort(key=lambda x: x["references"], reverse=True) - - # Underdeveloped: exists, has refs, but very short content - underdeveloped = [] - for name_lower, original in page_names.items(): - refs = incoming_refs.get(name_lower, 0) - length = page_content_lengths.get(name_lower, 0) - if refs >= min_refs and length < 100: - if _is_incidental_page(original): - continue - # An empty page whose namespaced twin is written is an anchor for - # the name, not a gap. Only checked here, where the list is short. - if _has_richer_namesake(name_lower, page_names, - lambda n: get_page_content(api, n)): - continue - underdeveloped.append({ - "page": original, - "references": refs, - "content_length": length, - }) - underdeveloped.sort(key=lambda x: x["references"], reverse=True) - - # Orphaned: zero incoming references (exclude journals) - orphans = [] - if include_orphans: - journal_pages = set() - for page in pages: - if page.get("journalDay") or page.get("journal-day") or page.get("journal?"): - name = page.get("originalName") or page.get("name", "") - journal_pages.add(name.lower()) - - for name_lower, original in page_names.items(): - if name_lower in journal_pages: - continue - if _is_incidental_page(original): - continue - if incoming_refs.get(name_lower, 0) == 0: - orphans.append(original) - orphans.sort(key=str.lower) - - result = { - "missing_pages": missing[:20], - "underdeveloped_pages": underdeveloped[:20], - "orphaned_pages": orphans[:30] if include_orphans else [], - "summary": { - "missing": len(missing), - "underdeveloped": len(underdeveloped), - "orphaned": len(orphans) if include_orphans else "n/a", - }, - } - - if as_json: - output(result, True) - else: - click.echo("=== Knowledge Gaps ===\n") - click.echo(f"Missing pages (referenced but don't exist): {len(missing)}") - for m in result["missing_pages"]: - click.echo(f" {m['page']} ({m['references']} refs)") - - click.echo(f"\nUnderdeveloped pages (< 100 chars, {min_refs}+ refs): {len(underdeveloped)}") - for u in result["underdeveloped_pages"]: - click.echo(f" {u['page']} ({u['references']} refs, {u['content_length']} chars)") - - if include_orphans: - click.echo(f"\nOrphaned pages (zero incoming links): {len(orphans)}") - for o in result["orphaned_pages"]: - click.echo(f" {o}") # --------------------------------------------------------------------------- # 9. analyze-journal-patterns # --------------------------------------------------------------------------- -@cli.command("analyze-journal-patterns", epilog="""\b -Example: - logseq-cli --token TOKEN analyze-journal-patterns --timeframe "last 30 days" --mood --topics -""") -@click.option("--timeframe", default="last 30 days", help="Date range for analysis") -@click.option("--mood/--no-mood", default=True, help="Include mood detection") -@click.option("--topics/--no-topics", default=True, help="Include topic analysis") -@click.option("--json", "as_json", is_flag=True, help="Output 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() - - # Word lists and the project tag are language- and graph-specific: the - # built-ins are English, so a journal written in another language scores - # zero moods and no project progress, silently. [analysis] in the config - # replaces them; see docs/configuration.md. - cfg = load_config() - mood_positive = _word_pattern( - get(cfg, "analysis", "mood_positive") - or ["happy", "great", "excited", "good", "wonderful", "productive", "grateful"]) - mood_negative = _word_pattern( - get(cfg, "analysis", "mood_negative") - or ["sad", "tired", "stressed", "frustrated", "anxious", "overwhelmed", "bad"]) - mood_labels = get(cfg, "analysis", "mood_labels") or ["mood", "feeling"] - mood_keyword = re.compile( - r"(?:" + "|".join(re.escape(str(w)) for w in mood_labels) + r"):\s*(\w+)", - re.IGNORECASE) - # Lines worth showing as evidence under the mood counts. Kept to explicit - # statements and emoji for the same reason the counting is: a bare "happy" - # in a sentence is as likely to be "not happy", and listing it under a - # count of zero reads as a contradiction. The labels come from config, so - # a graph writing "stimmung:" is covered. - 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 die Rückmeldung" - # 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 - # graph that namespaces its project pages usually writes both; graphs that - # tag flatly (#alpha) configure the tags themselves instead. - project_tag = get(cfg, "analysis", "project_tag_prefix") or "#project/" - project_pattern = _project_pattern( - str(project_tag), get(cfg, "analysis", "project_tags")) - # Habits stay checkbox-only on purpose: a habit is a repeated checkbox - # list, and treating every TODO as a habit would drown the real ones. - habit_checkbox = re.compile(r"- \[[ x]\]", re.IGNORECASE) - - entries = [] - topic_by_date = {} - mood_entries = [] - total_incomplete = 0 - total_complete = 0 - - # Extended analysis collectors - mood_patterns = [] # {date, mood, context} - habit_patterns = defaultdict(list) # habit_name -> [{date, done}] - project_progress = defaultdict(list) # project -> [{date, status}] - topics_by_month = defaultdict(set) # YYYY-MM -> set of topics - - for page in pages: - jd = page.get("journalDay") or page.get("journal-day") - if not jd: - continue - try: - d = journal_day_to_date(jd) - except (ValueError, TypeError): - continue - - dt = datetime.datetime.combine(d, datetime.time()) - if not (start <= dt <= end): - continue - - page_name = page.get("originalName") or page.get("name", "") - try: - content = get_page_content(api, page_name) - except Exception: - content = "" - - date_str = format_journal_date(d) - month_key = d.strftime("%Y-%m") - entry = {"date": date_str, "page": page_name} - - # Topics - if topics: - links = link_pattern.findall(content) - entry["topics"] = links - topic_by_date[date_str] = links - topics_by_month[month_key].update(links) - - # Mood, from explicit statements only. - # - # Counting every occurrence of a positive word measured how often such - # words appear in technical prose, not how the day went: "nicht - # zufrieden" and "läuft nicht gut" both scored as positive, and in the - # journal this was checked against 16% of positive hits were negations - # — concentrated in exactly the sentences that carry a judgement. A - # number that says the opposite of its own evidence is worse than no - # number, and negation is not something a word list can settle. - # - # So only a line that states a mood counts: "mood: good", - # "stimmung: mies" — the labels are configurable, and the word lists - # now classify that stated value rather than the whole journal. - if mood: - mood_data = {"positive": 0, "negative": 0, "keywords": []} - kw_matches = mood_keyword.findall(content) - mood_data["keywords"] = kw_matches - for stated in kw_matches: - if mood_positive.search(stated): - mood_data["positive"] += 1 - elif mood_negative.search(stated): - mood_data["negative"] += 1 - entry["mood"] = mood_data - if mood_data["positive"] or mood_data["negative"] or kw_matches: - mood_entries.append(entry) - - # Extended mood patterns - per block - if mood and content: - for line in content.split("\n"): - line_stripped = line.strip().lstrip("- ") - if not line_stripped: - continue - line_lower = line_stripped.lower() - for indicator in mood_indicator_patterns: - if indicator.lower() in line_lower: - mood_patterns.append({ - "date": date_str, - "month": month_key, - "mood": indicator, - "context": line_stripped[:120], - }) - 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 - - # Habit tracking - extract individual checkbox items - for line in content.split("\n"): - line_stripped = line.strip() - if habit_checkbox.search(line_stripped): - done = "[x]" in line_stripped.lower() - habit_text = re.sub(r"- \[[ x]\]\s*", "", line_stripped, flags=re.IGNORECASE).strip() - if habit_text: - habit_patterns[habit_text].append({"date": date_str, "done": done}) - - # Project progress - for line in content.split("\n"): - line_stripped = line.strip().lstrip("- ") - proj_match = project_pattern.search(line_stripped) - if proj_match: - # The pattern has one group per spelling (tag, link, and one - # per configured flat tag), so only one of them is filled. - name = next((g for g in proj_match.groups() if g), None) - if name is None: - continue - project_progress[name].append({ - "date": date_str, - "status": line_stripped[:150], - }) - - entries.append(entry) - - # Aggregate topics - all_topics = Counter() - for date_topics in topic_by_date.values(): - all_topics.update(date_topics) - - # Compute habit stats - habit_stats = {} - for habit_name, occurrences in habit_patterns.items(): - total = len(occurrences) - done_count = sum(1 for o in occurrences if o["done"]) - # Calculate streaks - current_streak = 0 - longest_streak = 0 - streak = 0 - for o in occurrences: - if o["done"]: - streak += 1 - longest_streak = max(longest_streak, streak) - else: - streak = 0 - current_streak = streak - habit_stats[habit_name] = { - "total": total, - "done": done_count, - "completion_rate": round(done_count / total * 100, 1) if total > 0 else 0, - "current_streak": current_streak, - "longest_streak": longest_streak, - } - - result = { - "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 - else 0 - ), - }, - } - if topics: - result["top_topics"] = [{"topic": t, "count": c} for t, c in all_topics.most_common(15)] - if mood: - total_pos = sum(e.get("mood", {}).get("positive", 0) for e in entries) - total_neg = sum(e.get("mood", {}).get("negative", 0) for e in entries) - result["mood_summary"] = { - "positive_signals": total_pos, - "negative_signals": total_neg, - "mood_keywords": [kw for e in entries for kw in e.get("mood", {}).get("keywords", [])], - } - result["mood_patterns"] = mood_patterns - if habit_stats: - result["habit_tracking"] = habit_stats - if project_progress: - result["project_progress"] = dict(project_progress) - if topics: - result["topic_evolution"] = {m: sorted(t) for m, t in sorted(topics_by_month.items(), reverse=True)} - result["entries"] = entries - - if as_json: - output(result, True) - 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)") - if topics and result.get("top_topics"): - click.echo("\nTop Topics:") - for t in result["top_topics"][:10]: - click.echo(f" {t['topic']}: {t['count']}") - if mood and result.get("mood_summary"): - ms = result["mood_summary"] - click.echo(f"\nMood: +{ms['positive_signals']} positive, -{ms['negative_signals']} negative") - if ms["mood_keywords"]: - click.echo(f" Keywords: {', '.join(ms['mood_keywords'])}") - - # Extended: Mood Patterns - if mood and mood_patterns: - click.echo("\nMood Patterns:") - mood_by_month = defaultdict(list) - for mp in mood_patterns: - mood_by_month[mp["month"]].append(mp) - for month in sorted(mood_by_month.keys(), reverse=True): - click.echo(f" {month}:") - for mp in mood_by_month[month][:5]: - click.echo(f" - {mp['mood']}: \"{mp['context']}\"") - - # Extended: Habit Tracking - if habit_stats: - click.echo("\nHabit Tracking:") - 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']})") - click.echo(f" Current streak: {stats['current_streak']} days") - click.echo(f" Longest streak: {stats['longest_streak']} days") - - # Extended: Project Progress - if project_progress: - click.echo("\nProject Progress:") - for project, updates in sorted(project_progress.items()): - click.echo(f" {project}:") - for u in updates[-5:]: - click.echo(f" - {u['date']}: {u['status']}") - - # Extended: Topic Evolution - if topics and topics_by_month: - click.echo("\nTopic Evolution:") - for month in sorted(topics_by_month.keys(), reverse=True): - month_topics = sorted(topics_by_month[month]) - click.echo(f" {month}: {', '.join(month_topics[:15])}") @@ -1370,134 +723,6 @@ def analyze_journal_patterns(ctx, timeframe, mood, topics, as_json): # --------------------------------------------------------------------------- # 11. suggest-connections # --------------------------------------------------------------------------- -@cli.command("suggest-connections", epilog="""\b -Example: - logseq-cli --token TOKEN suggest-connections --min-confidence 0.7 --max-suggestions 10 -""") -@click.option("--min-confidence", default=0.3, type=float, help="Minimum confidence score (0-1)") -@click.option("--min-shared", default=3, type=int, show_default=True, - help="Minimum shared topics for a pair to count as connected") -@click.option("--max-suggestions", default=10, type=int, help="Maximum suggestions to return (1 or greater); the remainder is reported as withheld") -@click.option("--focus", default=None, help="Focus on specific page/topic") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def suggest_connections(ctx, min_confidence, min_shared, max_suggestions, focus, as_json): - """Suggest connections between pages based on shared topics.""" - api = ctx.obj["api"] - - # 0 is refused rather than answered with an empty list: the empty result is - # reported as "no connections found above confidence threshold", which - # blames the graph for what the flag did. - if max_suggestions < 1: - fail("--max-suggestions must be 1 or greater.", as_json) - - pages = api.get_all_pages() - - # Build topic index: page -> set of topics - page_topics = {} - topic_pages = defaultdict(set) - - for page in pages: - if page.get("journalDay") or page.get("journal-day") or page.get("journal?"): - continue - name = page.get("originalName") or page.get("name", "") - try: - content = get_page_content(api, name) - except Exception: - content = "" - - topics_found = set(extract_topics(content)) - # Also add the page name itself as a topic - page_topics[name] = topics_found - for t in topics_found: - topic_pages[t.lower()].add(name) - - # Calculate similarity between page pairs - suggestions = [] - page_list = list(page_topics.keys()) - if focus: - # Only compare focus page against others - page_list = [p for p in page_list if p.lower() == focus.lower()] - - for i, page_a in enumerate(page_list): - topics_a = page_topics.get(page_a, set()) - if not topics_a: - continue - - compare_to = list(page_topics.keys()) if focus else page_list[i+1:] - for page_b in compare_to: - if page_a == page_b: - continue - topics_b = page_topics.get(page_b, set()) - if not topics_b: - continue - - # Jaccard similarity on lowercase topics - a_lower = {t.lower() for t in topics_a} - b_lower = {t.lower() for t in topics_b} - intersection = a_lower & b_lower - union = a_lower | b_lower - - if not union: - continue - - # Jaccard alone rewards the thinnest evidence there is: two pages - # that link one page each, the same one, score 1/1 = 1.0 and sort - # above a pair sharing 35 topics out of 38. A single shared topic - # is a coincidence, not a connection, so it does not qualify. - if len(intersection) < min_shared: - continue - - score = len(intersection) / len(union) - if score >= min_confidence: - suggestions.append({ - "page_a": page_a, - "page_b": page_b, - "confidence": round(score, 3), - "shared_topics": sorted(intersection), - }) - - # Ties on confidence are common and meaningless on their own; the pair with - # more shared topics is the better suggestion of the two. - suggestions.sort(key=lambda s: (s["confidence"], len(s["shared_topics"])), - reverse=True) - # Counted before the cap: "found" is a statement about the graph, and - # counting the survivors would report three pairs as one whenever the cap - # bites. What the cap left out is named rather than dropped in silence. - total_found = len(suggestions) - suggestions = suggestions[:max_suggestions] - withheld = total_found - len(suggestions) - - result = { - "suggestions": suggestions, - "total_found": total_found, - "min_confidence": min_confidence, - "min_shared_topics": min_shared, - } - if withheld: - result["withheld"] = withheld - - if as_json: - output(result, True) - else: - if not suggestions: - click.echo("No connections found above confidence threshold.") - else: - click.echo(f"=== Suggested Connections ({len(suggestions)}) ===\n") - for s in suggestions: - click.echo(f" {s['page_a']} <-> {s['page_b']}") - click.echo(f" Confidence: {s['confidence']:.1%}") - click.echo(f" Shared: {', '.join(s['shared_topics'][:5])}") - click.echo() - # Never truncate silently: the same promise the journal reads make. - if withheld: - click.echo( - f"Note: showing {len(suggestions)} of {total_found} " - f"suggestion(s); {withheld} omitted. Raise --max-suggestions " - "to see more.", - err=True, - ) # --------------------------------------------------------------------------- diff --git a/logseq_cli/commands/analysis.py b/logseq_cli/commands/analysis.py new file mode 100644 index 0000000..90a45c3 --- /dev/null +++ b/logseq_cli/commands/analysis.py @@ -0,0 +1,800 @@ +import click +import datetime +import re + +from logseq_cli.group import cli +from collections import Counter, defaultdict +from logseq_cli.config import get, load_config +from logseq_cli.helpers import ( + extract_topics, + format_journal_date, + get_page_content, + journal_day_to_date, + parse_date_range, +) +from logseq_cli.output import fail, handle_connection_error, output + + +def _project_pattern(tag_prefix: str, explicit_tags=None) -> "re.Pattern[str]": + """Match project mentions, as a tag or as a page link. + + ``#projects/alpha`` and ``[[projects/alpha]]`` name the same project, and a + graph that namespaces project pages tends to contain both, so matching only + the tag form undercounts. Group 1 is the project name either way. + + ``explicit_tags`` is for graphs that do not namespace at all: those names + cannot be inferred from a prefix, so they are listed. They match as a tag + and as a link for the same reason the namespaced form does — a graph that + writes ``[[Alpha]]`` in its journals and ``#Alpha`` in passing means the + same project both times, and counting only one of them undercounts. In the + journal this was measured against, the flat link outnumbered the flat tag + by two orders of magnitude, so tags alone would have found nothing. + """ + prefix = tag_prefix.lstrip("#") + alts = [ + r"#" + re.escape(prefix) + r"(\S+)", + r"\[\[" + re.escape(prefix) + r"([^\]]+)\]\]", + ] + for raw in explicit_tags or []: + name = str(raw).strip().lstrip("#") + if name: + escaped = re.escape(name) + alts.append(r"#(" + escaped + r")\b") + alts.append(r"\[\[(" + escaped + r")\]\]") + return re.compile("|".join(alts), re.IGNORECASE) + +def _word_pattern(words) -> "re.Pattern[str]": + """Case-insensitive whole-word alternation over a list of words. + + Words come from config, so they are escaped: a user writing "c++" or a + stray "(" must not turn into a broken or surprising pattern. \\b around a + word that starts or ends with a non-word character would never match, so + the boundary is applied per word only where it can bite. + """ + parts = [] + for raw in words: + w = str(raw).strip() + if not w: + continue + esc = re.escape(w) + left = r"\b" if w[0].isalnum() or w[0] == "_" else "" + right = r"\b" if w[-1].isalnum() or w[-1] == "_" else "" + parts.append(f"{left}{esc}{right}") + if not parts: + # Matches nothing, rather than an empty alternation that matches + # everywhere and would report every block as a mood hit. + return re.compile(r"(?!)") + return re.compile("|".join(parts), re.IGNORECASE) + +@cli.command("analyze-graph", epilog="""\b +Example: + logseq-cli --token TOKEN analyze-graph --days 30 +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("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def analyze_graph(ctx, days, as_json): + """Analyze the knowledge graph structure.""" + api = ctx.obj["api"] + + # A window, not a cap: a negative value moves the cutoff into the future, + # so "recently updated" silently empties and the report answers a question + # nobody asked. 0 puts the cutoff at this moment and is refused for the + # same reason - it can only ever report pages edited in the future. + if days is not None and days < 1: + fail("--days must be 1 or greater.", 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-Liste" + # 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 + cutoff_ms = None + if days is not None: + cutoff_dt = datetime.datetime.now() - datetime.timedelta(days=days) + cutoff_ms = int(cutoff_dt.timestamp() * 1000) + + page_names = set() + journal_count = 0 + total_todos = 0 + reference_count = Counter() + adjacency = defaultdict(set) + recently_updated = [] + + for page in pages: + name = page.get("originalName") or page.get("name", "") + page_names.add(name.lower()) + if page.get("journalDay") or page.get("journal-day") or page.get("journal?"): + journal_count += 1 + + for page in pages: + name = page.get("originalName") or page.get("name", "") + updated_at = page.get("updatedAt") or page.get("updated-at") or 0 + + # Track recently updated pages when --days is set + if cutoff_ms and updated_at >= cutoff_ms: + 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}) + + try: + content = get_page_content(api, name) + except Exception: + content = "" + + # count TODOs + todos = todo_pattern.findall(content) + total_todos += len(todos) + + # extract links + links = link_pattern.findall(content) + for link in links: + reference_count[link] += 1 + adjacency[name.lower()].add(link.lower()) + adjacency[link.lower()].add(name.lower()) + + # Sort recently updated by timestamp descending + recently_updated.sort(key=lambda x: x["updated_at"], reverse=True) + + # BFS clusters + visited = set() + clusters = [] + + for node in adjacency: + if node in visited: + continue + cluster = set() + queue = [node] + while queue: + current = queue.pop(0) + if current in visited: + continue + visited.add(current) + cluster.add(current) + for neighbor in adjacency.get(current, []): + if neighbor not in visited: + queue.append(neighbor) + if len(cluster) > 1: + clusters.append(sorted(cluster)) + + top_referenced = reference_count.most_common(15) + + result = { + "total_pages": len(pages), + "journal_pages": journal_count, + "non_journal_pages": len(pages) - journal_count, + "total_todos": total_todos, + "top_referenced": [{"page": p, "refs": c} for p, c in top_referenced], + "clusters": len(clusters), + "largest_cluster": len(clusters[0]) if clusters else 0, + } + if days is not None: + result["recently_updated"] = [{"page": r["page"], "updated": r["updated"]} for r in recently_updated[:30]] + + if as_json: + output(result, True) + else: + click.echo("=== Graph Analysis ===\n") + 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']}") + click.echo(f"Clusters: {result['clusters']}") + if clusters: + click.echo(f"Largest cluster: {result['largest_cluster']} pages") + if days is not None and recently_updated: + click.echo(f"\nRecently Updated (last {days} days): {len(recently_updated)} pages") + for r in recently_updated[:30]: + click.echo(f" {r['page']} ({r['updated']})") + click.echo(f"\nTop Referenced Pages:") + for item in result["top_referenced"]: + click.echo(f" {item['page']}: {item['refs']} refs") + +def _is_incidental_page(name: str) -> bool: + """True for pages that exist as a side effect, not as knowledge. + + Logseq turns `#272` in a sentence into a page called "272", and a stray + bracket or dash into a page of its own. Those are real pages with no + incoming links, so they answer "orphaned" truthfully and drown the answer: + in the graph this was measured against, 596 orphans were almost entirely + of this kind. Dates written in file-name form are the same story from the + other side — they look like missing pages but are journals under another + spelling. + """ + stripped = name.strip() + if len(stripped) < 3: + return True + if not any(c.isalpha() for c in stripped): + return True + if re.fullmatch(r"[\W_]+", stripped): + return True + # A name opening with punctuation is a tag that swallowed one: "#-AI" + if not (stripped[0].isalnum() or stripped[0] in "_@"): + return True + # 2025_10_10, 2025-10-10, 2025/10/10 — a journal, not a gap + if re.fullmatch(r"\d{4}[-_/]\d{1,2}[-_/]\d{1,2}", stripped): + return True + # An unclosed bracket dragged in from prose: "#Active)", "3b82f6)". + if stripped.endswith(")") and "(" not in stripped: + return True + # A ticket number that took the next word with it: "#272-Designentscheidung" + # comes from "#272-Designentscheidung" in a sentence. Three digits or more, + # so that "2-Faktor-Auth" and "4-Level-Struktur" — real terms — survive. + if re.match(r"\d{3,}-", stripped): + return True + return False + +def _has_richer_namesake(name_lower: str, page_names: dict, content_of) -> bool: + """True if some namespaced page shares this name and actually has content. + + An empty `Alpha` with 185 references sits next to + `projects/Alpha` with 8806 words: the bare page is an anchor for + the name, not a gap in the notes. Reporting it as underdeveloped sends the + reader to write something that is already written next door. + """ + for other_lower, other_original in page_names.items(): + if other_lower == name_lower: + continue + tail = other_lower.rsplit("/", 1)[-1] + if tail != name_lower: + continue + try: + if len((content_of(other_original) or "").strip()) > 200: + return True + except Exception: # noqa: BLE001 - unreadable page proves nothing + continue + return False + +@cli.command("find-knowledge-gaps", epilog="""\b +Example: + logseq-cli --token TOKEN find-knowledge-gaps --min-refs 3 --include-orphans +""") +@click.option("--min-refs", default=2, type=int, help="Min references for underdeveloped detection") +@click.option("--include-orphans/--no-orphans", default=True, help="Include orphaned pages") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def find_knowledge_gaps(ctx, min_refs, include_orphans, as_json): + """Find missing, underdeveloped, and orphaned pages.""" + api = ctx.obj["api"] + pages = api.get_all_pages() + + link_pattern = re.compile(r"\[\[(.*?)\]\]") + page_names = {} # lowercase -> original name + incoming_refs = Counter() + page_content_lengths = {} + + for page in pages: + name = page.get("originalName") or page.get("name", "") + page_names[name.lower()] = name + + # Pass 1: collect all references and content lengths + for page in pages: + name = page.get("originalName") or page.get("name", "") + try: + content = get_page_content(api, name) + except Exception: + content = "" + page_content_lengths[name.lower()] = len(content) + links = link_pattern.findall(content) + for link in links: + incoming_refs[link.lower()] += 1 + + # Missing pages: referenced but don't exist + missing = [] + for ref_lower, count in incoming_refs.items(): + if ref_lower in page_names: + continue + if _is_incidental_page(ref_lower): + continue + missing.append({"page": ref_lower, "references": count}) + missing.sort(key=lambda x: x["references"], reverse=True) + + # Underdeveloped: exists, has refs, but very short content + underdeveloped = [] + for name_lower, original in page_names.items(): + refs = incoming_refs.get(name_lower, 0) + length = page_content_lengths.get(name_lower, 0) + if refs >= min_refs and length < 100: + if _is_incidental_page(original): + continue + # An empty page whose namespaced twin is written is an anchor for + # the name, not a gap. Only checked here, where the list is short. + if _has_richer_namesake(name_lower, page_names, + lambda n: get_page_content(api, n)): + continue + underdeveloped.append({ + "page": original, + "references": refs, + "content_length": length, + }) + underdeveloped.sort(key=lambda x: x["references"], reverse=True) + + # Orphaned: zero incoming references (exclude journals) + orphans = [] + if include_orphans: + journal_pages = set() + for page in pages: + if page.get("journalDay") or page.get("journal-day") or page.get("journal?"): + name = page.get("originalName") or page.get("name", "") + journal_pages.add(name.lower()) + + for name_lower, original in page_names.items(): + if name_lower in journal_pages: + continue + if _is_incidental_page(original): + continue + if incoming_refs.get(name_lower, 0) == 0: + orphans.append(original) + orphans.sort(key=str.lower) + + result = { + "missing_pages": missing[:20], + "underdeveloped_pages": underdeveloped[:20], + "orphaned_pages": orphans[:30] if include_orphans else [], + "summary": { + "missing": len(missing), + "underdeveloped": len(underdeveloped), + "orphaned": len(orphans) if include_orphans else "n/a", + }, + } + + if as_json: + output(result, True) + else: + click.echo("=== Knowledge Gaps ===\n") + click.echo(f"Missing pages (referenced but don't exist): {len(missing)}") + for m in result["missing_pages"]: + click.echo(f" {m['page']} ({m['references']} refs)") + + click.echo(f"\nUnderdeveloped pages (< 100 chars, {min_refs}+ refs): {len(underdeveloped)}") + for u in result["underdeveloped_pages"]: + click.echo(f" {u['page']} ({u['references']} refs, {u['content_length']} chars)") + + if include_orphans: + click.echo(f"\nOrphaned pages (zero incoming links): {len(orphans)}") + for o in result["orphaned_pages"]: + click.echo(f" {o}") + +@cli.command("analyze-journal-patterns", epilog="""\b +Example: + logseq-cli --token TOKEN analyze-journal-patterns --timeframe "last 30 days" --mood --topics +""") +@click.option("--timeframe", default="last 30 days", help="Date range for analysis") +@click.option("--mood/--no-mood", default=True, help="Include mood detection") +@click.option("--topics/--no-topics", default=True, help="Include topic analysis") +@click.option("--json", "as_json", is_flag=True, help="Output 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() + + # Word lists and the project tag are language- and graph-specific: the + # built-ins are English, so a journal written in another language scores + # zero moods and no project progress, silently. [analysis] in the config + # replaces them; see docs/configuration.md. + cfg = load_config() + mood_positive = _word_pattern( + get(cfg, "analysis", "mood_positive") + or ["happy", "great", "excited", "good", "wonderful", "productive", "grateful"]) + mood_negative = _word_pattern( + get(cfg, "analysis", "mood_negative") + or ["sad", "tired", "stressed", "frustrated", "anxious", "overwhelmed", "bad"]) + mood_labels = get(cfg, "analysis", "mood_labels") or ["mood", "feeling"] + mood_keyword = re.compile( + r"(?:" + "|".join(re.escape(str(w)) for w in mood_labels) + r"):\s*(\w+)", + re.IGNORECASE) + # Lines worth showing as evidence under the mood counts. Kept to explicit + # statements and emoji for the same reason the counting is: a bare "happy" + # in a sentence is as likely to be "not happy", and listing it under a + # count of zero reads as a contradiction. The labels come from config, so + # a graph writing "stimmung:" is covered. + 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 die Rückmeldung" + # 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 + # graph that namespaces its project pages usually writes both; graphs that + # tag flatly (#alpha) configure the tags themselves instead. + project_tag = get(cfg, "analysis", "project_tag_prefix") or "#project/" + project_pattern = _project_pattern( + str(project_tag), get(cfg, "analysis", "project_tags")) + # Habits stay checkbox-only on purpose: a habit is a repeated checkbox + # list, and treating every TODO as a habit would drown the real ones. + habit_checkbox = re.compile(r"- \[[ x]\]", re.IGNORECASE) + + entries = [] + topic_by_date = {} + mood_entries = [] + total_incomplete = 0 + total_complete = 0 + + # Extended analysis collectors + mood_patterns = [] # {date, mood, context} + habit_patterns = defaultdict(list) # habit_name -> [{date, done}] + project_progress = defaultdict(list) # project -> [{date, status}] + topics_by_month = defaultdict(set) # YYYY-MM -> set of topics + + for page in pages: + jd = page.get("journalDay") or page.get("journal-day") + if not jd: + continue + try: + d = journal_day_to_date(jd) + except (ValueError, TypeError): + continue + + dt = datetime.datetime.combine(d, datetime.time()) + if not (start <= dt <= end): + continue + + page_name = page.get("originalName") or page.get("name", "") + try: + content = get_page_content(api, page_name) + except Exception: + content = "" + + date_str = format_journal_date(d) + month_key = d.strftime("%Y-%m") + entry = {"date": date_str, "page": page_name} + + # Topics + if topics: + links = link_pattern.findall(content) + entry["topics"] = links + topic_by_date[date_str] = links + topics_by_month[month_key].update(links) + + # Mood, from explicit statements only. + # + # Counting every occurrence of a positive word measured how often such + # words appear in technical prose, not how the day went: "nicht + # zufrieden" and "läuft nicht gut" both scored as positive, and in the + # journal this was checked against 16% of positive hits were negations + # — concentrated in exactly the sentences that carry a judgement. A + # number that says the opposite of its own evidence is worse than no + # number, and negation is not something a word list can settle. + # + # So only a line that states a mood counts: "mood: good", + # "stimmung: mies" — the labels are configurable, and the word lists + # now classify that stated value rather than the whole journal. + if mood: + mood_data = {"positive": 0, "negative": 0, "keywords": []} + kw_matches = mood_keyword.findall(content) + mood_data["keywords"] = kw_matches + for stated in kw_matches: + if mood_positive.search(stated): + mood_data["positive"] += 1 + elif mood_negative.search(stated): + mood_data["negative"] += 1 + entry["mood"] = mood_data + if mood_data["positive"] or mood_data["negative"] or kw_matches: + mood_entries.append(entry) + + # Extended mood patterns - per block + if mood and content: + for line in content.split("\n"): + line_stripped = line.strip().lstrip("- ") + if not line_stripped: + continue + line_lower = line_stripped.lower() + for indicator in mood_indicator_patterns: + if indicator.lower() in line_lower: + mood_patterns.append({ + "date": date_str, + "month": month_key, + "mood": indicator, + "context": line_stripped[:120], + }) + 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 + + # Habit tracking - extract individual checkbox items + for line in content.split("\n"): + line_stripped = line.strip() + if habit_checkbox.search(line_stripped): + done = "[x]" in line_stripped.lower() + habit_text = re.sub(r"- \[[ x]\]\s*", "", line_stripped, flags=re.IGNORECASE).strip() + if habit_text: + habit_patterns[habit_text].append({"date": date_str, "done": done}) + + # Project progress + for line in content.split("\n"): + line_stripped = line.strip().lstrip("- ") + proj_match = project_pattern.search(line_stripped) + if proj_match: + # The pattern has one group per spelling (tag, link, and one + # per configured flat tag), so only one of them is filled. + name = next((g for g in proj_match.groups() if g), None) + if name is None: + continue + project_progress[name].append({ + "date": date_str, + "status": line_stripped[:150], + }) + + entries.append(entry) + + # Aggregate topics + all_topics = Counter() + for date_topics in topic_by_date.values(): + all_topics.update(date_topics) + + # Compute habit stats + habit_stats = {} + for habit_name, occurrences in habit_patterns.items(): + total = len(occurrences) + done_count = sum(1 for o in occurrences if o["done"]) + # Calculate streaks + current_streak = 0 + longest_streak = 0 + streak = 0 + for o in occurrences: + if o["done"]: + streak += 1 + longest_streak = max(longest_streak, streak) + else: + streak = 0 + current_streak = streak + habit_stats[habit_name] = { + "total": total, + "done": done_count, + "completion_rate": round(done_count / total * 100, 1) if total > 0 else 0, + "current_streak": current_streak, + "longest_streak": longest_streak, + } + + result = { + "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 + else 0 + ), + }, + } + if topics: + result["top_topics"] = [{"topic": t, "count": c} for t, c in all_topics.most_common(15)] + if mood: + total_pos = sum(e.get("mood", {}).get("positive", 0) for e in entries) + total_neg = sum(e.get("mood", {}).get("negative", 0) for e in entries) + result["mood_summary"] = { + "positive_signals": total_pos, + "negative_signals": total_neg, + "mood_keywords": [kw for e in entries for kw in e.get("mood", {}).get("keywords", [])], + } + result["mood_patterns"] = mood_patterns + if habit_stats: + result["habit_tracking"] = habit_stats + if project_progress: + result["project_progress"] = dict(project_progress) + if topics: + result["topic_evolution"] = {m: sorted(t) for m, t in sorted(topics_by_month.items(), reverse=True)} + result["entries"] = entries + + if as_json: + output(result, True) + 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)") + if topics and result.get("top_topics"): + click.echo("\nTop Topics:") + for t in result["top_topics"][:10]: + click.echo(f" {t['topic']}: {t['count']}") + if mood and result.get("mood_summary"): + ms = result["mood_summary"] + click.echo(f"\nMood: +{ms['positive_signals']} positive, -{ms['negative_signals']} negative") + if ms["mood_keywords"]: + click.echo(f" Keywords: {', '.join(ms['mood_keywords'])}") + + # Extended: Mood Patterns + if mood and mood_patterns: + click.echo("\nMood Patterns:") + mood_by_month = defaultdict(list) + for mp in mood_patterns: + mood_by_month[mp["month"]].append(mp) + for month in sorted(mood_by_month.keys(), reverse=True): + click.echo(f" {month}:") + for mp in mood_by_month[month][:5]: + click.echo(f" - {mp['mood']}: \"{mp['context']}\"") + + # Extended: Habit Tracking + if habit_stats: + click.echo("\nHabit Tracking:") + 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']})") + click.echo(f" Current streak: {stats['current_streak']} days") + click.echo(f" Longest streak: {stats['longest_streak']} days") + + # Extended: Project Progress + if project_progress: + click.echo("\nProject Progress:") + for project, updates in sorted(project_progress.items()): + click.echo(f" {project}:") + for u in updates[-5:]: + click.echo(f" - {u['date']}: {u['status']}") + + # Extended: Topic Evolution + if topics and topics_by_month: + click.echo("\nTopic Evolution:") + for month in sorted(topics_by_month.keys(), reverse=True): + month_topics = sorted(topics_by_month[month]) + click.echo(f" {month}: {', '.join(month_topics[:15])}") + +@cli.command("suggest-connections", epilog="""\b +Example: + logseq-cli --token TOKEN suggest-connections --min-confidence 0.7 --max-suggestions 10 +""") +@click.option("--min-confidence", default=0.3, type=float, help="Minimum confidence score (0-1)") +@click.option("--min-shared", default=3, type=int, show_default=True, + help="Minimum shared topics for a pair to count as connected") +@click.option("--max-suggestions", default=10, type=int, help="Maximum suggestions to return (1 or greater); the remainder is reported as withheld") +@click.option("--focus", default=None, help="Focus on specific page/topic") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def suggest_connections(ctx, min_confidence, min_shared, max_suggestions, focus, as_json): + """Suggest connections between pages based on shared topics.""" + api = ctx.obj["api"] + + # 0 is refused rather than answered with an empty list: the empty result is + # reported as "no connections found above confidence threshold", which + # blames the graph for what the flag did. + if max_suggestions < 1: + fail("--max-suggestions must be 1 or greater.", as_json) + + pages = api.get_all_pages() + + # Build topic index: page -> set of topics + page_topics = {} + topic_pages = defaultdict(set) + + for page in pages: + if page.get("journalDay") or page.get("journal-day") or page.get("journal?"): + continue + name = page.get("originalName") or page.get("name", "") + try: + content = get_page_content(api, name) + except Exception: + content = "" + + topics_found = set(extract_topics(content)) + # Also add the page name itself as a topic + page_topics[name] = topics_found + for t in topics_found: + topic_pages[t.lower()].add(name) + + # Calculate similarity between page pairs + suggestions = [] + page_list = list(page_topics.keys()) + if focus: + # Only compare focus page against others + page_list = [p for p in page_list if p.lower() == focus.lower()] + + for i, page_a in enumerate(page_list): + topics_a = page_topics.get(page_a, set()) + if not topics_a: + continue + + compare_to = list(page_topics.keys()) if focus else page_list[i+1:] + for page_b in compare_to: + if page_a == page_b: + continue + topics_b = page_topics.get(page_b, set()) + if not topics_b: + continue + + # Jaccard similarity on lowercase topics + a_lower = {t.lower() for t in topics_a} + b_lower = {t.lower() for t in topics_b} + intersection = a_lower & b_lower + union = a_lower | b_lower + + if not union: + continue + + # Jaccard alone rewards the thinnest evidence there is: two pages + # that link one page each, the same one, score 1/1 = 1.0 and sort + # above a pair sharing 35 topics out of 38. A single shared topic + # is a coincidence, not a connection, so it does not qualify. + if len(intersection) < min_shared: + continue + + score = len(intersection) / len(union) + if score >= min_confidence: + suggestions.append({ + "page_a": page_a, + "page_b": page_b, + "confidence": round(score, 3), + "shared_topics": sorted(intersection), + }) + + # Ties on confidence are common and meaningless on their own; the pair with + # more shared topics is the better suggestion of the two. + suggestions.sort(key=lambda s: (s["confidence"], len(s["shared_topics"])), + reverse=True) + # Counted before the cap: "found" is a statement about the graph, and + # counting the survivors would report three pairs as one whenever the cap + # bites. What the cap left out is named rather than dropped in silence. + total_found = len(suggestions) + suggestions = suggestions[:max_suggestions] + withheld = total_found - len(suggestions) + + result = { + "suggestions": suggestions, + "total_found": total_found, + "min_confidence": min_confidence, + "min_shared_topics": min_shared, + } + if withheld: + result["withheld"] = withheld + + if as_json: + output(result, True) + else: + if not suggestions: + click.echo("No connections found above confidence threshold.") + else: + click.echo(f"=== Suggested Connections ({len(suggestions)}) ===\n") + for s in suggestions: + click.echo(f" {s['page_a']} <-> {s['page_b']}") + click.echo(f" Confidence: {s['confidence']:.1%}") + click.echo(f" Shared: {', '.join(s['shared_topics'][:5])}") + click.echo() + # Never truncate silently: the same promise the journal reads make. + if withheld: + click.echo( + f"Note: showing {len(suggestions)} of {total_found} " + f"suggestion(s); {withheld} omitted. Raise --max-suggestions " + "to see more.", + err=True, + ) diff --git a/tests/test_analyze_journal_patterns.py b/tests/test_analyze_journal_patterns.py index 95b3b5a..0980500 100644 --- a/tests/test_analyze_journal_patterns.py +++ b/tests/test_analyze_journal_patterns.py @@ -31,7 +31,7 @@ def run(text, *args, config=None, tmp_path=None): env = {"LOGSEQ_CLI_CONFIG": str(f)} with patch.dict(os.environ, env, clear=False), \ patch("logseq_cli.group.LogseqAPI", return_value=api), \ - patch("logseq_cli.cli.get_page_content", return_value=text): + patch("logseq_cli.commands.analysis.get_page_content", return_value=text): return split_runner().invoke( cli, ["--token", "X", "analyze-journal-patterns", "--timeframe", "last 30 days", "--json", *args]) diff --git a/tests/test_config_integration.py b/tests/test_config_integration.py index f0cdd28..8dd1942 100644 --- a/tests/test_config_integration.py +++ b/tests/test_config_integration.py @@ -21,7 +21,8 @@ import pytest -from logseq_cli.cli import _project_pattern, _word_pattern, cli +from logseq_cli.cli import cli +from logseq_cli.commands.analysis import _project_pattern, _word_pattern from tests.conftest import split_runner from tests.test_datalog_quoting import QueryRecorder diff --git a/tests/test_read_only_commands_smoke.py b/tests/test_read_only_commands_smoke.py index f0891a9..4f4e757 100644 --- a/tests/test_read_only_commands_smoke.py +++ b/tests/test_read_only_commands_smoke.py @@ -224,23 +224,23 @@ def test_artefacts_are_not_reported_as_orphans(self, tmp_path): def test_prose_fragments_dragged_in_by_brackets_are_ignored(self): """"#Active)" and "3b82f6)" come from parentheses in a sentence.""" - from logseq_cli.cli import _is_incidental_page + from logseq_cli.commands.analysis import _is_incidental_page for name in ("3b82f6)", "508-workaround)", "Active)"): assert _is_incidental_page(name), name def test_a_name_with_balanced_brackets_is_kept(self): - from logseq_cli.cli import _is_incidental_page + from logseq_cli.commands.analysis import _is_incidental_page assert not _is_incidental_page("Projekt (Phase 1)") def test_a_ticket_number_that_took_the_next_word_is_ignored(self): """"#272-Designentscheidung" in prose becomes a page of that name.""" - from logseq_cli.cli import _is_incidental_page + from logseq_cli.commands.analysis import _is_incidental_page assert _is_incidental_page("272-Designentscheidung") assert _is_incidental_page("149-Rekursionsrisiko") def test_a_real_term_starting_with_a_digit_is_kept(self): """Three digits or more, so "2-Faktor-Auth" is not caught by it.""" - from logseq_cli.cli import _is_incidental_page + from logseq_cli.commands.analysis import _is_incidental_page assert not _is_incidental_page("2-Faktor-Auth") assert not _is_incidental_page("4-Level-Struktur") @@ -284,7 +284,7 @@ def _run(self, text, tmp_path): {"originalName": "J", "journalDay": 20260910, "journal?": True}] with patch.dict(os.environ, {"LOGSEQ_CLI_CONFIG": str(cfg)}, clear=False), \ patch("logseq_cli.group.LogseqAPI", return_value=api), \ - patch("logseq_cli.cli.get_page_content", return_value=text): + patch("logseq_cli.commands.analysis.get_page_content", return_value=text): r = split_runner().invoke( cli, ["--token", "X", "analyze-journal-patterns", "--timeframe", "last 30 days", "--json"]) From fd729f16162a5292bd0e4e050b8e039458e559e1 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:24:14 +0200 Subject: [PATCH 16/25] Move the page commands into logseq_cli/commands/pages.py Nine commands and the backlink-context helper. tests/test_backlinks_context.py imports that helper and moves with it. It is the fifth file using the combined import form, and the second where the two names are the other way round, so the whole line is replaced rather than the symbol. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 662 +----------------------------- logseq_cli/commands/pages.py | 706 ++++++++++++++++++++++++++++++++ tests/test_backlinks_context.py | 3 +- 3 files changed, 709 insertions(+), 662 deletions(-) create mode 100644 logseq_cli/commands/pages.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 38a5f31..0346994 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -75,6 +75,7 @@ count_blocks, ) from logseq_cli.group import cli, resolve_version +from logseq_cli.commands import pages # noqa: F401 imported for registration from logseq_cli.commands import analysis # noqa: F401 imported for registration from logseq_cli.commands import meta # noqa: F401 imported for registration from logseq_cli.commands import query # noqa: F401 imported for registration @@ -115,72 +116,11 @@ # --------------------------------------------------------------------------- # 1. get-all-pages # --------------------------------------------------------------------------- -@cli.command("get-all-pages", epilog="""\b -Example: - logseq-cli --token TOKEN get-all-pages --json | head -""") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def get_all_pages(ctx, as_json): - """List all pages in the graph.""" - api = ctx.obj["api"] - pages = api.get_all_pages() - if as_json: - output(pages, True) - else: - for page in sorted(pages, key=lambda p: (p.get("name") or "").lower()): - name = page.get("originalName") or page.get("name", "") - click.echo(name) -def _extract_backlink_context(refs, limit: int) -> list: - """Extract linking pages together with the blocks that do the linking. - - ``getPageLinkedReferences`` already answers ``[page, [block, ...]]`` pairs, - so the blocks arrive with the same call that yields the names — no second - read. ``extract_backlink_names`` keeps only the name; this keeps both. - - ``limit`` caps the blocks kept per page and the remainder is reported as - ``withheld``, the same bargain the other reads make: a page mentioned fifty - times must not decide the size of the output. - """ - if not refs or not isinstance(refs, list): - return [] - entries = [] - for entry in refs: - if not (isinstance(entry, (list, tuple)) and len(entry) >= 1): - continue - page_info = entry[0] - if not isinstance(page_info, dict): - continue - name = page_info.get("originalName") or page_info.get("name", "") - if not name: - continue - raw_blocks = entry[1] if len(entry) > 1 and isinstance(entry[1], list) else [] - blocks = [] - for block in raw_blocks: - if not isinstance(block, dict): - continue - content = (block.get("content") or "").strip() - # A properties block is the linking page's own metadata; it holds no - # mention and would read as context that is not there. - if not content or is_properties_block(content): - continue - blocks.append({"uuid": block.get("uuid", ""), "content": content}) - kept = blocks[:limit] if limit else blocks - item = {"page": name, "blocks": kept} - # Counted against what was kept, not against ``limit``: the caller - # supplies that number, and deriving the count from it is what let a - # negative value report more withheld than the page ever held. - if len(kept) < len(blocks): - item["withheld"] = len(blocks) - len(kept) - entries.append(item) - return sorted(entries, key=lambda e: e["page"]) - @@ -193,123 +133,6 @@ def _extract_backlink_context(refs, limit: int) -> list: # --------------------------------------------------------------------------- # 2. get-page # --------------------------------------------------------------------------- -@cli.command("get-page", epilog="""\b -Examples: - logseq-cli --token TOKEN get-page --name "Project Alpha" - logseq-cli --token TOKEN get-page --name "2026-05-08, friday" --resolve-refs --with-ids - logseq-cli --token TOKEN get-page --name "Project Alpha" --heading "## Open Points" - logseq-cli --token TOKEN get-page --name A --name B # batch read -Notes: - --resolve-refs inlines ((uuid)) block-refs (saves N×get-block). - --with-ids prefixes each line with the block UUID (replaces --json | jq). - --heading returns only the matching heading-block + its children. -""") -@click.option("--page", "--name", required=True, multiple=True, help="Page name (repeatable for batch: --name A --name B)") -@click.option("--no-backlinks", is_flag=True, help="Skip backlink computation") -@click.option("--resolve-refs", is_flag=True, help="Inline ((uuid)) block references with their content") -@click.option("--with-ids", "with_ids", is_flag=True, help="Prefix each block line with its UUID (format: \\t\\t)") -@click.option("--heading", default=None, help="Return only the section under this heading (e.g. '## Focus Topics W17'). Searches recursively.") -@click.option("--format", "output_format", type=click.Choice(["text", "markdown"]), default="text", help="Output format: text (default) or markdown (Logseq-compatible)") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def get_page(ctx, page, no_backlinks, resolve_refs, with_ids, heading, output_format, as_json): - """Get page content with backlinks. Pass --name multiple times for batch reads.""" - api = ctx.obj["api"] - missing = [] - dead_refs = [] - - def _fetch_one(page_name): - # A page that does not exist is an error, not an empty result: Logseq's - # getPage returns null for it but a real object for an existing-but-empty - # page. Without this check both render as "(empty page)" and the caller - # cannot tell "typo in the name" from "nothing written yet". - if api.get_page(page_name) is None: - missing.append(page_name) - blocks = api.get_page_blocks_tree(page_name) - if no_backlinks or heading: - backlinks = [] - else: - try: - refs = api.get_page_linked_references(page_name) - backlinks = extract_backlink_names(refs) - except Exception: - backlinks = find_backlinks(api, page_name) - if heading and blocks: - blocks = extract_section(blocks, heading) - if not blocks: - click.echo(f"Warning: heading '{heading}' not found in '{page_name}'", err=True) - if resolve_refs and blocks: - resolve_refs_in_blocks(api, blocks, dead_refs) - return {"page": page_name, "blocks": blocks, "backlinks": backlinks} - - results = [_fetch_one(p) for p in page] - - for result in results: - if result["page"] in missing: - result["exists"] = False - if dead_refs: - for result in results: - in_this = [u for u in dead_refs - if f"(({u}))" in json.dumps(result.get("blocks") or [])] - if in_this: - result["dead_refs"] = in_this - - if not resolve_refs: - total_refs = sum(count_unresolved_refs(r.get("blocks") or []) for r in results) - if total_refs > 0: - click.echo( - f"⚠️ {total_refs} unresolved block-ref(s) in output — " - f"re-run with --resolve-refs to inline them.", - err=True, - ) - elif dead_refs: - # Only sayable with --resolve-refs: without it nothing is looked up, so - # a raw ((uuid)) in the output means "not resolved", not "gone". With - # it, the two look identical on stdout — this is what tells them apart. - # A notice rather than an error: the page is still readable, and one - # stale ref must not cost the whole read. - for uuid in dead_refs: - results_with = [r["page"] for r in results - if f"(({uuid}))" in json.dumps(r.get("blocks") or [])] - where = f" (on {', '.join(results_with)})" if results_with else "" - click.echo(f"⚠️ block-ref (({uuid})) points at a block that no " - f"longer exists{where}", err=True) - - if as_json: - output(results if len(results) > 1 else results[0], True) - else: - for result in results: - p, blocks, backlinks = result["page"], result["blocks"], result["backlinks"] - absent = p in missing - placeholder = "(page does not exist)" if absent else "(empty page)" - if with_ids: - click.echo(f"=== {p} ===\n") - click.echo(blocks_with_ids(blocks) if blocks else placeholder) - elif output_format == "markdown": - click.echo(blocks_to_markdown(blocks) if blocks else placeholder) - else: - click.echo(f"=== {p} ===\n") - click.echo(process_blocks(blocks) if blocks else placeholder) - if backlinks: - click.echo(f"\nBacklinks ({len(backlinks)}):") - for bl in backlinks: - click.echo(f" <- {bl}") - if len(results) > 1: - click.echo() - - # Exit non-zero if any requested page is absent. Batch reads still print every - # page that does exist first, so one typo does not cost the whole result. - # The payload already went to stdout; the error goes to stderr only. - if missing: - if as_json: - click.echo(json.dumps( - {"error": "Page(s) not found", "missing": missing}, - indent=2, default=str), err=True) - else: - for page_name in missing: - click.echo(f"Error: Page '{page_name}' not found", err=True) - sys.exit(1) # --------------------------------------------------------------------------- @@ -325,109 +148,11 @@ def _fetch_one(page_name): # --------------------------------------------------------------------------- # 4. search-pages # --------------------------------------------------------------------------- -@cli.command("search-pages", epilog="""\b -Example: - logseq-cli --token TOKEN search-pages --query "Roadmap" -Note: - Case-insensitive substring match on page names. For content search use find-block. -""") -@click.option("--query", required=True, help="Search query (case-insensitive)") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def search_pages(ctx, query, as_json): - """Search pages by name (case-insensitive substring match).""" - api = ctx.obj["api"] - # Filtered here rather than in datalog, which is deliberate and was - # measured: pulling all pages costs 187ms of Logseq's own time, the filter - # below 0.38ms, and the transfer nothing worth naming over loopback. - # Against that, a query would have to disjoin over :block/original-name and - # :block/name AND normalise case itself (clojure.string/includes? is - # case-sensitive) to match what the two lines below do for free - and it - # would interpolate a user value into datalog, a class of bug this codebase - # has already paid for once. get_all_pages() is cached and wanted by a - # dozen other commands anyway. find-block queries datalog because block - # content is orders of magnitude more data; page names are not. - pages = api.get_all_pages() - query_lower = query.lower() - matches = [ - p for p in pages - if query_lower in (p.get("name") or "").lower() - or query_lower in (p.get("originalName") or "").lower() - ] - - if as_json: - output(matches, True) - else: - if not matches: - click.echo("No pages found.") - else: - click.echo(f"Found {len(matches)} page(s):") - for p in sorted(matches, key=lambda x: (x.get("name") or "").lower()): - click.echo(f" {p.get('originalName') or p.get('name', '')}") # --------------------------------------------------------------------------- # 5. get-backlinks # --------------------------------------------------------------------------- -@cli.command("get-backlinks", epilog="""\b -Examples: - logseq-cli --token TOKEN get-backlinks --name "Alice" - logseq-cli --token TOKEN get-backlinks --name "Alice" --name "Bob" # batch -""") -@click.option("--page", "--name", required=True, multiple=True, help="Page name to find backlinks for (repeatable for batch: --name A --name B)") -@click.option("--with-context", is_flag=True, help="Also show the blocks that do the linking, not just the page names. They come with the same API call, so this costs no extra read") -@click.option("--limit", type=int, default=3, show_default=True, help="With --with-context: blocks kept per linking page; the remainder is reported as withheld. 0 keeps all, negative is rejected") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def get_backlinks(ctx, page, with_context, limit, as_json): - """Find pages that link to the given page(s) (uses native Logseq API). Pass --name multiple times for batch.""" - api = ctx.obj["api"] - - if limit < 0: - fail("--limit must be 0 or greater (0 keeps all).", as_json) - - def _fetch_one(page_name): - try: - refs = api.get_page_linked_references(page_name) - if not refs: - return [] - if with_context: - return _extract_backlink_context(refs, limit) - return extract_backlink_names(refs) - except (ConnectionError, requests.exceptions.ConnectionError, requests.exceptions.Timeout): - click.echo("Native backlinks API unavailable, using brute-force scan...", err=True) - return find_backlinks(api, page_name) - except Exception as e: - click.echo(f"Warning: Native backlinks API returned unexpected format ({e}), trying brute-force...", err=True) - try: - return find_backlinks(api, page_name) - except Exception: - return [] - - results = [{"page": p, "backlinks": (bl := _fetch_one(p)), "count": len(bl)} for p in page] - - if as_json: - output(results if len(results) > 1 else results[0], True) - else: - for result in results: - p, backlinks = result["page"], result["backlinks"] - if not backlinks: - click.echo(f"No backlinks found for '{p}'.") - else: - click.echo(f"Backlinks to '{p}' ({len(backlinks)}):") - for bl in backlinks: - if isinstance(bl, dict): - click.echo(f" <- {bl['page']}") - for block in bl["blocks"]: - click.echo(f" {block['content']}") - if bl.get("withheld"): - click.echo(f" ... {bl['withheld']} more not shown") - else: - click.echo(f" <- {bl}") - if len(results) > 1: - click.echo() # --------------------------------------------------------------------------- @@ -728,65 +453,6 @@ def _fetch_one(target): # --------------------------------------------------------------------------- # 12. create-page # --------------------------------------------------------------------------- -@cli.command("create-page", epilog="""\b -Example: - logseq-cli --token TOKEN create-page --name "Alice Example" -Note: - For pages with properties, use create-page (no --content) + multiple set-property, - THEN add-note-content for the body. Properties via --content land as bullet-blocks - (NOT as real properties). -""") -@click.option("--page", "--name", required=True, help="Page name") -@click.option("--content", default=None, help="Initial content for the page") -@click.option("--dry-run", is_flag=True, help="Report whether the page exists and what would be created, without writing") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def create_page(ctx, page, content, as_json, dry_run): - """Create a new page, optionally with initial content.""" - api = ctx.obj["api"] - - # Logseq answers createPage for an existing page with that page, so the - # call alone cannot tell "created" from "was already there" — the command - # reported success either way, and --content went on to append to the page - # that existed. A retry after a timeout therefore duplicated content and - # was told the write had succeeded. Ask first. - exists = api.get_page(page) is not None - - if dry_run: - # The preview reports the state the live run would refuse on, rather - # than refusing here: a preview that exits non-zero is indistinguishable - # from one that failed to run. - if as_json: - output({"page": page, "exists": exists, "would_create": not exists, - "has_content": content is not None, "dry_run": True}, True) - elif exists: - click.echo(f"[DRY RUN] Page '{page}' already exists — would not be created") - else: - click.echo(f"[DRY RUN] Would create page: {page}") - if content: - click.echo(f" content: {content[:60]}{'...' if len(content) > 60 else ''}") - return - - if exists: - fail(f"Page '{page}' already exists. Use add-note-content to add to it, " - "or delete-page first.", as_json=as_json, page=page, exists=True) - - properties = {"journal?": True} if is_journal_date(page) else None - result = api.create_page(page, properties) - - if content: - # Unchecked, this appended to a page that create_page may have failed to - # create, and both failures stayed invisible behind "Created page: ...". - require_insert(api.append_block_in_page(page, content), - f"the initial content on '{page}'") - - if as_json: - output({"created": page, "page": result, "has_content": content is not None}, True) - else: - click.echo(f"Created page: {page}") - if content: - click.echo(f"Added content: {content[:60]}{'...' if len(content) > 60 else ''}") # --------------------------------------------------------------------------- @@ -1335,121 +1001,6 @@ def add_journal_content(ctx, content, date, under_heading, top_level, dry_run, a # --------------------------------------------------------------------------- # 16. add-note-content # --------------------------------------------------------------------------- -@cli.command("add-note-content", epilog="""\b -Examples: - logseq-cli --token TOKEN add-note-content --page "Alice" --content "Body text" - logseq-cli --token TOKEN add-note-content --page "Project Alpha" \\ - --under-heading "## Roadmap" --content "Phase 2 - Kickoff" -Note: - Counterpart of add-journal-block --under-heading for non-journal pages. - Heading is created if missing. -""") -@click.option("--page", "--name", required=True, help="Page name") -@click.option("--content", required=True, help="Content to add") -@click.option("--create/--no-create", default=True, help="Create page if it doesn't exist") -@click.option("--under-heading", default=None, help="Insert content under this heading; create heading if missing") -@click.option("--property", "properties", multiple=True, help="Set KEY=VALUE property on the created (root) block; repeatable") -@click.option("--dry-run", "dry_run", is_flag=True, help="Show target page, heading and block count, without writing") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def add_note_content(ctx, page, content, create, under_heading, properties, dry_run, as_json): - """Add content to any page.""" - api = ctx.obj["api"] - - # Validate property pairs up-front so a bad pair fails before any write. - try: - parse_property_pairs(properties) - except ValueError as e: - if as_json: - output({"error": str(e)}, True) - else: - click.echo(f"Error: {e}", err=True) - sys.exit(1) - - # Check if page exists - existing = None - try: - existing = api.get_page(page) - except Exception: - pass - - if not existing and not create: - fail(f"Page '{page}' not found. Use --create to create it.", - as_json=as_json, page=page, created=False) - - content = strip_title_heading(content, page) - - if dry_run: - # Everything below this point writes — the page, possibly the heading, - # then the blocks. The block count comes from the same parse the live - # path uses, so the preview reports what would actually land, not the - # raw line count. - planned = count_blocks(parse_hierarchical_content(content)) - heading_exists = (find_heading(api, page, under_heading) is not None - if under_heading and existing else False) - position = f"under '{under_heading}' on '{page}'" if under_heading else f"'{page}'" - parsed_properties = dict(parse_property_pairs(properties)) - - if as_json: - output({"page": page, "would_create_page": existing is None, - "blocks_added": planned, "under_heading": under_heading, - "would_create_heading": bool(under_heading) and not heading_exists, - "properties": parsed_properties, "position": position, - "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would add {planned} block(s) to {position}") - if existing is None: - click.echo(f" page: {page} (would be created)") - if under_heading and not heading_exists: - click.echo(f" heading: {under_heading} (would be created)") - for key, value in parsed_properties.items(): - click.echo(f" {key}:: {value}") - return - - if not existing and create: - api.create_page(page) - - if under_heading: - heading_uuid = find_or_create_heading(api, page, under_heading) - if not heading_uuid: - click.echo(f"Failed to find or create heading '{under_heading}' on '{page}'", err=True) - sys.exit(1) - tree = parse_hierarchical_content(content) - uuids = insert_block_tree_with_uuids(api, tree, heading_uuid) - position = f"under '{under_heading}' on '{page}'" - else: - uuids = insert_formatted_content_with_uuids(api, page, content) - position = page - - n = len(uuids) - root_uuid = uuids[0] if uuids else None - - applied = {} - if properties: - if root_uuid: - applied = apply_block_properties(api, root_uuid, properties) - else: - click.echo("Warning: no block created, --property ignored", err=True) - - if as_json: - output({ - "page": page, - "created": existing is None, - "blocks_added": n, - "content_added": True, - "under_heading": under_heading, - **uuid_fields(uuids), - "properties": applied, - }, True) - else: - if existing is None: - click.echo(f"Created page: {page}") - click.echo(f"Added {n} block(s) to {position}") - if root_uuid: - click.echo(f" uuid: {root_uuid}") - for key, value in applied.items(): - click.echo(f" {key}:: {value}") # --- Block editing commands --- @@ -2134,153 +1685,11 @@ def add_block_ref(ctx, source_id, journal_date, page, under_heading, dry_run, as # --------------------------------------------------------------------------- # 26. rename-page # --------------------------------------------------------------------------- -@cli.command("rename-page", epilog="""\b -Example: - logseq-cli --token TOKEN rename-page --name "Old Name" --new-name "New Name" -Note: - Updates all [[Old Name]] references in the graph automatically. -""") -@click.option("--page", "--name", required=True, help="Current page name") -@click.option("--new-name", required=True, help="New page name") -@click.option("--dry-run", "dry_run", is_flag=True, help="Show the rename and the referencing pages, without writing") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def rename_page(ctx, page, new_name, dry_run, as_json): - """Rename a page (updates all references across the graph).""" - api = ctx.obj["api"] - - # Verify page exists first - page_data = api.get_page(page) - if not page_data: - fail(f"Page '{page}' not found", as_json=as_json, page=page) - - if dry_run: - # A rename reaches past the page itself: Logseq rewrites every [[Old]] - # in the graph. The blast radius is the point of the preview, so it is - # worth the extra read here — the write path never needs it. Backlinks - # are best-effort: if the call fails the rename is still previewed, with - # the reference count reported as unknown rather than as zero. - referencing = None - try: - refs = api.get_page_linked_references(page) - referencing = extract_backlink_names(refs) if refs else [] - except Exception as e: - click.echo(f"Warning: could not read backlinks ({e}); " - f"reference count unknown", err=True) - - payload = {"old_name": page, "new_name": new_name, "dry_run": True} - if referencing is None: - payload["referencing_pages"] = None - payload["referencing_page_count"] = None - else: - payload["referencing_pages"] = referencing - payload["referencing_page_count"] = len(referencing) - - if as_json: - output(payload, True) - else: - click.echo(f"[DRY RUN] Would rename page") - click.echo(f" from: {page}") - click.echo(f" to: {new_name}") - if referencing is None: - click.echo(f" pages with references that would be rewritten: unknown") - else: - click.echo(f" pages with references that would be rewritten: {len(referencing)}") - for name in referencing[:10]: - click.echo(f" <- {name}") - if len(referencing) > 10: - click.echo(f" ... and {len(referencing) - 10} more") - return - - api.rename_page(page, new_name) - - result = {"old_name": page, "new_name": new_name, "status": "renamed"} - if as_json: - output(result, True) - else: - click.echo(f"Renamed '{page}' -> '{new_name}'") # --------------------------------------------------------------------------- # 27. delete-page # --------------------------------------------------------------------------- -@cli.command("delete-page", epilog="""\b -Examples: - logseq-cli --token TOKEN delete-page --name "Obsolete Page" --dry-run - logseq-cli --token TOKEN delete-page --name "Obsolete Page" --force -Note: - Destructive. Interactively (TTY) it prompts; non-interactively it REQUIRES - --force and fails otherwise — --json alone is not a confirmation. - Backlinks ((uuid)) pointing to deleted blocks become dangling. -""") -@click.option("--page", "--name", required=True, help="Page name to delete") -@click.option("--force", is_flag=True, help="Skip confirmation prompt (required when non-interactive)") -@click.option("--dry-run", is_flag=True, help="Show what would be deleted, without deleting") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def delete_page(ctx, page, force, dry_run, as_json): - """Delete a page from the graph.""" - api = ctx.obj["api"] - - # Verify page exists first - page_data = api.get_page(page) - if not page_data: - fail(f"Page '{page}' not found", as_json=as_json, page=page) - - # The block count is what the user decides on, so a failed read must not - # become a "0". That is the one value that makes a full page look safe to - # drop, and it feeds the confirmation prompt as well as the preview. - try: - blocks = api.get_page_blocks_tree(page) or [] - block_count = count_blocks(blocks) - except Exception as exc: - block_count = None - read_error = exc - - if block_count is None and (dry_run or not force): - # Both paths exist to let someone decide. Without the count there is - # nothing to decide on, so they stop instead of showing a number that - # was never measured. --force is deliberately exempt below: there the - # count is output, not a gate. - fail(f"Cannot read the blocks of page '{page}' to report what would be " - f"deleted ({read_error}). The page was left untouched.", - as_json=as_json, page=page) - - if dry_run: - if as_json: - output({"page": page, "blocks": block_count, "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would delete page '{page}' ({block_count} block(s))") - return - - # Confirmation gate. Prompt only when stdin is an interactive terminal; - # otherwise --force is mandatory. The output format (--json) must never - # double as a confirmation: a script may request JSON purely to parse data. - if not force: - if sys.stdin.isatty(): - if not click.confirm(f"Delete page '{page}' ({block_count} block(s))?"): - click.echo("Aborted.") - return - else: - fail( - f"Refusing to delete page '{page}' non-interactively without --force. " - f"Re-run with --force to confirm, or --dry-run to preview.", - as_json=as_json, page=page, blocks=block_count, - ) - - api.delete_page(page) - - # Only --force reaches this with an unknown count (see the guard above). - # "unknown" is the honest word for it: the delete happened, the size did - # not get measured, and reporting 0 would misdescribe what was removed. - result = {"page": page, "status": "deleted", "blocks": block_count} - if as_json: - output(result, True) - else: - size = "unknown" if block_count is None else f"{block_count} block(s)" - click.echo(f"Deleted page '{page}' ({size})") # --------------------------------------------------------------------------- @@ -2430,75 +1839,6 @@ def move_block_cmd(ctx, block_id, under, before, dry_run, as_json): # --------------------------------------------------------------------------- # 30. get-page-stats # --------------------------------------------------------------------------- -@cli.command("get-page-stats", epilog="""\b -Example: - logseq-cli --token TOKEN get-page-stats --name "Alice" -Note: - Shows blocks, words, inbound/outbound link counts. -""") -@click.option("--page", "--name", required=True, help="Page name") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def get_page_stats(ctx, page, as_json): - """Show statistics for a page (blocks, words, links).""" - api = ctx.obj["api"] - blocks = api.get_page_blocks_tree(page) - if blocks is None: - fail(f"Page '{page}' not found.", as_json=as_json, page=page) - - def _collect(tree): - total_blocks = 0 - total_words = 0 - outbound = set() - all_text = [] - for block in tree: - total_blocks += 1 - content = block.get("content", "") - all_text.append(content) - words = len(content.split()) if content.strip() else 0 - total_words += words - outbound.update(extract_page_links(content)) - children = block.get("children", []) - if children: - cb, cw, co, ct = _collect(children) - total_blocks += cb - total_words += cw - outbound.update(co) - all_text.extend(ct) - return total_blocks, total_words, outbound, all_text - - block_count, word_count, outbound_links, _ = _collect(blocks) - - # Inbound links via native API - try: - refs = api.get_page_linked_references(page) - inbound = extract_backlink_names(refs) - except Exception: - inbound = [] - - stats = { - "page": page, - "blocks": block_count, - "words": word_count, - "outbound_links": sorted(outbound_links), - "outbound_count": len(outbound_links), - "inbound_links": inbound, - "inbound_count": len(inbound), - } - - if as_json: - output(stats, True) - else: - click.echo(f"=== {page} ===\n") - click.echo(f" Blocks: {block_count}") - click.echo(f" Words: {word_count}") - click.echo(f" Outbound links: {len(outbound_links)}") - click.echo(f" Inbound links: {len(inbound)}") - if outbound_links: - click.echo(f"\n Outbound: {', '.join(sorted(outbound_links))}") - if inbound: - click.echo(f"\n Inbound: {', '.join(inbound)}") # --------------------------------------------------------------------------- diff --git a/logseq_cli/commands/pages.py b/logseq_cli/commands/pages.py new file mode 100644 index 0000000..4d2a37a --- /dev/null +++ b/logseq_cli/commands/pages.py @@ -0,0 +1,706 @@ +import json +import sys + +import click +import requests + +from logseq_cli.group import cli +from logseq_cli.helpers import ( + apply_block_properties, + count_blocks, + extract_page_links, + find_backlinks, + find_heading, + find_or_create_heading, + insert_block_tree_with_uuids, + insert_formatted_content_with_uuids, + is_journal_date, + parse_hierarchical_content, + parse_property_pairs, + process_blocks, + require_insert, + strip_title_heading, + uuid_fields, +) +from logseq_cli.output import fail, handle_connection_error, output +from logseq_cli.render import ( + blocks_to_markdown, + blocks_with_ids, + count_unresolved_refs, + extract_backlink_names, + extract_section, + is_properties_block, + resolve_refs_in_blocks, +) + + +@cli.command("get-all-pages", epilog="""\b +Example: + logseq-cli --token TOKEN get-all-pages --json | head +""") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def get_all_pages(ctx, as_json): + """List all pages in the graph.""" + api = ctx.obj["api"] + pages = api.get_all_pages() + + if as_json: + output(pages, True) + else: + for page in sorted(pages, key=lambda p: (p.get("name") or "").lower()): + name = page.get("originalName") or page.get("name", "") + click.echo(name) + +def _extract_backlink_context(refs, limit: int) -> list: + """Extract linking pages together with the blocks that do the linking. + + ``getPageLinkedReferences`` already answers ``[page, [block, ...]]`` pairs, + so the blocks arrive with the same call that yields the names — no second + read. ``extract_backlink_names`` keeps only the name; this keeps both. + + ``limit`` caps the blocks kept per page and the remainder is reported as + ``withheld``, the same bargain the other reads make: a page mentioned fifty + times must not decide the size of the output. + """ + if not refs or not isinstance(refs, list): + return [] + entries = [] + for entry in refs: + if not (isinstance(entry, (list, tuple)) and len(entry) >= 1): + continue + page_info = entry[0] + if not isinstance(page_info, dict): + continue + name = page_info.get("originalName") or page_info.get("name", "") + if not name: + continue + raw_blocks = entry[1] if len(entry) > 1 and isinstance(entry[1], list) else [] + blocks = [] + for block in raw_blocks: + if not isinstance(block, dict): + continue + content = (block.get("content") or "").strip() + # A properties block is the linking page's own metadata; it holds no + # mention and would read as context that is not there. + if not content or is_properties_block(content): + continue + blocks.append({"uuid": block.get("uuid", ""), "content": content}) + kept = blocks[:limit] if limit else blocks + item = {"page": name, "blocks": kept} + # Counted against what was kept, not against ``limit``: the caller + # supplies that number, and deriving the count from it is what let a + # negative value report more withheld than the page ever held. + if len(kept) < len(blocks): + item["withheld"] = len(blocks) - len(kept) + entries.append(item) + return sorted(entries, key=lambda e: e["page"]) + +@cli.command("get-page", epilog="""\b +Examples: + logseq-cli --token TOKEN get-page --name "Project Alpha" + logseq-cli --token TOKEN get-page --name "2026-05-08, friday" --resolve-refs --with-ids + logseq-cli --token TOKEN get-page --name "Project Alpha" --heading "## Open Points" + logseq-cli --token TOKEN get-page --name A --name B # batch read +Notes: + --resolve-refs inlines ((uuid)) block-refs (saves N×get-block). + --with-ids prefixes each line with the block UUID (replaces --json | jq). + --heading returns only the matching heading-block + its children. +""") +@click.option("--page", "--name", required=True, multiple=True, help="Page name (repeatable for batch: --name A --name B)") +@click.option("--no-backlinks", is_flag=True, help="Skip backlink computation") +@click.option("--resolve-refs", is_flag=True, help="Inline ((uuid)) block references with their content") +@click.option("--with-ids", "with_ids", is_flag=True, help="Prefix each block line with its UUID (format: \\t\\t)") +@click.option("--heading", default=None, help="Return only the section under this heading (e.g. '## Focus Topics W17'). Searches recursively.") +@click.option("--format", "output_format", type=click.Choice(["text", "markdown"]), default="text", help="Output format: text (default) or markdown (Logseq-compatible)") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def get_page(ctx, page, no_backlinks, resolve_refs, with_ids, heading, output_format, as_json): + """Get page content with backlinks. Pass --name multiple times for batch reads.""" + api = ctx.obj["api"] + missing = [] + dead_refs = [] + + def _fetch_one(page_name): + # A page that does not exist is an error, not an empty result: Logseq's + # getPage returns null for it but a real object for an existing-but-empty + # page. Without this check both render as "(empty page)" and the caller + # cannot tell "typo in the name" from "nothing written yet". + if api.get_page(page_name) is None: + missing.append(page_name) + blocks = api.get_page_blocks_tree(page_name) + if no_backlinks or heading: + backlinks = [] + else: + try: + refs = api.get_page_linked_references(page_name) + backlinks = extract_backlink_names(refs) + except Exception: + backlinks = find_backlinks(api, page_name) + if heading and blocks: + blocks = extract_section(blocks, heading) + if not blocks: + click.echo(f"Warning: heading '{heading}' not found in '{page_name}'", err=True) + if resolve_refs and blocks: + resolve_refs_in_blocks(api, blocks, dead_refs) + return {"page": page_name, "blocks": blocks, "backlinks": backlinks} + + results = [_fetch_one(p) for p in page] + + for result in results: + if result["page"] in missing: + result["exists"] = False + if dead_refs: + for result in results: + in_this = [u for u in dead_refs + if f"(({u}))" in json.dumps(result.get("blocks") or [])] + if in_this: + result["dead_refs"] = in_this + + if not resolve_refs: + total_refs = sum(count_unresolved_refs(r.get("blocks") or []) for r in results) + if total_refs > 0: + click.echo( + f"⚠️ {total_refs} unresolved block-ref(s) in output — " + f"re-run with --resolve-refs to inline them.", + err=True, + ) + elif dead_refs: + # Only sayable with --resolve-refs: without it nothing is looked up, so + # a raw ((uuid)) in the output means "not resolved", not "gone". With + # it, the two look identical on stdout — this is what tells them apart. + # A notice rather than an error: the page is still readable, and one + # stale ref must not cost the whole read. + for uuid in dead_refs: + results_with = [r["page"] for r in results + if f"(({uuid}))" in json.dumps(r.get("blocks") or [])] + where = f" (on {', '.join(results_with)})" if results_with else "" + click.echo(f"⚠️ block-ref (({uuid})) points at a block that no " + f"longer exists{where}", err=True) + + if as_json: + output(results if len(results) > 1 else results[0], True) + else: + for result in results: + p, blocks, backlinks = result["page"], result["blocks"], result["backlinks"] + absent = p in missing + placeholder = "(page does not exist)" if absent else "(empty page)" + if with_ids: + click.echo(f"=== {p} ===\n") + click.echo(blocks_with_ids(blocks) if blocks else placeholder) + elif output_format == "markdown": + click.echo(blocks_to_markdown(blocks) if blocks else placeholder) + else: + click.echo(f"=== {p} ===\n") + click.echo(process_blocks(blocks) if blocks else placeholder) + if backlinks: + click.echo(f"\nBacklinks ({len(backlinks)}):") + for bl in backlinks: + click.echo(f" <- {bl}") + if len(results) > 1: + click.echo() + + # Exit non-zero if any requested page is absent. Batch reads still print every + # page that does exist first, so one typo does not cost the whole result. + # The payload already went to stdout; the error goes to stderr only. + if missing: + if as_json: + click.echo(json.dumps( + {"error": "Page(s) not found", "missing": missing}, + indent=2, default=str), err=True) + else: + for page_name in missing: + click.echo(f"Error: Page '{page_name}' not found", err=True) + sys.exit(1) + +@cli.command("search-pages", epilog="""\b +Example: + logseq-cli --token TOKEN search-pages --query "Roadmap" +Note: + Case-insensitive substring match on page names. For content search use find-block. +""") +@click.option("--query", required=True, help="Search query (case-insensitive)") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def search_pages(ctx, query, as_json): + """Search pages by name (case-insensitive substring match).""" + api = ctx.obj["api"] + # Filtered here rather than in datalog, which is deliberate and was + # measured: pulling all pages costs 187ms of Logseq's own time, the filter + # below 0.38ms, and the transfer nothing worth naming over loopback. + # Against that, a query would have to disjoin over :block/original-name and + # :block/name AND normalise case itself (clojure.string/includes? is + # case-sensitive) to match what the two lines below do for free - and it + # would interpolate a user value into datalog, a class of bug this codebase + # has already paid for once. get_all_pages() is cached and wanted by a + # dozen other commands anyway. find-block queries datalog because block + # content is orders of magnitude more data; page names are not. + pages = api.get_all_pages() + query_lower = query.lower() + matches = [ + p for p in pages + if query_lower in (p.get("name") or "").lower() + or query_lower in (p.get("originalName") or "").lower() + ] + + if as_json: + output(matches, True) + else: + if not matches: + click.echo("No pages found.") + else: + click.echo(f"Found {len(matches)} page(s):") + for p in sorted(matches, key=lambda x: (x.get("name") or "").lower()): + click.echo(f" {p.get('originalName') or p.get('name', '')}") + +@cli.command("get-backlinks", epilog="""\b +Examples: + logseq-cli --token TOKEN get-backlinks --name "Alice" + logseq-cli --token TOKEN get-backlinks --name "Alice" --name "Bob" # batch +""") +@click.option("--page", "--name", required=True, multiple=True, help="Page name to find backlinks for (repeatable for batch: --name A --name B)") +@click.option("--with-context", is_flag=True, help="Also show the blocks that do the linking, not just the page names. They come with the same API call, so this costs no extra read") +@click.option("--limit", type=int, default=3, show_default=True, help="With --with-context: blocks kept per linking page; the remainder is reported as withheld. 0 keeps all, negative is rejected") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def get_backlinks(ctx, page, with_context, limit, as_json): + """Find pages that link to the given page(s) (uses native Logseq API). Pass --name multiple times for batch.""" + api = ctx.obj["api"] + + if limit < 0: + fail("--limit must be 0 or greater (0 keeps all).", as_json) + + def _fetch_one(page_name): + try: + refs = api.get_page_linked_references(page_name) + if not refs: + return [] + if with_context: + return _extract_backlink_context(refs, limit) + return extract_backlink_names(refs) + except (ConnectionError, requests.exceptions.ConnectionError, requests.exceptions.Timeout): + click.echo("Native backlinks API unavailable, using brute-force scan...", err=True) + return find_backlinks(api, page_name) + except Exception as e: + click.echo(f"Warning: Native backlinks API returned unexpected format ({e}), trying brute-force...", err=True) + try: + return find_backlinks(api, page_name) + except Exception: + return [] + + results = [{"page": p, "backlinks": (bl := _fetch_one(p)), "count": len(bl)} for p in page] + + if as_json: + output(results if len(results) > 1 else results[0], True) + else: + for result in results: + p, backlinks = result["page"], result["backlinks"] + if not backlinks: + click.echo(f"No backlinks found for '{p}'.") + else: + click.echo(f"Backlinks to '{p}' ({len(backlinks)}):") + for bl in backlinks: + if isinstance(bl, dict): + click.echo(f" <- {bl['page']}") + for block in bl["blocks"]: + click.echo(f" {block['content']}") + if bl.get("withheld"): + click.echo(f" ... {bl['withheld']} more not shown") + else: + click.echo(f" <- {bl}") + if len(results) > 1: + click.echo() + +@cli.command("create-page", epilog="""\b +Example: + logseq-cli --token TOKEN create-page --name "Alice Example" +Note: + For pages with properties, use create-page (no --content) + multiple set-property, + THEN add-note-content for the body. Properties via --content land as bullet-blocks + (NOT as real properties). +""") +@click.option("--page", "--name", required=True, help="Page name") +@click.option("--content", default=None, help="Initial content for the page") +@click.option("--dry-run", is_flag=True, help="Report whether the page exists and what would be created, without writing") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def create_page(ctx, page, content, as_json, dry_run): + """Create a new page, optionally with initial content.""" + api = ctx.obj["api"] + + # Logseq answers createPage for an existing page with that page, so the + # call alone cannot tell "created" from "was already there" — the command + # reported success either way, and --content went on to append to the page + # that existed. A retry after a timeout therefore duplicated content and + # was told the write had succeeded. Ask first. + exists = api.get_page(page) is not None + + if dry_run: + # The preview reports the state the live run would refuse on, rather + # than refusing here: a preview that exits non-zero is indistinguishable + # from one that failed to run. + if as_json: + output({"page": page, "exists": exists, "would_create": not exists, + "has_content": content is not None, "dry_run": True}, True) + elif exists: + click.echo(f"[DRY RUN] Page '{page}' already exists — would not be created") + else: + click.echo(f"[DRY RUN] Would create page: {page}") + if content: + click.echo(f" content: {content[:60]}{'...' if len(content) > 60 else ''}") + return + + if exists: + fail(f"Page '{page}' already exists. Use add-note-content to add to it, " + "or delete-page first.", as_json=as_json, page=page, exists=True) + + properties = {"journal?": True} if is_journal_date(page) else None + result = api.create_page(page, properties) + + if content: + # Unchecked, this appended to a page that create_page may have failed to + # create, and both failures stayed invisible behind "Created page: ...". + require_insert(api.append_block_in_page(page, content), + f"the initial content on '{page}'") + + if as_json: + output({"created": page, "page": result, "has_content": content is not None}, True) + else: + click.echo(f"Created page: {page}") + if content: + click.echo(f"Added content: {content[:60]}{'...' if len(content) > 60 else ''}") + +@cli.command("add-note-content", epilog="""\b +Examples: + logseq-cli --token TOKEN add-note-content --page "Alice" --content "Body text" + logseq-cli --token TOKEN add-note-content --page "Project Alpha" \\ + --under-heading "## Roadmap" --content "Phase 2 - Kickoff" +Note: + Counterpart of add-journal-block --under-heading for non-journal pages. + Heading is created if missing. +""") +@click.option("--page", "--name", required=True, help="Page name") +@click.option("--content", required=True, help="Content to add") +@click.option("--create/--no-create", default=True, help="Create page if it doesn't exist") +@click.option("--under-heading", default=None, help="Insert content under this heading; create heading if missing") +@click.option("--property", "properties", multiple=True, help="Set KEY=VALUE property on the created (root) block; repeatable") +@click.option("--dry-run", "dry_run", is_flag=True, help="Show target page, heading and block count, without writing") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def add_note_content(ctx, page, content, create, under_heading, properties, dry_run, as_json): + """Add content to any page.""" + api = ctx.obj["api"] + + # Validate property pairs up-front so a bad pair fails before any write. + try: + parse_property_pairs(properties) + except ValueError as e: + if as_json: + output({"error": str(e)}, True) + else: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + # Check if page exists + existing = None + try: + existing = api.get_page(page) + except Exception: + pass + + if not existing and not create: + fail(f"Page '{page}' not found. Use --create to create it.", + as_json=as_json, page=page, created=False) + + content = strip_title_heading(content, page) + + if dry_run: + # Everything below this point writes — the page, possibly the heading, + # then the blocks. The block count comes from the same parse the live + # path uses, so the preview reports what would actually land, not the + # raw line count. + planned = count_blocks(parse_hierarchical_content(content)) + heading_exists = (find_heading(api, page, under_heading) is not None + if under_heading and existing else False) + position = f"under '{under_heading}' on '{page}'" if under_heading else f"'{page}'" + parsed_properties = dict(parse_property_pairs(properties)) + + if as_json: + output({"page": page, "would_create_page": existing is None, + "blocks_added": planned, "under_heading": under_heading, + "would_create_heading": bool(under_heading) and not heading_exists, + "properties": parsed_properties, "position": position, + "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would add {planned} block(s) to {position}") + if existing is None: + click.echo(f" page: {page} (would be created)") + if under_heading and not heading_exists: + click.echo(f" heading: {under_heading} (would be created)") + for key, value in parsed_properties.items(): + click.echo(f" {key}:: {value}") + return + + if not existing and create: + api.create_page(page) + + if under_heading: + heading_uuid = find_or_create_heading(api, page, under_heading) + if not heading_uuid: + click.echo(f"Failed to find or create heading '{under_heading}' on '{page}'", err=True) + sys.exit(1) + tree = parse_hierarchical_content(content) + uuids = insert_block_tree_with_uuids(api, tree, heading_uuid) + position = f"under '{under_heading}' on '{page}'" + else: + uuids = insert_formatted_content_with_uuids(api, page, content) + position = page + + n = len(uuids) + root_uuid = uuids[0] if uuids else None + + applied = {} + if properties: + if root_uuid: + applied = apply_block_properties(api, root_uuid, properties) + else: + click.echo("Warning: no block created, --property ignored", err=True) + + if as_json: + output({ + "page": page, + "created": existing is None, + "blocks_added": n, + "content_added": True, + "under_heading": under_heading, + **uuid_fields(uuids), + "properties": applied, + }, True) + else: + if existing is None: + click.echo(f"Created page: {page}") + click.echo(f"Added {n} block(s) to {position}") + if root_uuid: + click.echo(f" uuid: {root_uuid}") + for key, value in applied.items(): + click.echo(f" {key}:: {value}") + +@cli.command("rename-page", epilog="""\b +Example: + logseq-cli --token TOKEN rename-page --name "Old Name" --new-name "New Name" +Note: + Updates all [[Old Name]] references in the graph automatically. +""") +@click.option("--page", "--name", required=True, help="Current page name") +@click.option("--new-name", required=True, help="New page name") +@click.option("--dry-run", "dry_run", is_flag=True, help="Show the rename and the referencing pages, without writing") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def rename_page(ctx, page, new_name, dry_run, as_json): + """Rename a page (updates all references across the graph).""" + api = ctx.obj["api"] + + # Verify page exists first + page_data = api.get_page(page) + if not page_data: + fail(f"Page '{page}' not found", as_json=as_json, page=page) + + if dry_run: + # A rename reaches past the page itself: Logseq rewrites every [[Old]] + # in the graph. The blast radius is the point of the preview, so it is + # worth the extra read here — the write path never needs it. Backlinks + # are best-effort: if the call fails the rename is still previewed, with + # the reference count reported as unknown rather than as zero. + referencing = None + try: + refs = api.get_page_linked_references(page) + referencing = extract_backlink_names(refs) if refs else [] + except Exception as e: + click.echo(f"Warning: could not read backlinks ({e}); " + f"reference count unknown", err=True) + + payload = {"old_name": page, "new_name": new_name, "dry_run": True} + if referencing is None: + payload["referencing_pages"] = None + payload["referencing_page_count"] = None + else: + payload["referencing_pages"] = referencing + payload["referencing_page_count"] = len(referencing) + + if as_json: + output(payload, True) + else: + click.echo(f"[DRY RUN] Would rename page") + click.echo(f" from: {page}") + click.echo(f" to: {new_name}") + if referencing is None: + click.echo(f" pages with references that would be rewritten: unknown") + else: + click.echo(f" pages with references that would be rewritten: {len(referencing)}") + for name in referencing[:10]: + click.echo(f" <- {name}") + if len(referencing) > 10: + click.echo(f" ... and {len(referencing) - 10} more") + return + + api.rename_page(page, new_name) + + result = {"old_name": page, "new_name": new_name, "status": "renamed"} + if as_json: + output(result, True) + else: + click.echo(f"Renamed '{page}' -> '{new_name}'") + +@cli.command("delete-page", epilog="""\b +Examples: + logseq-cli --token TOKEN delete-page --name "Obsolete Page" --dry-run + logseq-cli --token TOKEN delete-page --name "Obsolete Page" --force +Note: + Destructive. Interactively (TTY) it prompts; non-interactively it REQUIRES + --force and fails otherwise — --json alone is not a confirmation. + Backlinks ((uuid)) pointing to deleted blocks become dangling. +""") +@click.option("--page", "--name", required=True, help="Page name to delete") +@click.option("--force", is_flag=True, help="Skip confirmation prompt (required when non-interactive)") +@click.option("--dry-run", is_flag=True, help="Show what would be deleted, without deleting") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def delete_page(ctx, page, force, dry_run, as_json): + """Delete a page from the graph.""" + api = ctx.obj["api"] + + # Verify page exists first + page_data = api.get_page(page) + if not page_data: + fail(f"Page '{page}' not found", as_json=as_json, page=page) + + # The block count is what the user decides on, so a failed read must not + # become a "0". That is the one value that makes a full page look safe to + # drop, and it feeds the confirmation prompt as well as the preview. + try: + blocks = api.get_page_blocks_tree(page) or [] + block_count = count_blocks(blocks) + except Exception as exc: + block_count = None + read_error = exc + + if block_count is None and (dry_run or not force): + # Both paths exist to let someone decide. Without the count there is + # nothing to decide on, so they stop instead of showing a number that + # was never measured. --force is deliberately exempt below: there the + # count is output, not a gate. + fail(f"Cannot read the blocks of page '{page}' to report what would be " + f"deleted ({read_error}). The page was left untouched.", + as_json=as_json, page=page) + + if dry_run: + if as_json: + output({"page": page, "blocks": block_count, "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would delete page '{page}' ({block_count} block(s))") + return + + # Confirmation gate. Prompt only when stdin is an interactive terminal; + # otherwise --force is mandatory. The output format (--json) must never + # double as a confirmation: a script may request JSON purely to parse data. + if not force: + if sys.stdin.isatty(): + if not click.confirm(f"Delete page '{page}' ({block_count} block(s))?"): + click.echo("Aborted.") + return + else: + fail( + f"Refusing to delete page '{page}' non-interactively without --force. " + f"Re-run with --force to confirm, or --dry-run to preview.", + as_json=as_json, page=page, blocks=block_count, + ) + + api.delete_page(page) + + # Only --force reaches this with an unknown count (see the guard above). + # "unknown" is the honest word for it: the delete happened, the size did + # not get measured, and reporting 0 would misdescribe what was removed. + result = {"page": page, "status": "deleted", "blocks": block_count} + if as_json: + output(result, True) + else: + size = "unknown" if block_count is None else f"{block_count} block(s)" + click.echo(f"Deleted page '{page}' ({size})") + +@cli.command("get-page-stats", epilog="""\b +Example: + logseq-cli --token TOKEN get-page-stats --name "Alice" +Note: + Shows blocks, words, inbound/outbound link counts. +""") +@click.option("--page", "--name", required=True, help="Page name") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def get_page_stats(ctx, page, as_json): + """Show statistics for a page (blocks, words, links).""" + api = ctx.obj["api"] + blocks = api.get_page_blocks_tree(page) + if blocks is None: + fail(f"Page '{page}' not found.", as_json=as_json, page=page) + + def _collect(tree): + total_blocks = 0 + total_words = 0 + outbound = set() + all_text = [] + for block in tree: + total_blocks += 1 + content = block.get("content", "") + all_text.append(content) + words = len(content.split()) if content.strip() else 0 + total_words += words + outbound.update(extract_page_links(content)) + children = block.get("children", []) + if children: + cb, cw, co, ct = _collect(children) + total_blocks += cb + total_words += cw + outbound.update(co) + all_text.extend(ct) + return total_blocks, total_words, outbound, all_text + + block_count, word_count, outbound_links, _ = _collect(blocks) + + # Inbound links via native API + try: + refs = api.get_page_linked_references(page) + inbound = extract_backlink_names(refs) + except Exception: + inbound = [] + + stats = { + "page": page, + "blocks": block_count, + "words": word_count, + "outbound_links": sorted(outbound_links), + "outbound_count": len(outbound_links), + "inbound_links": inbound, + "inbound_count": len(inbound), + } + + if as_json: + output(stats, True) + else: + click.echo(f"=== {page} ===\n") + click.echo(f" Blocks: {block_count}") + click.echo(f" Words: {word_count}") + click.echo(f" Outbound links: {len(outbound_links)}") + click.echo(f" Inbound links: {len(inbound)}") + if outbound_links: + click.echo(f"\n Outbound: {', '.join(sorted(outbound_links))}") + if inbound: + click.echo(f"\n Inbound: {', '.join(inbound)}") diff --git a/tests/test_backlinks_context.py b/tests/test_backlinks_context.py index c28a599..2d80c7f 100644 --- a/tests/test_backlinks_context.py +++ b/tests/test_backlinks_context.py @@ -16,7 +16,8 @@ import pytest from click.testing import CliRunner -from logseq_cli.cli import _extract_backlink_context, cli +from logseq_cli.cli import cli +from logseq_cli.commands.pages import _extract_backlink_context from tests.conftest import split_runner From 7165e7810aadbfb93a719e33b97606869645aa4d Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:24:47 +0200 Subject: [PATCH 17/25] Move the block editing commands into logseq_cli/commands/edit.py Seven commands, and the alias registration that sits outside every map entry: delete-block is added with cli.add_command a few lines below remove-block rather than by decorator, so it travels with the command it names or it stays behind referring to a symbol cli.py no longer defines. Probed here rather than trusted: commenting out that one line makes the registry guard from the start of this series fail naming delete-block. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 754 +-------------------------------- logseq_cli/commands/edit.py | 800 ++++++++++++++++++++++++++++++++++++ 2 files changed, 801 insertions(+), 753 deletions(-) create mode 100644 logseq_cli/commands/edit.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 0346994..2c77a41 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -75,6 +75,7 @@ count_blocks, ) from logseq_cli.group import cli, resolve_version +from logseq_cli.commands import edit # noqa: F401 imported for registration from logseq_cli.commands import pages # noqa: F401 imported for registration from logseq_cli.commands import analysis # noqa: F401 imported for registration from logseq_cli.commands import meta # noqa: F401 imported for registration @@ -1006,646 +1007,22 @@ def add_journal_content(ctx, content, date, under_heading, top_level, dry_run, a # --- Block editing commands --- -@cli.command("update-block", epilog="""\b -Example: - logseq-cli --token TOKEN update-block --id 12345678-... --content "New text" - logseq-cli --token TOKEN update-block --where-content "**14:22**" --page "2026-08-21, friday" --content "New text" -Note: - Use set-property/remove-property for properties, never edit them via update-block. - Existing block properties survive the update: they are read first and written - back, so changing the text no longer drops them. - Use set-todo-status to change TODO/DOING/DONE markers. - --content is ONE block: newline bullets stay raw text, indented or not. - Children go in via insert-block --child-of UUID. - --where-content selects the block by text instead of UUID; it aborts unless - exactly one block matches, since overwriting the wrong block loses its text. - Scope it with --page and check with --dry-run. -""") -@click.option("--id", "block_id", default=None, help="UUID of the block to update") -@click.option("--where-content", "where_content", default=None, help="Select the block by content instead of --id; must match exactly one") -@click.option("--page", "--name", "page", default=None, help="With --where-content: restrict the search to this page") -@click.option("--regex", "use_regex", is_flag=True, help="With --where-content: interpret it as a regex") -@click.option("--content", required=True, help="New content for the block") -@click.option("--dry-run", is_flag=True, help="Show the block that would be overwritten, without writing") -@click.option("--json", "as_json", is_flag=True, help="JSON output") -@click.pass_context -@handle_connection_error -def update_block(ctx, block_id, where_content, page, use_regex, content, dry_run, as_json): - """Update the content of an existing block.""" - # Guard: unlike insert-block / add-journal-block this command has no tree - # path — it replaces ONE block's content, so newline bullets (indented or - # flush) would land as raw text inside the block instead of becoming children. - try: - reject_unsupported_multiline(content, command="update-block", accepts_tree=False) - except MultilineContentError as e: - raise click.UsageError(str(e)) - - api = ctx.obj["api"] - require_content(content) - if bool(block_id) == bool(where_content): - fail("Specify exactly one of: --id, --where-content.", as_json=as_json) - if where_content: - clean_id = resolve_single_block(api, where_content, page=page, use_regex=use_regex) - else: - clean_id = block_id.strip().replace("((", "").replace("))", "") - - # Verify block exists - block = api.get_block(clean_id, include_children=False) - if not block: - fail(f"Block not found: {clean_id}", as_json=as_json, id=clean_id) - - old_content = block.get("content", "") if isinstance(block, dict) else "" - # Properties are stored inside the block content, so replacing the text - # would drop them. This command changes text; properties belong to - # set-block-property / remove-property, and losing them here was a silent - # side effect nobody asked for. Carrying them through keeps that split - # honest. ``id::`` is handled by Logseq outside this dict and survives on - # its own, so block references are unaffected either way. - kept_properties = block.get("properties") if isinstance(block, dict) else None - - if dry_run: - if as_json: - output({"id": clean_id, "old_content": old_content, - "new_content": content, "properties": kept_properties or {}, - "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would overwrite block {clean_id}") - if old_content: - preview = old_content[:60] + ("..." if len(old_content) > 60 else "") - click.echo(f" was: {preview}") - preview = content[:60] + ("..." if len(content) > 60 else "") - click.echo(f" now: {preview}") - if kept_properties: - click.echo(f" keeps: {', '.join(f'{k}::' for k in kept_properties)}") - return - - api.update_block(clean_id, content, properties=kept_properties) - - if as_json: - output({"id": clean_id, "old_content": old_content, "new_content": content, - "properties": kept_properties or {}}, True) - else: - click.echo(f"Updated block {clean_id}") - if old_content: - preview = old_content[:60] + ("..." if len(old_content) > 60 else "") - click.echo(f" was: {preview}") - preview = content[:60] + ("..." if len(content) > 60 else "") - click.echo(f" now: {preview}") - - -@cli.command("remove-block", epilog="""\b -Examples: - logseq-cli --token TOKEN remove-block --id 12345678-... --dry-run - logseq-cli --token TOKEN remove-block --id 12345678-... -Note: - Destructive. Children are removed too — --dry-run reports how many. - Check get-backlinks first if the block has id::. -""") -@click.option("--id", "block_id", required=True, help="UUID of the block to remove") -@click.option("--dry-run", is_flag=True, help="Show the block and its descendant count, without deleting") -@click.option("--json", "as_json", is_flag=True, help="JSON output") -@click.pass_context -@handle_connection_error -def remove_block_cmd(ctx, block_id, dry_run, as_json): - """Remove a block by UUID.""" - api = ctx.obj["api"] - clean_id = block_id.strip().replace("((", "").replace("))", "") - - # Fetch WITH children: removal cascades, so the descendant count is the - # decisive fact for --dry-run (and for the confirmation the caller may want). - block = api.get_block(clean_id, include_children=True) - if not block: - fail(f"Block not found: {clean_id}", as_json=as_json, id=clean_id) - - content = block.get("content", "") if isinstance(block, dict) else "" - children = block.get("children", []) if isinstance(block, dict) else [] - descendants = count_blocks(children) if children else 0 - - if dry_run: - if as_json: - output({"id": clean_id, "content": content, - "descendants": descendants, "blocks_removed": descendants + 1, - "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would remove block {clean_id}") - preview = content[:80] + ("..." if len(content) > 80 else "") - if preview: - click.echo(f" content: {preview}") - click.echo(f" descendants that would be removed too: {descendants}") - click.echo(f" total blocks affected: {descendants + 1}") - return - api.remove_block(clean_id) - if as_json: - output({"id": clean_id, "removed": True, "content": content, - "descendants": descendants, "blocks_removed": descendants + 1}, True) - else: - preview = content[:80] + ("..." if len(content) > 80 else "") - click.echo(f"Removed block {clean_id} ({descendants + 1} block(s) total)") - if preview: - click.echo(f" was: {preview}") # `remove-block` is the canonical name (Logseq's API verb is removeBlock), but # `delete-page` sits right next to it, so `delete-block` is the single most common # wrong guess. Register it as an alias so the guess works instead of erroring out. -cli.add_command(remove_block_cmd, "delete-block") - -@cli.command("replace-text", epilog="""\b -Example: - logseq-cli --token TOKEN replace-text --page "X" --find "old" --replace "new" --dry-run - logseq-cli --token TOKEN replace-text --page "X" --find "old" --replace "new" -Note: - ALWAYS run with --dry-run first to preview matches. Prefer set-todo-status - for TODO->DONE transitions and update-block for block content edits. -""") -@click.option("--page", "--name", required=True, help="Page name to search in") -@click.option("--find", "find_text", required=True, help="Text to find") -@click.option("--replace", "replace_text", required=True, help="Replacement text") -@click.option("--regex", "use_regex", is_flag=True, help="Treat --find as regex pattern") -@click.option("--dry-run", is_flag=True, help="Show matches without replacing") -@click.option("--json", "as_json", is_flag=True, help="JSON output") -@click.pass_context -@handle_connection_error -def replace_text(ctx, page, find_text, replace_text, use_regex, dry_run, as_json): - """Find and replace text in all blocks of a page.""" - api = ctx.obj["api"] - blocks = api.get_page_blocks_tree(page) - if not blocks: - fail(f"Page '{page}' not found or empty.", as_json=as_json, page=page) - if use_regex: - pattern = re.compile(find_text) - else: - pattern = re.compile(re.escape(find_text)) - replacements = [] - - def scan_blocks(block_list): - for block in block_list: - content = block.get("content", "") - uuid = block.get("uuid", "") - if not content or not uuid: - continue - # Replace only in text lines; a property line (id::/key:: value) is - # left verbatim so a --find that matches inside it cannot rewrite it. - new_lines = [ - ln if PROPERTY_LINE_RE.match(ln) else pattern.sub(replace_text, ln) - for ln in content.split("\n") - ] - new_content = "\n".join(new_lines) - if new_content != content: - replacements.append({ - "id": uuid, - "old": content, - "new": new_content, - }) - if not dry_run: - api.update_block(uuid, new_content) - children = block.get("children", []) - if children: - scan_blocks(children) - - scan_blocks(blocks) - - # updateBlock answers null whether it wrote or not (verified against a live - # graph), so the write cannot be checked from its return value. Counting the - # matches instead would report "Replaced N block(s)" for writes that never - # landed, complete with a before/after diff computed locally. Read the - # blocks back and compare. See the note above require_insert() in helpers.py - # for when this read can be dropped. - failed = [] - if replacements and not dry_run: - for r in replacements: - after = api.get_block(r["id"], include_children=False) or {} - if after.get("content") != r["new"]: - failed.append(r["id"]) - - if as_json: - payload = {"page": page, "replacements": len(replacements) - len(failed), - "dry_run": dry_run, "matches": replacements} - if failed: - payload["failed"] = failed - output(payload, True) - else: - if not replacements: - click.echo(f"No matches for '{find_text}' in '{page}'.") - else: - action = "Would replace" if dry_run else "Replaced" - click.echo(f"{action} {len(replacements) - len(failed)} block(s) in '{page}':") - for r in replacements: - old_preview = r["old"][:60] + ("..." if len(r["old"]) > 60 else "") - new_preview = r["new"][:60] + ("..." if len(r["new"]) > 60 else "") - mark = " !! not written" if r["id"] in failed else "" - click.echo(f" {r['id'][:8]}.. {old_preview}{mark}") - click.echo(f" → {new_preview}") - if failed: - fail(f"{len(failed)} of {len(replacements)} replacement(s) did not " - "reach the graph. Logseq reports no error for this, so the " - "blocks were read back to check.", as_json=as_json, - failed=failed) - - -@cli.command("insert-block", epilog="""\b -Examples: - logseq-cli --token TOKEN insert-block --child-of UUID --content "Sub-Block" - logseq-cli --token TOKEN insert-block --after UUID --content "Sibling block" - logseq-cli --token TOKEN insert-block --child-of UUID --first --content "New first child" - logseq-cli --token TOKEN insert-block --child-of UUID \\ - --tree "Parent\\n\\tChild1\\n\\tChild2\\n\\t\\tGrandchild" - logseq-cli --token TOKEN insert-block --page "X" --top-level \\ - --tree '[{"content":"...","children":[{"content":"..."}]}]' -Notes: - --tree accepts tab-indented text OR JSON (auto-detected). Use it instead of - N×insert-block for hierarchies — single API roundtrip. - --content, --tree and --tree-file are mutually exclusive. - --tree-file reads the same tab-indented text (or JSON) from a file, so - apostrophes/quotes/umlauts need no shell quoting. - --child-of UUID also accepts hierarchical --content (same tab-indent format). - --first puts the block at the HEAD of the child list instead of appending it - last; it only applies together with --child-of. - id:: lines in a tree are dropped unless --keep-ids is given, and the command - says so. Use --keep-ids when moving or restoring an outline; do NOT use it - when copying one whose original still exists, or two blocks share a uuid. -""") -@click.option("--page", "--name", default=None, help="Page name (append to end of page)") -@click.option("--after", default=None, help="UUID of block to insert after (as sibling)") -@click.option("--before", default=None, help="UUID of block to insert before (as sibling)") -@click.option("--child-of", default=None, help="UUID of parent block (insert as child)") -@click.option("--first", "as_first", is_flag=True, help="With --child-of: insert as FIRST child instead of appending last") -@click.option("--top-level", is_flag=True, help="With --page and --tree: insert at page top-level") -@click.option("--content", default=None, help="Content for the new block") -@click.option("--tree", "tree_input", default=None, help="Tab-indented hierarchy or JSON array of {content, children} nodes") -@click.option("--tree-file", "tree_file", default=None, help="Read the tree (tab-indented text or JSON) from a file. Mutually exclusive with --tree and --content.") -@click.option("--property", "properties", multiple=True, help="Set KEY=VALUE property on the created (root) block; repeatable") -@click.option("--keep-ids", "keep_ids", is_flag=True, help="Keep the id:: values in the tree instead of letting Logseq mint new ones. For moving or restoring an outline; do NOT use when copying one that still exists, as two blocks would share a uuid") -@click.option("--dry-run", is_flag=True, help="Show what would be inserted (block count + position) without writing") -@click.option("--quiet", is_flag=True, help="With --tree: print only the confirmation line, not one uuid line per block") -@click.option("--json", "as_json", is_flag=True, help="JSON output") -@click.pass_context -@handle_connection_error -def insert_block_cmd(ctx, page, after, before, child_of, as_first, top_level, content, tree_input, tree_file, properties, keep_ids, dry_run, quiet, as_json): - """Insert a block (or tree of blocks) at a specific position.""" - api = ctx.obj["api"] - - # --tree-file is --tree from a file; resolve it before any other validation - # so the rest of the command sees a single tree_input. - if tree_file is not None: - if tree_input is not None: - click.echo("Specify either --tree or --tree-file, not both.", err=True) - sys.exit(1) - tree_input = read_content_file(tree_file) - - # Validate property pairs up-front so a bad pair fails before any write. - try: - parse_property_pairs(properties) - except ValueError as e: - if as_json: - output({"error": str(e)}, True) - else: - click.echo(f"Error: {e}", err=True) - sys.exit(1) - - if tree_input is not None: - if content is not None: - click.echo("Specify either --content or --tree, not both.", err=True) - sys.exit(1) - tree = parse_tree_input(tree_input) - if not tree: - click.echo("Tree input is empty.", err=True) - sys.exit(1) - - # An id:: in the tree names a UUID the block is meant to keep. Logseq - # only honours it when the write asks for it, so without --keep-ids - # those ids are dropped and every ((uuid)) pointing at them dangles. - # That used to happen silently; it is now either refused or announced. - tree_ids = collect_block_ids(tree) - if keep_ids: - bad = invalid_block_ids(tree) - if bad: - msg = (f"{len(bad)} id:: value(s) are not valid UUIDs and cannot become " - f"block ids: {', '.join(bad[:3])}" - f"{' ...' if len(bad) > 3 else ''}. Nothing was written.") - if as_json: - output({"error": msg, "invalid_ids": bad}, True) - else: - click.echo(f"Error: {msg}", err=True) - sys.exit(1) - elif tree_ids: - click.echo( - f"Note: {len(tree_ids)} id:: propert(ies) in the tree will be dropped; " - "Logseq mints new UUIDs and any ((uuid)) pointing at the old ones " - "will dangle. Pass --keep-ids to preserve them (only when the " - "source outline is gone, or two blocks would share a uuid).", - err=True, - ) - - # Resolve target + position first (no writes), so --dry-run can report - # the plan and bail before touching the graph. - if child_of: - clean_id = child_of.strip().replace("((", "").replace("))", "") - position = f"{'first child' if as_first else 'child'} of {clean_id[:8]}..." - if as_first: - do_insert = lambda: insert_block_tree_as_first_children(api, tree, clean_id, keep_ids=keep_ids) - else: - do_insert = lambda: insert_block_tree_with_uuids(api, tree, clean_id, strict=True, keep_ids=keep_ids) - elif after: - clean_id = after.strip().replace("((", "").replace("))", "") - position = f"after {clean_id[:8]}..." - do_insert = lambda: insert_block_tree_as_siblings(api, tree, clean_id, before=False, keep_ids=keep_ids) - elif before: - clean_id = before.strip().replace("((", "").replace("))", "") - position = f"before {clean_id[:8]}..." - do_insert = lambda: insert_block_tree_as_siblings(api, tree, clean_id, before=True, keep_ids=keep_ids) - elif page and top_level: - position = f"top-level of '{page}'" - if keep_ids and any(block_id_property(b.get("content", "")) for b in tree): - # appendBlockInPage takes no options, so the roots cannot keep - # their ids here. Saying so beats a flag that half works. - click.echo( - "Note: --keep-ids cannot preserve ids on top-level blocks " - "(the page-append API takes no uuid); their children keep theirs. " - "Insert relative to a block (--child-of/--after/--before) to keep all of them.", - err=True, - ) - do_insert = lambda: insert_block_tree_at_page_top(api, tree, page, keep_ids=keep_ids) - else: - click.echo( - "Tree insert requires --child-of, --after, --before, or --page NAME --top-level", - err=True, - ) - sys.exit(1) - - if dry_run: - planned = count_blocks(tree) - if as_json: - output({"position": position, "blocks": planned, "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would insert {planned} block(s) {position}") - return - - uuids = do_insert() - root_uuid = uuids[0] if uuids else None - applied = {} - if properties: - if root_uuid: - applied = apply_block_properties(api, root_uuid, properties) - else: - click.echo("Warning: no block created, --property ignored", err=True) - - if as_json: - output({ - "position": position, - **uuid_fields(uuids), - "blocks_added": len(uuids), - "properties": applied, - }, True) - else: - click.echo(f"Inserted {len(uuids)} block(s) {position}") - if not quiet: - # One line per block: useful when a UUID is needed downstream, - # noise when only the confirmation matters, which is why this is - # suppressible rather than always printed. - for u in uuids: - click.echo(f" uuid: {u}") - for key, value in applied.items(): - click.echo(f" {key}:: {value}") - return - - if content is None: - click.echo("Specify --content or --tree.", err=True) - sys.exit(1) - require_content(content) - - targets = sum(1 for x in [page, after, before, child_of] if x) - if targets == 0: - click.echo("Specify one of: --page, --after, --before, --child-of", err=True) - sys.exit(1) - if targets > 1: - click.echo("Specify only one of: --page, --after, --before, --child-of", err=True) - sys.exit(1) - if as_first and not child_of: - click.echo("--first only applies to --child-of (it selects the first child position).", err=True) - sys.exit(1) - - result = None - position = "" - new_uuid = None - hierarchical = contains_hierarchical_content(content) - - if dry_run: - planned = count_blocks(parse_hierarchical_content(content)) if hierarchical else 1 - target = page or (f"after {after[:8]}..." if after else - f"before {before[:8]}..." if before else - f"{'first child' if as_first else 'child'} of {child_of[:8]}...") - target_desc = f"end of '{page}'" if page else target - if as_json: - output({"position": target_desc, "blocks": planned, "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would insert {planned} block(s) {target_desc}") - return - - if page: - if hierarchical: - tree = parse_hierarchical_content(content) - uuids = insert_formatted_content_with_uuids(api, page, content) - new_uuid = uuids[0] if uuids else None - result = {"blocks_added": len(uuids), "uuids": uuids} - position = f"end of '{page}' ({len(uuids)} block(s))" - else: - result = api.append_block_in_page(page, content) - new_uuid = require_insert(result, f"a block in '{page}'") - position = f"end of '{page}'" - elif after: - clean_id = after.strip().replace("((", "").replace("))", "") - if hierarchical: - tree = parse_hierarchical_content(content) - uuids = insert_block_tree_as_siblings(api, tree, clean_id, before=False) - new_uuid = uuids[0] if uuids else None - result = {"blocks_added": len(uuids), "uuids": uuids} - position = f"after {clean_id[:8]}... ({len(uuids)} block(s))" - else: - result = api.insert_block(clean_id, content, {"sibling": True, "before": False}) - new_uuid = require_insert(result, f"a block after {clean_id[:8]}...") - position = f"after {clean_id[:8]}..." - elif before: - clean_id = before.strip().replace("((", "").replace("))", "") - if hierarchical: - tree = parse_hierarchical_content(content) - uuids = insert_block_tree_as_siblings(api, tree, clean_id, before=True) - new_uuid = uuids[0] if uuids else None - result = {"blocks_added": len(uuids), "uuids": uuids} - position = f"before {clean_id[:8]}... ({len(uuids)} block(s))" - else: - result = api.insert_block(clean_id, content, {"sibling": True, "before": True}) - new_uuid = require_insert(result, f"a block before {clean_id[:8]}...") - position = f"before {clean_id[:8]}..." - elif child_of: - clean_id = child_of.strip().replace("((", "").replace("))", "") - where = "first child" if as_first else "child" - if hierarchical: - tree = parse_hierarchical_content(content) - if as_first: - uuids = insert_block_tree_as_first_children(api, tree, clean_id) - else: - uuids = insert_block_tree_with_uuids(api, tree, clean_id, strict=True) - new_uuid = uuids[0] if uuids else None - result = {"blocks_added": len(uuids), "uuids": uuids} - position = f"{where} of {clean_id[:8]}... ({len(uuids)} block(s))" - else: - opts = {"sibling": False, "before": True} if as_first else {"sibling": False} - result = api.insert_block(clean_id, content, opts) - new_uuid = require_insert(result, f"a {where} of {clean_id[:8]}...") - position = f"{where} of {clean_id[:8]}..." - - if new_uuid is None and isinstance(result, dict): - new_uuid = result.get("uuid") - - applied = {} - if properties: - if new_uuid: - applied = apply_block_properties(api, new_uuid, properties) - else: - click.echo("Warning: no block uuid returned, --property ignored", err=True) - - if as_json: - output({"position": position, "content": content, "result": result, "properties": applied, **uuid_fields([u for u in [new_uuid] if u])}, True) - else: - click.echo(f"Inserted block {position}") - preview = content[:80] + ("..." if len(content) > 80 else "") - click.echo(f" {preview}") - if new_uuid: - click.echo(f" uuid: {new_uuid}") - for key, value in applied.items(): - click.echo(f" {key}:: {value}") # --------------------------------------------------------------------------- # 20b. add-block-ref # --------------------------------------------------------------------------- -@cli.command("add-block-ref", epilog="""\b -Examples: - logseq-cli --token TOKEN add-block-ref --source-id UUID --under-heading "## Tasks" - logseq-cli --token TOKEN add-block-ref --source-id UUID --journal-date 2026-04-23 \\ - --under-heading "## Tasks" - logseq-cli --token TOKEN add-block-ref --source-id UUID --page "Project Alpha" \\ - --under-heading "## Open TODOs" -Note: - Default target: today's journal. Auto-creates the journal page if missing. -""") -@click.option("--source-id", required=True, help="UUID of the block to reference") -@click.option("--journal-date", default=None, help="Target journal date (YYYY-MM-DD), defaults to today") -@click.option("--page", "--name", default=None, help="Target page name (alternative to --journal-date)") -@click.option("--under-heading", default=None, help="Insert under this heading. Defaults to LOGSEQ_JOURNAL_HEADING env var, or top-level.") -@click.option("--dry-run", "dry_run", is_flag=True, help="Show source, target page and heading, without writing") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def add_block_ref(ctx, source_id, journal_date, page, under_heading, dry_run, as_json): - """Insert a ((block-reference)) to a target journal or page. - - Useful for carrying over TODOs from project pages into a journal's ## Tasks section. - - Examples: - logseq-cli add-block-ref --source-id UUID --under-heading "## Tasks" - logseq-cli add-block-ref --source-id UUID --journal-date 2026-04-23 --under-heading "## Tasks" - """ - api = ctx.obj["api"] - - if not journal_date and not page: - # Default: today's journal - import datetime as _dt - journal_date = _dt.date.today().strftime("%Y-%m-%d") - - would_create_page = False - if journal_date and not page: - d = parse_date_keyword(journal_date) - configs = api.get_user_configs() - date_fmt = configs.get("preferredDateFormat") if configs else None - page = format_journal_date(d, date_fmt) - # Ensure journal page exists - try: - existing = api.get_page(page) - except Exception: - existing = None - if not existing: - would_create_page = True - # Creating the journal page is itself a write, so under --dry-run it - # is only reported, never done. - if not dry_run: - api.create_page(page, {"journal?": True}) - - source_id = source_id.strip("()") - ref_content = f"(({source_id}))" - - under_heading = resolve_heading(load_config(), under_heading) - - if dry_run: - # A block-ref is only worth anything if its source exists; a typo'd UUID - # writes a ((...)) that renders as nothing. The live path cannot check - # this without an extra call, but the preview can afford one. - source_block = api.get_block(source_id, include_children=False) - source_content = (source_block.get("content", "") - if isinstance(source_block, dict) else "") - # Look the heading up WITHOUT creating it — find_or_create_heading would - # append it to the page and make the preview a write. - heading_exists = (find_heading(api, page, under_heading) is not None - if under_heading and not would_create_page else False) - if under_heading: - position = f"under '{under_heading}' on '{page}'" - else: - position = f"top-level on '{page}'" - - if as_json: - output({"source_id": source_id, "ref": ref_content, "page": page, - "position": position, "under_heading": under_heading, - "source_exists": bool(source_block), - "source_content": source_content, - "would_create_page": would_create_page, - "would_create_heading": bool(under_heading) and not heading_exists, - "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would add block-ref {position}") - click.echo(f" ref: {ref_content}") - if source_block: - preview = source_content[:60] + ("..." if len(source_content) > 60 else "") - click.echo(f" source: {preview}") - else: - click.echo(f" source: WARNING - block {source_id} not found; " - f"the ref would render as nothing") - click.echo(f" target page: {page}" - f"{' (would be created)' if would_create_page else ''}") - if under_heading: - click.echo(f" heading: {under_heading}" - f"{'' if heading_exists else ' (would be created)'}") - return - - if under_heading: - heading_uuid = find_or_create_heading(api, page, under_heading) - if heading_uuid: - result = api.insert_block(heading_uuid, ref_content, {"sibling": False}) - position = f"under '{under_heading}' on '{page}'" - else: - result = api.append_block_in_page(page, ref_content) - position = f"top-level on '{page}' (heading not found)" - else: - result = api.append_block_in_page(page, ref_content) - position = f"top-level on '{page}'" - - # A ref that was never written is worse than a visible error: the TODO looks - # linked on the project page and silently is not, which is exactly what - # block-refs are relied on for. - new_uuid = require_insert(result, f"the block-ref {position}") - - if as_json: - output({"source_id": source_id, "ref": ref_content, "page": page, "position": position, "uuid": new_uuid}, True) - else: - click.echo(f"Added block-ref {position}") - click.echo(f" {ref_content}") - click.echo(f" uuid: {new_uuid}") @@ -1700,140 +1077,11 @@ def add_block_ref(ctx, source_id, journal_date, page, under_heading, dry_run, as # --------------------------------------------------------------------------- # 29. copy-block # --------------------------------------------------------------------------- -@cli.command("copy-block", epilog="""\b -Examples: - logseq-cli --token TOKEN copy-block --id UUID --to-page "Target Page" - logseq-cli --token TOKEN copy-block --id UUID --to-page "Target Page" --remove -Note: - Copies block + all children. With --remove: original is deleted (move). -""") -@click.option("--id", "block_id", required=True, help="Source block UUID") -@click.option("--to-page", required=True, help="Target page name") -@click.option("--remove", is_flag=True, help="Remove source block after copying (move)") -@click.option("--dry-run", is_flag=True, help="Show what would be copied/moved, without writing") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def copy_block(ctx, block_id, to_page, remove, dry_run, as_json): - """Copy a block (with children) to another page.""" - api = ctx.obj["api"] - block_id = block_id.strip("()") - source = api.get_block(block_id, include_children=True) - if not source: - fail("Block not found.", as_json=as_json, id=block_id) - - if dry_run: - planned = count_blocks([source]) - action = "move" if remove else "copy" - content = source.get("content", "") if isinstance(source, dict) else "" - if as_json: - output({"action": action, "blocks": planned, "to_page": to_page, - "source_id": block_id, "removes_source": bool(remove), - "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would {action} {planned} block(s) to '{to_page}'") - preview = content[:80] + ("..." if len(content) > 80 else "") - if preview: - click.echo(f" root: {preview}") - if remove: - click.echo(f" source block {block_id} WOULD BE REMOVED after copying") - return - - # Every insert is checked: Logseq answers a failed write with HTTP 200 + - # null, so an unchecked copy reports "Moved N block(s)" with exit 0 while - # nothing arrived. With --remove that unverified success would then delete - # the source, which destroys the block for good. - written = [0] - - def _copy_tree(block, parent_uuid=None): - content = block.get("content", "") - if parent_uuid: - result = api.insert_block(parent_uuid, content, {"sibling": False}) - new_uuid = require_insert( - result, "a copied block", written_so_far=written[0]) - else: - result = api.append_block_in_page(to_page, content) - new_uuid = require_insert( - result, f"the copied block on '{to_page}'", written_so_far=written[0]) - written[0] += 1 - copied = 1 - for child in block.get("children", []): - copied += _copy_tree(child, new_uuid) - return copied - - count = _copy_tree(source) - - if remove: - # Only reached when every insert above returned a UUID, so the source is - # removed against a copy that is known to exist, never a claimed one. - api.remove_block(block_id) - - action = "Moved" if remove else "Copied" - result_data = {"action": action.lower(), "blocks": count, "to_page": to_page, "source_id": block_id} - - if as_json: - output(result_data, True) - else: - click.echo(f"{action} {count} block(s) to '{to_page}'.") # --------------------------------------------------------------------------- # 29b. move-block # --------------------------------------------------------------------------- -@cli.command("move-block", epilog="""\b -Examples: - logseq-cli --token TOKEN move-block --id UUID --under UUID - logseq-cli --token TOKEN move-block --id UUID --before UUID -Note: - Structural move: the block keeps its UUID, so ((block-refs)) to it survive. - Prefer this over `copy-block --remove`, which writes a new block (new UUID, - dead refs) and deletes the original. - --under nests the block as the target's FIRST child; --before puts it directly - in front of the target as a sibling. Children always move along. - A block cannot be moved into its own subtree; Logseq refuses that silently, so - the move is verified by re-reading and reported as an error if it did not take. -""") -@click.option("--id", "block_id", required=True, help="UUID of the block to move") -@click.option("--under", default=None, help="UUID of the new parent (block becomes its first child)") -@click.option("--before", default=None, help="UUID of the block to move in front of (as sibling)") -@click.option("--dry-run", is_flag=True, help="Show what would be moved, without writing") -@click.option("--json", "as_json", is_flag=True, help="JSON output") -@click.pass_context -@handle_connection_error -def move_block_cmd(ctx, block_id, under, before, dry_run, as_json): - """Move a block (with children) under or before another block.""" - api = ctx.obj["api"] - if bool(under) == bool(before): - fail("Specify exactly one of: --under, --before.", as_json=as_json) - - target = under or before - block_id = block_id.strip("()") - source = api.get_block(block_id, include_children=True) - if not source: - fail("Block not found.", as_json=as_json, id=block_id) - - position = f"under {target[:8]}..." if under else f"before {target[:8]}..." - if dry_run: - planned = count_blocks([source]) - content = source.get("content", "") if isinstance(source, dict) else "" - if as_json: - output({"action": "move", "blocks": planned, "position": position, - "source_id": block_id, "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would move {planned} block(s) {position}") - preview = content[:80] + ("..." if len(content) > 80 else "") - if preview: - click.echo(f" root: {preview}") - return - - move_block_verified(api, block_id, target, before=bool(before)) - count = count_blocks([source]) - - if as_json: - output({"action": "move", "blocks": count, "position": position, - "source_id": block_id}, True) - else: - click.echo(f"Moved {count} block(s) {position}") # --------------------------------------------------------------------------- diff --git a/logseq_cli/commands/edit.py b/logseq_cli/commands/edit.py new file mode 100644 index 0000000..1b282a1 --- /dev/null +++ b/logseq_cli/commands/edit.py @@ -0,0 +1,800 @@ +import re +import sys + +import click + +from logseq_cli.config import load_config, resolve_heading +from logseq_cli.group import cli +from logseq_cli.helpers import ( + MultilineContentError, + PROPERTY_LINE_RE, + apply_block_properties, + block_id_property, + collect_block_ids, + contains_hierarchical_content, + count_blocks, + find_heading, + find_or_create_heading, + format_journal_date, + insert_block_tree_as_first_children, + insert_block_tree_as_siblings, + insert_block_tree_at_page_top, + insert_block_tree_with_uuids, + insert_formatted_content_with_uuids, + invalid_block_ids, + move_block_verified, + parse_date_keyword, + parse_hierarchical_content, + parse_property_pairs, + parse_tree_input, + read_content_file, + reject_unsupported_multiline, + require_content, + require_insert, + resolve_single_block, + uuid_fields, +) +from logseq_cli.output import fail, handle_connection_error, output + + +@cli.command("update-block", epilog="""\b +Example: + logseq-cli --token TOKEN update-block --id 12345678-... --content "New text" + logseq-cli --token TOKEN update-block --where-content "**14:22**" --page "2026-08-21, friday" --content "New text" +Note: + Use set-property/remove-property for properties, never edit them via update-block. + Existing block properties survive the update: they are read first and written + back, so changing the text no longer drops them. + Use set-todo-status to change TODO/DOING/DONE markers. + --content is ONE block: newline bullets stay raw text, indented or not. + Children go in via insert-block --child-of UUID. + --where-content selects the block by text instead of UUID; it aborts unless + exactly one block matches, since overwriting the wrong block loses its text. + Scope it with --page and check with --dry-run. +""") +@click.option("--id", "block_id", default=None, help="UUID of the block to update") +@click.option("--where-content", "where_content", default=None, help="Select the block by content instead of --id; must match exactly one") +@click.option("--page", "--name", "page", default=None, help="With --where-content: restrict the search to this page") +@click.option("--regex", "use_regex", is_flag=True, help="With --where-content: interpret it as a regex") +@click.option("--content", required=True, help="New content for the block") +@click.option("--dry-run", is_flag=True, help="Show the block that would be overwritten, without writing") +@click.option("--json", "as_json", is_flag=True, help="JSON output") +@click.pass_context +@handle_connection_error +def update_block(ctx, block_id, where_content, page, use_regex, content, dry_run, as_json): + """Update the content of an existing block.""" + # Guard: unlike insert-block / add-journal-block this command has no tree + # path — it replaces ONE block's content, so newline bullets (indented or + # flush) would land as raw text inside the block instead of becoming children. + try: + reject_unsupported_multiline(content, command="update-block", accepts_tree=False) + except MultilineContentError as e: + raise click.UsageError(str(e)) + + api = ctx.obj["api"] + require_content(content) + if bool(block_id) == bool(where_content): + fail("Specify exactly one of: --id, --where-content.", as_json=as_json) + if where_content: + clean_id = resolve_single_block(api, where_content, page=page, use_regex=use_regex) + else: + clean_id = block_id.strip().replace("((", "").replace("))", "") + + # Verify block exists + block = api.get_block(clean_id, include_children=False) + if not block: + fail(f"Block not found: {clean_id}", as_json=as_json, id=clean_id) + + old_content = block.get("content", "") if isinstance(block, dict) else "" + # Properties are stored inside the block content, so replacing the text + # would drop them. This command changes text; properties belong to + # set-block-property / remove-property, and losing them here was a silent + # side effect nobody asked for. Carrying them through keeps that split + # honest. ``id::`` is handled by Logseq outside this dict and survives on + # its own, so block references are unaffected either way. + kept_properties = block.get("properties") if isinstance(block, dict) else None + + if dry_run: + if as_json: + output({"id": clean_id, "old_content": old_content, + "new_content": content, "properties": kept_properties or {}, + "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would overwrite block {clean_id}") + if old_content: + preview = old_content[:60] + ("..." if len(old_content) > 60 else "") + click.echo(f" was: {preview}") + preview = content[:60] + ("..." if len(content) > 60 else "") + click.echo(f" now: {preview}") + if kept_properties: + click.echo(f" keeps: {', '.join(f'{k}::' for k in kept_properties)}") + return + + api.update_block(clean_id, content, properties=kept_properties) + + if as_json: + output({"id": clean_id, "old_content": old_content, "new_content": content, + "properties": kept_properties or {}}, True) + else: + click.echo(f"Updated block {clean_id}") + if old_content: + preview = old_content[:60] + ("..." if len(old_content) > 60 else "") + click.echo(f" was: {preview}") + preview = content[:60] + ("..." if len(content) > 60 else "") + click.echo(f" now: {preview}") + +@cli.command("remove-block", epilog="""\b +Examples: + logseq-cli --token TOKEN remove-block --id 12345678-... --dry-run + logseq-cli --token TOKEN remove-block --id 12345678-... +Note: + Destructive. Children are removed too — --dry-run reports how many. + Check get-backlinks first if the block has id::. +""") +@click.option("--id", "block_id", required=True, help="UUID of the block to remove") +@click.option("--dry-run", is_flag=True, help="Show the block and its descendant count, without deleting") +@click.option("--json", "as_json", is_flag=True, help="JSON output") +@click.pass_context +@handle_connection_error +def remove_block_cmd(ctx, block_id, dry_run, as_json): + """Remove a block by UUID.""" + api = ctx.obj["api"] + clean_id = block_id.strip().replace("((", "").replace("))", "") + + # Fetch WITH children: removal cascades, so the descendant count is the + # decisive fact for --dry-run (and for the confirmation the caller may want). + block = api.get_block(clean_id, include_children=True) + if not block: + fail(f"Block not found: {clean_id}", as_json=as_json, id=clean_id) + + content = block.get("content", "") if isinstance(block, dict) else "" + children = block.get("children", []) if isinstance(block, dict) else [] + descendants = count_blocks(children) if children else 0 + + if dry_run: + if as_json: + output({"id": clean_id, "content": content, + "descendants": descendants, "blocks_removed": descendants + 1, + "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would remove block {clean_id}") + preview = content[:80] + ("..." if len(content) > 80 else "") + if preview: + click.echo(f" content: {preview}") + click.echo(f" descendants that would be removed too: {descendants}") + click.echo(f" total blocks affected: {descendants + 1}") + return + + api.remove_block(clean_id) + + if as_json: + output({"id": clean_id, "removed": True, "content": content, + "descendants": descendants, "blocks_removed": descendants + 1}, True) + else: + preview = content[:80] + ("..." if len(content) > 80 else "") + click.echo(f"Removed block {clean_id} ({descendants + 1} block(s) total)") + if preview: + click.echo(f" was: {preview}") + +@cli.command("replace-text", epilog="""\b +Example: + logseq-cli --token TOKEN replace-text --page "X" --find "old" --replace "new" --dry-run + logseq-cli --token TOKEN replace-text --page "X" --find "old" --replace "new" +Note: + ALWAYS run with --dry-run first to preview matches. Prefer set-todo-status + for TODO->DONE transitions and update-block for block content edits. +""") +@click.option("--page", "--name", required=True, help="Page name to search in") +@click.option("--find", "find_text", required=True, help="Text to find") +@click.option("--replace", "replace_text", required=True, help="Replacement text") +@click.option("--regex", "use_regex", is_flag=True, help="Treat --find as regex pattern") +@click.option("--dry-run", is_flag=True, help="Show matches without replacing") +@click.option("--json", "as_json", is_flag=True, help="JSON output") +@click.pass_context +@handle_connection_error +def replace_text(ctx, page, find_text, replace_text, use_regex, dry_run, as_json): + """Find and replace text in all blocks of a page.""" + api = ctx.obj["api"] + + blocks = api.get_page_blocks_tree(page) + if not blocks: + fail(f"Page '{page}' not found or empty.", as_json=as_json, page=page) + + if use_regex: + pattern = re.compile(find_text) + else: + pattern = re.compile(re.escape(find_text)) + + replacements = [] + + def scan_blocks(block_list): + for block in block_list: + content = block.get("content", "") + uuid = block.get("uuid", "") + if not content or not uuid: + continue + # Replace only in text lines; a property line (id::/key:: value) is + # left verbatim so a --find that matches inside it cannot rewrite it. + new_lines = [ + ln if PROPERTY_LINE_RE.match(ln) else pattern.sub(replace_text, ln) + for ln in content.split("\n") + ] + new_content = "\n".join(new_lines) + if new_content != content: + replacements.append({ + "id": uuid, + "old": content, + "new": new_content, + }) + if not dry_run: + api.update_block(uuid, new_content) + children = block.get("children", []) + if children: + scan_blocks(children) + + scan_blocks(blocks) + + # updateBlock answers null whether it wrote or not (verified against a live + # graph), so the write cannot be checked from its return value. Counting the + # matches instead would report "Replaced N block(s)" for writes that never + # landed, complete with a before/after diff computed locally. Read the + # blocks back and compare. See the note above require_insert() in helpers.py + # for when this read can be dropped. + failed = [] + if replacements and not dry_run: + for r in replacements: + after = api.get_block(r["id"], include_children=False) or {} + if after.get("content") != r["new"]: + failed.append(r["id"]) + + if as_json: + payload = {"page": page, "replacements": len(replacements) - len(failed), + "dry_run": dry_run, "matches": replacements} + if failed: + payload["failed"] = failed + output(payload, True) + else: + if not replacements: + click.echo(f"No matches for '{find_text}' in '{page}'.") + else: + action = "Would replace" if dry_run else "Replaced" + click.echo(f"{action} {len(replacements) - len(failed)} block(s) in '{page}':") + for r in replacements: + old_preview = r["old"][:60] + ("..." if len(r["old"]) > 60 else "") + new_preview = r["new"][:60] + ("..." if len(r["new"]) > 60 else "") + mark = " !! not written" if r["id"] in failed else "" + click.echo(f" {r['id'][:8]}.. {old_preview}{mark}") + click.echo(f" → {new_preview}") + if failed: + fail(f"{len(failed)} of {len(replacements)} replacement(s) did not " + "reach the graph. Logseq reports no error for this, so the " + "blocks were read back to check.", as_json=as_json, + failed=failed) + +@cli.command("insert-block", epilog="""\b +Examples: + logseq-cli --token TOKEN insert-block --child-of UUID --content "Sub-Block" + logseq-cli --token TOKEN insert-block --after UUID --content "Sibling block" + logseq-cli --token TOKEN insert-block --child-of UUID --first --content "New first child" + logseq-cli --token TOKEN insert-block --child-of UUID \\ + --tree "Parent\\n\\tChild1\\n\\tChild2\\n\\t\\tGrandchild" + logseq-cli --token TOKEN insert-block --page "X" --top-level \\ + --tree '[{"content":"...","children":[{"content":"..."}]}]' +Notes: + --tree accepts tab-indented text OR JSON (auto-detected). Use it instead of + N×insert-block for hierarchies — single API roundtrip. + --content, --tree and --tree-file are mutually exclusive. + --tree-file reads the same tab-indented text (or JSON) from a file, so + apostrophes/quotes/umlauts need no shell quoting. + --child-of UUID also accepts hierarchical --content (same tab-indent format). + --first puts the block at the HEAD of the child list instead of appending it + last; it only applies together with --child-of. + id:: lines in a tree are dropped unless --keep-ids is given, and the command + says so. Use --keep-ids when moving or restoring an outline; do NOT use it + when copying one whose original still exists, or two blocks share a uuid. +""") +@click.option("--page", "--name", default=None, help="Page name (append to end of page)") +@click.option("--after", default=None, help="UUID of block to insert after (as sibling)") +@click.option("--before", default=None, help="UUID of block to insert before (as sibling)") +@click.option("--child-of", default=None, help="UUID of parent block (insert as child)") +@click.option("--first", "as_first", is_flag=True, help="With --child-of: insert as FIRST child instead of appending last") +@click.option("--top-level", is_flag=True, help="With --page and --tree: insert at page top-level") +@click.option("--content", default=None, help="Content for the new block") +@click.option("--tree", "tree_input", default=None, help="Tab-indented hierarchy or JSON array of {content, children} nodes") +@click.option("--tree-file", "tree_file", default=None, help="Read the tree (tab-indented text or JSON) from a file. Mutually exclusive with --tree and --content.") +@click.option("--property", "properties", multiple=True, help="Set KEY=VALUE property on the created (root) block; repeatable") +@click.option("--keep-ids", "keep_ids", is_flag=True, help="Keep the id:: values in the tree instead of letting Logseq mint new ones. For moving or restoring an outline; do NOT use when copying one that still exists, as two blocks would share a uuid") +@click.option("--dry-run", is_flag=True, help="Show what would be inserted (block count + position) without writing") +@click.option("--quiet", is_flag=True, help="With --tree: print only the confirmation line, not one uuid line per block") +@click.option("--json", "as_json", is_flag=True, help="JSON output") +@click.pass_context +@handle_connection_error +def insert_block_cmd(ctx, page, after, before, child_of, as_first, top_level, content, tree_input, tree_file, properties, keep_ids, dry_run, quiet, as_json): + """Insert a block (or tree of blocks) at a specific position.""" + api = ctx.obj["api"] + + # --tree-file is --tree from a file; resolve it before any other validation + # so the rest of the command sees a single tree_input. + if tree_file is not None: + if tree_input is not None: + click.echo("Specify either --tree or --tree-file, not both.", err=True) + sys.exit(1) + tree_input = read_content_file(tree_file) + + # Validate property pairs up-front so a bad pair fails before any write. + try: + parse_property_pairs(properties) + except ValueError as e: + if as_json: + output({"error": str(e)}, True) + else: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + if tree_input is not None: + if content is not None: + click.echo("Specify either --content or --tree, not both.", err=True) + sys.exit(1) + tree = parse_tree_input(tree_input) + if not tree: + click.echo("Tree input is empty.", err=True) + sys.exit(1) + + # An id:: in the tree names a UUID the block is meant to keep. Logseq + # only honours it when the write asks for it, so without --keep-ids + # those ids are dropped and every ((uuid)) pointing at them dangles. + # That used to happen silently; it is now either refused or announced. + tree_ids = collect_block_ids(tree) + if keep_ids: + bad = invalid_block_ids(tree) + if bad: + msg = (f"{len(bad)} id:: value(s) are not valid UUIDs and cannot become " + f"block ids: {', '.join(bad[:3])}" + f"{' ...' if len(bad) > 3 else ''}. Nothing was written.") + if as_json: + output({"error": msg, "invalid_ids": bad}, True) + else: + click.echo(f"Error: {msg}", err=True) + sys.exit(1) + elif tree_ids: + click.echo( + f"Note: {len(tree_ids)} id:: propert(ies) in the tree will be dropped; " + "Logseq mints new UUIDs and any ((uuid)) pointing at the old ones " + "will dangle. Pass --keep-ids to preserve them (only when the " + "source outline is gone, or two blocks would share a uuid).", + err=True, + ) + + # Resolve target + position first (no writes), so --dry-run can report + # the plan and bail before touching the graph. + if child_of: + clean_id = child_of.strip().replace("((", "").replace("))", "") + position = f"{'first child' if as_first else 'child'} of {clean_id[:8]}..." + if as_first: + do_insert = lambda: insert_block_tree_as_first_children(api, tree, clean_id, keep_ids=keep_ids) + else: + do_insert = lambda: insert_block_tree_with_uuids(api, tree, clean_id, strict=True, keep_ids=keep_ids) + elif after: + clean_id = after.strip().replace("((", "").replace("))", "") + position = f"after {clean_id[:8]}..." + do_insert = lambda: insert_block_tree_as_siblings(api, tree, clean_id, before=False, keep_ids=keep_ids) + elif before: + clean_id = before.strip().replace("((", "").replace("))", "") + position = f"before {clean_id[:8]}..." + do_insert = lambda: insert_block_tree_as_siblings(api, tree, clean_id, before=True, keep_ids=keep_ids) + elif page and top_level: + position = f"top-level of '{page}'" + if keep_ids and any(block_id_property(b.get("content", "")) for b in tree): + # appendBlockInPage takes no options, so the roots cannot keep + # their ids here. Saying so beats a flag that half works. + click.echo( + "Note: --keep-ids cannot preserve ids on top-level blocks " + "(the page-append API takes no uuid); their children keep theirs. " + "Insert relative to a block (--child-of/--after/--before) to keep all of them.", + err=True, + ) + do_insert = lambda: insert_block_tree_at_page_top(api, tree, page, keep_ids=keep_ids) + else: + click.echo( + "Tree insert requires --child-of, --after, --before, or --page NAME --top-level", + err=True, + ) + sys.exit(1) + + if dry_run: + planned = count_blocks(tree) + if as_json: + output({"position": position, "blocks": planned, "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would insert {planned} block(s) {position}") + return + + uuids = do_insert() + root_uuid = uuids[0] if uuids else None + applied = {} + if properties: + if root_uuid: + applied = apply_block_properties(api, root_uuid, properties) + else: + click.echo("Warning: no block created, --property ignored", err=True) + + if as_json: + output({ + "position": position, + **uuid_fields(uuids), + "blocks_added": len(uuids), + "properties": applied, + }, True) + else: + click.echo(f"Inserted {len(uuids)} block(s) {position}") + if not quiet: + # One line per block: useful when a UUID is needed downstream, + # noise when only the confirmation matters, which is why this is + # suppressible rather than always printed. + for u in uuids: + click.echo(f" uuid: {u}") + for key, value in applied.items(): + click.echo(f" {key}:: {value}") + return + + if content is None: + click.echo("Specify --content or --tree.", err=True) + sys.exit(1) + require_content(content) + + targets = sum(1 for x in [page, after, before, child_of] if x) + if targets == 0: + click.echo("Specify one of: --page, --after, --before, --child-of", err=True) + sys.exit(1) + if targets > 1: + click.echo("Specify only one of: --page, --after, --before, --child-of", err=True) + sys.exit(1) + if as_first and not child_of: + click.echo("--first only applies to --child-of (it selects the first child position).", err=True) + sys.exit(1) + + result = None + position = "" + new_uuid = None + hierarchical = contains_hierarchical_content(content) + + if dry_run: + planned = count_blocks(parse_hierarchical_content(content)) if hierarchical else 1 + target = page or (f"after {after[:8]}..." if after else + f"before {before[:8]}..." if before else + f"{'first child' if as_first else 'child'} of {child_of[:8]}...") + target_desc = f"end of '{page}'" if page else target + if as_json: + output({"position": target_desc, "blocks": planned, "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would insert {planned} block(s) {target_desc}") + return + + if page: + if hierarchical: + tree = parse_hierarchical_content(content) + uuids = insert_formatted_content_with_uuids(api, page, content) + new_uuid = uuids[0] if uuids else None + result = {"blocks_added": len(uuids), "uuids": uuids} + position = f"end of '{page}' ({len(uuids)} block(s))" + else: + result = api.append_block_in_page(page, content) + new_uuid = require_insert(result, f"a block in '{page}'") + position = f"end of '{page}'" + elif after: + clean_id = after.strip().replace("((", "").replace("))", "") + if hierarchical: + tree = parse_hierarchical_content(content) + uuids = insert_block_tree_as_siblings(api, tree, clean_id, before=False) + new_uuid = uuids[0] if uuids else None + result = {"blocks_added": len(uuids), "uuids": uuids} + position = f"after {clean_id[:8]}... ({len(uuids)} block(s))" + else: + result = api.insert_block(clean_id, content, {"sibling": True, "before": False}) + new_uuid = require_insert(result, f"a block after {clean_id[:8]}...") + position = f"after {clean_id[:8]}..." + elif before: + clean_id = before.strip().replace("((", "").replace("))", "") + if hierarchical: + tree = parse_hierarchical_content(content) + uuids = insert_block_tree_as_siblings(api, tree, clean_id, before=True) + new_uuid = uuids[0] if uuids else None + result = {"blocks_added": len(uuids), "uuids": uuids} + position = f"before {clean_id[:8]}... ({len(uuids)} block(s))" + else: + result = api.insert_block(clean_id, content, {"sibling": True, "before": True}) + new_uuid = require_insert(result, f"a block before {clean_id[:8]}...") + position = f"before {clean_id[:8]}..." + elif child_of: + clean_id = child_of.strip().replace("((", "").replace("))", "") + where = "first child" if as_first else "child" + if hierarchical: + tree = parse_hierarchical_content(content) + if as_first: + uuids = insert_block_tree_as_first_children(api, tree, clean_id) + else: + uuids = insert_block_tree_with_uuids(api, tree, clean_id, strict=True) + new_uuid = uuids[0] if uuids else None + result = {"blocks_added": len(uuids), "uuids": uuids} + position = f"{where} of {clean_id[:8]}... ({len(uuids)} block(s))" + else: + opts = {"sibling": False, "before": True} if as_first else {"sibling": False} + result = api.insert_block(clean_id, content, opts) + new_uuid = require_insert(result, f"a {where} of {clean_id[:8]}...") + position = f"{where} of {clean_id[:8]}..." + + if new_uuid is None and isinstance(result, dict): + new_uuid = result.get("uuid") + + applied = {} + if properties: + if new_uuid: + applied = apply_block_properties(api, new_uuid, properties) + else: + click.echo("Warning: no block uuid returned, --property ignored", err=True) + + if as_json: + output({"position": position, "content": content, "result": result, "properties": applied, **uuid_fields([u for u in [new_uuid] if u])}, True) + else: + click.echo(f"Inserted block {position}") + preview = content[:80] + ("..." if len(content) > 80 else "") + click.echo(f" {preview}") + if new_uuid: + click.echo(f" uuid: {new_uuid}") + for key, value in applied.items(): + click.echo(f" {key}:: {value}") + +@cli.command("add-block-ref", epilog="""\b +Examples: + logseq-cli --token TOKEN add-block-ref --source-id UUID --under-heading "## Tasks" + logseq-cli --token TOKEN add-block-ref --source-id UUID --journal-date 2026-04-23 \\ + --under-heading "## Tasks" + logseq-cli --token TOKEN add-block-ref --source-id UUID --page "Project Alpha" \\ + --under-heading "## Open TODOs" +Note: + Default target: today's journal. Auto-creates the journal page if missing. +""") +@click.option("--source-id", required=True, help="UUID of the block to reference") +@click.option("--journal-date", default=None, help="Target journal date (YYYY-MM-DD), defaults to today") +@click.option("--page", "--name", default=None, help="Target page name (alternative to --journal-date)") +@click.option("--under-heading", default=None, help="Insert under this heading. Defaults to LOGSEQ_JOURNAL_HEADING env var, or top-level.") +@click.option("--dry-run", "dry_run", is_flag=True, help="Show source, target page and heading, without writing") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def add_block_ref(ctx, source_id, journal_date, page, under_heading, dry_run, as_json): + """Insert a ((block-reference)) to a target journal or page. + + Useful for carrying over TODOs from project pages into a journal's ## Tasks section. + + Examples: + logseq-cli add-block-ref --source-id UUID --under-heading "## Tasks" + logseq-cli add-block-ref --source-id UUID --journal-date 2026-04-23 --under-heading "## Tasks" + """ + api = ctx.obj["api"] + + if not journal_date and not page: + # Default: today's journal + import datetime as _dt + journal_date = _dt.date.today().strftime("%Y-%m-%d") + + would_create_page = False + if journal_date and not page: + d = parse_date_keyword(journal_date) + configs = api.get_user_configs() + date_fmt = configs.get("preferredDateFormat") if configs else None + page = format_journal_date(d, date_fmt) + # Ensure journal page exists + try: + existing = api.get_page(page) + except Exception: + existing = None + if not existing: + would_create_page = True + # Creating the journal page is itself a write, so under --dry-run it + # is only reported, never done. + if not dry_run: + api.create_page(page, {"journal?": True}) + + source_id = source_id.strip("()") + ref_content = f"(({source_id}))" + + under_heading = resolve_heading(load_config(), under_heading) + + if dry_run: + # A block-ref is only worth anything if its source exists; a typo'd UUID + # writes a ((...)) that renders as nothing. The live path cannot check + # this without an extra call, but the preview can afford one. + source_block = api.get_block(source_id, include_children=False) + source_content = (source_block.get("content", "") + if isinstance(source_block, dict) else "") + # Look the heading up WITHOUT creating it — find_or_create_heading would + # append it to the page and make the preview a write. + heading_exists = (find_heading(api, page, under_heading) is not None + if under_heading and not would_create_page else False) + if under_heading: + position = f"under '{under_heading}' on '{page}'" + else: + position = f"top-level on '{page}'" + + if as_json: + output({"source_id": source_id, "ref": ref_content, "page": page, + "position": position, "under_heading": under_heading, + "source_exists": bool(source_block), + "source_content": source_content, + "would_create_page": would_create_page, + "would_create_heading": bool(under_heading) and not heading_exists, + "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would add block-ref {position}") + click.echo(f" ref: {ref_content}") + if source_block: + preview = source_content[:60] + ("..." if len(source_content) > 60 else "") + click.echo(f" source: {preview}") + else: + click.echo(f" source: WARNING - block {source_id} not found; " + f"the ref would render as nothing") + click.echo(f" target page: {page}" + f"{' (would be created)' if would_create_page else ''}") + if under_heading: + click.echo(f" heading: {under_heading}" + f"{'' if heading_exists else ' (would be created)'}") + return + + if under_heading: + heading_uuid = find_or_create_heading(api, page, under_heading) + if heading_uuid: + result = api.insert_block(heading_uuid, ref_content, {"sibling": False}) + position = f"under '{under_heading}' on '{page}'" + else: + result = api.append_block_in_page(page, ref_content) + position = f"top-level on '{page}' (heading not found)" + else: + result = api.append_block_in_page(page, ref_content) + position = f"top-level on '{page}'" + + # A ref that was never written is worse than a visible error: the TODO looks + # linked on the project page and silently is not, which is exactly what + # block-refs are relied on for. + new_uuid = require_insert(result, f"the block-ref {position}") + + if as_json: + output({"source_id": source_id, "ref": ref_content, "page": page, "position": position, "uuid": new_uuid}, True) + else: + click.echo(f"Added block-ref {position}") + click.echo(f" {ref_content}") + click.echo(f" uuid: {new_uuid}") + +@cli.command("copy-block", epilog="""\b +Examples: + logseq-cli --token TOKEN copy-block --id UUID --to-page "Target Page" + logseq-cli --token TOKEN copy-block --id UUID --to-page "Target Page" --remove +Note: + Copies block + all children. With --remove: original is deleted (move). +""") +@click.option("--id", "block_id", required=True, help="Source block UUID") +@click.option("--to-page", required=True, help="Target page name") +@click.option("--remove", is_flag=True, help="Remove source block after copying (move)") +@click.option("--dry-run", is_flag=True, help="Show what would be copied/moved, without writing") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def copy_block(ctx, block_id, to_page, remove, dry_run, as_json): + """Copy a block (with children) to another page.""" + api = ctx.obj["api"] + block_id = block_id.strip("()") + source = api.get_block(block_id, include_children=True) + if not source: + fail("Block not found.", as_json=as_json, id=block_id) + + if dry_run: + planned = count_blocks([source]) + action = "move" if remove else "copy" + content = source.get("content", "") if isinstance(source, dict) else "" + if as_json: + output({"action": action, "blocks": planned, "to_page": to_page, + "source_id": block_id, "removes_source": bool(remove), + "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would {action} {planned} block(s) to '{to_page}'") + preview = content[:80] + ("..." if len(content) > 80 else "") + if preview: + click.echo(f" root: {preview}") + if remove: + click.echo(f" source block {block_id} WOULD BE REMOVED after copying") + return + + # Every insert is checked: Logseq answers a failed write with HTTP 200 + + # null, so an unchecked copy reports "Moved N block(s)" with exit 0 while + # nothing arrived. With --remove that unverified success would then delete + # the source, which destroys the block for good. + written = [0] + + def _copy_tree(block, parent_uuid=None): + content = block.get("content", "") + if parent_uuid: + result = api.insert_block(parent_uuid, content, {"sibling": False}) + new_uuid = require_insert( + result, "a copied block", written_so_far=written[0]) + else: + result = api.append_block_in_page(to_page, content) + new_uuid = require_insert( + result, f"the copied block on '{to_page}'", written_so_far=written[0]) + written[0] += 1 + copied = 1 + for child in block.get("children", []): + copied += _copy_tree(child, new_uuid) + return copied + + count = _copy_tree(source) + + if remove: + # Only reached when every insert above returned a UUID, so the source is + # removed against a copy that is known to exist, never a claimed one. + api.remove_block(block_id) + + action = "Moved" if remove else "Copied" + result_data = {"action": action.lower(), "blocks": count, "to_page": to_page, "source_id": block_id} + + if as_json: + output(result_data, True) + else: + click.echo(f"{action} {count} block(s) to '{to_page}'.") + +@cli.command("move-block", epilog="""\b +Examples: + logseq-cli --token TOKEN move-block --id UUID --under UUID + logseq-cli --token TOKEN move-block --id UUID --before UUID +Note: + Structural move: the block keeps its UUID, so ((block-refs)) to it survive. + Prefer this over `copy-block --remove`, which writes a new block (new UUID, + dead refs) and deletes the original. + --under nests the block as the target's FIRST child; --before puts it directly + in front of the target as a sibling. Children always move along. + A block cannot be moved into its own subtree; Logseq refuses that silently, so + the move is verified by re-reading and reported as an error if it did not take. +""") +@click.option("--id", "block_id", required=True, help="UUID of the block to move") +@click.option("--under", default=None, help="UUID of the new parent (block becomes its first child)") +@click.option("--before", default=None, help="UUID of the block to move in front of (as sibling)") +@click.option("--dry-run", is_flag=True, help="Show what would be moved, without writing") +@click.option("--json", "as_json", is_flag=True, help="JSON output") +@click.pass_context +@handle_connection_error +def move_block_cmd(ctx, block_id, under, before, dry_run, as_json): + """Move a block (with children) under or before another block.""" + api = ctx.obj["api"] + if bool(under) == bool(before): + fail("Specify exactly one of: --under, --before.", as_json=as_json) + + target = under or before + block_id = block_id.strip("()") + source = api.get_block(block_id, include_children=True) + if not source: + fail("Block not found.", as_json=as_json, id=block_id) + + position = f"under {target[:8]}..." if under else f"before {target[:8]}..." + if dry_run: + planned = count_blocks([source]) + content = source.get("content", "") if isinstance(source, dict) else "" + if as_json: + output({"action": "move", "blocks": planned, "position": position, + "source_id": block_id, "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would move {planned} block(s) {position}") + preview = content[:80] + ("..." if len(content) > 80 else "") + if preview: + click.echo(f" root: {preview}") + return + + move_block_verified(api, block_id, target, before=bool(before)) + count = count_blocks([source]) + + if as_json: + output({"action": "move", "blocks": count, "position": position, + "source_id": block_id}, True) + else: + click.echo(f"Moved {count} block(s) {position}") + + +cli.add_command(remove_block_cmd, "delete-block") From 5744cee015d609989993dfd965fbc3b4f4a5d4db Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:25:17 +0200 Subject: [PATCH 18/25] Move the journal commands into logseq_cli/commands/journal.py Five commands, the last of the nine modules. The two remaining get_page_content patches move with it, for the same reason as the analysis ones: the name is read here, and a patch left on cli.py would have replaced something nobody calls without turning anything red. With this commit the test migration is complete: no test reaches into logseq_cli.cli for anything but the group object, on any of the three forms the acceptance rule greps for. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 771 +------------------------ logseq_cli/commands/journal.py | 820 +++++++++++++++++++++++++++ tests/test_journal_bounded_output.py | 4 +- 3 files changed, 823 insertions(+), 772 deletions(-) create mode 100644 logseq_cli/commands/journal.py diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 2c77a41..1adfe85 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -75,6 +75,7 @@ count_blocks, ) from logseq_cli.group import cli, resolve_version +from logseq_cli.commands import journal # noqa: F401 imported for registration from logseq_cli.commands import edit # noqa: F401 imported for registration from logseq_cli.commands import pages # noqa: F401 imported for registration from logseq_cli.commands import analysis # noqa: F401 imported for registration @@ -159,87 +160,6 @@ # --------------------------------------------------------------------------- # 6. get-journal-summary # --------------------------------------------------------------------------- -@cli.command("get-journal-summary", epilog="""\b -Examples: - logseq-cli --token TOKEN get-journal-summary --range "this week" - logseq-cli --token TOKEN get-journal-summary --range "last month" --no-content -Note: - Despite the name this embeds each day's FULL text by default, so a month-long - range is large. --no-content drops the bodies and keeps dates + topics + - top concepts, which is what an overview usually needs. - For raw block content use get-journal-range (supports --tail/--heading). -""") -@click.option("--range", "date_range", default="today", help="Date range: today, this week, last 30 days, this month, this year") -@click.option("--no-content", "no_content", is_flag=True, help="Omit per-day body text; keep dates, topics and top concepts") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def get_journal_summary(ctx, date_range, no_content, as_json): - """Summarize journal entries within a date range.""" - api = ctx.obj["api"] - start, end = parse_date_range(date_range) - pages = api.get_all_pages() - - journal_entries = [] - all_topics = Counter() - - for page in pages: - jd = page.get("journalDay") or page.get("journal-day") - if not jd: - continue - try: - d = journal_day_to_date(jd) - except (ValueError, TypeError): - continue - - dt = datetime.datetime.combine(d, datetime.time()) - if start <= dt <= end: - page_name = page.get("originalName") or page.get("name", "") - content = get_page_content(api, page_name) - topics = extract_page_links(content) - all_topics.update(topics) - entry = { - "date": format_journal_date(d), - "page": page_name, - "topics": topics, - } - # Keep the character count even when the body is dropped, so the - # caller can see how much was withheld and re-fetch deliberately. - if no_content: - entry["content_length"] = len(content or "") - else: - entry["content"] = content - journal_entries.append(entry) - - journal_entries.sort(key=lambda e: e["date"]) - top_concepts = all_topics.most_common(10) - - result = { - "range": date_range, - "entries_count": len(journal_entries), - "entries": journal_entries, - "top_concepts": [{"topic": t, "count": c} for t, c in top_concepts], - } - if no_content: - result["content_omitted"] = True - - if as_json: - output(result, True) - else: - click.echo(f"Journal Summary ({date_range}): {len(journal_entries)} entries\n") - for entry in journal_entries: - if no_content: - topics = entry.get("topics") or [] - topic_str = f" — {', '.join(topics[:8])}" if topics else "" - click.echo(f"--- {entry['date']} ({entry['content_length']} chars){topic_str}") - continue - click.echo(f"--- {entry['date']} ---") - click.echo(entry["content"] or "(empty)") - click.echo() - if top_concepts: - click.echo("Top Concepts:") - for topic, count in top_concepts: - click.echo(f" {topic}: {count}") @@ -247,167 +167,6 @@ def get_journal_summary(ctx, date_range, no_content, as_json): # --------------------------------------------------------------------------- # 6b. get-journal-range # --------------------------------------------------------------------------- -@cli.command("get-journal-range", epilog="""\b -Examples: - logseq-cli --token TOKEN get-journal-range --from 2026-04-20 --to 2026-04-26 --resolve-refs - logseq-cli --token TOKEN get-journal-range --from 2026-01-01 --to 2026-04-30 --tail 5 - logseq-cli --token TOKEN get-journal-range --from 2026-04-01 --to 2026-04-30 \\ - --heading "## Log" --tail 7 - LOGSEQ_CLI_RANGE_WORKERS=10 logseq-cli --token TOKEN get-journal-range --from 2026-01-01 --to 2026-04-30 -Notes: - Output grows with the range - a month of journals is large. Narrow it with - --tail N (newest N days), --limit N (oldest N days) and/or --heading "## Log". - --tail/--limit apply BEFORE fetching, so skipped days cost no API calls. - Parallel pool (default 5 workers, 1-16 via LOGSEQ_CLI_RANGE_WORKERS). - Always pass --resolve-refs if downstream parses ((uuid)) refs. - Per-day errors embed as {error: "..."} per entry; range continues. -""") -@click.option("--from", "from_date", required=True, help="Start date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow', inclusive)") -@click.option("--to", "to_date", required=True, help="End date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow', inclusive)") -@click.option("--resolve-refs", is_flag=True, help="Inline ((uuid)) block references with their content") -@click.option("--tail", "tail", default=None, type=int, help="Only the newest N journal days of the range, 1 or greater (applied before fetching)") -@click.option("--limit", "limit", default=None, type=int, help="Only the oldest N journal days of the range, 1 or greater (applied before fetching)") -@click.option("--heading", default=None, help="Return only the section under this heading per day (e.g. '## Log')") -@click.option("--format", "output_format", type=click.Choice(["text", "markdown"]), default="text", help="Output format: text (default) or markdown") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def get_journal_range(ctx, from_date, to_date, resolve_refs, tail, limit, heading, output_format, as_json): - """Get full block content for all journal pages in a date range (inclusive). - - Returns one entry per journal day, with blocks and page name. - Unlike get-journal-summary this returns raw blocks, not an aggregated summary. - - Example: - logseq-cli get-journal-range --from 2026-04-20 --to 2026-04-24 - logseq-cli get-journal-range --from 2026-04-20 --to 2026-04-24 --resolve-refs --json - logseq-cli get-journal-range --from 2026-04-01 --to 2026-04-30 --heading "## Log" --tail 7 - """ - api = ctx.obj["api"] - - # fail() rather than BadParameter: this command speaks --json, and Click's - # refusal is a usage dump on stderr that no caller can parse. An agent - # reading stderr as JSON got prose exactly where it expected an object. - if tail is not None and tail < 1: - fail("--tail must be 1 or greater.", as_json) - if limit is not None and limit < 1: - fail("--limit must be 1 or greater.", as_json) - if tail is not None and limit is not None: - fail("--tail and --limit are mutually exclusive.", as_json) - - start = datetime.datetime.combine(parse_date_keyword(from_date), datetime.time()) - end = datetime.datetime.combine(parse_date_keyword(to_date), datetime.time()) - - if start > end: - fail("--from must be before or equal to --to.", as_json) - - pages = api.get_all_pages() - - targets = [] - for page in pages: - jd = page.get("journalDay") or page.get("journal-day") - if not jd: - continue - try: - d = journal_day_to_date(jd) - except (ValueError, TypeError): - continue - - dt = datetime.datetime.combine(d, datetime.time()) - if start <= dt <= end: - page_name = page.get("originalName") or page.get("name", "") - targets.append((d, page_name)) - - # Narrow BEFORE fetching: skipped days must not cost API calls. - targets.sort(key=lambda t: t[0]) - total_days = len(targets) - if tail is not None: - targets = targets[-tail:] - elif limit is not None: - targets = targets[:limit] - omitted = total_days - len(targets) - - try: - worker_setting = int(os.getenv("LOGSEQ_CLI_RANGE_WORKERS", "5")) - except ValueError: - worker_setting = 5 - max_workers = min(16, max(1, worker_setting)) - - def _fetch_one(target): - d, page_name = target - try: - blocks = api.get_page_blocks_tree(page_name) - if heading and blocks: - blocks = extract_section(blocks, heading) - if resolve_refs and blocks: - resolve_refs_in_blocks(api, blocks) - return { - "date": d.strftime("%Y-%m-%d"), - "page": page_name, - "blocks": blocks or [], - } - except Exception as exc: - return { - "date": d.strftime("%Y-%m-%d"), - "page": page_name, - "blocks": [], - "error": f"{type(exc).__name__}: {exc}", - } - - if not targets: - entries = [] - elif max_workers == 1 or len(targets) == 1: - entries = [_fetch_one(t) for t in targets] - else: - entries = [] - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = [executor.submit(_fetch_one, t) for t in targets] - for future in as_completed(futures): - entries.append(future.result()) - - entries.sort(key=lambda e: e["date"]) - - # Never truncate silently: a shortened result must not read as the full range. - if omitted > 0: - which = "newest" if tail is not None else "oldest" - click.echo( - f"Note: showing {len(entries)} of {total_days} journal day(s) " - f"({which} {len(entries)}); {omitted} omitted. " - f"Widen with --tail/--limit or drop the flag for the full range.", - err=True, - ) - - if not resolve_refs: - total_refs = sum(count_unresolved_refs(e.get("blocks", [])) for e in entries) - if total_refs > 0: - click.echo( - f"⚠️ {total_refs} unresolved block-ref(s) in output — " - f"re-run with --resolve-refs to inline them.", - err=True, - ) - - if as_json: - output(entries, True) - else: - for entry in entries: - err = entry.get("error") - if output_format == "markdown": - click.echo(f"# {entry['page']}\n") - if err: - click.echo(f"!! ERROR: {err}\n") - elif entry["blocks"]: - click.echo(blocks_to_markdown(entry["blocks"])) - else: - click.echo("(empty)\n") - else: - click.echo(f"=== {entry['page']} ===\n") - if err: - click.echo(f"!! ERROR: {err}") - elif entry["blocks"]: - click.echo(process_blocks(entry["blocks"])) - else: - click.echo("(empty)") - click.echo() # --------------------------------------------------------------------------- @@ -459,544 +218,16 @@ def _fetch_one(target): # --------------------------------------------------------------------------- # 13. add-journal-entry # --------------------------------------------------------------------------- -@cli.command("add-journal-entry", epilog="""\b -DEPRECATED. Use add-journal-block instead — it auto-detects hierarchy and supports ---under-heading / --upsert-heading. -""") -@click.option("--content", required=True, help="Content to add") -@click.option("--date", default=None, help="Date (YYYY-MM-DD), defaults to today") -@click.option("--as-block/--multi-block", default=True, help="Add as single block or split into multiple") -@click.option("--dry-run", is_flag=True, help="Report the target page and block count, without writing") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def add_journal_entry(ctx, content, date, as_block, as_json, dry_run): - """Add a simple entry to a journal page (top-level only). - - Deprecated: Prefer add-journal-block which supports --under-heading. - This command always appends at the top level of the page. - - Use --multi-block to split multi-line content into separate blocks. - """ - click.echo("Note: add-journal-entry is deprecated. Use add-journal-block instead (supports --under-heading).", err=True) - api = ctx.obj["api"] - - if date: - d = parse_date_keyword(date) - else: - d = datetime.date.today() - - configs = api.get_user_configs() - date_fmt = configs.get("preferredDateFormat") if configs else None - page_name = format_journal_date(d, date_fmt) - - # Ensure journal page exists with journal property - try: - existing = api.get_page(page_name) - except Exception: - existing = None - content = strip_title_heading(content, page_name) - - # Before the journal page is created: the preview must not be the one run - # that leaves a page behind. - if dry_run: - planned = 1 if as_block else len( - [l for l in content.split("\n") if l.strip()]) - if as_json: - output({"page": page_name, "date": str(d), "blocks_added": planned, - "would_create_page": not existing, "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would add {planned} block(s) to journal: {page_name}") - if not existing: - click.echo(f" page: {page_name} (would be created)") - return - - if not existing: - api.create_page(page_name, {"journal?": True}) - - # Count what the graph actually took, not how many lines were handed in: - # reporting len(lines) turned a partial write into "Added 3 block(s)" with - # no hint that two are missing, which invites a retry that duplicates the - # one that landed. - if as_block: - result = api.append_block_in_page(page_name, content) - require_insert(result, f"a block on '{page_name}'") - blocks_added = 1 - else: - lines = [l.strip() for l in content.split("\n") if l.strip()] - result = None - written = 0 - for line in lines: - result = api.append_block_in_page(page_name, line) - require_insert(result, f"a block on '{page_name}'", written_so_far=written) - written += 1 - blocks_added = written - - if as_json: - output({ - "page": page_name, - "date": str(d), - "blocks_added": blocks_added, - "result": result, - }, True) - else: - click.echo(f"Added {blocks_added} block(s) to journal: {page_name}") # --------------------------------------------------------------------------- # 14. add-journal-block # --------------------------------------------------------------------------- -@cli.command("add-journal-block", epilog="""\b -Examples: - logseq-cli --token TOKEN add-journal-block --content "**$(date +%H:%M)** Meeting with [[Bob]]" - logseq-cli --token TOKEN add-journal-block --date 2026-05-07 --content "**14:30** Nachtrag" - logseq-cli --token TOKEN add-journal-block --under-heading "## Meeting" --content "..." - logseq-cli --token TOKEN add-journal-block --content "TODO A" --content "TODO B" # batch - logseq-cli --token TOKEN add-journal-block --content-file entry.md # tree from file - logseq-cli --token TOKEN add-journal-block --under-heading "## Meeting" \\ - --upsert-heading "### [[Carol]]" --content "..." -Notes: - Default heading from LOGSEQ_JOURNAL_HEADING env (e.g. "## Log"). - --upsert-heading replaces a placeholder block under --under-heading without needing UUID. - Auto-detects tab-indented hierarchy in --content; no need to switch to add-journal-content. - --content-file reads the whole file as ONE tree: flush "- " lines become - sibling roots, tab-indented lines their children. No shell quoting, so - apostrophes/quotes/umlauts are safe. Mutually exclusive with --content. -""") -@click.option("--content", "contents", multiple=True, help="Block content (repeatable for batch: --content 'text1' --content 'text2')") -@click.option("--content-file", "content_file", default=None, help="Read block content from a file and insert it as a tree (multiple flush '- ' roots allowed). Mutually exclusive with --content.") -@click.option("--date", default=None, help="Date (YYYY-MM-DD), defaults to today") -@click.option("--under-heading", default=None, help="Insert as child of this heading (e.g. '## Log'). Creates heading if missing. Default from LOGSEQ_JOURNAL_HEADING env var, or top-level if unset.") -@click.option("--upsert-heading", default=None, help="Find child block matching this heading under --under-heading and update it; insert as new block if not found.") -@click.option("--top-level", is_flag=True, help="Add as top-level block (ignore --under-heading and env var)") -@click.option("--preserve-formatting/--no-preserve", default=True, help="Preserve content formatting") -@click.option("--dry-run", is_flag=True, help="Show what would be written without making changes") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def add_journal_block(ctx, contents, content_file, date, under_heading, upsert_heading, top_level, preserve_formatting, dry_run, as_json): - """Add one or more blocks to a journal page. - - Pass --content multiple times for batch inserts under the same heading. - This is the recommended command for journal entries. Inserts under the - heading from LOGSEQ_JOURNAL_HEADING env var (default: top-level). - - Examples: - logseq-cli add-journal-block --content "**14:30** Meeting notes" - logseq-cli add-journal-block --under-heading "## Tasks" --content "TODO Task A" --content "TODO Task B" - logseq-cli add-journal-block --date 2026-04-03 --content "Retroactive entry" - """ - # --content and --content-file are mutually exclusive; exactly one is required. - if content_file is not None and contents: - raise click.UsageError("Specify either --content or --content-file, not both.") - if content_file is None and not contents: - raise click.UsageError("Missing option '--content' (or '--content-file').") - - # --content-file: the file IS the tree. Flush "- " roots are siblings here, - # not the silent-failure case the guard below protects against, because the - # whole text is parsed hierarchically instead of written as one raw block. - from_file = content_file is not None - if from_file: - # --no-preserve collapses all whitespace, which would flatten the very - # tree --content-file exists to insert (and leave raw "- " markers in - # the text). The guard that catches this inline is skipped here, so the - # combination must be rejected rather than silently written. - if not preserve_formatting: - raise click.UsageError( - "--content-file and --no-preserve are incompatible: " - "--no-preserve would collapse the hierarchy into ONE " - "block.\n" - " - want the structure? -> drop --no-preserve\n" - " - want flowing text? -> use --content" - ) - contents = (read_content_file(content_file),) - - # Guard: reject flush (non-indented) newline bullets in ANY --content value. - # Such content is neither detected as hierarchy (needs indentation) nor split - # into siblings — it would silently become ONE block with raw "\n- " lines, - # breaking the outline. Fail loudly with a fix instruction instead. - # Skipped for --content-file, which always takes the structured path. - if not from_file: - for c in contents: - require_content(c) - try: - reject_unsupported_multiline( - c, command="add-journal-block", accepts_tree=True - ) - except MultilineContentError as e: - raise click.UsageError(str(e)) - - # For single content: unwrap to scalar for backward-compatible logic below - if len(contents) == 1: - content = contents[0] - else: - content = None # Will be handled in batch path below - - if top_level: - under_heading = None - else: - # A name from [journal.headings] resolves to its heading; anything else - # is passed through, so a literal "## Log" keeps working. With no value - # at all, the env var wins over the config's default_heading. - under_heading = resolve_heading(load_config(), under_heading) - - # --- Batch path: multiple --content values --- - if len(contents) > 1: - api = ctx.obj["api"] - if date: - d = parse_date_keyword(date) - else: - d = datetime.date.today() - configs = api.get_user_configs() - date_fmt = configs.get("preferredDateFormat") if configs else None - page_name = format_journal_date(d, date_fmt) - try: - existing = api.get_page(page_name) - except Exception: - existing = None - # Creating the journal page is a write, so it waits for the dry-run - # check below: a preview that brings a page into existence is not a - # preview. The flag is reported instead, because "the page does not - # exist yet" is part of what the run would do. - would_create_page = not existing - if not existing and not dry_run: - api.create_page(page_name, {"journal?": True}) - - # Plan each --content value the same way for dry-run and live, so the - # reported block count matches what is actually written (a value with - # tab sub-bullets expands to a header + children, not one flat block). - planned = [] # list of (kind, payload) where kind in {"tree", "flat"} - any_hierarchical = False - for c in contents: - c = strip_title_heading(c, page_name) - if preserve_formatting and contains_hierarchical_content(c): - any_hierarchical = True - planned.append(("tree", parse_hierarchical_content(c))) - else: - if has_mixed_indentation(c): - c = normalize_indentation(c) - planned.append(("flat", c)) - planned_total = sum(count_blocks(p) if k == "tree" else 1 for k, p in planned) - - if dry_run: - if as_json: - output({"page": page_name, "date": str(d), "blocks": planned_total, - "would_create_page": would_create_page, "contents": list(contents), "dry_run": True}, True) - else: - if any_hierarchical: - click.echo("Note: Hierarchical content detected, using structured insertion", err=True) - click.echo(f"[DRY RUN] Would add {planned_total} block(s) to journal: {page_name}") - if would_create_page: - click.echo(f" the journal page does not exist yet and would be created") - for c in contents: - click.echo(f" {c[:80]}") - return - - heading_uuid = find_or_create_heading(api, page_name, under_heading) if under_heading else None - uuids = [] - for kind, payload in planned: - if kind == "tree": - if heading_uuid: - uuids.extend(insert_block_tree_with_uuids( - api, payload, heading_uuid, strict=True, _written=len(uuids))) - else: - # payload is the parsed tree; insert top nodes + children at page level - uuids.extend(insert_block_tree_at_page_top( - api, payload, page_name, _written=len(uuids))) - else: - if heading_uuid: - r = api.insert_block(heading_uuid, payload, {"sibling": False}) - else: - r = api.append_block_in_page(page_name, payload) - uuids.append(require_insert(r, "a journal block", written_so_far=len(uuids))) - total = len(uuids) - if any_hierarchical: - click.echo("Note: Hierarchical content detected, using structured insertion", err=True) - - position = f"under '{under_heading}'" if under_heading else "top-level" - if as_json: - output({"page": page_name, "date": str(d), "position": position, "blocks_added": total, **uuid_fields(uuids)}, True) - else: - click.echo(f"Added {total} block(s) to journal: {page_name} ({position})") - return - - api = ctx.obj["api"] - - if date: - d = parse_date_keyword(date) - else: - d = datetime.date.today() - - configs = api.get_user_configs() - date_fmt = configs.get("preferredDateFormat") if configs else None - page_name = format_journal_date(d, date_fmt) - - # Ensure journal page exists with journal property. Deferred when only - # previewing: a dry run must not bring the page into existence. - try: - existing = api.get_page(page_name) - except Exception: - existing = None - would_create_page = not existing - if not existing and not dry_run: - api.create_page(page_name, {"journal?": True}) - - if not preserve_formatting: - # Collapse whitespace - content = " ".join(content.split()) - - content = strip_title_heading(content, page_name) - - # --- upsert-heading: find-or-replace child block under a heading --- - if upsert_heading: - if not under_heading: - click.echo("Error: --upsert-heading requires --under-heading", err=True) - sys.exit(1) - - if dry_run: - position_desc = f"upsert '{upsert_heading}' under '{under_heading}'" - if as_json: - output({"page": page_name, "date": str(d), "position": position_desc, "content": content, "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would upsert to journal: {page_name} ({position_desc})") - click.echo(f" {content[:120]}{'...' if len(content) > 120 else ''}") - return - - heading_uuid = find_or_create_heading(api, page_name, under_heading) - found_uuid = None - if heading_uuid: - heading_block = api.get_block(heading_uuid, include_children=True) - children = heading_block.get("children", []) if heading_block else [] - target_norm = normalize_heading(upsert_heading) - for child in children: - if normalize_heading(child.get("content", "")) == target_norm: - found_uuid = child.get("uuid") - break - - if found_uuid: - root_uuid = found_uuid - if from_file or contains_hierarchical_content(content): - tree = parse_hierarchical_content(content) - if tree: - # The first root replaces the matched block; its children - # nest under it. Any further roots are siblings after it — - # dropping them would lose content while still reporting - # count_blocks(tree) as written. - # - # Both inserts run strict and n counts the UUIDs actually - # returned, plus 1 for the update. Using count_blocks(tree) - # here would report the intended size even when a write - # silently failed, which is the exact "text is gone and - # nothing says so" case require_insert exists to prevent. - api.update_block(found_uuid, tree[0]["content"]) - n = 1 - kids = tree[0].get("children", []) - if kids: - n += len(insert_block_tree_with_uuids( - api, kids, found_uuid, strict=True, _written=n)) - if len(tree) > 1: - n += len(insert_block_tree_as_siblings( - api, tree[1:], found_uuid, _written=n)) - else: - api.update_block(found_uuid, content) - n = 1 - else: - api.update_block(found_uuid, content) - n = 1 - status = "updated" - elif heading_uuid: - if from_file or contains_hierarchical_content(content): - tree = parse_hierarchical_content(content) - created = insert_block_tree_with_uuids(api, tree, heading_uuid) - n = len(created) - root_uuid = created[0] if created else None - else: - r = api.insert_block(heading_uuid, content, {"sibling": False}) - n = 1 - root_uuid = require_insert(r, "the upsert block") - status = "created" - else: - r = api.append_block_in_page(page_name, content) - n = 1 - root_uuid = require_insert(r, f"a block on '{page_name}'") - status = "top-level (heading not found)" - - position = f"upsert '{upsert_heading}' under '{under_heading}' ({status})" - if as_json: - output({"page": page_name, "date": str(d), "position": position, "blocks": n, **uuid_fields([u for u in [root_uuid] if u])}, True) - else: - click.echo(f"Added {n} block(s) to journal: {page_name} ({position})") - return - - # Auto-detect hierarchical content and delegate to structured insertion. - # --content-file always takes this path: its flush "- " lines are roots, - # which contains_hierarchical_content (indentation-based) would not detect. - if preserve_formatting and (from_file or contains_hierarchical_content(content)): - click.echo("Note: Hierarchical content detected, using structured insertion", err=True) - tree = parse_hierarchical_content(content) - n = count_blocks(tree) - position = f"under '{under_heading}'" if under_heading else "top-level" - - if dry_run: - if as_json: - output({"page": page_name, "date": str(d), "position": position, "blocks": n, "content": content, "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would add {n} block(s) to journal: {page_name} ({position})") - if would_create_page: - click.echo(f" the journal page does not exist yet and would be created") - click.echo(f" {content[:120]}{'...' if len(content) > 120 else ''}") - return - - if under_heading: - heading_uuid = find_or_create_heading(api, page_name, under_heading) - if heading_uuid: - uuids = insert_block_tree_with_uuids(api, tree, heading_uuid) - else: - click.echo(f"Warning: Could not find or create '{under_heading}', adding as top-level", err=True) - uuids = insert_formatted_content_with_uuids(api, page_name, content) - position = "top-level (heading not found)" - else: - uuids = insert_formatted_content_with_uuids(api, page_name, content) - - n = len(uuids) - if as_json: - output({"page": page_name, "date": str(d), "position": position, "blocks_added": n, **uuid_fields(uuids)}, True) - else: - click.echo(f"Added {n} block(s) to journal: {page_name} ({position})") - return - - if dry_run: - position_desc = f"under '{under_heading}'" if under_heading else "top-level" - if as_json: - output({"page": page_name, "date": str(d), "position": position_desc, - "content": content, "would_create_page": would_create_page, - "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would add to journal: {page_name} ({position_desc})") - if would_create_page: - click.echo(" the journal page does not exist yet and would be created") - click.echo(f" {content}") - return - - # Every branch checks its write. The hierarchical path already aborted on a - # silent failure via require_insert; without the same check here a single - # flat entry (the common case for a timestamped log line) was reported as - # "Added block to journal" with exit 0 while nothing had been written. - if under_heading: - heading_uuid = find_or_create_heading(api, page_name, under_heading) - if heading_uuid: - result = api.insert_block(heading_uuid, content, {"sibling": False}) - position = f"under '{under_heading}'" - _u = require_insert(result, f"a block under '{under_heading}'") - else: - result = api.append_block_in_page(page_name, content) - position = "top-level (heading not found)" - click.echo(f"Warning: Could not find or create '{under_heading}', added as top-level block", err=True) - _u = require_insert(result, f"a block on '{page_name}'") - else: - result = api.append_block_in_page(page_name, content) - position = "top-level" - _u = require_insert(result, f"a block on '{page_name}'") - - if as_json: - output({"page": page_name, "date": str(d), "position": position, "result": result, **uuid_fields([u for u in [_u] if u])}, True) - else: - click.echo(f"Added block to journal: {page_name} ({position})") - click.echo(f" {content[:80]}{'...' if len(content) > 80 else ''}") # --------------------------------------------------------------------------- # 15. add-journal-content # --------------------------------------------------------------------------- -@cli.command("add-journal-content", epilog="""\b -Example: - logseq-cli --token TOKEN add-journal-content \\ - --content "- ## Log\\n\\t- 14:30 Meeting [[Bob]]" --date $(date +%Y-%m-%d) -Note: - Same heading logic as add-journal-block. Prefer add-journal-block for most cases — - it now auto-detects hierarchy. -""") -@click.option("--content", required=True, help="Hierarchical content to add") -@click.option("--date", default=None, help="Date (YYYY-MM-DD), defaults to today") -@click.option("--under-heading", default=None, help="Insert under this heading (e.g. '## Log'). Creates heading if missing. Default from LOGSEQ_JOURNAL_HEADING env var, or top-level if unset.") -@click.option("--top-level", is_flag=True, help="Add as top-level content (ignore --under-heading and env var)") -@click.option("--dry-run", is_flag=True, help="Show what would be written without making changes") -@click.option("--json", "as_json", is_flag=True, help="Output as JSON") -@click.pass_context -@handle_connection_error -def add_journal_content(ctx, content, date, under_heading, top_level, dry_run, as_json): - """Add hierarchical (nested) content to a journal page. - - Use this for structured multi-block content with parent-child relationships. - Content should use tab indentation for hierarchy: - - ## Heading - \\t- Child block - \\t\\t- Grandchild block - - Heading resolution order (same as add-journal-block): - 1. --top-level flag -> always top-level - 2. --under-heading VALUE -> use that heading - 3. LOGSEQ_JOURNAL_HEADING env var -> use that heading - 4. No env var, no flag -> top-level (backward compatible) - - For single blocks, prefer add-journal-block instead. - """ - require_content(content) - - if top_level: - under_heading = None - else: - under_heading = resolve_heading(load_config(), under_heading) - - api = ctx.obj["api"] - - if date: - d = parse_date_keyword(date) - else: - d = datetime.date.today() - - configs = api.get_user_configs() - date_fmt = configs.get("preferredDateFormat") if configs else None - page_name = format_journal_date(d, date_fmt) - - # Ensure journal page exists with journal property - try: - existing = api.get_page(page_name) - except Exception: - existing = None - if not existing and not dry_run: - api.create_page(page_name, {"journal?": True}) - - content = strip_title_heading(content, page_name) - position = f"under '{under_heading}'" if under_heading else "top-level" - - if dry_run: - tree = parse_hierarchical_content(content) - n = count_blocks(tree) - if as_json: - output({"page": page_name, "date": str(d), "position": position, "blocks": n, "content": content, "dry_run": True}, True) - else: - click.echo(f"[DRY RUN] Would add {n} block(s) to journal: {page_name} ({position})") - click.echo(f" {content[:120]}{'...' if len(content) > 120 else ''}") - return - - if under_heading: - heading_uuid = find_or_create_heading(api, page_name, under_heading) - if heading_uuid: - tree = parse_hierarchical_content(content) - uuids = insert_block_tree_with_uuids(api, tree, heading_uuid) - else: - click.echo(f"Warning: Could not find or create '{under_heading}', adding as top-level", err=True) - uuids = insert_formatted_content_with_uuids(api, page_name, content) - position = "top-level (heading not found)" - else: - uuids = insert_formatted_content_with_uuids(api, page_name, content) - - n = len(uuids) - if as_json: - output({"page": page_name, "date": str(d), "position": position, "blocks_added": n, "content_added": True, **uuid_fields(uuids)}, True) - else: - click.echo(f"Added {n} block(s) to journal: {page_name} ({position})") # --------------------------------------------------------------------------- diff --git a/logseq_cli/commands/journal.py b/logseq_cli/commands/journal.py new file mode 100644 index 0000000..5541f21 --- /dev/null +++ b/logseq_cli/commands/journal.py @@ -0,0 +1,820 @@ +import datetime +import os +import sys +from collections import Counter +from concurrent.futures import ThreadPoolExecutor, as_completed + +import click + +from logseq_cli.config import load_config, resolve_heading +from logseq_cli.group import cli +from logseq_cli.helpers import ( + MultilineContentError, + contains_hierarchical_content, + count_blocks, + extract_page_links, + find_or_create_heading, + format_journal_date, + get_page_content, + has_mixed_indentation, + insert_block_tree_as_siblings, + insert_block_tree_at_page_top, + insert_block_tree_with_uuids, + insert_formatted_content_with_uuids, + journal_day_to_date, + normalize_heading, + normalize_indentation, + parse_date_keyword, + parse_date_range, + parse_hierarchical_content, + process_blocks, + read_content_file, + reject_unsupported_multiline, + require_content, + require_insert, + strip_title_heading, + uuid_fields, +) +from logseq_cli.output import fail, handle_connection_error, output +from logseq_cli.render import ( + blocks_to_markdown, + count_unresolved_refs, + extract_section, + resolve_refs_in_blocks, +) + + +@cli.command("get-journal-summary", epilog="""\b +Examples: + logseq-cli --token TOKEN get-journal-summary --range "this week" + logseq-cli --token TOKEN get-journal-summary --range "last month" --no-content +Note: + Despite the name this embeds each day's FULL text by default, so a month-long + range is large. --no-content drops the bodies and keeps dates + topics + + top concepts, which is what an overview usually needs. + For raw block content use get-journal-range (supports --tail/--heading). +""") +@click.option("--range", "date_range", default="today", help="Date range: today, this week, last 30 days, this month, this year") +@click.option("--no-content", "no_content", is_flag=True, help="Omit per-day body text; keep dates, topics and top concepts") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def get_journal_summary(ctx, date_range, no_content, as_json): + """Summarize journal entries within a date range.""" + api = ctx.obj["api"] + start, end = parse_date_range(date_range) + pages = api.get_all_pages() + + journal_entries = [] + all_topics = Counter() + + for page in pages: + jd = page.get("journalDay") or page.get("journal-day") + if not jd: + continue + try: + d = journal_day_to_date(jd) + except (ValueError, TypeError): + continue + + dt = datetime.datetime.combine(d, datetime.time()) + if start <= dt <= end: + page_name = page.get("originalName") or page.get("name", "") + content = get_page_content(api, page_name) + topics = extract_page_links(content) + all_topics.update(topics) + entry = { + "date": format_journal_date(d), + "page": page_name, + "topics": topics, + } + # Keep the character count even when the body is dropped, so the + # caller can see how much was withheld and re-fetch deliberately. + if no_content: + entry["content_length"] = len(content or "") + else: + entry["content"] = content + journal_entries.append(entry) + + journal_entries.sort(key=lambda e: e["date"]) + top_concepts = all_topics.most_common(10) + + result = { + "range": date_range, + "entries_count": len(journal_entries), + "entries": journal_entries, + "top_concepts": [{"topic": t, "count": c} for t, c in top_concepts], + } + if no_content: + result["content_omitted"] = True + + if as_json: + output(result, True) + else: + click.echo(f"Journal Summary ({date_range}): {len(journal_entries)} entries\n") + for entry in journal_entries: + if no_content: + topics = entry.get("topics") or [] + topic_str = f" — {', '.join(topics[:8])}" if topics else "" + click.echo(f"--- {entry['date']} ({entry['content_length']} chars){topic_str}") + continue + click.echo(f"--- {entry['date']} ---") + click.echo(entry["content"] or "(empty)") + click.echo() + if top_concepts: + click.echo("Top Concepts:") + for topic, count in top_concepts: + click.echo(f" {topic}: {count}") + +@cli.command("get-journal-range", epilog="""\b +Examples: + logseq-cli --token TOKEN get-journal-range --from 2026-04-20 --to 2026-04-26 --resolve-refs + logseq-cli --token TOKEN get-journal-range --from 2026-01-01 --to 2026-04-30 --tail 5 + logseq-cli --token TOKEN get-journal-range --from 2026-04-01 --to 2026-04-30 \\ + --heading "## Log" --tail 7 + LOGSEQ_CLI_RANGE_WORKERS=10 logseq-cli --token TOKEN get-journal-range --from 2026-01-01 --to 2026-04-30 +Notes: + Output grows with the range - a month of journals is large. Narrow it with + --tail N (newest N days), --limit N (oldest N days) and/or --heading "## Log". + --tail/--limit apply BEFORE fetching, so skipped days cost no API calls. + Parallel pool (default 5 workers, 1-16 via LOGSEQ_CLI_RANGE_WORKERS). + Always pass --resolve-refs if downstream parses ((uuid)) refs. + Per-day errors embed as {error: "..."} per entry; range continues. +""") +@click.option("--from", "from_date", required=True, help="Start date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow', inclusive)") +@click.option("--to", "to_date", required=True, help="End date (YYYY-MM-DD or 'today'/'yesterday'/'tomorrow', inclusive)") +@click.option("--resolve-refs", is_flag=True, help="Inline ((uuid)) block references with their content") +@click.option("--tail", "tail", default=None, type=int, help="Only the newest N journal days of the range, 1 or greater (applied before fetching)") +@click.option("--limit", "limit", default=None, type=int, help="Only the oldest N journal days of the range, 1 or greater (applied before fetching)") +@click.option("--heading", default=None, help="Return only the section under this heading per day (e.g. '## Log')") +@click.option("--format", "output_format", type=click.Choice(["text", "markdown"]), default="text", help="Output format: text (default) or markdown") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def get_journal_range(ctx, from_date, to_date, resolve_refs, tail, limit, heading, output_format, as_json): + """Get full block content for all journal pages in a date range (inclusive). + + Returns one entry per journal day, with blocks and page name. + Unlike get-journal-summary this returns raw blocks, not an aggregated summary. + + Example: + logseq-cli get-journal-range --from 2026-04-20 --to 2026-04-24 + logseq-cli get-journal-range --from 2026-04-20 --to 2026-04-24 --resolve-refs --json + logseq-cli get-journal-range --from 2026-04-01 --to 2026-04-30 --heading "## Log" --tail 7 + """ + api = ctx.obj["api"] + + # fail() rather than BadParameter: this command speaks --json, and Click's + # refusal is a usage dump on stderr that no caller can parse. An agent + # reading stderr as JSON got prose exactly where it expected an object. + if tail is not None and tail < 1: + fail("--tail must be 1 or greater.", as_json) + if limit is not None and limit < 1: + fail("--limit must be 1 or greater.", as_json) + if tail is not None and limit is not None: + fail("--tail and --limit are mutually exclusive.", as_json) + + start = datetime.datetime.combine(parse_date_keyword(from_date), datetime.time()) + end = datetime.datetime.combine(parse_date_keyword(to_date), datetime.time()) + + if start > end: + fail("--from must be before or equal to --to.", as_json) + + pages = api.get_all_pages() + + targets = [] + for page in pages: + jd = page.get("journalDay") or page.get("journal-day") + if not jd: + continue + try: + d = journal_day_to_date(jd) + except (ValueError, TypeError): + continue + + dt = datetime.datetime.combine(d, datetime.time()) + if start <= dt <= end: + page_name = page.get("originalName") or page.get("name", "") + targets.append((d, page_name)) + + # Narrow BEFORE fetching: skipped days must not cost API calls. + targets.sort(key=lambda t: t[0]) + total_days = len(targets) + if tail is not None: + targets = targets[-tail:] + elif limit is not None: + targets = targets[:limit] + omitted = total_days - len(targets) + + try: + worker_setting = int(os.getenv("LOGSEQ_CLI_RANGE_WORKERS", "5")) + except ValueError: + worker_setting = 5 + max_workers = min(16, max(1, worker_setting)) + + def _fetch_one(target): + d, page_name = target + try: + blocks = api.get_page_blocks_tree(page_name) + if heading and blocks: + blocks = extract_section(blocks, heading) + if resolve_refs and blocks: + resolve_refs_in_blocks(api, blocks) + return { + "date": d.strftime("%Y-%m-%d"), + "page": page_name, + "blocks": blocks or [], + } + except Exception as exc: + return { + "date": d.strftime("%Y-%m-%d"), + "page": page_name, + "blocks": [], + "error": f"{type(exc).__name__}: {exc}", + } + + if not targets: + entries = [] + elif max_workers == 1 or len(targets) == 1: + entries = [_fetch_one(t) for t in targets] + else: + entries = [] + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [executor.submit(_fetch_one, t) for t in targets] + for future in as_completed(futures): + entries.append(future.result()) + + entries.sort(key=lambda e: e["date"]) + + # Never truncate silently: a shortened result must not read as the full range. + if omitted > 0: + which = "newest" if tail is not None else "oldest" + click.echo( + f"Note: showing {len(entries)} of {total_days} journal day(s) " + f"({which} {len(entries)}); {omitted} omitted. " + f"Widen with --tail/--limit or drop the flag for the full range.", + err=True, + ) + + if not resolve_refs: + total_refs = sum(count_unresolved_refs(e.get("blocks", [])) for e in entries) + if total_refs > 0: + click.echo( + f"⚠️ {total_refs} unresolved block-ref(s) in output — " + f"re-run with --resolve-refs to inline them.", + err=True, + ) + + if as_json: + output(entries, True) + else: + for entry in entries: + err = entry.get("error") + if output_format == "markdown": + click.echo(f"# {entry['page']}\n") + if err: + click.echo(f"!! ERROR: {err}\n") + elif entry["blocks"]: + click.echo(blocks_to_markdown(entry["blocks"])) + else: + click.echo("(empty)\n") + else: + click.echo(f"=== {entry['page']} ===\n") + if err: + click.echo(f"!! ERROR: {err}") + elif entry["blocks"]: + click.echo(process_blocks(entry["blocks"])) + else: + click.echo("(empty)") + click.echo() + +@cli.command("add-journal-entry", epilog="""\b +DEPRECATED. Use add-journal-block instead — it auto-detects hierarchy and supports +--under-heading / --upsert-heading. +""") +@click.option("--content", required=True, help="Content to add") +@click.option("--date", default=None, help="Date (YYYY-MM-DD), defaults to today") +@click.option("--as-block/--multi-block", default=True, help="Add as single block or split into multiple") +@click.option("--dry-run", is_flag=True, help="Report the target page and block count, without writing") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def add_journal_entry(ctx, content, date, as_block, as_json, dry_run): + """Add a simple entry to a journal page (top-level only). + + Deprecated: Prefer add-journal-block which supports --under-heading. + This command always appends at the top level of the page. + + Use --multi-block to split multi-line content into separate blocks. + """ + click.echo("Note: add-journal-entry is deprecated. Use add-journal-block instead (supports --under-heading).", err=True) + api = ctx.obj["api"] + + if date: + d = parse_date_keyword(date) + else: + d = datetime.date.today() + + configs = api.get_user_configs() + date_fmt = configs.get("preferredDateFormat") if configs else None + page_name = format_journal_date(d, date_fmt) + + # Ensure journal page exists with journal property + try: + existing = api.get_page(page_name) + except Exception: + existing = None + content = strip_title_heading(content, page_name) + + # Before the journal page is created: the preview must not be the one run + # that leaves a page behind. + if dry_run: + planned = 1 if as_block else len( + [l for l in content.split("\n") if l.strip()]) + if as_json: + output({"page": page_name, "date": str(d), "blocks_added": planned, + "would_create_page": not existing, "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would add {planned} block(s) to journal: {page_name}") + if not existing: + click.echo(f" page: {page_name} (would be created)") + return + + if not existing: + api.create_page(page_name, {"journal?": True}) + + # Count what the graph actually took, not how many lines were handed in: + # reporting len(lines) turned a partial write into "Added 3 block(s)" with + # no hint that two are missing, which invites a retry that duplicates the + # one that landed. + if as_block: + result = api.append_block_in_page(page_name, content) + require_insert(result, f"a block on '{page_name}'") + blocks_added = 1 + else: + lines = [l.strip() for l in content.split("\n") if l.strip()] + result = None + written = 0 + for line in lines: + result = api.append_block_in_page(page_name, line) + require_insert(result, f"a block on '{page_name}'", written_so_far=written) + written += 1 + blocks_added = written + + if as_json: + output({ + "page": page_name, + "date": str(d), + "blocks_added": blocks_added, + "result": result, + }, True) + else: + click.echo(f"Added {blocks_added} block(s) to journal: {page_name}") + +@cli.command("add-journal-block", epilog="""\b +Examples: + logseq-cli --token TOKEN add-journal-block --content "**$(date +%H:%M)** Meeting with [[Bob]]" + logseq-cli --token TOKEN add-journal-block --date 2026-05-07 --content "**14:30** Nachtrag" + logseq-cli --token TOKEN add-journal-block --under-heading "## Meeting" --content "..." + logseq-cli --token TOKEN add-journal-block --content "TODO A" --content "TODO B" # batch + logseq-cli --token TOKEN add-journal-block --content-file entry.md # tree from file + logseq-cli --token TOKEN add-journal-block --under-heading "## Meeting" \\ + --upsert-heading "### [[Carol]]" --content "..." +Notes: + Default heading from LOGSEQ_JOURNAL_HEADING env (e.g. "## Log"). + --upsert-heading replaces a placeholder block under --under-heading without needing UUID. + Auto-detects tab-indented hierarchy in --content; no need to switch to add-journal-content. + --content-file reads the whole file as ONE tree: flush "- " lines become + sibling roots, tab-indented lines their children. No shell quoting, so + apostrophes/quotes/umlauts are safe. Mutually exclusive with --content. +""") +@click.option("--content", "contents", multiple=True, help="Block content (repeatable for batch: --content 'text1' --content 'text2')") +@click.option("--content-file", "content_file", default=None, help="Read block content from a file and insert it as a tree (multiple flush '- ' roots allowed). Mutually exclusive with --content.") +@click.option("--date", default=None, help="Date (YYYY-MM-DD), defaults to today") +@click.option("--under-heading", default=None, help="Insert as child of this heading (e.g. '## Log'). Creates heading if missing. Default from LOGSEQ_JOURNAL_HEADING env var, or top-level if unset.") +@click.option("--upsert-heading", default=None, help="Find child block matching this heading under --under-heading and update it; insert as new block if not found.") +@click.option("--top-level", is_flag=True, help="Add as top-level block (ignore --under-heading and env var)") +@click.option("--preserve-formatting/--no-preserve", default=True, help="Preserve content formatting") +@click.option("--dry-run", is_flag=True, help="Show what would be written without making changes") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def add_journal_block(ctx, contents, content_file, date, under_heading, upsert_heading, top_level, preserve_formatting, dry_run, as_json): + """Add one or more blocks to a journal page. + + Pass --content multiple times for batch inserts under the same heading. + This is the recommended command for journal entries. Inserts under the + heading from LOGSEQ_JOURNAL_HEADING env var (default: top-level). + + Examples: + logseq-cli add-journal-block --content "**14:30** Meeting notes" + logseq-cli add-journal-block --under-heading "## Tasks" --content "TODO Task A" --content "TODO Task B" + logseq-cli add-journal-block --date 2026-04-03 --content "Retroactive entry" + """ + # --content and --content-file are mutually exclusive; exactly one is required. + if content_file is not None and contents: + raise click.UsageError("Specify either --content or --content-file, not both.") + if content_file is None and not contents: + raise click.UsageError("Missing option '--content' (or '--content-file').") + + # --content-file: the file IS the tree. Flush "- " roots are siblings here, + # not the silent-failure case the guard below protects against, because the + # whole text is parsed hierarchically instead of written as one raw block. + from_file = content_file is not None + if from_file: + # --no-preserve collapses all whitespace, which would flatten the very + # tree --content-file exists to insert (and leave raw "- " markers in + # the text). The guard that catches this inline is skipped here, so the + # combination must be rejected rather than silently written. + if not preserve_formatting: + raise click.UsageError( + "--content-file and --no-preserve are incompatible: " + "--no-preserve would collapse the hierarchy into ONE " + "block.\n" + " - want the structure? -> drop --no-preserve\n" + " - want flowing text? -> use --content" + ) + contents = (read_content_file(content_file),) + + # Guard: reject flush (non-indented) newline bullets in ANY --content value. + # Such content is neither detected as hierarchy (needs indentation) nor split + # into siblings — it would silently become ONE block with raw "\n- " lines, + # breaking the outline. Fail loudly with a fix instruction instead. + # Skipped for --content-file, which always takes the structured path. + if not from_file: + for c in contents: + require_content(c) + try: + reject_unsupported_multiline( + c, command="add-journal-block", accepts_tree=True + ) + except MultilineContentError as e: + raise click.UsageError(str(e)) + + # For single content: unwrap to scalar for backward-compatible logic below + if len(contents) == 1: + content = contents[0] + else: + content = None # Will be handled in batch path below + + if top_level: + under_heading = None + else: + # A name from [journal.headings] resolves to its heading; anything else + # is passed through, so a literal "## Log" keeps working. With no value + # at all, the env var wins over the config's default_heading. + under_heading = resolve_heading(load_config(), under_heading) + + # --- Batch path: multiple --content values --- + if len(contents) > 1: + api = ctx.obj["api"] + if date: + d = parse_date_keyword(date) + else: + d = datetime.date.today() + configs = api.get_user_configs() + date_fmt = configs.get("preferredDateFormat") if configs else None + page_name = format_journal_date(d, date_fmt) + try: + existing = api.get_page(page_name) + except Exception: + existing = None + # Creating the journal page is a write, so it waits for the dry-run + # check below: a preview that brings a page into existence is not a + # preview. The flag is reported instead, because "the page does not + # exist yet" is part of what the run would do. + would_create_page = not existing + if not existing and not dry_run: + api.create_page(page_name, {"journal?": True}) + + # Plan each --content value the same way for dry-run and live, so the + # reported block count matches what is actually written (a value with + # tab sub-bullets expands to a header + children, not one flat block). + planned = [] # list of (kind, payload) where kind in {"tree", "flat"} + any_hierarchical = False + for c in contents: + c = strip_title_heading(c, page_name) + if preserve_formatting and contains_hierarchical_content(c): + any_hierarchical = True + planned.append(("tree", parse_hierarchical_content(c))) + else: + if has_mixed_indentation(c): + c = normalize_indentation(c) + planned.append(("flat", c)) + planned_total = sum(count_blocks(p) if k == "tree" else 1 for k, p in planned) + + if dry_run: + if as_json: + output({"page": page_name, "date": str(d), "blocks": planned_total, + "would_create_page": would_create_page, "contents": list(contents), "dry_run": True}, True) + else: + if any_hierarchical: + click.echo("Note: Hierarchical content detected, using structured insertion", err=True) + click.echo(f"[DRY RUN] Would add {planned_total} block(s) to journal: {page_name}") + if would_create_page: + click.echo(f" the journal page does not exist yet and would be created") + for c in contents: + click.echo(f" {c[:80]}") + return + + heading_uuid = find_or_create_heading(api, page_name, under_heading) if under_heading else None + uuids = [] + for kind, payload in planned: + if kind == "tree": + if heading_uuid: + uuids.extend(insert_block_tree_with_uuids( + api, payload, heading_uuid, strict=True, _written=len(uuids))) + else: + # payload is the parsed tree; insert top nodes + children at page level + uuids.extend(insert_block_tree_at_page_top( + api, payload, page_name, _written=len(uuids))) + else: + if heading_uuid: + r = api.insert_block(heading_uuid, payload, {"sibling": False}) + else: + r = api.append_block_in_page(page_name, payload) + uuids.append(require_insert(r, "a journal block", written_so_far=len(uuids))) + total = len(uuids) + if any_hierarchical: + click.echo("Note: Hierarchical content detected, using structured insertion", err=True) + + position = f"under '{under_heading}'" if under_heading else "top-level" + if as_json: + output({"page": page_name, "date": str(d), "position": position, "blocks_added": total, **uuid_fields(uuids)}, True) + else: + click.echo(f"Added {total} block(s) to journal: {page_name} ({position})") + return + + api = ctx.obj["api"] + + if date: + d = parse_date_keyword(date) + else: + d = datetime.date.today() + + configs = api.get_user_configs() + date_fmt = configs.get("preferredDateFormat") if configs else None + page_name = format_journal_date(d, date_fmt) + + # Ensure journal page exists with journal property. Deferred when only + # previewing: a dry run must not bring the page into existence. + try: + existing = api.get_page(page_name) + except Exception: + existing = None + would_create_page = not existing + if not existing and not dry_run: + api.create_page(page_name, {"journal?": True}) + + if not preserve_formatting: + # Collapse whitespace + content = " ".join(content.split()) + + content = strip_title_heading(content, page_name) + + # --- upsert-heading: find-or-replace child block under a heading --- + if upsert_heading: + if not under_heading: + click.echo("Error: --upsert-heading requires --under-heading", err=True) + sys.exit(1) + + if dry_run: + position_desc = f"upsert '{upsert_heading}' under '{under_heading}'" + if as_json: + output({"page": page_name, "date": str(d), "position": position_desc, "content": content, "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would upsert to journal: {page_name} ({position_desc})") + click.echo(f" {content[:120]}{'...' if len(content) > 120 else ''}") + return + + heading_uuid = find_or_create_heading(api, page_name, under_heading) + found_uuid = None + if heading_uuid: + heading_block = api.get_block(heading_uuid, include_children=True) + children = heading_block.get("children", []) if heading_block else [] + target_norm = normalize_heading(upsert_heading) + for child in children: + if normalize_heading(child.get("content", "")) == target_norm: + found_uuid = child.get("uuid") + break + + if found_uuid: + root_uuid = found_uuid + if from_file or contains_hierarchical_content(content): + tree = parse_hierarchical_content(content) + if tree: + # The first root replaces the matched block; its children + # nest under it. Any further roots are siblings after it — + # dropping them would lose content while still reporting + # count_blocks(tree) as written. + # + # Both inserts run strict and n counts the UUIDs actually + # returned, plus 1 for the update. Using count_blocks(tree) + # here would report the intended size even when a write + # silently failed, which is the exact "text is gone and + # nothing says so" case require_insert exists to prevent. + api.update_block(found_uuid, tree[0]["content"]) + n = 1 + kids = tree[0].get("children", []) + if kids: + n += len(insert_block_tree_with_uuids( + api, kids, found_uuid, strict=True, _written=n)) + if len(tree) > 1: + n += len(insert_block_tree_as_siblings( + api, tree[1:], found_uuid, _written=n)) + else: + api.update_block(found_uuid, content) + n = 1 + else: + api.update_block(found_uuid, content) + n = 1 + status = "updated" + elif heading_uuid: + if from_file or contains_hierarchical_content(content): + tree = parse_hierarchical_content(content) + created = insert_block_tree_with_uuids(api, tree, heading_uuid) + n = len(created) + root_uuid = created[0] if created else None + else: + r = api.insert_block(heading_uuid, content, {"sibling": False}) + n = 1 + root_uuid = require_insert(r, "the upsert block") + status = "created" + else: + r = api.append_block_in_page(page_name, content) + n = 1 + root_uuid = require_insert(r, f"a block on '{page_name}'") + status = "top-level (heading not found)" + + position = f"upsert '{upsert_heading}' under '{under_heading}' ({status})" + if as_json: + output({"page": page_name, "date": str(d), "position": position, "blocks": n, **uuid_fields([u for u in [root_uuid] if u])}, True) + else: + click.echo(f"Added {n} block(s) to journal: {page_name} ({position})") + return + + # Auto-detect hierarchical content and delegate to structured insertion. + # --content-file always takes this path: its flush "- " lines are roots, + # which contains_hierarchical_content (indentation-based) would not detect. + if preserve_formatting and (from_file or contains_hierarchical_content(content)): + click.echo("Note: Hierarchical content detected, using structured insertion", err=True) + tree = parse_hierarchical_content(content) + n = count_blocks(tree) + position = f"under '{under_heading}'" if under_heading else "top-level" + + if dry_run: + if as_json: + output({"page": page_name, "date": str(d), "position": position, "blocks": n, "content": content, "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would add {n} block(s) to journal: {page_name} ({position})") + if would_create_page: + click.echo(f" the journal page does not exist yet and would be created") + click.echo(f" {content[:120]}{'...' if len(content) > 120 else ''}") + return + + if under_heading: + heading_uuid = find_or_create_heading(api, page_name, under_heading) + if heading_uuid: + uuids = insert_block_tree_with_uuids(api, tree, heading_uuid) + else: + click.echo(f"Warning: Could not find or create '{under_heading}', adding as top-level", err=True) + uuids = insert_formatted_content_with_uuids(api, page_name, content) + position = "top-level (heading not found)" + else: + uuids = insert_formatted_content_with_uuids(api, page_name, content) + + n = len(uuids) + if as_json: + output({"page": page_name, "date": str(d), "position": position, "blocks_added": n, **uuid_fields(uuids)}, True) + else: + click.echo(f"Added {n} block(s) to journal: {page_name} ({position})") + return + + if dry_run: + position_desc = f"under '{under_heading}'" if under_heading else "top-level" + if as_json: + output({"page": page_name, "date": str(d), "position": position_desc, + "content": content, "would_create_page": would_create_page, + "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would add to journal: {page_name} ({position_desc})") + if would_create_page: + click.echo(" the journal page does not exist yet and would be created") + click.echo(f" {content}") + return + + # Every branch checks its write. The hierarchical path already aborted on a + # silent failure via require_insert; without the same check here a single + # flat entry (the common case for a timestamped log line) was reported as + # "Added block to journal" with exit 0 while nothing had been written. + if under_heading: + heading_uuid = find_or_create_heading(api, page_name, under_heading) + if heading_uuid: + result = api.insert_block(heading_uuid, content, {"sibling": False}) + position = f"under '{under_heading}'" + _u = require_insert(result, f"a block under '{under_heading}'") + else: + result = api.append_block_in_page(page_name, content) + position = "top-level (heading not found)" + click.echo(f"Warning: Could not find or create '{under_heading}', added as top-level block", err=True) + _u = require_insert(result, f"a block on '{page_name}'") + else: + result = api.append_block_in_page(page_name, content) + position = "top-level" + _u = require_insert(result, f"a block on '{page_name}'") + + if as_json: + output({"page": page_name, "date": str(d), "position": position, "result": result, **uuid_fields([u for u in [_u] if u])}, True) + else: + click.echo(f"Added block to journal: {page_name} ({position})") + click.echo(f" {content[:80]}{'...' if len(content) > 80 else ''}") + +@cli.command("add-journal-content", epilog="""\b +Example: + logseq-cli --token TOKEN add-journal-content \\ + --content "- ## Log\\n\\t- 14:30 Meeting [[Bob]]" --date $(date +%Y-%m-%d) +Note: + Same heading logic as add-journal-block. Prefer add-journal-block for most cases — + it now auto-detects hierarchy. +""") +@click.option("--content", required=True, help="Hierarchical content to add") +@click.option("--date", default=None, help="Date (YYYY-MM-DD), defaults to today") +@click.option("--under-heading", default=None, help="Insert under this heading (e.g. '## Log'). Creates heading if missing. Default from LOGSEQ_JOURNAL_HEADING env var, or top-level if unset.") +@click.option("--top-level", is_flag=True, help="Add as top-level content (ignore --under-heading and env var)") +@click.option("--dry-run", is_flag=True, help="Show what would be written without making changes") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") +@click.pass_context +@handle_connection_error +def add_journal_content(ctx, content, date, under_heading, top_level, dry_run, as_json): + """Add hierarchical (nested) content to a journal page. + + Use this for structured multi-block content with parent-child relationships. + Content should use tab indentation for hierarchy: + - ## Heading + \\t- Child block + \\t\\t- Grandchild block + + Heading resolution order (same as add-journal-block): + 1. --top-level flag -> always top-level + 2. --under-heading VALUE -> use that heading + 3. LOGSEQ_JOURNAL_HEADING env var -> use that heading + 4. No env var, no flag -> top-level (backward compatible) + + For single blocks, prefer add-journal-block instead. + """ + require_content(content) + + if top_level: + under_heading = None + else: + under_heading = resolve_heading(load_config(), under_heading) + + api = ctx.obj["api"] + + if date: + d = parse_date_keyword(date) + else: + d = datetime.date.today() + + configs = api.get_user_configs() + date_fmt = configs.get("preferredDateFormat") if configs else None + page_name = format_journal_date(d, date_fmt) + + # Ensure journal page exists with journal property + try: + existing = api.get_page(page_name) + except Exception: + existing = None + if not existing and not dry_run: + api.create_page(page_name, {"journal?": True}) + + content = strip_title_heading(content, page_name) + position = f"under '{under_heading}'" if under_heading else "top-level" + + if dry_run: + tree = parse_hierarchical_content(content) + n = count_blocks(tree) + if as_json: + output({"page": page_name, "date": str(d), "position": position, "blocks": n, "content": content, "dry_run": True}, True) + else: + click.echo(f"[DRY RUN] Would add {n} block(s) to journal: {page_name} ({position})") + click.echo(f" {content[:120]}{'...' if len(content) > 120 else ''}") + return + + if under_heading: + heading_uuid = find_or_create_heading(api, page_name, under_heading) + if heading_uuid: + tree = parse_hierarchical_content(content) + uuids = insert_block_tree_with_uuids(api, tree, heading_uuid) + else: + click.echo(f"Warning: Could not find or create '{under_heading}', adding as top-level", err=True) + uuids = insert_formatted_content_with_uuids(api, page_name, content) + position = "top-level (heading not found)" + else: + uuids = insert_formatted_content_with_uuids(api, page_name, content) + + n = len(uuids) + if as_json: + output({"page": page_name, "date": str(d), "position": position, "blocks_added": n, "content_added": True, **uuid_fields(uuids)}, True) + else: + click.echo(f"Added {n} block(s) to journal: {page_name} ({position})") diff --git a/tests/test_journal_bounded_output.py b/tests/test_journal_bounded_output.py index 6ddc67c..40bc97e 100644 --- a/tests/test_journal_bounded_output.py +++ b/tests/test_journal_bounded_output.py @@ -119,7 +119,7 @@ def test_without_heading_all_sections_present(self, api): class TestJournalSummaryNoContent: def test_no_content_drops_bodies_but_keeps_length(self, api, monkeypatch): - monkeypatch.setattr("logseq_cli.cli.get_page_content", + monkeypatch.setattr("logseq_cli.commands.journal.get_page_content", lambda api_, name: "x" * 500 + " [[Alice]]") result = CliRunner().invoke(cli, [ "get-journal-summary", "--range", "this year", "--no-content", "--json"]) @@ -131,7 +131,7 @@ def test_no_content_drops_bodies_but_keeps_length(self, api, monkeypatch): assert entry["topics"] == ["Alice"] def test_default_still_includes_content(self, api, monkeypatch): - monkeypatch.setattr("logseq_cli.cli.get_page_content", + monkeypatch.setattr("logseq_cli.commands.journal.get_page_content", lambda api_, name: "voller text") result = CliRunner().invoke(cli, [ "get-journal-summary", "--range", "this year", "--json"]) From dd06ce095d4b941ebfa911ad74524715acecb3f8 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:26:01 +0200 Subject: [PATCH 19/25] Reduce cli.py to the entry point Everything has moved, so what is left is the import list that makes the commands exist and the main() the console script points at: 5390 lines down to 33. The 70-odd imports the file still carried were read by nothing. The largest file in the package is now commands/journal.py at 820 lines, and logseq_cli/ holds nine command modules plus group, output and render. Wheel checked against the source tree with --no-cache-dir: 19 modules in both. Suite 833, both help baselines diff empty, audit exit 0. --- logseq_cli/cli.py | 389 +++------------------------------------------- 1 file changed, 24 insertions(+), 365 deletions(-) diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 1adfe85..e2aae99 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -1,369 +1,28 @@ -import datetime -import json -import os -import re -import sys -from collections import Counter, defaultdict -from concurrent.futures import ThreadPoolExecutor, as_completed -from importlib import import_module -from importlib.metadata import version as _pkg_version, PackageNotFoundError -from pathlib import Path - -import click -import requests - -from logseq_cli.api import LogseqAPI, InvalidPortError -from logseq_cli.config import ( - ConfigError, - config_search_paths, - get, - load_config, - require, - resolve_heading, -) -from logseq_cli.datalog import ( - edn_keyword, - edn_string, - page_name_literal, -) -from logseq_cli.helpers import ( - parse_repeater, - next_occurrence, - escape_regex, - journal_day_to_date, - format_journal_date, - parse_date_keyword, - parse_date_range, - process_blocks, - get_page_content, - find_backlinks, - parse_hierarchical_content, - parse_tree_input, - read_content_file, - require_content, - contains_hierarchical_content, - reject_unsupported_multiline, - MultilineContentError, - has_flush_newline_bullets, - has_mixed_indentation, - normalize_indentation, - find_heading, - find_or_create_heading, - PROPERTY_LINE_RE, - insert_block_tree_with_uuids, - insert_block_tree_as_siblings, - insert_block_tree_as_first_children, - insert_block_tree_at_page_top, - collect_block_ids, - invalid_block_ids, - block_id_property, - insert_formatted_content_with_uuids, - block_uuid_from_result, - require_insert, - move_block_verified, - find_blocks_by_content, - resolve_single_block, - parse_property_pairs, - apply_block_properties, - coerce_property_value, - uuid_fields, - normalize_heading, - extract_page_links, - extract_topics, - strip_title_heading, - is_journal_date, - count_blocks, -) -from logseq_cli.group import cli, resolve_version -from logseq_cli.commands import journal # noqa: F401 imported for registration -from logseq_cli.commands import edit # noqa: F401 imported for registration -from logseq_cli.commands import pages # noqa: F401 imported for registration -from logseq_cli.commands import analysis # noqa: F401 imported for registration -from logseq_cli.commands import meta # noqa: F401 imported for registration -from logseq_cli.commands import query # noqa: F401 imported for registration -from logseq_cli.commands import properties # noqa: F401 imported for registration -from logseq_cli.commands import todos # noqa: F401 imported for registration -from logseq_cli.commands import blocks # noqa: F401 imported for registration -from logseq_cli.output import fail, handle_connection_error, output -from logseq_cli.render import ( - BLOCK_REF_RE, blocks_to_markdown, blocks_with_ids, count_unresolved_refs, - extract_backlink_names, extract_section, is_properties_block, - resolve_refs_in_blocks, +"""Console entry point. Importing this module registers every command. + +Each command module decorates against the group in :mod:`logseq_cli.group`, so +a command exists only once its module has been imported. That is what the list +below does, and it is the reason a test takes the group from here rather than +from ``logseq_cli.group``: imported directly, the group holds whatever happens +to have been imported so far — the full registry when the whole suite runs, and +a partial one when a file runs alone. + +The list is explicit rather than a directory scan. ``docs/adr/0001`` records +why; the short version is that a typo in a filename should be an import error, +not a command that silently does not exist. +""" +from logseq_cli.group import cli +from logseq_cli.commands import ( # noqa: F401 imported for registration + analysis, + blocks, + edit, + journal, + meta, + pages, + properties, + query, + todos, ) - - - - - - - - -# A text replacement must skip property lines: rewriting an id:: line breaks -# every ((block-ref)) to that block, irreversibly. Regex shared via helpers. - -# find-block --with-children costs one extra read per match (the datalog pull -# carries no children), so the fan-out is capped and the remainder reported. - - - - - - - - - - - - -# --------------------------------------------------------------------------- -# 1. get-all-pages -# --------------------------------------------------------------------------- - - - - - - - - - - - - - - -# --------------------------------------------------------------------------- -# 2. get-page -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 3. get-block -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 3b. find-block -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 4. search-pages -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 5. get-backlinks -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 6. get-journal-summary -# --------------------------------------------------------------------------- - - - - -# --------------------------------------------------------------------------- -# 6b. get-journal-range -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 7. analyze-graph -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 8. find-knowledge-gaps -# --------------------------------------------------------------------------- - - - - - - -# --------------------------------------------------------------------------- -# 9. analyze-journal-patterns -# --------------------------------------------------------------------------- - - - - - - - - - - - - - - -# --------------------------------------------------------------------------- -# 10. smart-query -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 11. suggest-connections -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 12. create-page -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 13. add-journal-entry -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 14. add-journal-block -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 15. add-journal-content -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 16. add-note-content -# --------------------------------------------------------------------------- - - -# --- Block editing commands --- - - - - - - -# `remove-block` is the canonical name (Logseq's API verb is removeBlock), but -# `delete-page` sits right next to it, so `delete-block` is the single most common -# wrong guess. Register it as an alias so the guess works instead of erroring out. - - - - - - -# --------------------------------------------------------------------------- -# 20b. add-block-ref -# --------------------------------------------------------------------------- - - - - - - -# --------------------------------------------------------------------------- -# 21. get-todos -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 21b. set-todo-status -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 22. get-properties -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 23. set-property -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 24. remove-property -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 25. set-block-property -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 26. rename-page -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 27. delete-page -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 28. query-pages-by-property -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 29. copy-block -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 29b. move-block -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 30. get-page-stats -# --------------------------------------------------------------------------- - - -# --------------------------------------------------------------------------- -# 31. doctor -# --------------------------------------------------------------------------- -# Logseq's own rule, from deps/db/src/logseq/db/sqlite/util.cljs: -# (defn db-based-graph? [graph-name] -# (when graph-name (string/starts-with? graph-name db-version-prefix))) -# with the two prefixes defined in deps/common/src/logseq/common/config.cljs -# as "logseq_db_" and "logseq_local_". Taken from there rather than inferred -# from an observed response, so the rule rests on the definition both kinds are -# built from. -# -# Not used: logseq.App.checkCurrentIsDbGraph. It is exported in 2.x -# (src/main/logseq/api.cljs) and is the direct answer, but 0.10.15 does not -# carry it — it answers `MethodNotExist: check_current_is_db_graph`, checked -# against the running server. The prefix is the one signal both lines share. -# -# Also not used: logseq.App.getInfo().supportDb. It reads like the flag for -# this, and is not: the implementation returns a hardcoded `true` -# (src/main/logseq/api/app.cljs), meaning "this build can open DB graphs", -# not "this graph is one". On 0.10.15 getInfo does not exist at all. -# -# Rejected as signals, measured against a 1845-page graph: `file` is set on -# 962 pages and `format` on 22, so neither separates the two kinds — they -# only look like they would. - - - - - - - - - - - - - - - - - - - - def main(): From 169ecf954f3a2a1d2b6d2262e9464a7a18af089d Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:27:27 +0200 Subject: [PATCH 20/25] Check in CI that the built wheel contains every source module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pyproject.toml lists packages explicitly, and setuptools does not infer subpackages from an explicit list. So logseq_cli/commands could be left out of a release without anything going red: the tests run against an editable install, which links the source tree and cannot see the difference. What reaches a user is a CLI that installs, starts, and has no commands. The job compares the wheel against the source tree rather than looking for logseq_cli/commands by name, so a later subpackage is covered without touching this file. Two flags earn their place, both measured while writing this. Without --no-cache-dir, pip serves a cached build and the check inspects a wheel it did not build. With a stale logseq_cli.egg-info in the tree, setuptools reuses its SOURCES.txt and ships the subpackage even after `packages` was narrowed — so the clean step comes first. With both in place, reverting the packages line fails the check and names all ten missing modules. --- .github/workflows/tests.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 83dcb53..43021b2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,3 +19,31 @@ jobs: python-version: ${{ matrix.python-version }} - run: pip install -e ".[dev]" - run: python -m pytest -q + + wheel: + # pip install -e links the source tree, so an editable install cannot see a + # subpackage missing from `packages` in pyproject.toml. The built artefact + # can: this compares the wheel against the source tree rather than looking + # for logseq_cli/commands by name, so it keeps working when another + # subpackage is added. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # Both flags are load-bearing, and both were measured rather than + # assumed. Without --no-cache-dir pip serves a cached build of an earlier + # commit, and the check then passes on a wheel it never inspected. A + # stale logseq_cli.egg-info does the same thing: setuptools reuses its + # SOURCES.txt, so a wheel built after `packages` was narrowed still + # contains the subpackage. CI starts from a clean checkout, so the rm is + # a no-op there and a safeguard for anyone running this locally. + - run: rm -rf dist build *.egg-info + - run: pip wheel . -w dist --no-deps --no-cache-dir + - run: > + python -c "import pathlib,zipfile,glob; + src={str(p) for p in pathlib.Path('logseq_cli').rglob('*.py')}; + whl={n for n in zipfile.ZipFile(glob.glob('dist/*.whl')[0]).namelist() if n.endswith('.py')}; + missing=src-whl; + assert not missing, sorted(missing)" From 8a6ee1f4ed0f4a4e613126ff1d8382db4b7955b0 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:29:42 +0200 Subject: [PATCH 21/25] Describe the package the way it is now in README and CONTRIBUTING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both structure trees named four modules and one cli.py "with all commands". They now list group, output, render and the nine command modules — and config.py, which was missing from both before this work started. CONTRIBUTING told a reader to start in cli.py, "over five thousand lines, which is more than one file should carry and is being split". That file is 33 lines now, so the sentence points at the wrong place twice. It is replaced by what a contributor actually needs: which module a command lives in, that a new module has to be named in the import list or its commands do not exist, and which test says so by name when it is forgotten. Neither tree carries a command count or a line number. Both would drift, and the registry is the authority anyway. Re-checked against the built state rather than assumed: the ADR's three claims (empty commands/__init__.py, group.py importing nothing from commands/, cli.py naming all nine modules) hold; CONTEXT.md makes no claim about file structure; 38 command names over 37 callbacks; every --page option still has its --name alias; check-links reports 8 files, 0 broken links; suite 833. --- CONTRIBUTING.md | 34 +++++++++++++++++++++++++--------- README.md | 28 +++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23658ea..5ea2d71 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,19 +23,35 @@ Requires Python 3.10+ and a running Logseq Desktop app with the HTTP API enabled ``` logseq-cli/ ├── logseq_cli/ -│ ├── api.py # HTTP API client (thin wrapper around Logseq's API) -│ ├── datalog.py # EDN/datalog query building (value quoting, keywords) -│ ├── helpers.py # Date parsing, block processing, content formatting -│ └── cli.py # Click CLI with all commands -├── tests/ # pytest suite (no fixtures beyond tests/conftest.py) -├── examples/ # Shell scripts for common workflows -├── AGENTS.md # AI agent reference -└── pyproject.toml # Package config +│ ├── api.py # HTTP API client (thin wrapper around Logseq's API) +│ ├── config.py # Config file discovery, loading and lookup +│ ├── datalog.py # EDN/datalog query building (value quoting, keywords) +│ ├── helpers.py # Date parsing, block processing, content formatting +│ ├── group.py # The click group: global options, API client +│ ├── output.py # Results on stdout, failures on stderr, --json +│ ├── render.py # Blocks to text, and resolving block references +│ ├── commands/ # One module per group of commands +│ │ ├── pages.py # create/get/search/rename/delete a page +│ │ ├── blocks.py # read a block, find blocks +│ │ ├── edit.py # write, move, copy and remove blocks +│ │ ├── journal.py # journal entries and ranges +│ │ ├── todos.py # TODO markers and their references +│ │ ├── properties.py # page and block properties +│ │ ├── analysis.py # graph-wide analysis and suggestions +│ │ ├── query.py # smart-query +│ │ └── meta.py # init and doctor +│ └── cli.py # Entry point: imports every command module +├── tests/ # pytest suite (no fixtures beyond tests/conftest.py) +├── examples/ # Shell scripts for common workflows +├── AGENTS.md # AI agent reference +└── pyproject.toml # Package config ``` ## Making Changes -1. **Read the code first.** `cli.py` is the main file — over five thousand lines, which is more than one file should carry and is being split. Each command is a self-contained function decorated with `@cli.command()`. +1. **Read the code first.** Commands live in `logseq_cli/commands/`, one module per group — the tree above says which. Each command is a self-contained function decorated with `@cli.command()`, and it reaches the group through `from logseq_cli.group import cli`. + + A new module has to be added to the import list in `cli.py`, or its commands simply do not exist. `tests/test_command_registry.py` holds every Command Name and fails by name when one goes missing; `docs/adr/0001-explicit-command-registration.md` records why that list is written out rather than discovered by scanning. 2. **Follow existing patterns.** New commands should: - Use `@click.option("--page", "--name", ...)` for page parameters (dual alias) diff --git a/README.md b/README.md index 340e13c..e542565 100644 --- a/README.md +++ b/README.md @@ -625,13 +625,31 @@ See `examples/` directory: ``` logseq-cli/ ├── logseq_cli/ -│ ├── api.py # HTTP API client (requests.post against Logseq) -│ ├── datalog.py # EDN/datalog query building (value quoting, keywords) -│ ├── helpers.py # Date parsing, block processing, backlink search -│ └── cli.py # Click CLI with all commands -├── examples/ # Shell scripts for scripting/cronjobs +│ ├── api.py # HTTP API client (requests.post against Logseq) +│ ├── config.py # Config file discovery, loading and lookup +│ ├── datalog.py # EDN/datalog query building (value quoting, keywords) +│ ├── helpers.py # Date parsing, block processing, backlink search +│ ├── group.py # The click group: global options, API client +│ ├── output.py # Results on stdout, failures on stderr, --json +│ ├── render.py # Blocks to text, and resolving block references +│ ├── commands/ # One module per group of commands +│ │ ├── pages.py # create/get/search/rename/delete a page +│ │ ├── blocks.py # read a block, find blocks +│ │ ├── edit.py # write, move, copy and remove blocks +│ │ ├── journal.py # journal entries and ranges +│ │ ├── todos.py # TODO markers and their references +│ │ ├── properties.py # page and block properties +│ │ ├── analysis.py # graph-wide analysis and suggestions +│ │ ├── query.py # smart-query +│ │ └── meta.py # init and doctor +│ └── cli.py # Entry point: imports every command module +├── examples/ # Shell scripts for scripting/cronjobs └── pyproject.toml ``` +A command exists once its module has been imported, and `cli.py` is the file +that imports them. `docs/adr/0001-explicit-command-registration.md` says why +that list is written out rather than discovered by scanning the directory. + The CLI communicates with Logseq's built-in HTTP API (Fastify server on port 12315). The core commands are inspired by [joelhooks/logseq-mcp-tools](https://github.com/joelhooks/logseq-mcp-tools), extended with property management, page operations, and property-based queries. From 57d4111fcc6179472a80e47c11c03a38baff86aa Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:30:26 +0200 Subject: [PATCH 22/25] Record the module split and the packaging line in the CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two entries. The Changed one says what a user of the package needs: nothing about the CLI changes, and the two parts of the work that are not pure text moves are named rather than folded into "refactoring". The Fixed one is worded for what actually happened. `packages = ["logseq_cli"]` was correct for a flat package and only became wrong once there was a subpackage, which landed in the same commit as the fix — so it ships something new rather than repairing something that used to fail. No release was affected. The CI job that checks this gets no entry: it changes nothing a user can observe. --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b624e65..776a291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The build now ships `logseq_cli.commands`. `pyproject.toml` lists packages + explicitly, which was right while the package was flat and became wrong the + moment it had a subpackage: setuptools does not infer one from an explicit + list. No release was ever affected — the subpackage and the list entry landed + in the same commit — but the failure mode is worth naming, because it is + invisible to the tests. `pip install -e .` links the source tree, so an + editable install imports the subpackage regardless; what a user would have + installed is a CLI that starts and has no commands. + - `get-backlinks --with-context --limit` accepted a negative value and answered with less data and a count larger than the page held. Three linking blocks came back as two, with `... 4 more not shown`, exit code 0, in both output @@ -78,6 +87,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The commands moved out of `cli.py` into `logseq_cli/commands/`, one module per + group of commands, with the click group in `group.py`, the result and error + helpers in `output.py` and the block rendering in `render.py`. `cli.py` is now + the entry point that imports them: 5390 lines to 33. + + Nothing about using the tool changes. The console entry point is unchanged, + every command keeps its name, its options, its defaults and its help text — + the per-command `--help` output of all 38 command names was captured before + the first commit and diffed against after every one of them, and it never + differed. The commands themselves were moved as text, in one commit per + module, with the suite green at each. + + Two changes are not pure moves and are called out because they are the ones + that could behave differently. Nine helpers that are read from more than one + module lost their leading underscore, in a commit where nothing else happens. + And `handle_connection_error` now builds its wrapper with `functools.wraps` + instead of copying two attributes by hand, so a callback still names the + module it came from — without that, the scan that holds "under `--dry-run` + nothing mutating goes out" across 18 commands would have found nothing at + all and said so by passing. + - Every numeric option now states its lower bound in `--help`, including what `0` means there, because it differs and the difference was written down nowhere. `0` lifts the cap for `get-backlinks --limit` and `get-todos From 24a8cccadc76ed7dcc3ee2115b8649fae38728c9 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:12:25 +0200 Subject: [PATCH 23/25] Assert directly what the connection-error wrapper has to carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper was given functools.wraps earlier in this series because the --dry-run scan unwraps each command callback and parses the module __module__ names. Nothing asserted that property: at the commit that introduced it, taking functools.wraps back out again left all 833 tests green. It only becomes checkable one commit later, through a test about --dry-run coverage. That test does notice, but it cannot say why. It reports that create-page and sixteen others write without offering --dry-run, which reads as a defect in the commands rather than in the decorator two files away. Four assertions instead: the wrapper keeps the wrapped function's module, it exposes __wrapped__, it still carries name and docstring, and every callback in the registry unwraps to its own module rather than to logseq_cli.output. Probed by removing functools.wraps again: three of the four go red and name the cause. The fourth stays green, correctly — the hand-built wrapper copied __name__ and __doc__ all along, and that was exactly what made the gap look like it was covered. --- tests/test_connection_error_metadata.py | 71 +++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/test_connection_error_metadata.py diff --git a/tests/test_connection_error_metadata.py b/tests/test_connection_error_metadata.py new file mode 100644 index 0000000..9ea2091 --- /dev/null +++ b/tests/test_connection_error_metadata.py @@ -0,0 +1,71 @@ +"""What `handle_connection_error` has to carry across, and why it is asserted here. + +`tests/test_dry_run_coverage.py` holds the `--dry-run` guarantee across every +writing command. It finds those commands by unwrapping each callback and parsing +the module `__module__` names. That only works while the wrapper carries the +wrapped function's metadata rather than its own. + +A wrapper built by hand does not. Before this was fixed, the decorator copied +`__name__` and `__doc__` and nothing else, so every callback reported the module +that defines the decorator. While decorator and commands shared one file the two +were indistinguishable; once the commands moved to `logseq_cli/commands/`, the +scan would have looked in the wrong file and found no writing command at all — +and said so by passing. + +So the dry-run test does notice. What it cannot do is say why: it reports that +`create-page` and sixteen others write without offering `--dry-run`, which reads +as a defect in the commands rather than in the decorator. This file asserts the +property directly, so the failure names the cause. + +The commands are decorated at import time and cannot be undecorated, so the +assertions run against a function defined here: the decorator is applied to a +local function, and what comes back has to point at this module. +""" +import functools + +from logseq_cli.cli import cli +from logseq_cli.output import handle_connection_error + + +def _probe(ctx=None, as_json=False): + """A stand-in command. Only its metadata matters.""" + return "probe" + + +def test_the_wrapper_keeps_the_module_of_the_function_it_wraps(): + wrapped = handle_connection_error(_probe) + assert wrapped.__module__ == __name__, ( + "the wrapper reports the decorator's module, not the wrapped " + "function's — tests/test_dry_run_coverage.py parses the file " + "__module__ names, so it would scan logseq_cli/output.py and find " + "no command at all" + ) + + +def test_the_wrapper_exposes_the_original_function(): + wrapped = handle_connection_error(_probe) + assert getattr(wrapped, "__wrapped__", None) is _probe, ( + "__wrapped__ is missing, so unwrapping a command callback stops at " + "the wrapper and its source is the decorator's, not the command's" + ) + + +def test_the_wrapper_still_carries_name_and_docstring(): + wrapped = handle_connection_error(_probe) + assert wrapped.__name__ == "_probe" + assert wrapped.__doc__ == _probe.__doc__ + + +def test_every_command_callback_unwraps_to_its_own_module(): + """The property the dry-run scan actually relies on, across the registry.""" + wrong = {} + for name, command in cli.commands.items(): + func = command.callback + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + if func.__module__ == "logseq_cli.output": + wrong[name] = func.__module__ + assert not wrong, ( + f"{sorted(wrong)} report logseq_cli.output as their module, which is " + f"where the decorator lives, not where the command is defined" + ) From 0b600d0bba58f2be7b31aaae6869b58799e2f046 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:14:44 +0200 Subject: [PATCH 24/25] Check the import list itself, not only the registry it produces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry assertion in this file is supposed to catch a command module that cli.py stops importing. Run on its own it does. In the full suite it does not, for four of the nine: test_find_block_children.py, test_property_list_values.py, test_config_integration.py and test_backlinks_context.py import logseq_cli.commands.blocks, .properties, .analysis and .pages directly to reach a helper, and importing a command module registers its commands as a side effect. By the time the registry is asserted, another file has filled it. Measured by dropping each of the nine from the import list in turn: blocks, properties, analysis and pages left all 837 tests green. The other five failed loudly, but only because their commands were exercised elsewhere — nothing was asserting the import list. So assert it: parse cli.py and compare the names it imports against the modules on disk. That holds regardless of what any other test imported first. With it, all nine removals fail. --- tests/test_command_registry.py | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_command_registry.py b/tests/test_command_registry.py index 0bf4ebc..170da60 100644 --- a/tests/test_command_registry.py +++ b/tests/test_command_registry.py @@ -18,7 +18,20 @@ The cost is a deliberate line here for every new Command Name, next to the README row and the CHANGELOG entry that spec 007 already asks for. + +One thing the registry assertion alone cannot do, measured rather than +assumed: run on its own it goes red when a module drops out of the import +list, but in the full suite it stays green for four of the nine. Other test +files import `logseq_cli.commands.blocks`, `.properties`, `.analysis` and +`.pages` directly to reach a helper, and importing a command module registers +its commands as a side effect. By the time this file runs, the registry has +been filled by somebody else. `test_every_command_module_is_imported_by_the_entry_point` +reads the import list itself, so it does not depend on what ran before it. """ +import ast +import pathlib + +import logseq_cli.cli from logseq_cli.cli import cli # 38 Command Names for 37 Commands: `delete-block` is a second name for @@ -67,3 +80,30 @@ def test_every_command_name_is_registered(): assert set(cli.commands) == EXPECTED + + +def test_every_command_module_is_imported_by_the_entry_point(): + """The import list in cli.py names every module under commands/. + + Asserted against the source rather than against `sys.modules`: a module + another test imported for a helper is loaded either way, so a registry + that looks complete says nothing about the entry point. + """ + entry = pathlib.Path(logseq_cli.cli.__file__) + imported = { + alias.name + for node in ast.parse(entry.read_text(encoding="utf-8")).body + if isinstance(node, ast.ImportFrom) and node.module == "logseq_cli.commands" + for alias in node.names + } + on_disk = { + p.stem + for p in (entry.parent / "commands").glob("*.py") + if p.stem != "__init__" + } + assert imported == on_disk, ( + f"not imported by cli.py: {sorted(on_disk - imported)}; " + f"imported but no such module: {sorted(imported - on_disk)}. " + f"A command module that cli.py does not import registers nothing, and " + f"the CLI starts without its commands." + ) From 45156fe6d0b801a185c0a4ef3dc05841588105f7 Mon Sep 17 00:00:00 2001 From: Patrick <320190286+muellerei@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:18:38 +0200 Subject: [PATCH 25/25] Assert the package's import rules where they can be run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0001 states three rules the split rests on: a command module decorates against the group, commands/__init__.py stays empty, and group.py imports nothing from commands/. Until now only one of them failed on its own — the cycle, loudly. The other two were quiet: a command module reaching sideways for a neighbour's helper works until two of them reach for each other, and an __init__.py that imports a module makes it register whether or not cli.py names it, which turns the explicit import list into decoration and hides the very omission the registry test exists to catch. Both were checked during the split by local/specs/audit-001-map.py. That script parses the module map out of a specification that is now archived, and it runs in no test and no CI job. A rule enforced only by a tool nobody runs is a rule on paper. Four assertions against the source, plus the layering rule the ADR implies: nothing below the command layer imports from commands/. Each probed by breaking it — sideways import, non-empty __init__, group.py reaching down, render.py reaching up. All four go red; the cycle as a collection error, which is louder still. --- tests/test_package_layering.py | 95 ++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/test_package_layering.py diff --git a/tests/test_package_layering.py b/tests/test_package_layering.py new file mode 100644 index 0000000..10ed1f9 --- /dev/null +++ b/tests/test_package_layering.py @@ -0,0 +1,95 @@ +"""The import rules the package split rests on, checked against the source. + +`docs/adr/0001-explicit-command-registration.md` states three of them: a +command module decorates against the group, `commands/__init__.py` stays empty, +and `group.py` imports nothing from `commands/`, so the dependency runs one way +and there is no cycle. + +Only one of the three fails on its own. Making `group.py` import a command +module is a real cycle and the suite collapses. The other two are quiet: a +command module reaching sideways for a helper works fine until two of them +reach for each other, and a `commands/__init__.py` that imports a module makes +that module load whether or not `cli.py` names it — which turns the explicit +import list into decoration and hides a missing entry. + +Both were checked by `local/specs/audit-001-map.py` while the split was being +made. That script parses the module map out of a specification which is now +archived, and it runs nowhere on its own. These assertions do not depend on it. +""" +import ast +import pathlib + +import logseq_cli.cli + +PACKAGE = pathlib.Path(logseq_cli.cli.__file__).parent +COMMANDS = PACKAGE / "commands" + + +def _imports(path): + """Every `logseq_cli.*` module this file imports, by dotted name.""" + out = set() + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("logseq_cli"): + out.add(node.module) + for alias in node.names: + out.add(f"{node.module}.{alias.name}") + elif isinstance(node, ast.Import): + out.update(a.name for a in node.names if a.name.startswith("logseq_cli")) + return out + + +def test_no_command_module_imports_another_command_module(): + """One module is one command domain; shared code lives beside them.""" + offenders = {} + for path in sorted(COMMANDS.glob("*.py")): + if path.name == "__init__.py": + continue + sideways = { + i for i in _imports(path) + if i.startswith("logseq_cli.commands") + and not i.startswith(f"logseq_cli.commands.{path.stem}") + } + if sideways: + offenders[path.name] = sorted(sideways) + assert not offenders, ( + f"{offenders} — a command module reached sideways. Either the helper " + f"belongs in render.py/output.py, or it is shared and should move there; " + f"a command module is not a library for its neighbours." + ) + + +def test_the_commands_package_init_stays_empty(): + """An importing __init__ loads modules cli.py never named.""" + init = COMMANDS / "__init__.py" + body = init.read_text(encoding="utf-8").strip() + assert body == "", ( + f"commands/__init__.py is not empty: {body!r}. Anything imported here " + f"is registered regardless of the import list in cli.py, so a module " + f"missing from that list would stop being visible as missing." + ) + + +def test_group_does_not_import_from_commands(): + """The dependency runs one way: commands -> group, never back.""" + offenders = sorted( + i for i in _imports(PACKAGE / "group.py") if i.startswith("logseq_cli.commands") + ) + assert not offenders, ( + f"group.py imports {offenders}, which closes the cycle the explicit " + f"registration list exists to avoid" + ) + + +def test_only_the_entry_point_imports_from_commands(): + """Everything else in the package stays below the command layer.""" + offenders = {} + for path in sorted(PACKAGE.glob("*.py")): + if path.name == "cli.py": + continue + reaching = sorted(i for i in _imports(path) if i.startswith("logseq_cli.commands")) + if reaching: + offenders[path.name] = reaching + assert not offenders, ( + f"{offenders} — only cli.py imports from commands/; a module below the " + f"command layer that reaches up inverts the dependency" + )