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)" 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 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. 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. diff --git a/logseq_cli/cli.py b/logseq_cli/cli.py index 13e13de..e2aae99 100644 --- a/logseq_cli/cli.py +++ b/logseq_cli/cli.py @@ -1,5387 +1,30 @@ -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, DatalogQueryError, InvalidPortError -from logseq_cli.config import ( - ConfigError, - config_search_paths, - get, - load_config, - require, - resolve_heading, -) -from logseq_cli.datalog import ( - InvalidKeywordError, - 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, +"""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, ) -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. - """ - 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", - ) - wrapper.__name__ = func.__name__ - wrapper.__doc__ = func.__doc__ - 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]": - """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) - - -_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. - -# 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 - - -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: - """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 _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 - - -# --------------------------------------------------------------------------- -# 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_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: - """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"]) - - -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 [] - - -# --------------------------------------------------------------------------- -# 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) - - -# --------------------------------------------------------------------------- -# 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) - - -# --------------------------------------------------------------------------- -# 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() - - -# --------------------------------------------------------------------------- -# 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}") - - -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 - - -# --------------------------------------------------------------------------- -# 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() - - -# --------------------------------------------------------------------------- -# 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])}") - - -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 _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): - """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) - - -# --------------------------------------------------------------------------- -# 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, - ) - - -# --------------------------------------------------------------------------- -# 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 ''}") - - -# --------------------------------------------------------------------------- -# 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})") - - -# --------------------------------------------------------------------------- -# 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 --- - - -@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}") - - -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 ''}") - - -# --------------------------------------------------------------------------- -# 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}'") - - -# --------------------------------------------------------------------------- -# 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})") - - -# --------------------------------------------------------------------------- -# 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']})") - - -# --------------------------------------------------------------------------- -# 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}") - - -# --------------------------------------------------------------------------- -# 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)}") - - -# --------------------------------------------------------------------------- -# 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. -_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(): cli() 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/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/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/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") 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/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/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/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/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) 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 ''}") 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/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) 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 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_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..0980500 100644 --- a/tests/test_analyze_journal_patterns.py +++ b/tests/test_analyze_journal_patterns.py @@ -30,8 +30,8 @@ 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.cli.get_page_content", return_value=text): + patch("logseq_cli.group.LogseqAPI", return_value=api), \ + 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_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: diff --git a/tests/test_backlinks_context.py b/tests/test_backlinks_context.py index 38484b8..2d80c7f 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. @@ -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 @@ -34,7 +35,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 +128,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_command_registry.py b/tests/test_command_registry.py new file mode 100644 index 0000000..170da60 --- /dev/null +++ b/tests/test_command_registry.py @@ -0,0 +1,109 @@ +"""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. + +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 +# `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 + + +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." + ) diff --git a/tests/test_config_integration.py b/tests/test_config_integration.py index 2c38dee..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 @@ -91,7 +92,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 +104,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 +114,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 +124,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 +134,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 +146,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 +155,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 +168,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 +180,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 +193,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 +207,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 +227,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 +238,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 +250,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 +271,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 +290,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 +305,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 +318,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 +329,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 +346,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 +356,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 +366,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 +377,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 +388,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_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" + ) 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..612ab06 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 @@ -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 diff --git a/tests/test_dry_run_coverage.py b/tests/test_dry_run_coverage.py index 05be834..bc96637 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 @@ -35,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 @@ -482,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): @@ -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 = [] @@ -615,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..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): @@ -43,7 +44,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 +59,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 +69,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 +77,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 +95,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 +105,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 +113,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..40bc97e 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", @@ -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"]) 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_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" + ) diff --git a/tests/test_property_list_values.py b/tests/test_property_list_values.py index 90a4a18..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): @@ -26,7 +27,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 +53,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..4f4e757 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) @@ -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") @@ -283,8 +283,8 @@ 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.cli.get_page_content", return_value=text): + patch("logseq_cli.group.LogseqAPI", return_value=api), \ + 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"]) 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 ad011ae..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" @@ -22,7 +23,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 +33,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()) 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"])