"""Shared query helpers for the knowtation-domain CLI commands. This module provides the low-level primitives that multiple knowtation-domain commands need — note classification, vault-wide file inspection, commit-graph walking (reused from ``muse.plugins.code._query``), and project grouping — so each CLI command can stay thin. None of these functions are part of the public ``KnowtationPlugin`` API. They are internal helpers for the CLI layer and must not be imported by any core module. Phase notes ----------- Phase 1.1 provides classification and vault-wide helpers. Phase 1.4 (symbol model and parser) will add ``symbols_for_note`` once the parser for frontmatter + sections exists. Phase 3.1 (link indexer) will add ``links_for_note``. """ from __future__ import annotations import pathlib import logging from muse.core._types import Manifest logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Note file classification # --------------------------------------------------------------------------- #: File extensions that receive Markdown-level semantic parsing. NOTE_EXTENSIONS: frozenset[str] = frozenset({".md", ".markdown", ".mdx"}) #: File extensions that are tracked at raw-bytes level only. #: Kept here so CLI display commands can label files accurately. _OTHER_TRACKED_EXTENSIONS: dict[str, str] = { ".txt": "Text", ".rst": "reStructuredText", ".yaml": "YAML", ".yml": "YAML", ".json": "JSON", ".toml": "TOML", ".html": "HTML", ".htm": "HTML", ".css": "CSS", ".svg": "SVG", ".png": "Image", ".jpg": "Image", ".jpeg": "Image", ".gif": "Image", ".webp": "Image", ".pdf": "PDF", ".mp3": "Audio", ".m4a": "Audio", ".wav": "Audio", ".mp4": "Video", ".mov": "Video", ".sh": "Shell", ".zsh": "Shell", ".bash": "Shell", } def file_type_of(file_path: str) -> str: """Return a display type label for *file_path* based on its extension. Markdown files return ``"Markdown"``. Other known types return their category name. Unknown extensions return the raw suffix string (or ``"(no ext)"`` when there is none). Args: file_path: Workspace-relative POSIX path. Returns: Human-readable file type string, e.g. ``"Markdown"``, ``"YAML"``, ``"Image"``, etc. """ suffix = pathlib.PurePosixPath(file_path).suffix.lower() if suffix in NOTE_EXTENSIONS: return "Markdown" return _OTHER_TRACKED_EXTENSIONS.get(suffix, suffix or "(no ext)") def is_note(file_path: str) -> bool: """Return ``True`` when *file_path* has a Markdown extension. This is the gate for semantic (frontmatter + section) processing. Files for which this returns ``False`` are tracked at raw-bytes level only. Args: file_path: Workspace-relative POSIX path. Returns: ``True`` when the extension is in ``NOTE_EXTENSIONS``. """ suffix = pathlib.PurePosixPath(file_path).suffix.lower() return suffix in NOTE_EXTENSIONS # --------------------------------------------------------------------------- # Vault-wide note enumeration # --------------------------------------------------------------------------- def notes_in_manifest(manifest: Manifest) -> dict[str, str]: """Return the subset of *manifest* that contains only Markdown notes. Args: manifest: Full snapshot manifest mapping path → content_hash. Returns: A new dict containing only paths where :func:`is_note` is ``True``. """ return {path: oid for path, oid in manifest.items() if is_note(path)} def non_notes_in_manifest(manifest: Manifest) -> dict[str, str]: """Return the subset of *manifest* that contains non-Markdown files. Args: manifest: Full snapshot manifest mapping path → content_hash. Returns: A new dict containing only paths where :func:`is_note` is ``False``. """ return {path: oid for path, oid in manifest.items() if not is_note(path)} def vault_note_count(manifest: Manifest) -> int: """Return the number of Markdown notes in *manifest*. Args: manifest: Full snapshot manifest. Returns: Integer count of ``.md`` / ``.markdown`` / ``.mdx`` paths. """ return sum(1 for path in manifest if is_note(path)) # --------------------------------------------------------------------------- # Project grouping helpers # --------------------------------------------------------------------------- def group_by_project( manifest: Manifest, ) -> dict[str, list[str]]: """Group note paths in *manifest* by their POSIX directory prefix. This is a fast heuristic grouping used by ``muse plumbing domain-info`` (Phase 1.5) before the full frontmatter parser (Phase 1.3) is available. Notes at the vault root are placed under the ``"__root__"`` key. Phase 1.5 will replace this with a frontmatter-aware grouper once ``FrontmatterSchema`` is wired. Args: manifest: Full snapshot manifest mapping path → content_hash. Returns: ``{project_name: [note_paths]}`` dict, sorted by project name. """ groups: dict[str, list[str]] = {} for path in sorted(manifest): if not is_note(path): continue parts = path.split("/") project = parts[0] if len(parts) > 1 else "__root__" groups.setdefault(project, []).append(path) return dict(sorted(groups.items())) # --------------------------------------------------------------------------- # Mist ID validation # --------------------------------------------------------------------------- #: Mist attachment IDs are 12-char base58 SHA-256 prefixes. #: Valid character set: Bitcoin base58 (no 0, O, I, l). _BASE58_CHARS: frozenset[str] = frozenset( "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" ) _MIST_ID_LENGTH = 12 def is_valid_mist_id(mist_id: str) -> bool: """Return ``True`` when *mist_id* looks like a valid mist attachment ID. A valid mist ID is exactly 12 characters from the Bitcoin base58 alphabet (no 0, O, I, or lowercase l). This mirrors the mist domain manifest which defines ``artifacts`` with ``id = first 12 chars of base58 SHA-256``. Args: mist_id: The candidate mist attachment ID string. Returns: ``True`` when the ID is exactly 12 valid base58 characters. """ if len(mist_id) != _MIST_ID_LENGTH: return False return all(c in _BASE58_CHARS for c in mist_id)