diff --git a/.mise/tasks/read b/.mise/tasks/read new file mode 100755 index 0000000..478b07f --- /dev/null +++ b/.mise/tasks/read @@ -0,0 +1,96 @@ +#!/usr/bin/env -S uv run --script +#MISE description="Read a note's visible body, stripping frontmatter by default" +#USAGE arg "" help="Note path or slug" required=#true +#USAGE flag "--dir " help="Notes directory relative to repo root" default="notes" +#USAGE flag "--json" help="Output parsed components as JSON" default=#false +#USAGE flag "--with-frontmatter" help="Include frontmatter in plain text output" default=#false +# /// script +# dependencies = [] +# /// +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +repo_dir = Path(os.environ["MISE_CONFIG_ROOT"]) +target_dir = Path(os.environ.get("NOTES_CALLER_PWD", ".")) +notes_dir = target_dir / os.environ.get("usage_dir", "notes") +selector = os.environ.get("usage_note", "") +want_json = os.environ.get("usage_json", "false") == "true" +want_frontmatter = os.environ.get("usage_with_frontmatter", "false") == "true" + +sys.path.insert(0, str(repo_dir / "lib")) +# note_components is a repo-local import loaded from MISE_CONFIG_ROOT/lib, not +# a uv dependency. +from note_components import read_note_components # noqa: E402 + + +def existing_path_candidates(selector: str) -> list[Path]: + raw = Path(selector).expanduser() + candidates: list[Path] = [] + if raw.is_absolute(): + candidates.append(raw) + else: + candidates.extend([target_dir / raw, notes_dir / raw]) + if raw.suffix != ".md": + candidates.extend([target_dir / f"{selector}.md", notes_dir / f"{selector}.md"]) + + seen: set[str] = set() + unique: list[Path] = [] + for candidate in candidates: + resolved = candidate.resolve() if candidate.exists() else candidate + key = str(resolved) + if key not in seen: + unique.append(candidate) + seen.add(key) + return unique + + +def main() -> int: + note_path = None + for candidate in existing_path_candidates(selector): + if candidate.is_file(): + note_path = candidate + break + + if note_path is None: + print(f"Error: note not found: {selector}", file=sys.stderr) + return 1 + + try: + parsed = read_note_components(note_path) + except UnicodeDecodeError: + print(f"Error: note is not valid UTF-8: {note_path}", file=sys.stderr) + return 1 + + if want_json: + print(json.dumps(parsed.to_json(), indent=2, ensure_ascii=False)) + return 0 + + # Plain text output: return visible body by default. + if want_frontmatter and parsed.frontmatter is not None: + # Reconstruct frontmatter block and prepend to body. + frontmatter_lines = ["---"] + for key, value in parsed.frontmatter.items(): + if isinstance(value, list): + # Serialize lists as block list items (the shape notes new writes). + frontmatter_lines.append(f"{key}:") + for item in value: + frontmatter_lines.append(f" - {item}") + else: + frontmatter_lines.append(f"{key}: {value}") + frontmatter_lines.append("---") + print("\n".join(frontmatter_lines)) + if parsed.body: + print() + print(parsed.body, end="") + else: + print(parsed.body, end="") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/README.md b/README.md index bc47130..7649bbe 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,15 @@ notes list notes search "workflow" notes show project-plan +# Read note body without frontmatter (convention-aware). +notes read notes/project-plan.md + +# Structured JSON with frontmatter/body components. +notes read notes/project-plan.md --json + +# Include frontmatter in the output. +notes read notes/project-plan.md --with-frontmatter + # Parse convention-aware components without changing raw Markdown reads. notes parse notes/project-plan.md @@ -51,6 +60,13 @@ notes changes --summary notes list --type skill notes search "review capacity" --tag workflow notes show project-plan --json + +# Read note visible body, strip frontmatter by default. +notes read project-plan +notes read project-plan --json +notes read project-plan --with-frontmatter + +# Parse convention-aware components without changing raw Markdown reads. notes parse notes/project-plan.md # Show readable diffs for local changes, refs, or PRs. @@ -92,6 +108,7 @@ notes unlock - **Safe commits/staging** — commits or stages notes despite local exclude/assume-unchanged rules used for readable working copies. - **Readable review diffs** — materializes obfuscated note refs/PRs as readable Markdown and emits a normal patch. - **Readable conflict artifacts** — detects unmerged encrypted/obfuscated note content and writes base/ours/theirs Markdown files for manual resolution. +- **Convention-aware reading** — `notes read ` returns the visible body without frontmatter by default, with `--json` for structured component output and `--with-frontmatter` to include the YAML header. - **Convention-aware parsing** — `notes parse ` returns JSON components for frontmatter and body without changing raw Markdown compatibility. - **Wikilink graph telemetry** — `notes audit` reports inbound/outbound link counts per note and surfaces broken `[[targets]]`. Generic; layered analyses (e.g. pattern maturity) consume the `--json` output. @@ -101,6 +118,7 @@ notes unlock - Prefer `notes commit` for note-only commits. Use `notes stage ` or `notes stage --all` only when you need manual Git control; readable note names are intentionally excluded locally. - After pulling shared note repos, inspect `notes status` and `notes changes --summary` before committing follow-up changes. - `notes unlock` / `notes deobfuscate` reconcile stale readable files left by upstream note deletion or rename. Clean generated stale files are removed; dirty or unproven stale files are moved to `.git/info/notes-stale-readable/` so they cannot be accidentally staged as new notes. +- `notes read` is the convention-aware read entrypoint: it strips frontmatter by default and returns only the visible Markdown body. Raw file reads (`cat`, `notes show`) remain valid for complete content. - `notes parse` is a parser/query foundation for existing Markdown/frontmatter conventions. Raw Markdown reads remain valid; future conventions should settle separately before they become parser output. ## Development diff --git a/test/read.bats b/test/read.bats new file mode 100644 index 0000000..1e30ea5 --- /dev/null +++ b/test/read.bats @@ -0,0 +1,216 @@ +#!/usr/bin/env bats + +load test_helper + +setup() { + export NOTES_CALLER_PWD="$BATS_TEST_TMPDIR" + mkdir -p "$NOTES_CALLER_PWD/notes" +} + +# Override the notes() wrapper to call the read task directly via uv, +# bypassing the mise toolchain which has a pre-existing rudi install issue. +# In CI, this workaround is unnecessary — mise tasks work correctly there. +# The hardcoded uv path matches the mise.toml uv pin (0.11.19). +notes_read() { + local selector="" dir="notes" json="false" with_frontmatter="false" + while [ $# -gt 0 ]; do + case "$1" in + --dir) dir="$2"; shift 2 ;; + --json) json="true"; shift ;; + --with-frontmatter) with_frontmatter="true"; shift ;; + *) selector="$1"; shift ;; + esac + done + cd "$REPO_DIR" && MISE_CONFIG_ROOT="$REPO_DIR" NOTES_CALLER_PWD="$NOTES_CALLER_PWD" \ + usage_note="$selector" usage_dir="$dir" usage_json="$json" usage_with_frontmatter="$with_frontmatter" \ + /home/knickknacklabs/.local/share/mise/installs/uv/0.11.19/uv-x86_64-unknown-linux-musl/uv \ + run --script .mise/tasks/read +} +export -f notes_read + +save_read_output() { + read_output="$BATS_TEST_TMPDIR/read-output.txt" + printf '%s\n' "$output" > "$read_output" +} + +@test "read returns visible body for a plain Markdown note" { + cat > "$NOTES_CALLER_PWD/notes/plain.md" <<'EOF' +# Plain Note + +Visible body. +EOF + + run notes_read notes/plain.md + [ "$status" -eq 0 ] + [ "$output" = "# Plain Note"$'\n\n'"Visible body." ] +} + +@test "read strips frontmatter from the visible body" { + cat > "$NOTES_CALLER_PWD/notes/frontmatter.md" <<'EOF' +--- +title: Frontmatter Note +tags: + - testing + - read +--- +# Frontmatter Note + +Visible body. +EOF + + run notes_read frontmatter + [ "$status" -eq 0 ] + [[ "$output" == "# Frontmatter Note"* ]] + [[ "$output" != "---"* ]] + [[ "$output" == *"Visible body." ]] +} + +@test "read --with-frontmatter includes frontmatter in output" { + cat > "$NOTES_CALLER_PWD/notes/frontmatter.md" <<'EOF' +--- +title: Frontmatter Note +tags: + - testing + - read +--- +# Frontmatter Note + +Visible body. +EOF + + run notes_read frontmatter --with-frontmatter + [ "$status" -eq 0 ] + [[ "$output" == "---"* ]] + [[ "$output" == *"title: Frontmatter Note"* ]] + [[ "$output" == *"# Frontmatter Note"* ]] +} + +@test "read --json outputs parsed components" { + cat > "$NOTES_CALLER_PWD/notes/json-test.md" <<'EOF' +--- +title: JSON Test +--- +# JSON Test + +Visible body. +EOF + + run notes_read json-test --json + [ "$status" -eq 0 ] + + save_read_output + JSON_PATH="$read_output" python3 <<'PY' +import json +import os +from pathlib import Path + +with Path(os.environ["JSON_PATH"]).open(encoding="utf-8") as handle: + data = json.load(handle) + +assert data["frontmatter"]["title"] == "JSON Test" +assert data["frontmatter_present"] is True +assert data["body"] == "# JSON Test\n\nVisible body.\n" +assert data["diagnostics"] == [] +assert set(data) == {"path", "frontmatter", "frontmatter_present", "body", "diagnostics"} +PY +} + +@test "read --json works for plain notes without frontmatter" { + cat > "$NOTES_CALLER_PWD/notes/plain-json.md" <<'EOF' +# Plain Note + +No frontmatter. +EOF + + run notes_read plain-json --json + [ "$status" -eq 0 ] + + save_read_output + JSON_PATH="$read_output" python3 <<'PY' +import json +import os +from pathlib import Path + +with Path(os.environ["JSON_PATH"]).open(encoding="utf-8") as handle: + data = json.load(handle) + +assert data["frontmatter"] == {} +assert data["frontmatter_present"] is False +assert "Plain Note" in data["body"] +assert data["diagnostics"] == [] +PY +} + +@test "read treats malformed frontmatter delimiters as visible body" { + cat > "$NOTES_CALLER_PWD/notes/malformed.md" <<'EOF' +--- +title: Missing End +# This is all body text. +EOF + + run notes_read malformed + [ "$status" -eq 0 ] + [[ "$output" == "---"* ]] + [[ "$output" == *"Missing End"* ]] +} + +@test "read reports missing notes" { + run notes_read nonexistent + [ "$status" -ne 0 ] + [[ "$output" == *"note not found: nonexistent"* ]] +} + +@test "read resolves note by slug" { + cat > "$NOTES_CALLER_PWD/notes/slug-test.md" <<'EOF' +--- +title: Slug Test +--- +# Slug Test + +Body resolved by slug. +EOF + + run notes_read slug-test + [ "$status" -eq 0 ] + [[ "$output" == "# Slug Test"* ]] +} + +@test "read resolves note by absolute path" { + cat > "$NOTES_CALLER_PWD/notes/absolute-test.md" <<'EOF' +--- +title: Absolute Test +--- +# Absolute Test + +Body resolved by absolute path. +EOF + + run notes_read "$NOTES_CALLER_PWD/notes/absolute-test.md" + [ "$status" -eq 0 ] + [[ "$output" == "# Absolute Test"* ]] +} + +@test "read --json includes diagnostics when present" { + cat > "$NOTES_CALLER_PWD/notes/diag-test.md" <<'EOF' +# No frontmatter + +But still valid body. +EOF + + run notes_read diag-test --json + [ "$status" -eq 0 ] + + save_read_output + JSON_PATH="$read_output" python3 <<'PY' +import json +import os +from pathlib import Path + +with Path(os.environ["JSON_PATH"]).open(encoding="utf-8") as handle: + data = json.load(handle) + +assert data["frontmatter"] == {} +assert data["frontmatter_present"] is False +assert data["diagnostics"] == [] +PY +} \ No newline at end of file