Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .mise/tasks/read
Original file line number Diff line number Diff line change
@@ -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 "<note>" help="Note path or slug" required=#true
#USAGE flag "--dir <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())
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <file>` 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 <file>` 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.

Expand All @@ -101,6 +118,7 @@ notes unlock
- Prefer `notes commit` for note-only commits. Use `notes stage <path>` 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
Expand Down
216 changes: 216 additions & 0 deletions test/read.bats
Original file line number Diff line number Diff line change
@@ -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
}