Compare commits
49 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d9af0fb0f | ||
|
|
1ca2662a4b | ||
|
|
eb2758c154 | ||
|
|
7d308a65e0 | ||
|
|
74850b5b69 | ||
|
|
e486e863ce | ||
|
|
132618ebd3 | ||
|
|
d29d5a03ae | ||
|
|
084c58fed8 | ||
|
|
63d85bf67f | ||
|
|
17d67013dc | ||
|
|
458898f632 | ||
|
|
89a605262c | ||
|
|
e097c5b717 | ||
|
|
c0df2124ff | ||
|
|
7f11f3e3ea | ||
|
|
f02bb68021 | ||
|
|
80914e1e3c | ||
|
|
94d07f0d47 | ||
|
|
ef0709a795 | ||
|
|
eac3b123c4 | ||
|
|
25d9da5440 | ||
|
|
e366c84b65 | ||
|
|
7daf6898c1 | ||
|
|
377a3f8d0e | ||
|
|
901326d06f | ||
|
|
d378e488f1 | ||
|
|
e581ad3691 | ||
|
|
09f7498e86 | ||
|
|
7443b9a468 | ||
|
|
12f8fad0f0 | ||
|
|
c90b1d6694 | ||
|
|
806b552d47 | ||
|
|
2cd192fae6 | ||
|
|
46106da47a | ||
|
|
0051559b8e | ||
|
|
3f19528cb6 | ||
|
|
24e5b537d6 | ||
|
|
f8f6e3d003 | ||
|
|
ba9b5c7c31 | ||
|
|
1930e7132b | ||
|
|
8a51ff5f3c | ||
|
|
56cb878d0b | ||
|
|
52a673e16e | ||
|
|
fe44153ac6 | ||
|
|
088e0bca8e | ||
|
|
45249bade4 | ||
|
|
917773bc4c | ||
|
|
d39d691ccd |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.56
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
HOTSPOT_THRESHOLD=9.45
|
||||
AVERAGE_THRESHOLD=9.29
|
||||
|
||||
@@ -18,44 +18,56 @@ pnpm test --run --silent
|
||||
echo "✅ Pre-commit passed"
|
||||
|
||||
# ── CodeScene Code Health gate ────────────────────────────────────────────
|
||||
# Blocks commit if Hotspot < 9.5 OR Average < 9.0.
|
||||
# Blocks commit if scores drop below thresholds in .codescene-thresholds.
|
||||
# Thresholds are a ratchet — only go up, auto-updated after each successful push.
|
||||
# Note: remote scores lag behind local changes (update after push + re-analysis).
|
||||
# This catches regressions from previous pushes and notifies Claude Code immediately.
|
||||
# If `pre_commit_code_health_safeguard` fails: extract hooks, split components,
|
||||
# reduce complexity. Never use eslint-disable, #[allow(...)], or `as any`.
|
||||
# If check fails: extract hooks, split components, reduce complexity.
|
||||
# Never use eslint-disable, #[allow(...)], or `as any`.
|
||||
echo "🏥 CodeScene code health check..."
|
||||
THRESHOLDS_FILE=".codescene-thresholds"
|
||||
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
|
||||
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping (CI will enforce)"
|
||||
elif [ ! -f "$THRESHOLDS_FILE" ]; then
|
||||
echo " ⚠️ $THRESHOLDS_FILE not found — skipping (CI will enforce)"
|
||||
else
|
||||
API_RESPONSE=$(curl -sf \
|
||||
-H "Authorization: Bearer $CODESCENE_PAT" \
|
||||
-H "Accept: application/json" \
|
||||
"https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID" 2>/dev/null || echo "{}")
|
||||
HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])" 2>/dev/null || echo "")
|
||||
AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['code_health']['now'])" 2>/dev/null || echo "")
|
||||
if [ -z "$HOTSPOT_SCORE" ] || [ -z "$AVERAGE_SCORE" ]; then
|
||||
echo " ⚠️ Could not fetch CodeScene scores — skipping (CI will enforce)"
|
||||
# Read ratchet thresholds
|
||||
HOTSPOT_THRESHOLD=$(grep '^HOTSPOT_THRESHOLD=' "$THRESHOLDS_FILE" | cut -d= -f2)
|
||||
AVERAGE_THRESHOLD=$(grep '^AVERAGE_THRESHOLD=' "$THRESHOLDS_FILE" | cut -d= -f2)
|
||||
if [ -z "$HOTSPOT_THRESHOLD" ] || [ -z "$AVERAGE_THRESHOLD" ]; then
|
||||
echo " ⚠️ Could not parse thresholds from $THRESHOLDS_FILE — skipping"
|
||||
else
|
||||
echo " Hotspot Code Health: $HOTSPOT_SCORE (threshold: 9.5)"
|
||||
echo " Average Code Health: $AVERAGE_SCORE (threshold: 9.33)"
|
||||
python3 -c "
|
||||
API_RESPONSE=$(curl -sf \
|
||||
-H "Authorization: Bearer $CODESCENE_PAT" \
|
||||
-H "Accept: application/json" \
|
||||
"https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID" 2>/dev/null || echo "{}")
|
||||
HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])" 2>/dev/null || echo "")
|
||||
AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['code_health']['now'])" 2>/dev/null || echo "")
|
||||
if [ -z "$HOTSPOT_SCORE" ] || [ -z "$AVERAGE_SCORE" ]; then
|
||||
echo " ⚠️ Could not fetch CodeScene scores — skipping (CI will enforce)"
|
||||
else
|
||||
echo " Hotspot Code Health: $HOTSPOT_SCORE (threshold: $HOTSPOT_THRESHOLD)"
|
||||
echo " Average Code Health: $AVERAGE_SCORE (threshold: $AVERAGE_THRESHOLD)"
|
||||
python3 -c "
|
||||
import sys
|
||||
hotspot = float('$HOTSPOT_SCORE')
|
||||
average = float('$AVERAGE_SCORE')
|
||||
h_thresh = float('$HOTSPOT_THRESHOLD')
|
||||
a_thresh = float('$AVERAGE_THRESHOLD')
|
||||
failed = False
|
||||
if hotspot < 9.5:
|
||||
print(f'FAIL: Hotspot Code Health {hotspot:.2f} < 9.5 — extract hooks, split components, reduce complexity')
|
||||
if hotspot < h_thresh:
|
||||
print(f'FAIL: Hotspot Code Health {hotspot:.2f} < {h_thresh} — extract hooks, split components, reduce complexity')
|
||||
failed = True
|
||||
else:
|
||||
print(f'OK: Hotspot {hotspot:.2f} >= 9.5')
|
||||
if average < 9.33:
|
||||
print(f'FAIL: Average Code Health {average:.2f} < 9.33 — recent changes introduced regressions in non-hotspot files')
|
||||
print(f'OK: Hotspot {hotspot:.2f} >= {h_thresh}')
|
||||
if average < a_thresh:
|
||||
print(f'FAIL: Average Code Health {average:.2f} < {a_thresh} — recent changes introduced regressions in non-hotspot files')
|
||||
print(' Review files changed in this task. Never use eslint-disable, #[allow(...)], or as any to bypass.')
|
||||
failed = True
|
||||
else:
|
||||
print(f'OK: Average {average:.2f} >= 9.33')
|
||||
print(f'OK: Average {average:.2f} >= {a_thresh}')
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
" || exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
51
CLAUDE.md
51
CLAUDE.md
@@ -10,6 +10,8 @@
|
||||
|
||||
Run `/laputa-next-task` — fetches next task (To Rework first, then Open), moves to In Progress, returns full description.
|
||||
|
||||
**Before writing a single line of code:** run `mcp__codescene__code_health_score` to check the current codebase health against `.codescene-thresholds`. If the score is already below the threshold, **stop and refactor first** — find the worst files with the MCP, improve them, commit, then start the task. Never start feature work on a codebase that is already below the gate.
|
||||
|
||||
- Read task description and all comments fully
|
||||
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
|
||||
- Check `docs/adr/` for relevant architecture decisions before structural choices
|
||||
@@ -45,7 +47,15 @@ bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
|
||||
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
||||
|
||||
After both phases pass, run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
|
||||
After both phases pass, add a **completion comment** to the Todoist task before running `/laputa-done`. The comment must include:
|
||||
- What was implemented (1–2 lines)
|
||||
- QA: what was tested and how (Playwright / native screenshot / osascript)
|
||||
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
|
||||
- ADRs: any new/updated ADRs (or "none")
|
||||
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
|
||||
- Code health: final Hotspot and Average scores after push
|
||||
|
||||
Then run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
|
||||
|
||||
---
|
||||
|
||||
@@ -55,43 +65,35 @@ After both phases pass, run `/laputa-done <task_id>` → moves to In Review, not
|
||||
|
||||
- Push directly to `main` — no PRs, no branches
|
||||
- Pre-push hook runs full check suite (build + tests + Playwright + CodeScene)
|
||||
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. Never use `--no-verify`.
|
||||
- **⛔ NEVER use --no-verify**
|
||||
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. **⛔ NEVER use --no-verify**
|
||||
|
||||
### TDD (mandatory)
|
||||
|
||||
Red → Green → Refactor → Commit. One cycle per commit. For bugs: write failing regression test first, then fix. Exception: pure CSS/layout changes.
|
||||
|
||||
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests before adding new ones. Prefer E2E over unit tests for user flows.
|
||||
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows.
|
||||
|
||||
### Code health (mandatory)
|
||||
|
||||
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up, auto-updated after each successful push. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
|
||||
|
||||
**Before every commit:**
|
||||
- `mcp__codescene__code_health_review` — check file before touching
|
||||
- `mcp__codescene__code_health_score` — verify score is higher after changes
|
||||
**⛔ NEVER edit `.codescene-thresholds` to lower the values.** If the gate blocks you, improve the code — do not lower the bar.
|
||||
|
||||
**Boy Scout Rule:** every file you touch must leave with a higher score. If Average drops below 9.0, fix regressions before pushing.
|
||||
**Before every commit:** run `mcp__codescene__code_health_review` on files you touched and verify score is higher. **Boy Scout Rule:** every file you touch must leave with a higher score.
|
||||
|
||||
**If CodeScene gate blocks your push:** use `mcp__codescene__code_health_score` to find the worst file, refactor it, commit, push again. Do NOT stop or wait for laputa-refactor — that is a background loop, not a substitute for fixing your own regressions.
|
||||
|
||||
### Check suite (runs on every push)
|
||||
```bash
|
||||
pnpm lint && npx tsc --noEmit
|
||||
pnpm test
|
||||
pnpm test:coverage # frontend ≥70%
|
||||
cargo test
|
||||
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
|
||||
pnpm lint && npx tsc --noEmit && pnpm test && pnpm test:coverage # frontend ≥70%
|
||||
cargo test && cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
|
||||
```
|
||||
|
||||
### Architecture Decision Records (ADRs)
|
||||
### ADRs & docs
|
||||
|
||||
ADRs live in `docs/adr/`. Check before structural choices. Create the ADR **in the same commit as the code**. Never edit existing ADRs — create a new one that supersedes. Use `/create-adr` for template.
|
||||
ADRs live in `docs/adr/`. Create in the same commit as the code. Never edit existing — create a new one that supersedes. Use `/create-adr`. **When:** new dependency, storage strategy, platform target, core abstraction, cross-cutting pattern. **Not for:** bug fixes, styling, refactors.
|
||||
|
||||
**When to create one:** new dependency, storage strategy, platform target, core abstraction change, cross-cutting pattern. **Not for:** bug fixes, UI styling, refactors, test additions.
|
||||
|
||||
### Keep docs/ in sync
|
||||
|
||||
After Tauri command, new component/hook, data model change, or new integration: update `docs/ARCHITECTURE.md`, `docs/ABSTRACTIONS.md`, and/or `docs/GETTING-STARTED.md` in the same commit.
|
||||
After any Tauri command, new component/hook, data model change, or new integration: update `docs/ARCHITECTURE.md`, `docs/ABSTRACTIONS.md`, and/or `docs/GETTING-STARTED.md` in the same commit.
|
||||
|
||||
---
|
||||
|
||||
@@ -103,9 +105,7 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
|
||||
|
||||
### UI design
|
||||
|
||||
1. Open `ui-design.pen` first — study existing frames for visual language
|
||||
2. Design in light mode. Create `design/<slug>.pen` for the task
|
||||
3. On completion: merge frames into `ui-design.pen`, delete `design/<slug>.pen`
|
||||
Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task; on completion merge into `ui-design.pen` and delete it.
|
||||
|
||||
### UI components — mandatory rules
|
||||
|
||||
@@ -124,9 +124,7 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
|
||||
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
|
||||
| Dialog/modal | `Dialog` from shadcn/ui |
|
||||
|
||||
**When in doubt:** search `src/components/` for an existing component that does what you need before building a new one. The app already has many reusable pieces — use them.
|
||||
|
||||
**Visual language:** all new UI must feel native to Laputa. Take inspiration from `ui-design.pen` and existing components. If something looks like a browser default, it's wrong.
|
||||
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Laputa — if it looks like a browser default, it's wrong.
|
||||
|
||||
---
|
||||
|
||||
@@ -138,7 +136,6 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
|
||||
- Tauri menu accelerators: `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")`
|
||||
- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus
|
||||
- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native testing
|
||||
|
||||
### QA scripts
|
||||
|
||||
```bash
|
||||
|
||||
10
adr.md
10
adr.md
@@ -1,10 +0,0 @@
|
||||
---
|
||||
type: Type
|
||||
"sidebar label": ADRs
|
||||
icon: article
|
||||
color: red
|
||||
title: Adr
|
||||
---
|
||||
|
||||
# ADR
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
title: Custom views as .yml files with client-side filter engine
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 343
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 342
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 213
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
7
demo-vault-v2/responsive-rename-test-renamed.md
Normal file
7
demo-vault-v2/responsive-rename-test-renamed.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
|
||||
|
||||
Appended by raw editor test
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 215
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
title: Untitled note 345
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-1775473680.md
Normal file
5
demo-vault-v2/untitled-note-1775473680.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
My Custom H1 Heading
|
||||
7
demo-vault-v2/untitled-note-1775473969.md
Normal file
7
demo-vault-v2/untitled-note-1775473969.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
[[2024-03|March 2024]]
|
||||
|
||||
[[2024-03|March 2024]]
|
||||
4
demo-vault-v2/untitled-note-1775496473.md
Normal file
4
demo-vault-v2/untitled-note-1775496473.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-1775496825.md
Normal file
5
demo-vault-v2/untitled-note-1775496825.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
My Custom H1 Heading
|
||||
7
demo-vault-v2/untitled-note-1775497099.md
Normal file
7
demo-vault-v2/untitled-note-1775497099.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
[[2024-03|March 2024]]
|
||||
|
||||
[[2024-03|March 2024]]
|
||||
4
demo-vault-v2/untitled-note-1775541952.md
Normal file
4
demo-vault-v2/untitled-note-1775541952.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542285.md
Normal file
4
demo-vault-v2/untitled-note-1775542285.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542291.md
Normal file
4
demo-vault-v2/untitled-note-1775542291.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542296.md
Normal file
4
demo-vault-v2/untitled-note-1775542296.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542431.md
Normal file
4
demo-vault-v2/untitled-note-1775542431.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542439.md
Normal file
4
demo-vault-v2/untitled-note-1775542439.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542446.md
Normal file
4
demo-vault-v2/untitled-note-1775542446.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542459.md
Normal file
4
demo-vault-v2/untitled-note-1775542459.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542467.md
Normal file
4
demo-vault-v2/untitled-note-1775542467.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542474.md
Normal file
4
demo-vault-v2/untitled-note-1775542474.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542676.md
Normal file
4
demo-vault-v2/untitled-note-1775542676.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542684.md
Normal file
4
demo-vault-v2/untitled-note-1775542684.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542692.md
Normal file
4
demo-vault-v2/untitled-note-1775542692.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542700.md
Normal file
4
demo-vault-v2/untitled-note-1775542700.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542708.md
Normal file
4
demo-vault-v2/untitled-note-1775542708.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
7
demo-vault-v2/untitled-note-1775542716-renamed.md
Normal file
7
demo-vault-v2/untitled-note-1775542716-renamed.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
|
||||
Appended by raw editor test
|
||||
4
demo-vault-v2/untitled-note-1775542923.md
Normal file
4
demo-vault-v2/untitled-note-1775542923.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542931.md
Normal file
4
demo-vault-v2/untitled-note-1775542931.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542938.md
Normal file
4
demo-vault-v2/untitled-note-1775542938.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542951.md
Normal file
4
demo-vault-v2/untitled-note-1775542951.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542959.md
Normal file
4
demo-vault-v2/untitled-note-1775542959.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
4
demo-vault-v2/untitled-note-1775542967.md
Normal file
4
demo-vault-v2/untitled-note-1775542967.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
7
demo-vault-v2/untitled-note-1775542981.md
Normal file
7
demo-vault-v2/untitled-note-1775542981.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
[[2024-03|March 2024]]
|
||||
|
||||
[[2024-03|March 2024]]
|
||||
6
demo-vault-v2/untitled-note-342.md
Normal file
6
demo-vault-v2/untitled-note-342.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Untitled note 342
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
My Custom H1 Heading
|
||||
8
demo-vault-v2/untitled-note-343.md
Normal file
8
demo-vault-v2/untitled-note-343.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Untitled note 343
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
[[2024-03|March 2024]]
|
||||
|
||||
[[2024-03|March 2024]]
|
||||
5
demo-vault-v2/untitled-note-346.md
Normal file
5
demo-vault-v2/untitled-note-346.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 346
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-347.md
Normal file
5
demo-vault-v2/untitled-note-347.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 347
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-348.md
Normal file
5
demo-vault-v2/untitled-note-348.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 348
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
6
demo-vault-v2/untitled-note-349.md
Normal file
6
demo-vault-v2/untitled-note-349.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Untitled note 349
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
My Custom H1 Heading
|
||||
5
demo-vault-v2/untitled-note-350.md
Normal file
5
demo-vault-v2/untitled-note-350.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 350
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-351.md
Normal file
5
demo-vault-v2/untitled-note-351.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 351
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
6
demo-vault-v2/untitled-note-352.md
Normal file
6
demo-vault-v2/untitled-note-352.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Untitled note 352
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
My Custom H1 Heading
|
||||
8
demo-vault-v2/untitled-note-353-renamed.md
Normal file
8
demo-vault-v2/untitled-note-353-renamed.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Untitled note 353
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
|
||||
Appended by raw editor test
|
||||
8
demo-vault-v2/untitled-note-354.md
Normal file
8
demo-vault-v2/untitled-note-354.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Untitled note 354
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
[[2024-03|March 2024]]
|
||||
|
||||
[[2024-03|March 2024]]
|
||||
5
demo-vault-v2/untitled-note-355.md
Normal file
5
demo-vault-v2/untitled-note-355.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 355
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-356.md
Normal file
5
demo-vault-v2/untitled-note-356.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 356
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
5
demo-vault-v2/untitled-note-357.md
Normal file
5
demo-vault-v2/untitled-note-357.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 357
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
8
demo-vault-v2/untitled-note-359-renamed.md
Normal file
8
demo-vault-v2/untitled-note-359-renamed.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Untitled note 359
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
|
||||
Appended by raw editor test
|
||||
5
demo-vault-v2/untitled-note-361.md
Normal file
5
demo-vault-v2/untitled-note-361.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
title: Untitled note 361
|
||||
type: Note
|
||||
status: Active
|
||||
---
|
||||
@@ -1,11 +1,8 @@
|
||||
---
|
||||
title: Untitled video
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
# Untitled project 13
|
||||
|
||||
## Objective
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
---
|
||||
title: Untitled project 7
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
# Untitled project 7
|
||||
|
||||
## Objective
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
---
|
||||
title: Untitled project 8
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
# Untitled project 8
|
||||
|
||||
## Objective
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
---
|
||||
title: Untitled project 9
|
||||
title: Untitled project 57
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
# Untitled project 9
|
||||
|
||||
## Objective
|
||||
|
||||
|
||||
16
demo-vault-v2/untitled-project-58.md
Normal file
16
demo-vault-v2/untitled-project-58.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: Untitled project 58
|
||||
type: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
|
||||
|
||||
## Key Results
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -77,8 +77,8 @@ classDiagram
|
||||
+Number wordCount
|
||||
+String? snippet
|
||||
+Boolean archived
|
||||
+Boolean trashed
|
||||
+Number? trashedAt
|
||||
+Boolean trashed ⚠ legacy
|
||||
+Number? trashedAt ⚠ legacy
|
||||
+Record~string,string~ properties
|
||||
}
|
||||
|
||||
@@ -127,8 +127,8 @@ interface VaultEntry {
|
||||
wordCount: number | null // Body word count (excludes frontmatter)
|
||||
snippet: string | null // First 200 chars of body
|
||||
archived: boolean // Archived flag
|
||||
trashed: boolean // Trashed flag
|
||||
trashedAt: number | null // When trashed (for auto-purge)
|
||||
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
|
||||
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
|
||||
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
|
||||
}
|
||||
```
|
||||
@@ -248,7 +248,7 @@ The editor displays a dedicated `TitleField` component above the BlockNote edito
|
||||
Navigation state is modeled as a discriminated union:
|
||||
|
||||
```typescript
|
||||
type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
type SidebarFilter = 'all' | 'archived' | 'changes' | 'pulse'
|
||||
|
||||
type SidebarSelection =
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
|
||||
@@ -361,7 +361,7 @@ Search is keyword-based, using `walkdir` to scan all `.md` files in the vault di
|
||||
- Matches query against file titles and content (case-insensitive)
|
||||
- Scores results: title matches ranked higher than content-only matches
|
||||
- Extracts contextual snippets around the first match
|
||||
- Skips trashed and hidden files
|
||||
- Skips hidden files
|
||||
|
||||
The `search_vault` Tauri command runs the scan in a blocking Tokio task and returns results sorted by relevance score.
|
||||
|
||||
@@ -466,7 +466,7 @@ sequenceDiagram
|
||||
participant MCP as MCP Server
|
||||
participant U as User
|
||||
|
||||
T->>T: run_startup_tasks()<br/>(purge trash, register MCP)
|
||||
T->>T: run_startup_tasks()<br/>(register MCP)
|
||||
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
|
||||
T->>A: App mounts
|
||||
|
||||
@@ -548,7 +548,6 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title`, `slug_to_title` |
|
||||
| `title_sync.rs` | `sync_title_on_open` — ensures `title` frontmatter matches filename on note open |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
|
||||
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days |
|
||||
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
@@ -559,7 +558,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `vault/` | Vault scanning, caching, parsing, trash, rename, image, migration |
|
||||
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
|
||||
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
|
||||
@@ -581,14 +580,11 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
|
||||
| `get_note_content` | Read note file content |
|
||||
| `save_note_content` | Write note content to disk |
|
||||
| `delete_note` | Move note to trash |
|
||||
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
|
||||
| `rename_note` | Rename note + update `title` frontmatter + cross-vault wikilinks |
|
||||
| `sync_note_title` | Sync `title` frontmatter with filename on note open → `bool` (modified) |
|
||||
| `batch_archive_notes` | Archive multiple notes |
|
||||
| `batch_trash_notes` | Trash multiple notes |
|
||||
| `batch_delete_notes` | Permanently delete notes from disk |
|
||||
| `empty_trash` | Permanently delete all trashed notes from disk |
|
||||
| `purge_trash` | Delete notes trashed >30 days ago |
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
@@ -857,7 +853,7 @@ Desktop-only features gated at the function level in `commands/`:
|
||||
- Menu state updates
|
||||
|
||||
Features that work on both platforms without changes:
|
||||
- Vault scan, note read/write, rename, delete, trash, archive
|
||||
- Vault scan, note read/write, rename, delete, archive
|
||||
- Frontmatter read/write/delete
|
||||
- AI chat (Anthropic API via `reqwest`)
|
||||
- Search (pure Rust in-memory)
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
# Design System Proposal for Laputa
|
||||
|
||||
## Current State Assessment
|
||||
|
||||
### What exists today
|
||||
- **53 top-level frames** scattered across a ~6800x11400px canvas with no spatial logic
|
||||
- **0 reusable components** (every frame is ad-hoc)
|
||||
- **~40 design variables** with light/dark theme support (colors, radii, fonts)
|
||||
- **2 foundation frames**: Color Palette and Typography & Spacing Specs
|
||||
- **1 full-app layout** frame (light mode only)
|
||||
- **4 sidebar-collapse variations** showing different panel states
|
||||
|
||||
### Recurring patterns that should be components (but aren't)
|
||||
By analyzing the 53 frames, these patterns are duplicated 3-10x each:
|
||||
|
||||
| Pattern | Where it appears | Times duplicated |
|
||||
|---------|-----------------|-----------------|
|
||||
| Inspector section header (label + action icon) | rbF01-03, reF01-03, pi01 | 7x |
|
||||
| Note list item (title + snippet + date + dot) | dp01-04, mni01-04, trNL1 | ~10x |
|
||||
| Tab bar tab (label + close + dot indicator) | dp10, mni10, full layout | 3x |
|
||||
| Sidebar filter item (icon + label + count) | trSB1, full layout sidebar | 4x |
|
||||
| Sidebar section group (chevron + icon + label) | full layout, drag frames | 4x |
|
||||
| Breadcrumb bar (icon + path + badges) | dp20, archBtnNorm/Arc, full layout | 4x |
|
||||
| Modal shell (header + body + footer) | ghv01-04, iogBH (settings) | 5x |
|
||||
| Property row (label + value) | urlDefault/Hover/Edit, pi01 | 4x |
|
||||
| Relationship pill (icon + label + X) | trRI1, reF01-03, rbF01-03 | 6x |
|
||||
| Status badge (colored dot/pill) | dp01, dp10, dp20, dp30 | 4x |
|
||||
| Status bar (version + branch + sync) | mni20, full layout | 2x |
|
||||
| Search bar (dark themed) | 3aG9b, K1O2x, nrIcZ | 3x |
|
||||
| Warning/info banner | trW30 | 1x (but will recur) |
|
||||
|
||||
Every time a new feature is designed, these get rebuilt from scratch.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Structure
|
||||
|
||||
### Single file: `ui-design.pen`
|
||||
|
||||
Multiple files creates friction (can't reference components cross-file, harder to maintain). Keep one file, organized into logical **sections** using large container frames as visual grouping. Each section is a horizontal band across the canvas.
|
||||
|
||||
### Canvas Layout (top to bottom)
|
||||
|
||||
```
|
||||
y ≈ 0 ┌─────────────────────────────────────────────┐
|
||||
│ 0. COVER │
|
||||
│ App name, version, last updated, TOC │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
y ≈ 200 ┌─────────────────────────────────────────────┐
|
||||
│ 1. FOUNDATIONS │
|
||||
│ Color palette, Typography scale, Spacing, │
|
||||
│ Border radius, Shadows, Iconography │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
y ≈ 1800 ┌─────────────────────────────────────────────┐
|
||||
│ 2. COMPONENTS (Reusable) │
|
||||
│ Atoms → Molecules → Organisms │
|
||||
│ Each component: default + states side by side│
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
y ≈ 5000 ┌─────────────────────────────────────────────┐
|
||||
│ 3. PATTERNS / COMPOSITIONS │
|
||||
│ Panel Inspector, Panel NoteList, Panel Editor│
|
||||
│ Modal shell, Popover/dropdown shell │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
y ≈ 7000 ┌─────────────────────────────────────────────┐
|
||||
│ 4. FULL LAYOUTS │
|
||||
│ Default state, Sidebar collapsed, Editor │
|
||||
│ only, etc. (full 1440x900 frames) │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
y ≈ 12000 ┌─────────────────────────────────────────────┐
|
||||
│ 5. FEATURE SPECS │
|
||||
│ Grouped by feature area — all current 53 │
|
||||
│ frames reorganized here │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each section starts with a **section label frame** (large text, description, horizontal rule). Within each section, frames flow **left to right, wrapping down**, with 100px spacing.
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Foundations
|
||||
|
||||
### What to keep
|
||||
- **Color Palette** (frame `mOf4J`) — already good. Move to top of file.
|
||||
- **Typography & Spacing** (frame `HZonq`) — already good. Place next to color palette.
|
||||
|
||||
### What to add
|
||||
- **Spacing Scale** — explicit 4px grid reference (4, 8, 12, 16, 20, 24, 32, 40, 48, 64) with visual boxes
|
||||
- **Shadow Tokens** — overlay shadow, dialog shadow, dropdown shadow (currently only documented in CSS, not in the design file)
|
||||
- **Icon Reference** — small grid showing common Lucide + Phosphor icons used in the app, at 14px and 16px sizes
|
||||
|
||||
### Variables to add
|
||||
The .pen file already has `--radius-sm/md/lg` but is missing spacing variables. Add:
|
||||
|
||||
| Variable | Value | Usage |
|
||||
|----------|-------|-------|
|
||||
| `--space-xs` | 4 | Tight gaps (icon-to-label) |
|
||||
| `--space-sm` | 8 | Standard gap |
|
||||
| `--space-md` | 12 | Section padding |
|
||||
| `--space-lg` | 16 | Panel padding |
|
||||
| `--space-xl` | 24 | Large section gaps |
|
||||
| `--space-2xl` | 32 | Page padding |
|
||||
| `--space-3xl` | 48 | Section dividers |
|
||||
|
||||
---
|
||||
|
||||
## Section 2: Component Library
|
||||
|
||||
### Atomic Design Hierarchy
|
||||
|
||||
#### Atoms (smallest building blocks)
|
||||
|
||||
| Component | Variants/States | ID Prefix |
|
||||
|-----------|----------------|-----------|
|
||||
| **Button/Primary** | default, hover, active, disabled | `c/btn-pri` |
|
||||
| **Button/Secondary** | default, hover, active, disabled | `c/btn-sec` |
|
||||
| **Button/Ghost** | default, hover, active | `c/btn-ghost` |
|
||||
| **Button/Destructive** | default, hover, disabled | `c/btn-dest` |
|
||||
| **Button/Icon** | default, hover (icon-only button, e.g. close, settings) | `c/btn-icon` |
|
||||
| **Input/Text** | empty, filled, focused, error, disabled | `c/input` |
|
||||
| **Input/Search** | empty, with query, with clear button | `c/input-search` |
|
||||
| **Badge** | default, secondary, outline, destructive, status colors (green/orange/red/blue/purple) | `c/badge` |
|
||||
| **Status Dot** | green (new), orange (modified), none (clean) | `c/status-dot` |
|
||||
| **Checkbox** | unchecked, checked, indeterminate | `c/checkbox` |
|
||||
| **Toggle** | off, on | `c/toggle` |
|
||||
| **Icon** | 14px, 16px, 20px wrapper with Lucide/Phosphor | `c/icon` |
|
||||
| **Separator** | horizontal, vertical | `c/sep` |
|
||||
| **Avatar/Initial** | letter avatar for Person entities | `c/avatar` |
|
||||
| **Tooltip** | top, bottom (container with pointer) | `c/tooltip` |
|
||||
|
||||
#### Molecules (composed from atoms)
|
||||
|
||||
| Component | Composition | States | ID Prefix |
|
||||
|-----------|------------|--------|-----------|
|
||||
| **Sidebar/FilterItem** | icon + label + count badge | default, active, hover | `c/sb-filter` |
|
||||
| **Sidebar/SectionHeader** | chevron + icon + label + add button | collapsed, expanded, hover, dragging | `c/sb-section` |
|
||||
| **Sidebar/SectionItem** | indent + label | default, active, hover | `c/sb-item` |
|
||||
| **Sidebar/TopicItem** | hash + label | default, active, hover | `c/sb-topic` |
|
||||
| **NoteList/Item** | status dot + title + snippet + date + type pill | default, selected, hover, modified, new | `c/nl-item` |
|
||||
| **NoteList/Header** | title + count + sort button | — | `c/nl-header` |
|
||||
| **Tab** | label + close btn + modified dot | default, active, hover, modified | `c/tab` |
|
||||
| **BreadcrumbBar** | path segments + word count + status badge | new, modified, clean | `c/breadcrumb` |
|
||||
| **Property/Row** | label + value | default, hover, editing | `c/prop-row` |
|
||||
| **Property/URLRow** | label + link + copy/edit icons | default, hover, editing | `c/prop-url` |
|
||||
| **Relationship/Pill** | icon + label + (X remove on hover) | default, hover, trashed, archived | `c/rel-pill` |
|
||||
| **Relationship/AddInput** | search input with autocomplete | empty, typing, results | `c/rel-add` |
|
||||
| **Inspector/SectionHeader** | label + action button | default, hover | `c/insp-header` |
|
||||
| **Git/CommitItem** | hash + message + author + date (timeline style) | — | `c/git-commit` |
|
||||
| **Search/ResultItem** | title + path + highlighted snippet | default, selected | `c/search-result` |
|
||||
| **Sort/DropdownItem** | label + direction arrow + checkmark | default, selected | `c/sort-item` |
|
||||
| **Banner/Warning** | icon + message + optional action | info, warning, error | `c/banner` |
|
||||
| **Wikilink** | colored link text (varies by entity type) | 8 type colors | `c/wikilink` |
|
||||
|
||||
#### Organisms (panel-level compositions)
|
||||
|
||||
| Component | Content | ID Prefix |
|
||||
|-----------|---------|-----------|
|
||||
| **Panel/Sidebar** | title bar + filters + sections + commit area | `c/panel-sidebar` |
|
||||
| **Panel/NoteList** | header + type pills + item list | `c/panel-notelist` |
|
||||
| **Panel/Editor** | tab bar + breadcrumb + content area | `c/panel-editor` |
|
||||
| **Panel/Inspector** | properties section + relations + backlinks + history | `c/panel-inspector` |
|
||||
| **Modal/Shell** | header + tab bar (opt) + body + footer | `c/modal` |
|
||||
| **Popover/Dropdown** | list of items + separator + annotation | `c/popover` |
|
||||
| **StatusBar** | left info + right info | `c/statusbar` |
|
||||
| **TitleBar** | traffic lights (macOS) | `c/titlebar` |
|
||||
|
||||
---
|
||||
|
||||
## Section 3: Patterns / Compositions
|
||||
|
||||
These are **non-reusable** reference frames showing how components compose into real UI sections. Think of them as "component playgrounds" — they use component instances to build real UI, but they're documentation, not building blocks.
|
||||
|
||||
- **Inspector states**: Properties expanded, Relations with pills, Referenced By with/without entries, Backlinks, Git history
|
||||
- **NoteList states**: All Notes view, filtered by section, empty state, search results
|
||||
- **Sidebar states**: All sections expanded, one collapsed, dragging reorder, after reorder
|
||||
- **Editor states**: Single tab, multiple tabs, modified tabs, diff view
|
||||
- **Modal patterns**: GitHub Vault flow (4 states), Settings, Create Note, Commit Dialog
|
||||
|
||||
---
|
||||
|
||||
## Section 4: Full Layouts
|
||||
|
||||
| Frame | Description |
|
||||
|-------|-------------|
|
||||
| **Default — All Panels** | 1440x900, sidebar + notelist + editor + inspector |
|
||||
| **Sidebar Collapsed** | Editor area wider, collapse button visible |
|
||||
| **Editor + NoteList Only** | No sidebar or inspector |
|
||||
| **Editor Only** | Focused writing mode |
|
||||
| **AI Chat Open** | Inspector replaced with AI Chat panel |
|
||||
| **Quick Open Modal** | Cmd+P overlay on default layout |
|
||||
|
||||
Keep the existing 5 layout frames (qHhaj + SC_all/nosb/edonly/ednl). Add AI Chat and Quick Open.
|
||||
|
||||
---
|
||||
|
||||
## Section 5: Feature Specs
|
||||
|
||||
Reorganize existing 53 frames into feature groups. Each group gets a **feature label** frame above it:
|
||||
|
||||
| Group | Frames to include |
|
||||
|-------|-------------------|
|
||||
| **Note Status & Indicators** | dp01, dp10, dp20, dp30, mni01, mni10, mni20 |
|
||||
| **Trash & Archive** | trSB1, trNL1, trRI1, trW30, archBtnNorm, archBtnArc |
|
||||
| **Inspector & Properties** | pi01, pi50, rbF01-03, reF01-03, urlDefault/Hover/Edit |
|
||||
| **Sidebar** | dsg01a-04j, csB01, csA01 |
|
||||
| **GitHub Vault** | ghv01-04, ghv03 |
|
||||
| **Git History** | ghCL1, ghDO1, vc001, vc100, vc200 |
|
||||
| **Sort & Search** | srtDD, srtBN, 3aG9b, K1O2x, nrIcZ |
|
||||
| **Editor & Wikilinks** | wlc01 |
|
||||
| **Settings** | iogBH |
|
||||
| **Virtual List** | vl001 |
|
||||
|
||||
---
|
||||
|
||||
## Naming Convention
|
||||
|
||||
### Frames
|
||||
```
|
||||
Section-level: "== FOUNDATIONS =="
|
||||
"== COMPONENTS =="
|
||||
"== FULL LAYOUTS =="
|
||||
|
||||
Feature groups: "Feature: Trash & Archive"
|
||||
"Feature: Note Status"
|
||||
```
|
||||
|
||||
### Reusable Components
|
||||
```
|
||||
component/<category>/<name>
|
||||
|
||||
Examples:
|
||||
component/button/primary
|
||||
component/button/ghost
|
||||
component/sidebar/filter-item
|
||||
component/notelist/item
|
||||
component/inspector/section-header
|
||||
component/modal/shell
|
||||
```
|
||||
|
||||
### States (within a component frame)
|
||||
```
|
||||
<component-name> — <state>
|
||||
|
||||
Examples:
|
||||
Button/Primary — Default
|
||||
Button/Primary — Hover
|
||||
NoteList/Item — Selected
|
||||
NoteList/Item — Modified
|
||||
```
|
||||
|
||||
### Feature Spec Frames
|
||||
```
|
||||
<Feature> — <Specific View>
|
||||
|
||||
Examples:
|
||||
Trash — Note List View
|
||||
Trash — 30-Day Warning
|
||||
Git History — Commit List
|
||||
Git History — Diff Open
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Foundation Cleanup (2-3 hours)
|
||||
1. Add spacing variables to the .pen file
|
||||
2. Move Color Palette and Typography frames to top of canvas (y ≈ 200)
|
||||
3. Add section label frames as visual dividers
|
||||
4. Reorganize existing 53 frames into the feature-spec section (y ≈ 12000+)
|
||||
5. Create cover frame at y ≈ 0
|
||||
|
||||
**Impact**: Clean canvas, easy to navigate. Zero component work yet.
|
||||
|
||||
### Phase 2: Core Atoms (3-4 hours)
|
||||
1. **Button** — 4 variants x 3 states = ~12 sub-frames, all as `reusable: true`
|
||||
2. **Badge** — 6 color variants + 4 style variants
|
||||
3. **Status Dot** — green, orange, red, none
|
||||
4. **Input** — text + search variants
|
||||
5. **Separator** — horizontal + vertical
|
||||
6. **Icon Button** — close, settings, chevron, etc.
|
||||
|
||||
**Impact**: Building blocks exist. Future features can pull from them.
|
||||
|
||||
### Phase 3: Core Molecules (4-5 hours)
|
||||
1. **Sidebar/FilterItem** — most reused sidebar pattern
|
||||
2. **Sidebar/SectionHeader** — with chevron and add button
|
||||
3. **NoteList/Item** — the most duplicated pattern in the file
|
||||
4. **Tab** — with modified indicator states
|
||||
5. **Property/Row** — default/hover/edit states
|
||||
6. **Relationship/Pill** — normal/trashed/archived states
|
||||
7. **Inspector/SectionHeader** — label + collapse/action
|
||||
|
||||
**Impact**: Feature specs can be rebuilt from instances. Consistency guaranteed.
|
||||
|
||||
### Phase 4: Organisms (3-4 hours)
|
||||
1. **Modal/Shell** — replace 5 ad-hoc modals with instances of one shell
|
||||
2. **Panel/Sidebar** — full sidebar composition
|
||||
3. **Panel/NoteList** — full notelist composition
|
||||
4. **StatusBar** — standardize
|
||||
5. **Popover/Dropdown** — for sort, autocomplete, etc.
|
||||
|
||||
**Impact**: Full layouts can be composed from panel organisms.
|
||||
|
||||
### Phase 5: Rebuild Full Layouts (2-3 hours)
|
||||
1. Rebuild "Laputa App — Full Layout (Light)" using component instances
|
||||
2. Create remaining layout variants (AI Chat, Quick Open)
|
||||
3. Ensure all layouts use the same component instances for consistency
|
||||
|
||||
**Impact**: Single source of truth. Change a button component, all layouts update.
|
||||
|
||||
### Phase 6: Feature Spec Cleanup (2-3 hours)
|
||||
1. Rebuild key feature specs using component instances where possible
|
||||
2. Remove frames that are now redundant (covered by component states)
|
||||
3. Add any missing feature-specific variations
|
||||
|
||||
**Impact**: Clean, maintainable feature documentation.
|
||||
|
||||
---
|
||||
|
||||
## Estimated Total Effort
|
||||
|
||||
| Phase | Effort | Cumulative |
|
||||
|-------|--------|-----------|
|
||||
| 1. Foundation Cleanup | 2-3h | 2-3h |
|
||||
| 2. Core Atoms | 3-4h | 5-7h |
|
||||
| 3. Core Molecules | 4-5h | 9-12h |
|
||||
| 4. Organisms | 3-4h | 12-16h |
|
||||
| 5. Full Layouts | 2-3h | 14-19h |
|
||||
| 6. Feature Cleanup | 2-3h | 16-22h |
|
||||
|
||||
**Recommendation**: Do phases 1-3 first (9-12h). This covers 80% of the value — clean organization + the most reused components. Phases 4-6 can happen incrementally as features are designed.
|
||||
|
||||
---
|
||||
|
||||
## What "World Class" Looks Like
|
||||
|
||||
After this work, `ui-design.pen` will:
|
||||
|
||||
1. **Open to a cover page** with app identity and section navigation
|
||||
2. **Have ~30 reusable components** that match the real implementation 1:1
|
||||
3. **Use variables everywhere** — no hardcoded colors, spacing, or radii
|
||||
4. **Show every component state** — no guessing what hover/active/disabled looks like
|
||||
5. **Compose layouts from instances** — change a component, all layouts update
|
||||
6. **Group features logically** — find any feature spec in seconds
|
||||
7. **Grow naturally** — new features just add instances of existing components + new feature-specific frames
|
||||
|
||||
The key shift: **from "collection of screenshots" to "living system of composable parts"**.
|
||||
@@ -138,7 +138,6 @@ laputa-app/
|
||||
│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault
|
||||
│ │ │ ├── cache.rs # Git-based incremental caching
|
||||
│ │ │ ├── parsing.rs # Text processing + title extraction
|
||||
│ │ │ ├── trash.rs # Trash auto-purge
|
||||
│ │ │ ├── rename.rs # Rename + cross-vault wikilink update
|
||||
│ │ │ ├── image.rs # Image attachment saving
|
||||
│ │ │ ├── migration.rs # Frontmatter migration
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
# Laputa App — Personal Knowledge & Life Management App
|
||||
|
||||
## Status: V1 Complete! 🎉
|
||||
|
||||
## Vision
|
||||
Custom desktop + mobile app to manage Luca's life — projects, responsibilities, knowledge, people, events. Built around a specific ontology that no existing app gets right. The current Laputa vault (Obsidian, 9200+ markdown files) is the data layer and predecessor — the app gives it a proper UI.
|
||||
|
||||
## Tech Stack
|
||||
- **Desktop**: Tauri (Rust shell + system webview)
|
||||
- **Frontend**: React + TypeScript
|
||||
- **Editor**: CodeMirror 6 — live preview with reveal-on-focus (Obsidian/Bear style)
|
||||
- **Mobile**: Capacitor (wraps same web UI for iOS/Android) — later phase
|
||||
- **Data**: plain markdown files on disk with YAML frontmatter, git-versioned
|
||||
- **Core logic**: Rust (file parsing, git ops, indexing) via Tauri commands
|
||||
|
||||
---
|
||||
|
||||
## Ontology
|
||||
|
||||
### Core Entities
|
||||
|
||||
#### Year
|
||||
- Top-level time container
|
||||
- Has: Quarters, Targets
|
||||
- Naming convention: `2026` (Laputa legacy: Chinese zodiac names like `2022-tiger`)
|
||||
|
||||
#### Quarter
|
||||
- Time container within a Year
|
||||
- Has: Projects, Targets
|
||||
- Natural planning/review cycle
|
||||
|
||||
#### Responsibility
|
||||
- Long-running duty, possibly indefinite
|
||||
- Has clear and measurable KPIs (via Measures)
|
||||
- Has an owner (Person)
|
||||
- Has: Projects, Procedures, Tasks, Measures
|
||||
- Examples: "Grow newsletter", "Manage sponsorships", "Stay healthy"
|
||||
|
||||
#### Measure
|
||||
- A trackable metric tied to one or more Responsibilities
|
||||
- Quantitative and observable
|
||||
- Examples: "Resting heart rate", "Newsletter subscribers", "Monthly revenue", "Sponsorship close rate"
|
||||
|
||||
#### Target
|
||||
- A time-bound goal for a Measure
|
||||
- Typically quarterly
|
||||
- Examples: "Resting HR < 55 by Q1 2026", "Reach 100k subscribers by Q2 2026"
|
||||
- Belongs to: a Quarter (or Year), references a Measure
|
||||
|
||||
#### Project
|
||||
- Has a beginning and an end
|
||||
- Can't be completed in one sitting
|
||||
- Has success criteria (key results, described informally in the project page)
|
||||
- Has a timeline — at minimum a deadline (default: end of quarter)
|
||||
- Advances one or more Responsibilities
|
||||
- Has an owner (Person)
|
||||
- Belongs to: Quarter (primary), Responsibility
|
||||
- Has: Tasks, Notes
|
||||
|
||||
#### Experiment
|
||||
- Like an unplanned baby project — exploring something promising without expectation of success
|
||||
- Can't be completed in one sitting
|
||||
- No strict success criteria or deadline
|
||||
- Has an owner (Person)
|
||||
- Examples: "Vibe-coding a stock screener", "Testing a new content format"
|
||||
|
||||
#### Procedure
|
||||
- Recurring piece of work, completable in one sitting
|
||||
- Has a cadence (daily, weekly, monthly, etc.)
|
||||
- Belongs to: a Responsibility (usually) or a Project (less common)
|
||||
- Has an owner (Person)
|
||||
- Examples: "Weekly sponsorship report", "Morning briefing", "Monthly review"
|
||||
|
||||
#### Task
|
||||
- One-off piece of work, completable in one sitting
|
||||
- Belongs to: a Responsibility or a Project
|
||||
- Has an owner (Person)
|
||||
|
||||
#### Topic
|
||||
- An area of interest — no performance expectations
|
||||
- Things link to Topics for categorization/discovery
|
||||
- Examples: "AI/ML", "Trading", "Fitness", "Frontend"
|
||||
|
||||
#### Note
|
||||
- A document, tool, resource, or any material helpful to advance work
|
||||
- Can belong to: Procedures, Responsibilities, Projects (one or many)
|
||||
- Can be related to: Topics (one or many)
|
||||
- Examples: evergreen notes, reference docs, templates, bookmarks
|
||||
|
||||
#### Person
|
||||
- A real-world person or an AI agent
|
||||
- Owner of Projects, Responsibilities, Procedures, Tasks
|
||||
- Related to Events
|
||||
|
||||
#### Event
|
||||
- Something that happened on a given day, stored in long-term memory
|
||||
- Examples: conversations, meetings, achievements, personal milestones
|
||||
- Can be related to: any entity above
|
||||
- Date-based: one specific day
|
||||
|
||||
---
|
||||
|
||||
## Relationships Summary
|
||||
|
||||
```
|
||||
Year
|
||||
└── Quarter
|
||||
└── Project ──→ Responsibility (advances)
|
||||
└── Target ──→ Measure ──→ Responsibility
|
||||
|
||||
Responsibility
|
||||
├── Project
|
||||
├── Procedure (recurring)
|
||||
├── Task (one-off)
|
||||
└── Measure → Target
|
||||
|
||||
Project
|
||||
├── Task
|
||||
└── Note
|
||||
|
||||
Procedure
|
||||
└── Note
|
||||
|
||||
Note ──→ Topic (categorization)
|
||||
Event ──→ anything (loose association)
|
||||
Person = owner of Project, Responsibility, Procedure, Task
|
||||
```
|
||||
|
||||
### Relationship Types
|
||||
- **Belongs to**: hierarchical parent (Project→Quarter, Task→Project, Procedure→Responsibility)
|
||||
- **Advances**: Project→Responsibility (why this project exists)
|
||||
- **Related to**: loose association (Event→anything, Note→Topic)
|
||||
- **Owner**: Person who is accountable (single owner per entity)
|
||||
|
||||
---
|
||||
|
||||
## Laputa Legacy Mapping
|
||||
|
||||
| Laputa Concept | Life OS Equivalent |
|
||||
|---|---|
|
||||
| Year | Year |
|
||||
| Quarter | Quarter |
|
||||
| Goal | Target + Measure |
|
||||
| Project | Project |
|
||||
| Key Result | Informal in Project page |
|
||||
| Responsibility | Responsibility |
|
||||
| Procedure | Procedure |
|
||||
| Topic | Topic |
|
||||
| Person | Person |
|
||||
| Event | Event |
|
||||
| Evergreen | Note |
|
||||
| Note (reading notes) | Note |
|
||||
| Readings | Note |
|
||||
| Essay | Note (belongs to a Responsibility/Project) |
|
||||
| Monday Ideas | Note |
|
||||
| Area | Folded into Responsibility or Topic |
|
||||
| Month | Removed (Quarter is the planning unit) |
|
||||
| Journal | Removed or becomes a Note |
|
||||
| Movie/Restaurant/Hotel | Out of scope (or Topic-linked Notes) |
|
||||
| Vital | Measure |
|
||||
| Video/Buckets | Note (belongs to Project/Responsibility) |
|
||||
|
||||
---
|
||||
|
||||
## UI Design
|
||||
|
||||
### Design Reference
|
||||
- Wireframes: `/Users/luca/OpenClaw/Laputa-app-design.pen`
|
||||
- Inspiration: Bear Notes, Obsidian
|
||||
|
||||
### Core Principle: Type-Agnostic UI
|
||||
Minimize custom UI behavior per entity type. Everything is a file, everything gets the same treatment. Type only determines which sidebar section an entity appears under (and maybe an icon/color hint). The editor, right panel, and note list behave identically regardless of type.
|
||||
|
||||
### Layout: Four Panels
|
||||
|
||||
#### 1. Left Sidebar — Navigation
|
||||
**Filters** (flat, switch the note list view):
|
||||
- All Notes
|
||||
- Untagged
|
||||
- Favorites
|
||||
- People
|
||||
- Events
|
||||
- Trash
|
||||
|
||||
**Section Groups** (expandable, show entity list):
|
||||
- PROJECTS +
|
||||
- EXPERIMENTS +
|
||||
- RESPONSIBILITIES +
|
||||
- PROCEDURES +
|
||||
|
||||
**Topics** (flat list, not nested):
|
||||
- work
|
||||
- strategy
|
||||
- ideas
|
||||
- research
|
||||
- drafts
|
||||
- archive
|
||||
|
||||
#### 2. Note List — Middle Panel
|
||||
- Contextual to sidebar selection
|
||||
- Title, preview snippet, date, tags as colored pills
|
||||
- Status/type indicators
|
||||
- Search bar + create button
|
||||
- Default sort: last edited (descending)
|
||||
- Quick type filter pills: `All | Notes | Events | People | ...`
|
||||
- When viewing a section group entity (e.g. a Project): its own page is **pinned at top**, children listed below
|
||||
|
||||
#### 3. Editor — Main Panel
|
||||
- CodeMirror 6 with live preview (reveal markdown syntax on active line)
|
||||
- **Tab bar** at top: multiple open notes, closeable, shows parent context
|
||||
- Title + content only — NO properties/frontmatter shown in editor
|
||||
- Tags shown as clickable pills below title
|
||||
- Created/last edited dates below title
|
||||
- Wikilinks rendered as clickable in-app navigation links
|
||||
- Frontmatter exists in the file but is hidden from the editor view
|
||||
|
||||
#### 4. Right Panel — Inspector
|
||||
- **Status** pills (Active, Draft, etc.)
|
||||
- **Properties**: Created, Modified, Author, Word Count, custom properties. Editable — writes to YAML frontmatter.
|
||||
- **Relationships**: all "belongs to", "related to", "advances" links. Editable.
|
||||
- **Backlinks**: notes that reference this note
|
||||
- **Revision History**: git commits for this file — hash, message, author, timestamp. "View all revisions" link.
|
||||
|
||||
### Key UI Decisions
|
||||
- **Editor is sacred** — all metadata/relationships live in the right panel, not above the content. Avoids the Obsidian/Notion problem of properties pushing content down.
|
||||
- **Everything is a file** — a Project, Responsibility, Topic are all just notes with a type. Clicking one in the sidebar shows its page pinned at top of note list, children below.
|
||||
- **Topics are flat** — no nesting for v1
|
||||
- **Saved views / smart filters** — future feature. Saved queries that live under any entity in the sidebar (e.g. "Evergreen 60+ days" under a Procedure). Not in v1.
|
||||
- **Tasks stay on Todoist** — no task management in v1
|
||||
|
||||
## Design Decisions
|
||||
- **Month/Week are NOT entities** — they're time-based views/filters, not containers. Monthly reviews are a Procedure.
|
||||
- **Essays, Monday Ideas, Videos, Readings** = Notes (belonging to Responsibility/Project)
|
||||
- **Movies, Restaurants, Hotels** = Notes attached to Topics
|
||||
- **Key Results** = informal, embedded in Project pages (not a separate entity)
|
||||
- **Owner** = single Person per entity (no multi-owner for now)
|
||||
- **Type-agnostic UI** — minimize type-specific behavior, keep everything consistent
|
||||
- **Metadata in right panel, not in editor** — frontmatter is hidden from the editor, rendered as editable UI in the inspector
|
||||
|
||||
## Dev Workflow
|
||||
|
||||
> Full process documented in **`dev-workflow` skill** (`~/.openclaw/skills/dev-workflow/SKILL.md`).
|
||||
> Below: Laputa-specific details only.
|
||||
|
||||
- **Repo**: `~/Workspace/laputa-app/` + GitHub `LucaRonin/laputa-app`
|
||||
- **Design file**: `~/OpenClaw/projects/Laputa-app-design.pen`
|
||||
- **Mock layer**: `src/mock-tauri.ts` (realistic test data for browser/Playwright testing without Tauri)
|
||||
|
||||
### Laputa-Specific MCPs (for Claude Code)
|
||||
- **Context7** — up-to-date docs for Tauri v2, CodeMirror 6, React
|
||||
- **Pencil MCP** — reads the .pen design file
|
||||
|
||||
### Key Lesson from M1
|
||||
M1 passed all tests but showed 0 notes — vault path wrong, error silently swallowed. This drove the mandatory verification workflow now captured in the dev-workflow skill.
|
||||
|
||||
---
|
||||
|
||||
## V1 Milestones
|
||||
|
||||
### M1: Scaffold & Shell ✅ COMPLETE
|
||||
**Goal:** Empty Tauri + React app that opens a window, reads a vault path, and lists files.
|
||||
- [x] Init repo at `~/Workspace/laputa-app/`
|
||||
- [x] Tauri v2 + React 19 + TypeScript + Vite 7 project setup
|
||||
- [x] Configure Vitest (7 tests), Playwright (2 E2E tests), Rust tests (10 tests)
|
||||
- [x] Rust backend: `list_vault` command — scans directory, parses YAML frontmatter via `gray_matter` crate
|
||||
- [x] Rust backend: extracts type (Is A), aliases, Belongs to, Related to, Status, Owner, Cadence, title from H1
|
||||
- [x] React: four-panel layout (Sidebar 250px, NoteList 300px, Editor flex, Inspector 280px), all resizable
|
||||
- [x] Tauri mock layer for browser testing (`src/mock-tauri.ts`)
|
||||
- [x] Screenshot verification via Playwright (`e2e/screenshot.spec.ts`)
|
||||
- [ ] Push to GitHub (not yet done)
|
||||
|
||||
**Git log (5 commits):**
|
||||
```
|
||||
e72d66a Add Tauri mock layer for browser testing and visual verification workflow
|
||||
bc75647 Remove unused Vite scaffold files
|
||||
7d5c48c Add unit and E2E tests for all panel components
|
||||
57083ad Add four-panel layout shell with vault scanning on load
|
||||
6b53cf8 Initialize Tauri v2 + React + TypeScript project with vault scanner
|
||||
```
|
||||
|
||||
### M2: Sidebar & Note List
|
||||
**Goal:** Navigate the vault via sidebar, see filtered note lists.
|
||||
- [ ] Sidebar: Filters section (All Notes, Favorites, Trash)
|
||||
- [ ] Sidebar: Section Groups (Projects, Experiments, Responsibilities, Procedures) — populated from frontmatter `type:`
|
||||
- [ ] Sidebar: Topics — flat list, populated from `Related to` topic links
|
||||
- [ ] Note list: show title, preview snippet, date, type indicator
|
||||
- [ ] Note list: sort by last modified (descending)
|
||||
- [ ] Note list: search (full-text across vault)
|
||||
- [ ] Note list: type filter pills (All | Notes | Events | People | ...)
|
||||
- [ ] Clicking a section group entity: pin its page at top, show children below
|
||||
- [ ] Filesystem watching: live reload when files change externally
|
||||
|
||||
### M3: Editor
|
||||
**Goal:** Open and edit markdown files with CodeMirror 6 live preview.
|
||||
- [ ] CodeMirror 6 integration with React
|
||||
- [ ] Live preview: hide markdown syntax, reveal on active line (Obsidian/Bear style)
|
||||
- [ ] Frontmatter hidden from editor view
|
||||
- [ ] Tab bar: open multiple notes, close tabs, show parent context
|
||||
- [ ] Wikilinks rendered as clickable links → navigate in-app
|
||||
- [ ] Save: write markdown + YAML frontmatter to disk
|
||||
- [ ] Auto-save (debounced)
|
||||
- [ ] Basic markdown features: headings, bold/italic, lists, code blocks, links, images
|
||||
|
||||
### M4: Right Panel — Inspector
|
||||
**Goal:** View and edit metadata, relationships, and git history.
|
||||
- [ ] Properties panel: Created, Modified, Author, Word Count, Status, custom fields
|
||||
- [ ] Properties editable → writes to YAML frontmatter
|
||||
- [ ] Relationships panel: Belongs to, Related to, Advances — rendered as clickable links
|
||||
- [ ] Relationships editable (add/remove)
|
||||
- [ ] Backlinks: auto-computed from wikilinks across vault
|
||||
- [ ] Git revision history: show commits for current file (hash, message, author, date)
|
||||
- [ ] "View all revisions" link
|
||||
|
||||
### M5: File Operations & Polish
|
||||
**Goal:** Create, rename, delete files. Polish for daily-driver use.
|
||||
- [ ] Create new note (with type selector → sets `type:` in frontmatter, created at vault root)
|
||||
- [ ] Rename file (updates filename + title)
|
||||
- [ ] Delete → move to trash
|
||||
- [ ] Keyboard shortcuts (Cmd+N new, Cmd+S save, Cmd+P quick open/search)
|
||||
- [ ] Quick open palette (Cmd+P) — fuzzy search across all files
|
||||
- [ ] People and Events filters in sidebar
|
||||
- [ ] Visual polish: match Bear-inspired design from .pen wireframes
|
||||
- [ ] E2E tests with Playwright for core flows
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
- [ ] Migration path from current Laputa vault (legacy cleanup, schema normalization)
|
||||
- [ ] Multi-user or just Luca + AI agents?
|
||||
- [ ] How does Brian (and other agents) interact with the data?
|
||||
- [ ] Mobile: when to start the Capacitor layer?
|
||||
- [ ] Year/Quarter navigation in sidebar — dedicated section or time-based filter?
|
||||
- [ ] Home/dashboard view — what does it show?
|
||||
113
docs/ROADMAP.md
113
docs/ROADMAP.md
@@ -1,113 +0,0 @@
|
||||
---
|
||||
title: ROADMAP
|
||||
---
|
||||
# Laputa — Product Roadmap
|
||||
|
||||
*Strategic directions, not implementation tasks. Each item here represents a direction that will be broken down into many smaller tasks when the time comes.*
|
||||
|
||||
*Updated: March 2026.*
|
||||
|
||||
---
|
||||
|
||||
## Consolidation sprint (current priority)
|
||||
|
||||
Before building new features, the architectural foundations must be solid. Key structural fixes underway:
|
||||
|
||||
- Move vault cache outside the vault directory (→ `~/.laputa/cache/`) with atomic writes
|
||||
- Flip `type:` to canonical field in Rust parser (`Is A:` becomes alias)
|
||||
- Remove `allContent` from the architecture — derive backlinks from open tabs only
|
||||
- ~~Remove hardcoded `RELATIONSHIP_KEYS` — detect wikilink fields dynamically~~ ✅ Done
|
||||
- Fix hardcoded vault path in `resolveNewNote` / `resolveNewType` / `resolveDailyNote`
|
||||
- Define and enforce the three-source-of-truth contract (filesystem → cache → React state)
|
||||
|
||||
These are not features — they are the foundation everything else is built on.
|
||||
|
||||
---
|
||||
|
||||
## Strategic directions
|
||||
|
||||
### 1. Semantic properties
|
||||
|
||||
**What:** Conventional frontmatter field names (`status:`, `url:`, `start_date:`, `end_date:`, `goal:`, `result:`) trigger rich UI rendering beyond the Properties panel — chips in the note list, progress indicators in the editor header, date range badges.
|
||||
|
||||
**Why:** Notes are not just documents. A Project has a start and end. A Responsibility has KPIs. A Procedure has an owner and a cadence. The app should surface this structure visually, not just store it as plain text.
|
||||
|
||||
**Convention over configuration:** the rendering rules ship as sensible defaults. Users can override via `config/semantic-properties.md` in the vault — a plain markdown file, editable from within the app.
|
||||
|
||||
**Draft tasks:** created in Todoist. To be prioritized after consolidation sprint.
|
||||
|
||||
---
|
||||
|
||||
### 2. Default relationships in Properties panel
|
||||
|
||||
**What:** The Properties panel shows a set of relationship fields by default — even when empty — guiding the user toward a connected knowledge graph. Defaults include: Belongs to, Related to, Events, People (Type is already shown).
|
||||
|
||||
**Why:** A new note starts with a completely empty Properties panel today. There's no guidance on how to connect it. Laputa is opinionated — it should show you the connections that matter.
|
||||
|
||||
**Convention over configuration:** the default list is built in, but can be overridden via `config/relations.md` in the vault.
|
||||
|
||||
**Draft tasks:** created in Todoist. Needs design discussion (per-type overrides?) before implementation.
|
||||
|
||||
---
|
||||
|
||||
### 3. Global workspace filter
|
||||
|
||||
**What:** A top-level workspace switcher (below the traffic lights) that filters the entire app — sidebar, note list, search — to show only notes belonging to the selected workspace, plus shared notes (those without a Workspace field).
|
||||
|
||||
**Why:** A single vault often contains both personal and work content. A workspace filter lets you focus on one context at a time without cognitive overhead.
|
||||
|
||||
**How:** Notes opt into a workspace via `Workspace: [[workspace/refactoring]]` frontmatter. Workspace notes are auto-detected from the `workspace/` folder. No setup required.
|
||||
|
||||
**Future trajectory:** Workspaces are the seed of a multi-vault, multi-user access control model. In the future, workspaces may map to separate Git repositories — each with their own access permissions. Different people see different workspaces (vaults). Git provides the audit trail. This enables Laputa to grow from a personal tool to a small-team knowledge base without rebuilding the product.
|
||||
|
||||
**Draft tasks:** created in Todoist. Lower priority than semantic properties and default relationships.
|
||||
|
||||
---
|
||||
|
||||
### 4. Inbox and capture pipeline
|
||||
|
||||
**What:** An Inbox section that surfaces all unorganized notes — those with no outgoing relationships. Replaces "All Notes" as the primary landing section. Capture integrations (Chrome extension, iPhone share sheet, Readwise sync) feed into the inbox automatically.
|
||||
|
||||
**Why:** Capture and organize are fundamentally different activities and should be treated separately. Today Laputa has no concept of an unorganized note — everything lands in the same pool. The inbox makes the unorganized state visible and actionable, creating a discipline: Inbox Zero, reached weekly.
|
||||
|
||||
**The inbox as a smart filter:** not a folder. Any note without `Belongs to:`, `Related to:`, or other meaningful relationship is automatically in the inbox. Connecting a note to something removes it from the inbox, automatically.
|
||||
|
||||
**Capture integrations (future, each a separate feature):**
|
||||
- Chrome extension → saves URL/clip as a note to the vault via Git
|
||||
- iPhone share sheet → quick capture from any app
|
||||
- Readwise / Kindle highlights → synced via Git automation
|
||||
- Voice memo → transcribed and dropped into inbox
|
||||
|
||||
**Priority:** The Inbox UI is high-value and can be implemented without the capture integrations. Integrations come after.
|
||||
|
||||
---
|
||||
|
||||
### 5. Mobile apps
|
||||
|
||||
**What:** Native apps for iPhone and iPad — not ports of the desktop app, but purpose-built for each form factor.
|
||||
|
||||
**iPhone:** Optimized for fast capture. Quick note creation, voice memos, brief thoughts. The primary use case is getting something into the vault quickly while away from the desk. Minimal reading and editing.
|
||||
|
||||
**iPad:** A more capable mirror of the desktop experience — reading, editing, navigating the vault. Not a full four-panel layout, but enough to work on notes meaningfully. Think "laptop replacement for light work sessions."
|
||||
|
||||
**Why it matters:** Laputa's value as a personal knowledge system depends on being able to capture things wherever you are. Without mobile capture, important notes get lost or end up scattered in other apps.
|
||||
|
||||
**Sync:** Git-based, same as desktop. The vault is a Git repo — mobile apps commit and pull like any other client.
|
||||
|
||||
**Priority:** After the desktop experience is solid. Not before.
|
||||
|
||||
---
|
||||
|
||||
## Principles for this roadmap
|
||||
|
||||
- **Foundations before features** — a shaky architecture multiplies the cost of every feature built on top of it
|
||||
- **Convention over configuration** — ship strong defaults, allow customization via vault files
|
||||
- **File-first** — every strategic direction must be achievable without breaking the markdown-files-on-disk model
|
||||
- **AI-readable by design** — conventions that humans find intuitive should also be legible to AI agents navigating the vault
|
||||
|
||||
---
|
||||
|
||||
*For active tasks and bugs, see the Todoist board (Laputa App project).*
|
||||
*For architectural decisions and design principles, see [ARCHITECTURE.md](./ARCHITECTURE.md) and [VISION.md](./VISION.md).*
|
||||
|
||||
<!-- QA test edit Wed Apr 1 16:17:01 CEST 2026 -->
|
||||
@@ -2,8 +2,9 @@
|
||||
type: ADR
|
||||
id: "0007"
|
||||
title: "Title equals filename (slug sync)"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-03-15
|
||||
superseded_by: "0044"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
56
docs/adr/0042-trash-auto-purge-safety-model.md
Normal file
56
docs/adr/0042-trash-auto-purge-safety-model.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0042"
|
||||
title: "Trash auto-purge safety model"
|
||||
status: superseded
|
||||
date: 2026-04-05
|
||||
superseded_by: "0045"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Trash view already shows a "Notes trashed more than 30 days ago will be permanently deleted" warning, but the app never actually enforces this. Users expect trashed notes to be cleaned up automatically after 30 days — if we advertise it, we must implement it.
|
||||
|
||||
This is one of the most dangerous operations in the app: a bug could cause irreversible data loss. The safety model must be explicit and conservative.
|
||||
|
||||
## Decision
|
||||
|
||||
**Auto-purge trashed notes older than 30 days on app launch and window focus (max once per hour), using OS trash (`trash::delete`) for soft-deletion, with mandatory 5-point safety validation per file and an audit log at `.laputa/purge.log`.**
|
||||
|
||||
### Safety checks (all must pass before deleting any file)
|
||||
|
||||
1. `_trashed: true` (or legacy aliases `Trashed`, `trashed`) is present in frontmatter and set to a truthy value
|
||||
2. `_trashed_at` (or legacy aliases `Trashed at`, `trashed_at`) is present and parseable as a date
|
||||
3. The parsed date is **strictly more than 30 days ago** (exactly 30 days = skip)
|
||||
4. The file exists on disk at the expected path
|
||||
5. The file's canonical path is inside the vault root (prevents path traversal)
|
||||
|
||||
If any check fails, the file is skipped with a warning log. The purge never aborts early — it processes all candidates independently.
|
||||
|
||||
### Deletion method
|
||||
|
||||
Use the `trash` crate (`trash::delete`) to move files to the OS trash (macOS Trash, Windows Recycle Bin) instead of `fs::remove_file`. This gives users a last-resort recovery path. If OS trash fails, fall back to `fs::remove_file` and log a warning.
|
||||
|
||||
### Trigger conditions
|
||||
|
||||
- On app launch (in `run_startup_tasks`)
|
||||
- On window focus (`WindowEvent::Focused(true)`) — throttled to max once per hour using a `Mutex<Instant>` timestamp
|
||||
|
||||
### Audit log
|
||||
|
||||
Every purge run appends to `.laputa/purge.log` with timestamp, files checked count, files purged count, and each purged file path. Users can inspect this file to audit what was deleted and when.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A — OS trash via `trash` crate** (chosen): moves to OS trash, user can recover from Trash app. Adds a ~small dependency. Safe default.
|
||||
- **Option B — `fs::remove_file` (permanent)**: simpler, no dependency, but no recovery path. Too risky for an automatic background operation.
|
||||
- **Option C — Move to `.laputa/purged/` archive folder**: custom recovery mechanism, but clutters vault directory and users wouldn't know to look there.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users get the auto-cleanup behavior already advertised in the UI
|
||||
- Accidentally trashed notes have a second chance via OS Trash
|
||||
- The `trash` crate adds a platform-specific dependency (macOS: `NSFileManager`, Windows: `IFileOperation`, Linux: freedesktop spec)
|
||||
- The hourly throttle prevents excessive disk I/O on rapid focus/unfocus cycles
|
||||
- The purge log provides auditability but will grow over time (acceptable for a text log)
|
||||
- Re-evaluate if users report OS Trash filling up with vault files
|
||||
46
docs/adr/0044-h1-as-title-primary-source.md
Normal file
46
docs/adr/0044-h1-as-title-primary-source.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0044"
|
||||
title: "H1 as primary title source — filename as stable identifier"
|
||||
status: active
|
||||
date: 2026-04-07
|
||||
supersedes: "0007"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0007 established that the `title:` frontmatter field is the source of truth for display titles, with filenames derived from it via slugification and kept in sync bidirectionally. This model had a key assumption: the user explicitly types a title before writing content.
|
||||
|
||||
In practice this created friction: new notes required a title upfront, the TitleField was always visible cluttering the editor, and the "title = filename slug" contract was fragile when users renamed files externally. The team wanted a more natural writing flow where you just start writing — like most text editors — and the title emerges from the document.
|
||||
|
||||
A pair of commits on 2026-04-06 (`377a3f8d`, `7daf6898`) implemented a fundamentally different model.
|
||||
|
||||
## Decision
|
||||
|
||||
**The first `# H1` heading in the note body is the canonical display title. The `title:` frontmatter field is legacy/backward-compat only. New notes are created with filename `untitled-{type}-{timestamp}.md` and no `title:` in frontmatter. On save, if the note has an H1, the file is auto-renamed to a slug derived from it (collision-safe with `-2`, `-3` suffixes).**
|
||||
|
||||
Title resolution priority (Rust `extract_title`):
|
||||
1. H1 on the first non-empty line of the body
|
||||
2. Frontmatter `title:` field (legacy, backward-compat)
|
||||
3. Slug-to-title derivation from filename stem
|
||||
|
||||
The `has_h1: bool` field on `VaultEntry` signals the frontend to hide `TitleField` and the icon picker when an H1 is present, since the H1 serves as the title surface.
|
||||
|
||||
The breadcrumb bar shows the **filename stem** (not display title) so users always know the actual file identifier.
|
||||
|
||||
Auto-rename (`auto_rename_untitled` Tauri command) fires on save for `untitled-*` files that gain an H1, converting them to a human-readable slug.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A — H1 as primary title + auto-rename on save** (chosen): natural writing flow, filename eventually reflects content, TitleField hidden when H1 present. Downside: auto-rename can surprise users; breadcrumb must show filename to stay honest.
|
||||
- **Option B — Keep `title:` frontmatter as source of truth** (ADR-0007, now superseded): explicit, deterministic. Downside: forces upfront titling, TitleField always visible, friction for quick capture.
|
||||
- **Option C — UUID-based filenames, title only in H1**: filenames never change, no rename logic needed. Downside: vault unreadable in Finder/terminal, breaks the plain-files principle (ADR-0002).
|
||||
|
||||
## Consequences
|
||||
|
||||
- New notes start as `untitled-note-{timestamp}.md` — the vault may accumulate untitled files if users abandon drafts without writing an H1
|
||||
- `TitleField` component is hidden when `has_h1 = true`; icon picker is also hidden (icons only make sense on titled notes)
|
||||
- Frontmatter `title:` still parsed for backward-compat; existing vaults with explicit titles continue to work
|
||||
- Auto-rename on save introduces a file rename side-effect during editing — wikilinks pointing to the old filename may break until the rename propagates
|
||||
- The breadcrumb filename display makes the system more honest but slightly more technical for non-power users
|
||||
- Re-evaluate if users find auto-rename disorienting or if wikilink breakage during rename becomes a reliability concern
|
||||
37
docs/adr/0045-permanent-delete-no-trash.md
Normal file
37
docs/adr/0045-permanent-delete-no-trash.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0045"
|
||||
title: "Permanent delete with confirm modal — no Trash system"
|
||||
status: active
|
||||
date: 2026-04-07
|
||||
supersedes: "0042"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0042 designed a Trash auto-purge safety model (soft-delete with 30-day retention, OS trash via `trash` crate, audit log). This was built on top of a Trash system that treated deletion as a two-phase operation: move to trash → auto-purge after 30 days.
|
||||
|
||||
The Trash system was subsequently identified as unnecessary complexity: it required `trashed`/`trashedAt` frontmatter fields, sidebar filtering, editor banners, inspector components, dedicated smoke tests, and a `trash` crate dependency. The safety guarantee users actually need is a **confirmation prompt before irreversible action**, not a soft-delete buffer — especially given notes live in a git repo (vault git history is already a recovery mechanism per ADR-0034 and ADR-0014).
|
||||
|
||||
Commit `e581ad36` on 2026-04-06 removed the entire Trash system (123 files changed, ~3164 lines deleted).
|
||||
|
||||
## Decision
|
||||
|
||||
**Delete is permanent and immediate, gated only by a confirmation modal (`useDeleteActions`). Notes with `trashed: true` in existing vault frontmatter are treated as normal notes (the flag is ignored by the parser). The `trash` crate dependency is removed.**
|
||||
|
||||
The confirmation modal is the sole safety gate. No soft-delete, no Trash view, no auto-purge scheduler, no `.laputa/purge.log`.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A — Permanent delete + confirm modal** (chosen): simple, honest, no hidden state. Git history provides recovery. Removes ~3000 lines of code and a platform-specific dependency. Downside: no in-app recovery path for users who don't know about git.
|
||||
- **Option B — OS Trash via `trash` crate** (ADR-0042, now superseded): soft-delete to OS Trash, user can recover from macOS Trash app. Downside: additional dependency, complex auto-purge scheduler, misleading "auto-purge" promise that was never actually implemented.
|
||||
- **Option C — `.laputa/deleted/` archive folder**: custom recovery mechanism inside vault. Downside: clutters vault, users wouldn't know to look there, still requires manual cleanup.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users who accidentally delete a note must recover from git history (`git checkout HEAD -- path/to/note.md`) — this is a power-user action
|
||||
- `trashed`/`trashedAt` frontmatter fields in existing vaults are silently ignored — no migration needed, no data loss
|
||||
- The `trash` crate is removed from `Cargo.toml` — build times improve marginally
|
||||
- Smoke tests for trash flows are deleted; delete-related test coverage is now purely the confirm modal behavior
|
||||
- The Trash view, sidebar filter, note banners, and bulk-trash actions are all gone — simpler UI surface
|
||||
- Re-evaluate if user feedback shows significant accidental deletion incidents, or if git-based recovery proves too inaccessible for non-technical users
|
||||
@@ -97,3 +97,7 @@ proposed → active → superseded
|
||||
| [0039](0039-git-history-for-note-dates.md) | Git history as source of truth for note creation/modification dates | active |
|
||||
| [0040](0040-custom-views-yml-filter-engine.md) | Custom Views — .laputa/views/*.yml with YAML filter engine | active |
|
||||
| [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active |
|
||||
| [0042](0042-trash-auto-purge-safety-model.md) | Trash auto-purge safety model | superseded → [0045](0045-permanent-delete-no-trash.md) |
|
||||
| [0043](0043-reactive-vault-state-on-save.md) | Reactive vault state: editor changes propagate immediately to all UI | active |
|
||||
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | active |
|
||||
| [0045](0045-permanent-delete-no-trash.md) | Permanent delete with confirm modal — no Trash system | active |
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
# iPad Prototype — Tauri v2 iOS Feasibility Report
|
||||
|
||||
**Date:** 2026-03-27\
|
||||
\
|
||||
**Status:** VERIFIED — App builds, installs, and renders React UI on iPad Pro 13" simulator (iOS 18.3.1)
|
||||
|
||||
## Summary
|
||||
|
||||
Laputa can be ported to iPad using Tauri v2 iOS (beta) with **minimal code changes**. The React frontend stays identical. The Rust backend compiles for iOS with conditional compilation to gate desktop-only features (git CLI, menu bar, MCP, Claude CLI). Vault read/write operations work without changes.
|
||||
|
||||
**Key result:** `tauri ios build --target aarch64-sim` succeeds. The app launches on iPad simulator and the React UI renders correctly (telemetry consent dialog, welcome screen, all styled correctly).
|
||||
|
||||
## What Works
|
||||
|
||||
| Feature | Status | Notes |
|
||||
| ------------------------------ | ---------------- | --------------------------------------------------------------------- |
|
||||
| Rust backend cross-compilation | VERIFIED | Zero errors, zero warnings for `aarch64-apple-ios-sim` |
|
||||
| Desktop build (no regressions) | VERIFIED | 581 Rust tests pass, 2201 frontend tests pass, CodeScene gates pass |
|
||||
| Tauri iOS project generation | VERIFIED | `tauri ios init` generates Xcode project successfully |
|
||||
| Xcode build for simulator | VERIFIED | `tauri ios build --target aarch64-sim` — **BUILD SUCCEEDED** |
|
||||
| App launch on iPad simulator | VERIFIED | Installs and launches on iPad Pro 13" (M4), PID assigned |
|
||||
| React UI in WebView | VERIFIED | Telemetry consent dialog renders with correct styling, fonts, buttons |
|
||||
| Vault file read/write | Expected to work | Pure filesystem operations, no process spawning |
|
||||
| AI chat (Anthropic API) | Expected to work | Uses `reqwest` HTTP, no CLI dependency |
|
||||
| Search | Expected to work | Pure Rust in-memory search |
|
||||
| Settings persistence | Expected to work | JSON file read/write |
|
||||
|
||||
## What Doesn't Work (Yet)
|
||||
|
||||
| Feature | Blocker | Recommended Solution |
|
||||
| ---------------------- | ---------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Git operations | No `git` binary on iOS | **Option B (Working Copy)** for prototype; **Option A (isomorphic-git)** for production |
|
||||
| GitHub clone/push/pull | Depends on git CLI | Same as above |
|
||||
| Claude CLI streaming | No `claude` binary on iOS | Use Anthropic API directly (requires new implementation) |
|
||||
| MCP server / WS bridge | Spawns Node.js child process | Skip for mobile; explore in-process MCP later |
|
||||
| macOS menu bar | Desktop-only API | Touch-native navigation (already handled by React) |
|
||||
| Updater plugin | Desktop-only | Use TestFlight for updates |
|
||||
| File open dialog | Different on iOS | `tauri-plugin-dialog` supports iOS — needs testing |
|
||||
|
||||
## Changes Made
|
||||
|
||||
### `src-tauri/src/lib.rs`
|
||||
|
||||
* Gated `WsBridgeChild`, `run_startup_tasks`, `spawn_ws_bridge`, `log_startup_result` behind `#[cfg(desktop)]`
|
||||
* Mobile build skips MCP registration, WS bridge, and vault migrations at startup
|
||||
* Run event handler gated for desktop (child process cleanup)
|
||||
|
||||
### `src-tauri/src/commands.rs`
|
||||
|
||||
* Git commands: desktop implementations remain unchanged; mobile stubs return graceful errors or empty results
|
||||
* GitHub commands: desktop-only; mobile stubs return errors
|
||||
* Claude CLI commands: desktop-only; mobile stubs return `installed: false`
|
||||
* MCP commands: desktop-only; mobile stubs return `NotInstalled`
|
||||
* Menu commands: desktop-only; mobile stub is a no-op
|
||||
* Vault, frontmatter, search, AI chat, settings: **unchanged** (work on both platforms)
|
||||
|
||||
### `src-tauri/src/lib.rs`
|
||||
|
||||
* `pub mod menu` gated behind `#[cfg(desktop)]`
|
||||
|
||||
### `src-tauri/capabilities/`
|
||||
|
||||
* `default.json`: scoped to desktop platforms (`linux`, `macOS`, `windows`)
|
||||
* `mobile.json`: new file with iOS/Android permissions (core, dialog)
|
||||
|
||||
### `src-tauri/gen/apple/`
|
||||
|
||||
* Full Xcode project generated by `tauri ios init`
|
||||
* iPad support: all orientations enabled, arm64 architecture
|
||||
|
||||
## Architecture: How Mobile Stubs Work
|
||||
|
||||
```text
|
||||
Frontend (React) ──invoke──> Tauri Commands ──> Rust Backend
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
#[cfg(desktop)] #[cfg(mobile)]
|
||||
│ │
|
||||
Real impl Stub (error/empty)
|
||||
(git CLI, (graceful degradation)
|
||||
menu, MCP)
|
||||
```
|
||||
|
||||
The frontend code doesn't change at all. Commands that aren't available on mobile return errors that the UI can handle gracefully (e.g., hiding the git sync panel, disabling commit buttons).
|
||||
|
||||
## Git Strategy for iPad
|
||||
|
||||
### Phase 1: Working Copy (Recommended for prototype)
|
||||
|
||||
* User manages vault with [Working Copy](https://workingcopy.app/) (git client for iPad)
|
||||
* Laputa opens the vault via iOS Files API / FileProvider
|
||||
* Zero git code needed in Laputa — Working Copy handles sync
|
||||
* **Pro:** Works immediately, robust git implementation
|
||||
* **Con:** Requires separate app, split UX for sync
|
||||
|
||||
### Phase 2: isomorphic-git (Production)
|
||||
|
||||
* Pure JS git implementation running in the WebView
|
||||
* Replace `invoke("git_commit")` etc. with JS-side git operations
|
||||
* **Pro:** Integrated UX, no external dependency
|
||||
* **Con:** Slower on large vaults (~9200 files), limited advanced git features
|
||||
|
||||
### Phase 3: git2-rs / gitoxide (Future)
|
||||
|
||||
* Pure Rust git library compiled into the Tauri binary
|
||||
* Best performance, no JS bridge overhead
|
||||
* **Con:** Larger binary size, more integration work
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Install iOS simulator runtime** — `xcodebuild -downloadPlatform iOS` (downloading ~7GB)
|
||||
2. **Full Xcode build** — `npx tauri ios build --target aarch64-sim`
|
||||
3. **Launch on iPad simulator** — `npx tauri ios dev`
|
||||
4. **Test vault read/write** — open a vault, read/edit a note
|
||||
5. **Test AI chat** — verify Anthropic API calls work from iOS WebView
|
||||
6. **Evaluate touch UX** — identify layout issues on iPad screen size
|
||||
7. **File Provider integration** — test opening vaults from Working Copy via iOS Files
|
||||
|
||||
## Prerequisites Installed
|
||||
|
||||
* Rust iOS targets: `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios`
|
||||
* Xcode 16.2 with iOS 18.3.1 simulator runtime (downloading)
|
||||
* CocoaPods 1.16.2, xcodegen 2.45.3, libimobiledevice 1.4.0
|
||||
* Tauri CLI 2.10.0 with iOS support
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 262 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB |
@@ -1,70 +1,59 @@
|
||||
# CLAUDE.md — Laputa Vault Guide
|
||||
# CLAUDE.md — Laputa Vault
|
||||
|
||||
This file explains how Laputa vaults work so you can create and edit notes correctly.
|
||||
This is a [Laputa](https://github.com/refactoringhq/laputa-app) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
## Note structure
|
||||
|
||||
Every note is a markdown file with YAML frontmatter:
|
||||
Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My Note Title # required — do NOT use H1 in the body
|
||||
is_a: TypeName # the note's type (must match a type file in the vault)
|
||||
status: Active # example property
|
||||
url: https://example.com # example property
|
||||
belongs_to: "[[Other Note]]" # relationship via wikilink
|
||||
is_a: TypeName # the note's type (must match the title of a type file in the vault)
|
||||
url: https://... # example property
|
||||
belongs_to: "[[other-note]]"
|
||||
related_to:
|
||||
- "[[Note A]]"
|
||||
- "[[Note B]]"
|
||||
- "[[note-a]]"
|
||||
- "[[note-b]]"
|
||||
---
|
||||
|
||||
Body content in markdown. No H1 — the title is in the frontmatter.
|
||||
# Note Title
|
||||
|
||||
Body content in markdown.
|
||||
```
|
||||
|
||||
**Key rules:**
|
||||
- `title` is the note's display name — never use `# H1` in the body
|
||||
- `is_a` must match the `title` of an existing type file
|
||||
- Properties are any YAML key-value pairs in the frontmatter
|
||||
- System properties are prefixed with `_` (e.g. `_pinned`, `_organized`, `_icon`) — don't show these to users
|
||||
System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked.
|
||||
|
||||
## Types
|
||||
|
||||
A type is a note with `is_a: Type` in the frontmatter. It lives in the vault root:
|
||||
A type is a note with `is_a: Type`. Type files live in the vault root:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Book
|
||||
is_a: Type
|
||||
_icon: BookOpen # Phosphor icon name
|
||||
_color: "#8b5cf6" # hex color for sidebar
|
||||
_icon: books # Phosphor icon name in kebab-case
|
||||
_color: "#8b5cf6" # hex color
|
||||
---
|
||||
Description of the type.
|
||||
|
||||
# TypeName
|
||||
```
|
||||
|
||||
To create a new type: create a markdown file with `is_a: Type`.
|
||||
To find what types exist: look for files with `is_a: Type` in the vault root.
|
||||
|
||||
## Relationships
|
||||
|
||||
Relationships are frontmatter properties whose values are wikilinks:
|
||||
Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically.
|
||||
|
||||
```yaml
|
||||
belongs_to: "[[Project Name]]"
|
||||
related_to:
|
||||
- "[[Note A]]"
|
||||
- "[[Note B]]"
|
||||
has:
|
||||
- "[[Child Note]]"
|
||||
```
|
||||
|
||||
Standard names: `belongs_to`, `related_to`, `has`. Custom names are allowed.
|
||||
Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
Syntax: `[[Note Title]]` or `[[filename]]`. Used for relationships and inline references.
|
||||
- `[[filename]]` or `[[Note Title]]` — link by filename or title
|
||||
- `[[filename|display text]]` — with custom display text
|
||||
- Works in frontmatter values and markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved filters stored as `.view.json` in the `views/` folder:
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -77,13 +66,16 @@ Saved filters stored as `.view.json` in the `views/` folder:
|
||||
}
|
||||
```
|
||||
|
||||
## What you can do on this vault
|
||||
## Filenames
|
||||
|
||||
- Create/edit notes with correct frontmatter
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
|
||||
## What you can do
|
||||
|
||||
- Create/edit notes with correct frontmatter and H1 title
|
||||
- Create new type files
|
||||
- Add or modify relationships between notes
|
||||
- Add or modify relationships
|
||||
- Create/edit views in `views/`
|
||||
- Change `_icon` and `_color` on type files
|
||||
- Edit `CLAUDE.md` (this file)
|
||||
|
||||
**Do not** modify app configuration files — those are local to each installation.
|
||||
Do not modify app configuration files — those are local to each installation.
|
||||
|
||||
61
prompt.txt
61
prompt.txt
@@ -1,61 +0,0 @@
|
||||
Title/filename sync: derive title from filename, keep in sync via frontmatter
|
||||
|
||||
## Goal
|
||||
Every note has a `title` field in frontmatter that stores the human-readable title with original casing and spacing. The filename is always the slug of the title. The two are kept in sync at all times within Laputa.
|
||||
|
||||
## Current state
|
||||
`extract_title` reads the first H1 heading as the display title, with filename as fallback. This approach is fragile: H1 can diverge from the filename, and using filename directly loses casing information.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
### Title/filename contract
|
||||
- `title` frontmatter = human-readable title (e.g. `Career Tracks Depend on Company Shape`)
|
||||
- filename = slug of title (e.g. `career-tracks-depend-on-company-shape.md`)
|
||||
- The two are always kept in sync within Laputa
|
||||
|
||||
### On note open (sync check)
|
||||
When Laputa opens a note, it checks for desync:
|
||||
- If `title` frontmatter is absent → derive title from filename (hyphens → spaces), write it to frontmatter
|
||||
- If `title` frontmatter is present but its slug doesn't match the filename → **filename wins**: overwrite `title` with the filename-derived value
|
||||
- If both are in sync → no-op
|
||||
|
||||
This means: edits made outside Laputa that change only the filename or only the title frontmatter are auto-corrected on next open. **Filename is the source of truth.**
|
||||
|
||||
### On title edit (inside Laputa)
|
||||
When the user renames a note inside Laputa:
|
||||
- Update `title` frontmatter to the new value
|
||||
- Rename the file to the new slug
|
||||
- Update all wikilinks in the vault (existing rename flow)
|
||||
|
||||
### Title display
|
||||
`extract_title` should read `title` from frontmatter (never from H1). H1 in the body is just content.
|
||||
|
||||
## Keyboard access
|
||||
- No new keyboard interactions — title editing is part of the existing rename flow
|
||||
- QA: Cmd+P → open note → verify title in header matches frontmatter `title` field
|
||||
|
||||
## Edge cases
|
||||
1. Note has no `title` frontmatter → derive from filename on open (silent, idempotent)
|
||||
2. `title` frontmatter desync from filename (edited outside Laputa) → filename wins, title overwritten
|
||||
3. Filename has hyphens that are actual content (e.g. `e2e-test.md`) → becomes `E2e Test` — acceptable tradeoff
|
||||
4. Fresh note created in Laputa → title set immediately from user input, filename = slug
|
||||
5. Note renamed outside Laputa (filename changed, title not) → title updated to match new filename on next open
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Every note has `title` in frontmatter after being opened in Laputa
|
||||
- [ ] Filename = slug of `title` at all times within Laputa
|
||||
- [ ] On open: desync is detected and resolved (filename wins)
|
||||
- [ ] `extract_title` reads from frontmatter `title`, never H1
|
||||
- [ ] Rename flow updates both `title` frontmatter and filename atomically
|
||||
- [ ] **Keyboard access:** open note → rename via title field → file renamed + wikilinks updated
|
||||
- [ ] No regressions (`pnpm test` + `cargo test` pass)
|
||||
- [ ] **Native app QA (keyboard only):** Open note → edit title → confirm filename changed + wikilinks updated; then manually desync title in a file → reopen in Laputa → confirm title auto-corrected
|
||||
|
||||
---
|
||||
CONTEXT:
|
||||
- Worktree: /Users/luca/Workspace/laputa-worktrees/title-filename-sync
|
||||
- Dev port: 5393 (use --port 5393 when running pnpm dev)
|
||||
- Run pnpm test before finishing
|
||||
- Push directly to main when done: git push origin HEAD:main — NEVER open PRs, NEVER --no-verify
|
||||
- Finish signal: openclaw system event --text 'laputa-task-done:6g9hCFpjP7qRRhqv:title-filename-sync' --mode now
|
||||
- See CLAUDE.md for all coding guidelines
|
||||
@@ -45,6 +45,16 @@ pub fn rename_note(
|
||||
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn auto_rename_untitled(
|
||||
vault_path: String,
|
||||
note_path: String,
|
||||
) -> Result<Option<RenameResult>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let note_path = expand_tilde(¬e_path);
|
||||
vault::auto_rename_untitled(&vault_path, ¬e_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -60,12 +70,6 @@ pub fn update_wikilinks_for_renames(
|
||||
vault::update_wikilinks_for_renames(&vault_path, &renames)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_note(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
@@ -78,12 +82,6 @@ pub fn batch_delete_notes(paths: Vec<String>) -> Result<Vec<String>, String> {
|
||||
vault::batch_delete_notes(&expanded)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn empty_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::empty_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -230,23 +228,6 @@ pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "_trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(
|
||||
&path,
|
||||
"_trashed_at",
|
||||
FrontmatterValue::String(now.clone()),
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// ── Search commands ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -297,18 +278,6 @@ mod tests {
|
||||
assert!(content.contains("Status: Active"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_trash_notes() {
|
||||
let (_dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
|
||||
assert_eq!(
|
||||
batch_trash_notes(vec![note.to_str().unwrap().to_string()]).unwrap(),
|
||||
1
|
||||
);
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("_trashed: true"));
|
||||
assert!(content.contains("_trashed_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_vault_entry_reads_from_disk() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
@@ -359,7 +328,7 @@ mod tests {
|
||||
|
||||
std::fs::write(
|
||||
vault_path.join("note.md"),
|
||||
"---\nTrashed: false\n---\n# Note\n",
|
||||
"---\n_archived: false\n---\n# Note\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
@@ -374,11 +343,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let entries = list_vault(vault_path.to_str().unwrap().to_string()).unwrap();
|
||||
assert!(!entries[0].trashed);
|
||||
assert!(!entries[0].archived);
|
||||
|
||||
std::fs::write(
|
||||
vault_path.join("note.md"),
|
||||
"---\nTrashed: true\n---\n# Note\n",
|
||||
"---\n_archived: true\n---\n# Note\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -386,8 +355,8 @@ mod tests {
|
||||
crate::vault::invalidate_cache(std::path::Path::new(vp_str));
|
||||
let fresh = crate::vault::scan_vault_cached(std::path::Path::new(vp_str)).unwrap();
|
||||
assert!(
|
||||
fresh[0].trashed,
|
||||
"reload_vault must reflect disk state after trashing"
|
||||
fresh[0].archived,
|
||||
"reload_vault must reflect disk state after archiving"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run startup housekeeping on the default vault (purge old trash, migrate legacy frontmatter).
|
||||
/// Run startup housekeeping on the default vault (migrate legacy frontmatter, seed configs).
|
||||
#[cfg(desktop)]
|
||||
fn run_startup_tasks() {
|
||||
let vault_path = dirs::home_dir()
|
||||
@@ -39,10 +39,6 @@ fn run_startup_tasks() {
|
||||
return;
|
||||
}
|
||||
let vp_str = vault_path.to_str().unwrap_or_default();
|
||||
log_startup_result(
|
||||
"Purged trashed files on startup",
|
||||
vault::purge_trash(vp_str).map(|d| d.len()),
|
||||
);
|
||||
log_startup_result(
|
||||
"Migrated is_a to type on startup",
|
||||
vault::migrate_is_a_to_type(vp_str),
|
||||
@@ -123,6 +119,7 @@ pub fn run() {
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::auto_rename_untitled,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
commands::get_file_history,
|
||||
@@ -151,14 +148,11 @@ pub fn run() {
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::purge_trash,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::empty_trash,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::batch_trash_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::save_settings,
|
||||
|
||||
@@ -32,13 +32,11 @@ const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
|
||||
const GO_ALL_NOTES: &str = "go-all-notes";
|
||||
const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_TRASH: &str = "go-trash";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
const GO_INBOX: &str = "go-inbox";
|
||||
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_TRASH: &str = "note-trash";
|
||||
const NOTE_EMPTY_TRASH: &str = "note-empty-trash";
|
||||
const NOTE_DELETE: &str = "note-delete";
|
||||
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
|
||||
|
||||
const VAULT_OPEN: &str = "vault-open";
|
||||
@@ -77,11 +75,9 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
NOTE_DELETE,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
@@ -99,7 +95,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
FILE_SAVE,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_DELETE,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
VIEW_TOGGLE_BACKLINKS,
|
||||
@@ -249,7 +245,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
let archived = MenuItemBuilder::new("Archived")
|
||||
.id(GO_ARCHIVED)
|
||||
.build(app)?;
|
||||
let trash = MenuItemBuilder::new("Trash").id(GO_TRASH).build(app)?;
|
||||
let changes = MenuItemBuilder::new("Changes").id(GO_CHANGES).build(app)?;
|
||||
let inbox = MenuItemBuilder::new("Inbox").id(GO_INBOX).build(app)?;
|
||||
let go_back = MenuItemBuilder::new("Go Back")
|
||||
@@ -264,7 +259,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
Ok(SubmenuBuilder::new(app, "Go")
|
||||
.item(&all_notes)
|
||||
.item(&archived)
|
||||
.item(&trash)
|
||||
.item(&changes)
|
||||
.item(&inbox)
|
||||
.separator()
|
||||
@@ -278,13 +272,10 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.id(NOTE_ARCHIVE)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let trash_note = MenuItemBuilder::new("Trash Note")
|
||||
.id(NOTE_TRASH)
|
||||
let delete_note = MenuItemBuilder::new("Delete Note")
|
||||
.id(NOTE_DELETE)
|
||||
.accelerator("CmdOrCtrl+Backspace")
|
||||
.build(app)?;
|
||||
let empty_trash = MenuItemBuilder::new("Empty Trash…")
|
||||
.id(NOTE_EMPTY_TRASH)
|
||||
.build(app)?;
|
||||
let open_new_window = MenuItemBuilder::new("Open in New Window")
|
||||
.id(NOTE_OPEN_IN_NEW_WINDOW)
|
||||
.accelerator("CmdOrCtrl+Shift+O")
|
||||
@@ -295,7 +286,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
|
||||
.id(VIEW_TOGGLE_AI_CHAT)
|
||||
.accelerator("CmdOrCtrl+Alt+I")
|
||||
.accelerator("CmdOrCtrl+Shift+L")
|
||||
.build(app)?;
|
||||
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
|
||||
.id(VIEW_TOGGLE_BACKLINKS)
|
||||
@@ -303,8 +294,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Note")
|
||||
.item(&archive_note)
|
||||
.item(&trash_note)
|
||||
.item(&empty_trash)
|
||||
.item(&delete_note)
|
||||
.separator()
|
||||
.item(&open_new_window)
|
||||
.separator()
|
||||
@@ -462,11 +452,9 @@ mod tests {
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
NOTE_DELETE,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::vault;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
@@ -75,9 +74,6 @@ pub fn search_vault(
|
||||
if !path.extension().is_some_and(|ext| ext == "md") {
|
||||
continue;
|
||||
}
|
||||
if vault::is_file_trashed(path) {
|
||||
continue;
|
||||
}
|
||||
// Skip hidden dirs and .laputa config
|
||||
if path
|
||||
.components()
|
||||
|
||||
@@ -11,7 +11,8 @@ use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 11;
|
||||
/// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items)
|
||||
const CACHE_VERSION: u32 = 12;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
@@ -888,18 +889,18 @@ mod tests {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "note.md", "---\nTrashed: false\n---\n# Note\n");
|
||||
create_test_file(vault, "note.md", "---\n_archived: false\n---\n# Note\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Build cache — note is not trashed
|
||||
// Build cache — note is not archived
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(!entries[0].trashed, "note must not be trashed initially");
|
||||
assert!(!entries[0].archived, "note must not be archived initially");
|
||||
|
||||
// Simulate trashing the note on disk (update frontmatter directly)
|
||||
create_test_file(vault, "note.md", "---\nTrashed: true\n---\n# Note\n");
|
||||
// Simulate archiving the note on disk (update frontmatter directly)
|
||||
create_test_file(vault, "note.md", "---\n_archived: true\n---\n# Note\n");
|
||||
// Stage the change so git sees it
|
||||
git_add_commit(vault, "trash");
|
||||
git_add_commit(vault, "archive");
|
||||
|
||||
// Without invalidation, scan_vault_cached uses incremental update.
|
||||
// With invalidation, it must do a full rescan from disk.
|
||||
@@ -907,8 +908,8 @@ mod tests {
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert!(
|
||||
entries2[0].trashed,
|
||||
"note must be trashed after invalidate + rescan"
|
||||
entries2[0].archived,
|
||||
"note must be archived after invalidate + rescan"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -936,23 +937,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: `Trashed: Yes` (string) through full cached path.
|
||||
#[test]
|
||||
fn test_cached_vault_trashed_yes_string() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "trashed-note.md", "---\nTrashed: Yes\n---\n# Gone\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].trashed,
|
||||
"'Trashed: Yes' must be parsed as true through the cached vault path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: stale cache with old version is invalidated and
|
||||
/// re-parses `Archived: Yes` correctly after cache version bump.
|
||||
#[test]
|
||||
|
||||
@@ -159,7 +159,7 @@ mod tests {
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
assert!(content.contains("Laputa Vault"));
|
||||
// Must NOT create config/ directory
|
||||
assert!(!vault.join("config").exists());
|
||||
}
|
||||
@@ -205,7 +205,7 @@ mod tests {
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
assert!(content.contains("Laputa Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -272,7 +272,7 @@ mod tests {
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("Vault Instructions"));
|
||||
assert!(root.contains("Laputa Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -305,7 +305,7 @@ mod tests {
|
||||
assert!(!vault.join("config").exists());
|
||||
|
||||
let agents = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(agents.contains("Vault Instructions for AI Agents"));
|
||||
assert!(agents.contains("Laputa Vault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,9 +26,6 @@ pub struct VaultEntry {
|
||||
pub related_to: Vec<String>,
|
||||
pub status: Option<String>,
|
||||
pub archived: bool,
|
||||
pub trashed: bool,
|
||||
#[serde(rename = "trashedAt")]
|
||||
pub trashed_at: Option<u64>,
|
||||
#[serde(rename = "modifiedAt")]
|
||||
pub modified_at: Option<u64>,
|
||||
#[serde(rename = "createdAt")]
|
||||
@@ -81,6 +78,10 @@ pub struct VaultEntry {
|
||||
/// Configured via `_list_properties_display` in the type file's frontmatter.
|
||||
#[serde(rename = "listPropertiesDisplay", default)]
|
||||
pub list_properties_display: Vec<String>,
|
||||
/// Whether the note body has an H1 heading on the first non-empty line.
|
||||
/// Used by the frontend to decide whether to show the TitleField.
|
||||
#[serde(rename = "hasH1")]
|
||||
pub has_h1: bool,
|
||||
/// File kind: "markdown", "text", or "binary".
|
||||
/// Determines how the frontend renders and opens the file.
|
||||
#[serde(rename = "fileKind", default = "default_file_kind")]
|
||||
|
||||
@@ -19,18 +19,8 @@ pub(crate) struct Frontmatter {
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub archived: Option<bool>,
|
||||
#[serde(
|
||||
rename = "_trashed",
|
||||
alias = "Trashed",
|
||||
alias = "trashed",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub trashed: Option<bool>,
|
||||
#[serde(rename = "Status", alias = "status", default)]
|
||||
pub status: Option<StringOrList>,
|
||||
#[serde(rename = "_trashed_at", alias = "Trashed at", alias = "trashed_at")]
|
||||
pub trashed_at: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
@@ -147,6 +137,44 @@ impl StringOrList {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a JSON value so that arrays of mixed types (strings + objects + nulls)
|
||||
/// are flattened to arrays of strings. gray_matter mis-parses certain YAML patterns:
|
||||
///
|
||||
/// 1. Unquoted colons in list items: `- Bitcoin: Net Unrealized` becomes
|
||||
/// `{"Bitcoin": "Net Unrealized"}` instead of a plain string.
|
||||
/// 2. Hash comments in list items: `- # Heading` becomes `Null` because
|
||||
/// gray_matter treats `#` as a YAML comment.
|
||||
///
|
||||
/// This sanitizer converts objects back to "key: value" strings and removes nulls,
|
||||
/// preventing serde deserialization of the entire Frontmatter struct from failing.
|
||||
fn sanitize_value(value: &serde_json::Value) -> serde_json::Value {
|
||||
match value {
|
||||
serde_json::Value::Array(arr) => {
|
||||
let sanitized: Vec<serde_json::Value> = arr
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
// Drop nulls (from `# comment` in list items)
|
||||
serde_json::Value::Null => None,
|
||||
// Convert mis-parsed objects back to "key: value" strings
|
||||
serde_json::Value::Object(map) if !map.is_empty() => {
|
||||
let parts: Vec<String> = map
|
||||
.iter()
|
||||
.map(|(k, v)| match v {
|
||||
serde_json::Value::String(s) => format!("{}: {}", k, s),
|
||||
_ => format!("{}: {}", k, v),
|
||||
})
|
||||
.collect();
|
||||
Some(serde_json::Value::String(parts.join(", ")))
|
||||
}
|
||||
other => Some(other.clone()),
|
||||
})
|
||||
.collect();
|
||||
serde_json::Value::Array(sanitized)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse frontmatter from raw YAML data extracted by gray_matter.
|
||||
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
static KNOWN_KEYS: &[&str] = &[
|
||||
@@ -158,12 +186,6 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"_archived",
|
||||
"Archived",
|
||||
"archived",
|
||||
"_trashed",
|
||||
"Trashed",
|
||||
"trashed",
|
||||
"_trashed_at",
|
||||
"Trashed at",
|
||||
"trashed_at",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
@@ -183,7 +205,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
let filtered: serde_json::Map<String, serde_json::Value> = data
|
||||
.iter()
|
||||
.filter(|(k, _)| KNOWN_KEYS.contains(&k.as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.map(|(k, v)| (k.clone(), sanitize_value(v)))
|
||||
.collect();
|
||||
let value = serde_json::Value::Object(filtered);
|
||||
serde_json::from_value(value).unwrap_or_default()
|
||||
@@ -199,11 +221,6 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"aliases",
|
||||
"_archived",
|
||||
"archived",
|
||||
"_trashed",
|
||||
"trashed",
|
||||
"_trashed_at",
|
||||
"trashed at",
|
||||
"trashed_at",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
|
||||
@@ -18,103 +18,91 @@ struct SampleFile {
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Content for config/agents.md — vault instructions for AI agents.
|
||||
/// This file has no YAML frontmatter — it is a convention file for AI agents,
|
||||
/// not a vault note. The vault scanner will still pick it up as a regular entry.
|
||||
pub(super) const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
|
||||
/// Default AGENTS.md content — vault instructions for AI agents.
|
||||
/// Describes Laputa vault mechanics only; no vault-specific structure.
|
||||
/// The vault scanner will pick this up as a regular entry.
|
||||
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Laputa Vault
|
||||
|
||||
This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph.
|
||||
This is a [Laputa](https://github.com/refactoringhq/laputa-app) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
## Structure
|
||||
## Note structure
|
||||
|
||||
Files are organized in folders by type:
|
||||
|
||||
| Folder | Type | Purpose |
|
||||
|--------|------|---------|
|
||||
| `note/` | Note | General-purpose documents, research, meeting notes |
|
||||
| `project/` | Project | Time-bounded efforts with clear goals |
|
||||
| `person/` | Person | People — colleagues, collaborators, contacts |
|
||||
| `topic/` | Topic | Subject areas that group related notes |
|
||||
| `responsibility/` | Responsibility | Long-running duties with KPIs |
|
||||
| `procedure/` | Procedure | Recurring workflows (weekly, monthly) |
|
||||
| `event/` | Event | Something that happened on a specific date |
|
||||
| `quarter/` | Quarter | Time containers (e.g. 24Q1) |
|
||||
| `measure/` | Measure | Trackable metrics tied to responsibilities |
|
||||
| `target/` | Target | Time-bound goals for a measure |
|
||||
| `type/` | Type | Type definitions — icon, color, ordering |
|
||||
|
||||
Custom folders are valid — the folder name becomes the type (capitalized).
|
||||
|
||||
## Frontmatter
|
||||
|
||||
YAML frontmatter between `---` delimiters defines metadata:
|
||||
Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Project
|
||||
Status: Active
|
||||
Owner: "[[person/jane-doe]]"
|
||||
Belongs to: "[[quarter/24q1]]"
|
||||
Related to:
|
||||
- "[[topic/growth]]"
|
||||
- "[[note/research-findings]]"
|
||||
is_a: TypeName # the note's type (must match the title of a type file in the vault)
|
||||
url: https://... # example property
|
||||
belongs_to: "[[other-note]]"
|
||||
related_to:
|
||||
- "[[note-a]]"
|
||||
- "[[note-b]]"
|
||||
---
|
||||
|
||||
# Note Title
|
||||
|
||||
Body content in markdown.
|
||||
```
|
||||
|
||||
### Standard fields
|
||||
System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked.
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `type` | Entity type (usually inferred from folder) |
|
||||
| `Status` | Active, Done, Paused, Archived, Dropped |
|
||||
| `Owner` | Person responsible (wikilink) |
|
||||
| `Belongs to` | Parent relationship(s) |
|
||||
| `Related to` | Lateral associations |
|
||||
| `Cadence` | For Procedures: Weekly, Monthly, etc. |
|
||||
| `aliases` | Alternative names for wikilink resolution |
|
||||
## Types
|
||||
|
||||
### Custom fields
|
||||
|
||||
Any YAML field containing `[[wikilinks]]` becomes a navigable relationship:
|
||||
A type is a note with `is_a: Type`. Type files live in the vault root:
|
||||
|
||||
```yaml
|
||||
Has Measures: ["[[measure/revenue]]", "[[measure/churn]]"]
|
||||
Resources: "[[note/api-docs]]"
|
||||
---
|
||||
is_a: Type
|
||||
_icon: books # Phosphor icon name in kebab-case
|
||||
_color: "#8b5cf6" # hex color
|
||||
---
|
||||
|
||||
# TypeName
|
||||
```
|
||||
|
||||
To find what types exist: look for files with `is_a: Type` in the vault root.
|
||||
|
||||
## Relationships
|
||||
|
||||
Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically.
|
||||
|
||||
Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
Connect notes with double-bracket syntax:
|
||||
- `[[filename]]` or `[[Note Title]]` — link by filename or title
|
||||
- `[[filename|display text]]` — with custom display text
|
||||
- Works in frontmatter values and markdown body
|
||||
|
||||
- `[[note/my-note]]` — link by path
|
||||
- `[[My Note Title]]` — link by title or alias
|
||||
- `[[note/my-note|display text]]` — link with custom display text
|
||||
## Views
|
||||
|
||||
Wikilinks work in both frontmatter values and markdown body. Backlinks are computed automatically — linking A to B makes B show a backlink to A.
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
## Type definitions
|
||||
|
||||
Files in `type/` define entity types and control how they appear in the sidebar:
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: rocket-launch
|
||||
color: purple
|
||||
order: 1
|
||||
---
|
||||
```json
|
||||
{
|
||||
"title": "Active Notes",
|
||||
"filters": [
|
||||
{"property": "is_a", "operator": "equals", "value": "Note"},
|
||||
{"property": "status", "operator": "equals", "value": "Active"}
|
||||
],
|
||||
"sort": {"property": "title", "direction": "asc"}
|
||||
}
|
||||
```
|
||||
|
||||
Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor names in kebab-case.
|
||||
## Filenames
|
||||
|
||||
## Conventions
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
|
||||
- First `# Heading` in a file becomes its title
|
||||
- One entity per file
|
||||
- Filenames use kebab-case: `my-note-title.md`
|
||||
- Type is inferred from parent folder if not set in frontmatter
|
||||
- Relationships are bidirectional via automatic backlinks
|
||||
"#;
|
||||
## What you can do
|
||||
|
||||
- Create/edit notes with correct frontmatter and H1 title
|
||||
- Create new type files
|
||||
- Add or modify relationships
|
||||
- Create/edit views in `views/`
|
||||
- Edit `AGENTS.md` (this file)
|
||||
|
||||
Do not modify app configuration files — those are local to each installation.
|
||||
"##;
|
||||
|
||||
const SAMPLE_FILES: &[SampleFile] = &[
|
||||
SampleFile {
|
||||
@@ -519,12 +507,12 @@ mod tests {
|
||||
assert!(agents_path.exists(), "AGENTS.md should exist at vault root");
|
||||
|
||||
let content = fs::read_to_string(&agents_path).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
assert!(content.contains("## Structure"));
|
||||
assert!(content.contains("## Frontmatter"));
|
||||
assert!(content.contains("Laputa Vault"));
|
||||
assert!(content.contains("## Note structure"));
|
||||
assert!(content.contains("## Types"));
|
||||
assert!(content.contains("## Wikilinks"));
|
||||
assert!(content.contains("## Type definitions"));
|
||||
assert!(content.contains("## Conventions"));
|
||||
assert!(content.contains("## Relationships"));
|
||||
assert!(content.contains("## Views"));
|
||||
// Must NOT be a stub
|
||||
assert!(
|
||||
!content.contains("See config/agents.md"),
|
||||
@@ -539,8 +527,8 @@ mod tests {
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md"), None).unwrap();
|
||||
// No frontmatter title → derived from filename slug (H1 is body content)
|
||||
assert_eq!(entry.title, "AGENTS");
|
||||
// H1 is now the primary title source
|
||||
assert_eq!(entry.title, "AGENTS.md \u{2014} Laputa Vault");
|
||||
// Config files have no frontmatter type field — type is None
|
||||
assert_eq!(entry.is_a, None);
|
||||
}
|
||||
|
||||
@@ -20,10 +20,11 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{
|
||||
detect_renames, rename_note, update_wikilinks_for_renames, DetectedRename, RenameResult,
|
||||
auto_rename_untitled, detect_renames, rename_note, update_wikilinks_for_renames,
|
||||
DetectedRename, RenameResult,
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
|
||||
pub use trash::{batch_delete_notes, delete_note};
|
||||
pub use views::{
|
||||
delete_view, evaluate_view, save_view, scan_views, FilterCondition, FilterGroup, FilterNode,
|
||||
FilterOp, ViewDefinition, ViewFile,
|
||||
@@ -57,6 +58,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
|
||||
|
||||
let title = extract_title(frontmatter.title.as_deref(), &content, &filename);
|
||||
let has_h1 = parsing::extract_h1_title(&content).is_some();
|
||||
let snippet = extract_snippet(&content);
|
||||
let word_count = count_body_words(&content);
|
||||
let outgoing_links = extract_outgoing_links(&parsed.content);
|
||||
@@ -98,12 +100,6 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
related_to,
|
||||
status: frontmatter.status.and_then(|v| v.into_scalar()),
|
||||
archived: frontmatter.archived.unwrap_or(false),
|
||||
trashed: frontmatter.trashed.unwrap_or(false),
|
||||
trashed_at: frontmatter
|
||||
.trashed_at
|
||||
.and_then(|v| v.into_scalar())
|
||||
.as_deref()
|
||||
.and_then(parsing::parse_iso_date),
|
||||
modified_at,
|
||||
created_at,
|
||||
file_size,
|
||||
@@ -122,12 +118,13 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
word_count,
|
||||
outgoing_links,
|
||||
properties,
|
||||
has_h1,
|
||||
file_kind: "markdown".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a non-markdown file into a minimal VaultEntry.
|
||||
/// Uses filename as title, no frontmatter extraction.
|
||||
/// Uses filename as title, except for `.yml` files where the YAML `name` field is used.
|
||||
pub(crate) fn parse_non_md_file(
|
||||
path: &Path,
|
||||
git_dates: Option<(u64, u64)>,
|
||||
@@ -142,11 +139,12 @@ pub(crate) fn parse_non_md_file(
|
||||
None => (fs_modified, fs_created),
|
||||
};
|
||||
let file_kind = classify_file_kind(path).to_string();
|
||||
let title = extract_yml_name(path).unwrap_or_else(|| filename.clone());
|
||||
|
||||
Ok(VaultEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
filename: filename.clone(),
|
||||
title: filename,
|
||||
title,
|
||||
file_kind,
|
||||
modified_at,
|
||||
created_at,
|
||||
@@ -155,6 +153,17 @@ pub(crate) fn parse_non_md_file(
|
||||
})
|
||||
}
|
||||
|
||||
/// For `.yml` files, try to extract the `name` field from the YAML content.
|
||||
fn extract_yml_name(path: &Path) -> Option<String> {
|
||||
let ext = path.extension()?.to_str()?;
|
||||
if ext != "yml" && ext != "yaml" {
|
||||
return None;
|
||||
}
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let mapping: serde_yaml::Value = serde_yaml::from_str(&content).ok()?;
|
||||
mapping.get("name")?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Re-read a single file from disk and return a fresh VaultEntry.
|
||||
/// Uses filesystem dates (no git lookup) since the file was likely just saved.
|
||||
pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
|
||||
|
||||
@@ -94,8 +94,8 @@ fn test_parse_empty_frontmatter() {
|
||||
"just-a-title.md",
|
||||
"---\n---\n# Just a Title\n\nNo frontmatter fields.",
|
||||
);
|
||||
// No title in frontmatter → derived from filename slug (H1 is body content)
|
||||
assert_eq!(entry.title, "Just A Title");
|
||||
// H1 is now the primary title source
|
||||
assert_eq!(entry.title, "Just a Title");
|
||||
assert!(entry.aliases.is_empty());
|
||||
|
||||
assert!(entry.belongs_to.is_empty());
|
||||
@@ -955,30 +955,6 @@ Company: Acme Corp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_title_case() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: true\nTrashed at: \"2025-02-01\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone.md", content);
|
||||
assert!(entry.trashed);
|
||||
assert!(entry.trashed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_lowercase_alias() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntrashed: true\ntrashed_at: \"2025-02-01\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"lowercase 'trashed' must be parsed via alias"
|
||||
);
|
||||
assert!(
|
||||
entry.trashed_at.is_some(),
|
||||
"lowercase 'trashed_at' must be parsed via alias"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_lowercase_alias() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -998,16 +974,7 @@ fn test_parse_archived_titlecase() {
|
||||
assert!(entry.archived, "titlecase 'Archived' must also be parsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trashed_false_when_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Note\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active.md", content);
|
||||
assert!(!entry.trashed);
|
||||
assert!(entry.trashed_at.is_none());
|
||||
}
|
||||
|
||||
// --- archived/trashed string-value tests ---
|
||||
// --- archived string-value tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_yes_titlecase() {
|
||||
@@ -1068,44 +1035,8 @@ fn test_parse_archived_absent() {
|
||||
assert!(!entry.archived, "absent archived must default to false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_yes_titlecase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: Yes\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone2.md", content);
|
||||
assert!(entry.trashed, "'Trashed: Yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_yes_lowercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntrashed: yes\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone3.md", content);
|
||||
assert!(entry.trashed, "'trashed: yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_no() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: No\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active6.md", content);
|
||||
assert!(!entry.trashed, "'Trashed: No' must be parsed as false");
|
||||
}
|
||||
|
||||
// --- new canonical underscore-prefixed keys ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_underscore_trashed_canonical() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\n_trashed: true\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone-new.md", content);
|
||||
assert!(entry.trashed, "'_trashed: true' must be parsed as trashed");
|
||||
assert!(
|
||||
entry.trashed_at.is_some(),
|
||||
"'_trashed_at' must be parsed as trashed_at"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_underscore_archived_canonical() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1166,22 +1097,6 @@ fn test_parse_visible_missing_defaults_to_none() {
|
||||
assert_eq!(entry.visible, None);
|
||||
}
|
||||
|
||||
// --- Regression: trashed/archived must survive unquoted date in "Trashed at" ---
|
||||
|
||||
#[test]
|
||||
fn test_trashed_true_with_unquoted_date_in_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Reproduces the engineering-management.md scenario: Trashed at has an
|
||||
// unquoted YAML date (2026-03-11) which gray_matter may parse as a non-string.
|
||||
// The entire Frontmatter deserialization must NOT fail because of this.
|
||||
let content = "---\ntype: Topic\nTrashed: true\n\"Trashed at\": 2026-03-11\n---\n# Engineering Management\n";
|
||||
let entry = parse_test_entry(&dir, "engineering-management.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when 'Trashed at' contains an unquoted date"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archived_true_with_extra_non_string_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1194,91 +1109,6 @@ fn test_archived_true_with_extra_non_string_fields() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trashed_with_reviewed_false_field() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Topic\nReviewed: False\nTrashed: true\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "reviewed-test.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when frontmatter contains Reviewed: False"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||||
}
|
||||
|
||||
/// Regression: wikilinks containing curly braces + nested quotes in YAML arrays
|
||||
/// cause gray_matter to produce Hash values instead of strings for array elements.
|
||||
/// This must NOT make parse_frontmatter fall back to default (losing trashed/archived).
|
||||
#[test]
|
||||
fn test_trashed_survives_malformed_wikilinks_in_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// This YAML has curly braces inside a double-quoted string, producing nested
|
||||
// Hash values in some YAML parsers. The Frontmatter serde must not fail.
|
||||
let content = "---\ntype: Topic\nNotes:\n - \"[[foo|bar]]\"\n - \"[[slug|{'Title': 'Subtitle'}]]\"\nTrashed: true\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "malformed-links.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even with curly-brace wikilinks in frontmatter arrays"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: files with malformed YAML (e.g. Notion exports with unescaped quotes
|
||||
/// in wikilinks) cause gray_matter to return Null instead of a Hash. The fallback
|
||||
/// parser must still extract Trashed, type, and other simple key:value fields.
|
||||
#[test]
|
||||
fn test_parse_real_engineering_management_file() {
|
||||
let path = std::path::Path::new("/Users/luca/Laputa/engineering-management.md");
|
||||
if !path.exists() {
|
||||
return; // Skip when the Laputa vault is not available
|
||||
}
|
||||
let entry = parse_md_file(path, None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"engineering-management.md must be trashed (has Trashed: true in frontmatter)"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_trashed_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Simulate malformed YAML that gray_matter can't parse: unescaped double
|
||||
// quotes inside a double-quoted YAML string cause the YAML parser to fail.
|
||||
// The fallback line-by-line parser must still extract simple key:value pairs.
|
||||
//
|
||||
// Write the file manually with literal unescaped quotes (can't use Rust string
|
||||
// escaping for this since the YAML itself is the malformed part).
|
||||
let fm = [
|
||||
"---",
|
||||
"type: Topic",
|
||||
"Status: Draft",
|
||||
"Belongs to:",
|
||||
" - \"[[engineering|Engineering]]\"",
|
||||
"aliases:",
|
||||
" - Engineering Management",
|
||||
"Notes:",
|
||||
// This line has unescaped " inside a "-quoted YAML string — malformed YAML
|
||||
" - \"[[slug|{\"Title\": 'Subtitle'}]]\"",
|
||||
"Trashed: true",
|
||||
"\"Trashed at\": 2026-03-11",
|
||||
"---",
|
||||
"",
|
||||
"# Engineering Management",
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "eng-mgmt.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("eng-mgmt.md"), None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when YAML is malformed (fallback parser)"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Topic".to_string()),
|
||||
"isA must be extracted by fallback parser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1491,6 +1321,421 @@ fn test_list_properties_display_not_in_properties_or_relationships() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yml_file_uses_name_field_as_title() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let yml_content = "name: Active Projects\nicon: rocket\ncolor: blue\n";
|
||||
let yml_path = dir.path().join("active-projects.yml");
|
||||
std::fs::write(&yml_path, yml_content).unwrap();
|
||||
let entry = super::parse_non_md_file(&yml_path, None).unwrap();
|
||||
assert_eq!(entry.title, "Active Projects");
|
||||
assert_eq!(entry.filename, "active-projects.yml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yml_file_without_name_falls_back_to_filename() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let yml_content = "key: value\n";
|
||||
let yml_path = dir.path().join("config.yml");
|
||||
std::fs::write(&yml_path, yml_content).unwrap();
|
||||
let entry = super::parse_non_md_file(&yml_path, None).unwrap();
|
||||
assert_eq!(entry.title, "config.yml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_yml_file_uses_filename_as_title() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let txt_path = dir.path().join("notes.txt");
|
||||
std::fs::write(&txt_path, "some content").unwrap();
|
||||
let entry = super::parse_non_md_file(&txt_path, None).unwrap();
|
||||
assert_eq!(entry.title, "notes.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_file_kind_yml_is_text() {
|
||||
assert_eq!(
|
||||
classify_file_kind(Path::new("views/active-projects.yml")),
|
||||
"text"
|
||||
);
|
||||
assert_eq!(classify_file_kind(Path::new("config.yaml")), "text");
|
||||
assert_eq!(classify_file_kind(Path::new("data.json")), "text");
|
||||
assert_eq!(classify_file_kind(Path::new("script.py")), "text");
|
||||
assert_eq!(classify_file_kind(Path::new("readme.txt")), "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_file_kind_md_is_markdown() {
|
||||
assert_eq!(classify_file_kind(Path::new("note.md")), "markdown");
|
||||
assert_eq!(classify_file_kind(Path::new("README.markdown")), "markdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_file_kind_unknown_is_binary() {
|
||||
assert_eq!(classify_file_kind(Path::new("photo.png")), "binary");
|
||||
assert_eq!(classify_file_kind(Path::new("archive.zip")), "binary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_md_file_gets_text_file_kind() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"views/my-view.yml",
|
||||
"name: My View\nicon: rocket\n",
|
||||
);
|
||||
let entry = super::parse_non_md_file(&dir.path().join("views/my-view.yml"), None).unwrap();
|
||||
assert_eq!(entry.file_kind, "text");
|
||||
assert_eq!(entry.title, "My View");
|
||||
}
|
||||
|
||||
/// Regression test: notes with complex frontmatter (quoted values, wikilinks
|
||||
/// in arrays, Notion-imported fields) must parse type and _organized correctly.
|
||||
/// Bug: initial load showed Type: None and notes appeared in Inbox despite
|
||||
/// having type and _organized: true in frontmatter.
|
||||
#[test]
|
||||
fn test_complex_frontmatter_type_and_organized() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Notion-style frontmatter with quoted dates, wikilinks in arrays, non-standard fields
|
||||
let content = r#"---
|
||||
type: Note
|
||||
_organized: true
|
||||
aliases: ["My Complex Note"]
|
||||
Topics: ["[[topic-writing]]", "[[topic-productivity|Productivity]]"]
|
||||
"Created at": "2021-12-31T14:19:00.000Z"
|
||||
Status: Published
|
||||
Owner: "[[person-luca|Luca]]"
|
||||
---
|
||||
# My Complex Note
|
||||
|
||||
Content here.
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "complex-note.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must be parsed correctly with complex frontmatter"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true with complex frontmatter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_frontmatter_is_a_alias_with_extra_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Uses "Is A" instead of "type", plus many non-standard fields
|
||||
let content = r#"---
|
||||
Is A: Evergreen
|
||||
_organized: true
|
||||
aliases: ["Writing for Clarity vs. Writing for Credit"]
|
||||
Topics: ["[[topic-writing]]"]
|
||||
Status: Published
|
||||
"Last edited": "2024-06-15T10:30:00.000Z"
|
||||
notion_id: "abc123def456"
|
||||
---
|
||||
# Writing for Clarity vs. Writing for Credit
|
||||
|
||||
Content.
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "writing-note.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Evergreen".to_string()),
|
||||
"Is A must be parsed correctly with quoted keys nearby"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true even with complex surrounding fields"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_frontmatter_boolean_as_yaml_yes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// _organized as YAML boolean Yes (not true)
|
||||
let content = "---\ntype: Note\n_organized: Yes\nStatus: Draft\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "yes-note.md", content);
|
||||
assert_eq!(entry.is_a, Some("Note".to_string()));
|
||||
assert!(entry.organized, "_organized: Yes must be parsed as true");
|
||||
}
|
||||
|
||||
/// Regression: gray_matter may fail to parse YAML with unquoted timestamp values
|
||||
/// (colons in values like "14:19:00"), causing the entire frontmatter to be lost.
|
||||
#[test]
|
||||
fn test_complex_frontmatter_unquoted_timestamp() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let content = "---\ntype: Note\n_organized: true\nCreated at: 2021-12-31T14:19:00.000Z\nTopics:\n - \"[[topic-writing]]\"\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "timestamp-note.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must survive unquoted timestamp in sibling field"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must survive unquoted timestamp in sibling field"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: gray_matter may fail with flow-style arrays containing wikilinks
|
||||
/// whose brackets look like nested YAML structures.
|
||||
#[test]
|
||||
fn test_complex_frontmatter_flow_array_with_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
let content = r#"---
|
||||
type: Evergreen
|
||||
_organized: true
|
||||
Topics: ["[[topic-writing]]", "[[topic-productivity|Productivity]]"]
|
||||
Has: ["[[note-one]]", "[[note-two]]", "[[note-three]]"]
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "flow-array.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Evergreen".to_string()),
|
||||
"type must survive flow arrays with wikilinks"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must survive flow arrays with wikilinks"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: YAML that gray_matter cannot parse at all (returns Pod::String)
|
||||
/// must still extract type and _organized via fallback parser.
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_type_and_organized() {
|
||||
use super::frontmatter::extract_fm_and_rels;
|
||||
// Simulate gray_matter returning String (parse failure) by passing None
|
||||
let raw_content =
|
||||
"---\ntype: Note\n_organized: true\nBroken: value: with: colons\n---\n# Test\n";
|
||||
let (fm, _, _) = extract_fm_and_rels(None, raw_content);
|
||||
assert_eq!(
|
||||
fm.is_a.as_ref().and_then(|v| v.clone().into_scalar()),
|
||||
Some("Note".to_string()),
|
||||
"fallback parser must extract type"
|
||||
);
|
||||
assert_eq!(
|
||||
fm.organized,
|
||||
Some(true),
|
||||
"fallback parser must extract _organized"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: real-world Notion-imported note with quoted keys containing
|
||||
/// special characters (?, spaces) and many non-standard fields.
|
||||
#[test]
|
||||
fn test_notion_imported_frontmatter_with_special_quoted_keys() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
type: Readings
|
||||
aliases:
|
||||
- "1 to 1s"
|
||||
"Discarded for digest?": false
|
||||
"Note Status": Saved
|
||||
URL: "http://theengineeringmanager.com/management-101/121s/"
|
||||
Author: James Stanier
|
||||
Category: Articles
|
||||
"Full Title": 1 to 1s
|
||||
Highlights: 21
|
||||
"Last Synced": 2025-12-10
|
||||
"Last Highlighted": 2021-04-12
|
||||
notion_id: 2c5bdf02-815c-81ce-9dce-eca60ddaeb08
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# 1 to 1s
|
||||
|
||||
Content.
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "1-to-1s.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Readings".to_string()),
|
||||
"type must be parsed with Notion-style quoted keys"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true with Notion-style frontmatter"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: unquoted colon in YAML list item causes gray_matter to mis-parse.
|
||||
/// `aliases:\n - Bitcoin: Net Unrealized...` has an unquoted `:` in a list item.
|
||||
/// gray_matter may return a partial/mangled Hash instead of failing cleanly.
|
||||
#[test]
|
||||
fn test_unquoted_colon_in_list_item_breaks_parsing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\n_organized: true\naliases:\n - Bitcoin: Net Unrealized Profit/Loss\n - Note\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "colon-alias.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must be parsed correctly even when gray_matter fails due to unquoted colon in list item"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true even when gray_matter fails due to unquoted colon in list item"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: `# ` in a YAML list item is interpreted as a comment by gray_matter,
|
||||
/// truncating the frontmatter and losing subsequent fields.
|
||||
#[test]
|
||||
fn test_hash_in_list_item_treated_as_comment() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\n_organized: true\naliases:\n - # Writing a Good CLAUDE.md\n - Note\n---\n# Title\n";
|
||||
let entry = parse_test_entry(&dir, "hash-alias.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must be parsed even when alias starts with # (YAML comment char)"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true even when alias starts with # (YAML comment char)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full reproduction: real-world Notion-imported note with unquoted colon in alias.
|
||||
#[test]
|
||||
fn test_real_world_notion_note_with_unquoted_colon() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
type: Note
|
||||
workspace: personal
|
||||
notion_id: de48a4ad-e7ad-42aa-a5ce-1efdc259d7f9
|
||||
"Created at": "2021-12-17T10:21:00.000Z"
|
||||
Reviewed: True
|
||||
Topics:
|
||||
- "[[web3|Web3 / Crypto]]"
|
||||
URL: https://studio.glassnode.com/metrics?a=BTC&category=Market%20Indicators&m=indicators.NetUnrealizedProfitLoss&s=1320105600&u=1639267199&zoom=
|
||||
aliases:
|
||||
- Bitcoin: Net Unrealized Profit/Loss (NUPL) - Glassnode Studio
|
||||
- Note
|
||||
Trashed: true
|
||||
"Trashed at": 2026-03-11
|
||||
_organized: true
|
||||
---
|
||||
|
||||
# Bitcoin: Net Unrealized Profit/Loss (NUPL) - Glassnode Studio
|
||||
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "bitcoin-note.md", content);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Note".to_string()),
|
||||
"type must be parsed correctly even with unquoted colon in alias"
|
||||
);
|
||||
assert!(
|
||||
entry.organized,
|
||||
"_organized must be true even with unquoted colon in alias"
|
||||
);
|
||||
}
|
||||
|
||||
/// Scan the real user vault and verify all notes with type/organized in frontmatter
|
||||
/// have those fields correctly parsed.
|
||||
#[test]
|
||||
fn test_real_vault_type_and_organized_consistency() {
|
||||
let vault_path = std::path::Path::new("/Users/luca/Laputa");
|
||||
if !vault_path.exists() {
|
||||
eprintln!("Skipping: ~/Laputa vault not found");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut mismatches = Vec::new();
|
||||
let walker = walkdir::WalkDir::new(vault_path)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !e.file_name().to_string_lossy().starts_with('.'));
|
||||
|
||||
for dir_entry in walker.filter_map(|e| e.ok()) {
|
||||
let path = dir_entry.path();
|
||||
if !path.is_file() || path.extension().is_none_or(|ext| ext != "md") {
|
||||
continue;
|
||||
}
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Only check files that have frontmatter
|
||||
if !content.starts_with("---\n") {
|
||||
continue;
|
||||
}
|
||||
let fm_end = match content[4..].find("\n---") {
|
||||
Some(pos) => pos + 4,
|
||||
None => continue,
|
||||
};
|
||||
let fm_block = &content[4..fm_end];
|
||||
|
||||
// Check if frontmatter has type with a non-empty value
|
||||
let has_type_with_value = fm_block.lines().any(|l| {
|
||||
let trimmed = l.trim();
|
||||
for prefix in &["type:", "Is A:", "is_a:"] {
|
||||
if let Some(rest) = trimmed.strip_prefix(prefix) {
|
||||
return !rest.trim().is_empty();
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
|
||||
// Check if frontmatter has _organized: true
|
||||
let has_organized_true = fm_block.lines().any(|l| {
|
||||
let t = l.trim();
|
||||
t == "_organized: true"
|
||||
|| t == "_organized: True"
|
||||
|| t == "_organized: yes"
|
||||
|| t == "_organized: Yes"
|
||||
});
|
||||
|
||||
if !has_type_with_value && !has_organized_true {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed = match parse_md_file(path, None) {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
mismatches.push(format!("PARSE ERROR: {} -> {}", path.display(), err));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if has_type_with_value && parsed.is_a.is_none() {
|
||||
mismatches.push(format!(
|
||||
"TYPE MISSING: {} (raw has type with value but parsed isA=None)",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
if has_organized_true && !parsed.organized {
|
||||
mismatches.push(format!(
|
||||
"ORGANIZED MISSING: {} (raw has _organized: true but parsed organized=false)",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !mismatches.is_empty() {
|
||||
let summary = mismatches
|
||||
.iter()
|
||||
.take(20)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
panic!(
|
||||
"Found {} parsing mismatches in real vault:\n{}",
|
||||
mismatches.len(),
|
||||
summary
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -21,15 +21,40 @@ pub(super) fn slug_to_title(stem: &str) -> String {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Extract the H1 title from the first non-empty line of the body (after frontmatter).
|
||||
/// Returns `None` if no H1 is found on the first non-empty line.
|
||||
pub(super) fn extract_h1_title(content: &str) -> Option<String> {
|
||||
let body = strip_frontmatter(content);
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(title) = trimmed.strip_prefix("# ") {
|
||||
let title = strip_markdown_chars(title).trim().to_string();
|
||||
if !title.is_empty() {
|
||||
return Some(title);
|
||||
}
|
||||
}
|
||||
break; // first non-empty line is not H1
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the display title for a note.
|
||||
/// Priority: frontmatter `title:` → filename-derived title.
|
||||
/// H1 headings are treated as body content, not title source.
|
||||
pub(super) fn extract_title(fm_title: Option<&str>, _content: &str, filename: &str) -> String {
|
||||
/// Priority: H1 on first non-empty line → frontmatter `title:` → filename-derived title.
|
||||
pub(super) fn extract_title(fm_title: Option<&str>, content: &str, filename: &str) -> String {
|
||||
// 1. H1 on first non-empty line of body
|
||||
if let Some(h1) = extract_h1_title(content) {
|
||||
return h1;
|
||||
}
|
||||
// 2. frontmatter title (legacy, backward compat)
|
||||
if let Some(title) = fm_title {
|
||||
if !title.is_empty() {
|
||||
return title.to_string();
|
||||
}
|
||||
}
|
||||
// 3. filename slug
|
||||
let stem = filename.strip_suffix(".md").unwrap_or(filename);
|
||||
slug_to_title(stem)
|
||||
}
|
||||
@@ -285,32 +310,6 @@ pub(super) fn extract_outgoing_links(content: &str) -> Vec<String> {
|
||||
links
|
||||
}
|
||||
|
||||
/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch).
|
||||
/// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats.
|
||||
pub(super) fn parse_iso_date(date_str: &str) -> Option<u64> {
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
|
||||
let trimmed = date_str.trim().trim_matches('"');
|
||||
|
||||
// Try full datetime with optional fractional seconds and Z suffix
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.fZ") {
|
||||
return Some(dt.and_utc().timestamp() as u64);
|
||||
}
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%SZ") {
|
||||
return Some(dt.and_utc().timestamp() as u64);
|
||||
}
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
|
||||
return Some(dt.and_utc().timestamp() as u64);
|
||||
}
|
||||
|
||||
// Try date-only
|
||||
if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
|
||||
return Some(d.and_hms_opt(0, 0, 0)?.and_utc().timestamp() as u64);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -342,29 +341,72 @@ mod tests {
|
||||
assert_eq!(slug_to_title("a--b"), "A B");
|
||||
}
|
||||
|
||||
// --- extract_h1_title tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_basic() {
|
||||
assert_eq!(
|
||||
extract_h1_title("# Hello World\n\nBody."),
|
||||
Some("Hello World".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_after_frontmatter() {
|
||||
let content = "---\ntype: Note\n---\n# My Note\n\nBody.";
|
||||
assert_eq!(extract_h1_title(content), Some("My Note".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_with_empty_lines_before() {
|
||||
let content = "---\ntype: Note\n---\n\n# Spaced Title\n\nBody.";
|
||||
assert_eq!(extract_h1_title(content), Some("Spaced Title".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_none_when_no_h1() {
|
||||
assert_eq!(extract_h1_title("Just body text."), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_h1_title_none_when_h1_not_first() {
|
||||
assert_eq!(extract_h1_title("Some text\n# Not first\n"), None);
|
||||
}
|
||||
|
||||
// --- extract_title tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_from_frontmatter() {
|
||||
fn test_extract_title_h1_takes_priority_over_frontmatter() {
|
||||
assert_eq!(
|
||||
extract_title(Some("My Great Note"), "", "my-great-note.md"),
|
||||
"My Great Note"
|
||||
extract_title(
|
||||
Some("FM Title"),
|
||||
"---\ntitle: FM Title\n---\n# H1 Title\n\nBody.",
|
||||
"note.md"
|
||||
),
|
||||
"H1 Title"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_ignores_h1_uses_filename() {
|
||||
// H1 is body content, not a title source
|
||||
fn test_extract_title_h1_when_no_frontmatter_title() {
|
||||
assert_eq!(
|
||||
extract_title(None, "# Hello World\n\nBody text.", "some-file.md"),
|
||||
"Some File"
|
||||
"Hello World"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_ignores_h1_after_frontmatter() {
|
||||
fn test_extract_title_h1_after_frontmatter() {
|
||||
let content = "---\nIs A: Note\n---\n# My Note\n\nBody.";
|
||||
assert_eq!(extract_title(None, content, "fallback.md"), "Fallback");
|
||||
assert_eq!(extract_title(None, content, "fallback.md"), "My Note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_frontmatter_when_no_h1() {
|
||||
assert_eq!(
|
||||
extract_title(Some("My Great Note"), "Just body text.", "my-great-note.md"),
|
||||
"My Great Note"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -376,11 +418,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_title_empty_fm_falls_back_to_filename() {
|
||||
// Empty frontmatter title falls back to filename, not H1
|
||||
fn test_extract_title_h1_wins_over_empty_frontmatter() {
|
||||
assert_eq!(
|
||||
extract_title(Some(""), "# From H1\n", "empty-h1.md"),
|
||||
"Empty H1"
|
||||
"From H1"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -747,50 +788,6 @@ mod tests {
|
||||
assert!(!contains_wikilink("only ]] closing"));
|
||||
}
|
||||
|
||||
// --- parse_iso_date tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_full_datetime_with_z() {
|
||||
let ts = parse_iso_date("2025-05-23T14:35:00.000Z");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1748010900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_datetime_no_fractional() {
|
||||
let ts = parse_iso_date("2025-05-23T14:35:00Z");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1748010900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_datetime_no_z() {
|
||||
let ts = parse_iso_date("2025-05-23T14:35:00");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1748010900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_date_only() {
|
||||
let ts = parse_iso_date("2025-05-23");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1747958400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_with_quotes_and_whitespace() {
|
||||
let ts = parse_iso_date(" \"2025-05-23\" ");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1747958400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_invalid() {
|
||||
assert!(parse_iso_date("not-a-date").is_none());
|
||||
assert!(parse_iso_date("").is_none());
|
||||
assert!(parse_iso_date("2025-13-45").is_none());
|
||||
}
|
||||
|
||||
// --- extract_outgoing_links tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -251,6 +251,46 @@ pub fn rename_note(
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a filename matches the untitled pattern (e.g. "untitled-note-1234567890.md").
|
||||
fn is_untitled_filename(filename: &str) -> bool {
|
||||
let stem = filename.strip_suffix(".md").unwrap_or(filename);
|
||||
// Match: untitled-note-{digits} or untitled-{type}-{digits}
|
||||
stem.starts_with("untitled-")
|
||||
&& stem
|
||||
.rsplit('-')
|
||||
.next()
|
||||
.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// Auto-rename an untitled note based on its H1 heading.
|
||||
/// Returns `Some(RenameResult)` if renamed, `None` if conditions not met.
|
||||
/// This is a ONE-SHOT rename: only fires for untitled-* files with an H1.
|
||||
pub fn auto_rename_untitled(
|
||||
vault_path: &str,
|
||||
note_path: &str,
|
||||
) -> Result<Option<RenameResult>, String> {
|
||||
let path = Path::new(note_path);
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !is_untitled_filename(&filename) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
|
||||
|
||||
let h1_title = match super::parsing::extract_h1_title(&content) {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let result = rename_note(vault_path, note_path, &h1_title, None)?;
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
/// A detected rename: old path → new path (both relative to vault root).
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DetectedRename {
|
||||
|
||||
@@ -1,49 +1,5 @@
|
||||
use gray_matter::engine::YAML;
|
||||
use gray_matter::Matter;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Check if a file path points to a markdown file.
|
||||
fn is_markdown_file(path: &Path) -> bool {
|
||||
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
|
||||
}
|
||||
|
||||
/// Extract the "Trashed at" date string from parsed gray_matter data.
|
||||
fn extract_trashed_at_string(data: &Option<gray_matter::Pod>) -> Option<String> {
|
||||
let gray_matter::Pod::Hash(ref map) = data.as_ref()? else {
|
||||
return None;
|
||||
};
|
||||
let pod = map
|
||||
.get("_trashed_at")
|
||||
.or_else(|| map.get("Trashed at"))
|
||||
.or_else(|| map.get("trashed_at"))?;
|
||||
match pod {
|
||||
gray_matter::Pod::String(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a "Trashed at" date string into a NaiveDate. Supports "2026-01-01" and "2026-01-01T..." formats.
|
||||
fn parse_trashed_date(date_str: &str) -> Option<chrono::NaiveDate> {
|
||||
let trimmed = date_str.trim().trim_matches('"');
|
||||
let date_part = trimmed.split('T').next().unwrap_or(trimmed);
|
||||
chrono::NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok()
|
||||
}
|
||||
|
||||
/// Delete a file and log the result. Returns the path string if successful.
|
||||
fn try_purge_file(path: &Path) -> Option<String> {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => {
|
||||
log::info!("Purged trashed file: {}", path.display());
|
||||
Some(path.to_string_lossy().to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to delete {}: {}", path.display(), e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Permanently delete a single note file.
|
||||
/// Returns the deleted path on success, or an error if the file doesn't exist.
|
||||
@@ -60,41 +16,6 @@ pub fn delete_note(path: &str) -> Result<String, String> {
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
/// Check whether a file's frontmatter marks it as trashed.
|
||||
/// Returns `true` if `Trashed: true` or `Trashed at` is present.
|
||||
pub fn is_file_trashed(path: &Path) -> bool {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&content);
|
||||
|
||||
// Check for "Trashed at" field — its presence implies trashed
|
||||
if extract_trashed_at_string(&parsed.data).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for "Trashed: true"
|
||||
if let Some(gray_matter::Pod::Hash(ref map)) = parsed.data {
|
||||
if let Some(pod) = map
|
||||
.get("_trashed")
|
||||
.or_else(|| map.get("Trashed"))
|
||||
.or_else(|| map.get("trashed"))
|
||||
{
|
||||
return match pod {
|
||||
gray_matter::Pod::Boolean(b) => *b,
|
||||
gray_matter::Pod::String(s) => {
|
||||
matches!(s.to_ascii_lowercase().as_str(), "yes" | "true")
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Delete multiple note files from disk.
|
||||
/// Returns the list of successfully deleted paths.
|
||||
/// Skips files that don't exist or fail to delete (logs warnings).
|
||||
@@ -102,78 +23,23 @@ pub fn batch_delete_notes(paths: &[String]) -> Result<Vec<String>, String> {
|
||||
let mut deleted = Vec::new();
|
||||
for path in paths {
|
||||
let file = Path::new(path.as_str());
|
||||
match try_purge_file(file) {
|
||||
Some(p) => deleted.push(p),
|
||||
None if !file.exists() => {
|
||||
log::warn!("File does not exist, skipping: {}", path);
|
||||
if !file.exists() {
|
||||
log::warn!("File does not exist, skipping: {}", path);
|
||||
continue;
|
||||
}
|
||||
match fs::remove_file(file) {
|
||||
Ok(()) => {
|
||||
log::info!("Permanently deleted note: {}", path);
|
||||
deleted.push(path.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to delete {}: {}", path, e);
|
||||
}
|
||||
None => {} // try_purge_file already logged the warning
|
||||
}
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete ALL trashed notes
|
||||
/// (regardless of age). Returns the list of deleted file paths.
|
||||
pub fn empty_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.exists() || !vault.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path
|
||||
));
|
||||
}
|
||||
|
||||
let deleted: Vec<String> = WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| is_markdown_file(e.path()))
|
||||
.filter(|e| is_file_trashed(e.path()))
|
||||
.filter_map(|entry| try_purge_file(entry.path()))
|
||||
.collect();
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete those where
|
||||
/// `Trashed at` frontmatter is more than 30 days ago.
|
||||
/// Returns the list of deleted file paths.
|
||||
pub fn purge_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.exists() || !vault.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path
|
||||
));
|
||||
}
|
||||
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let matter = Matter::<YAML>::new();
|
||||
let max_age_days = 30;
|
||||
|
||||
let deleted: Vec<String> = WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| is_markdown_file(e.path()))
|
||||
.filter_map(|entry| {
|
||||
let content = fs::read_to_string(entry.path()).ok()?;
|
||||
let parsed = matter.parse(&content);
|
||||
let date_str = extract_trashed_at_string(&parsed.data)?;
|
||||
let trashed_date = parse_trashed_date(&date_str)?;
|
||||
let age = today.signed_duration_since(trashed_date);
|
||||
if age.num_days() > max_age_days {
|
||||
try_purge_file(entry.path())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -211,208 +77,6 @@ mod tests {
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_deletes_old_trashed_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// File trashed 60 days ago — should be deleted
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"old-trash.md",
|
||||
"---\nTrashed at: \"2025-01-01\"\n---\n# Old Trash\n",
|
||||
);
|
||||
// File trashed recently — should be kept
|
||||
let recent = chrono::Utc::now()
|
||||
.date_naive()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"recent-trash.md",
|
||||
&format!("---\nTrashed at: \"{}\"\n---\n# Recent Trash\n", recent),
|
||||
);
|
||||
// File without trashed_at — should be kept
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"normal.md",
|
||||
"---\ntype: Note\n---\n# Normal Note\n",
|
||||
);
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("old-trash.md"));
|
||||
// Verify old file is actually gone
|
||||
assert!(!dir.path().join("old-trash.md").exists());
|
||||
// Verify other files still exist
|
||||
assert!(dir.path().join("recent-trash.md").exists());
|
||||
assert!(dir.path().join("normal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_supports_datetime_format() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"datetime-trash.md",
|
||||
"---\nTrashed at: \"2025-01-01T10:30:00Z\"\n---\n# Datetime Trash\n",
|
||||
);
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("datetime-trash.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_empty_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert!(deleted.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_nonexistent_path() {
|
||||
let result = purge_trash("/nonexistent/path/that/does/not/exist");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_exactly_30_days_not_deleted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let thirty_days_ago = (chrono::Utc::now().date_naive() - chrono::Duration::days(30))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"borderline.md",
|
||||
&format!(
|
||||
"---\nTrashed at: \"{}\"\n---\n# Borderline\n",
|
||||
thirty_days_ago
|
||||
),
|
||||
);
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert!(deleted.is_empty());
|
||||
assert!(dir.path().join("borderline.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_31_days_deleted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let thirty_one_days_ago = (chrono::Utc::now().date_naive() - chrono::Duration::days(31))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"expired.md",
|
||||
&format!(
|
||||
"---\nTrashed at: \"{}\"\n---\n# Expired\n",
|
||||
thirty_one_days_ago
|
||||
),
|
||||
);
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(!dir.path().join("expired.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_nested_directories() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"sub/deep/old.md",
|
||||
"---\nTrashed at: \"2025-01-01\"\n---\n# Deep Old\n",
|
||||
);
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("old.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\nTrashed: true\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\nTrashed at: \"2026-01-01\"\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_yes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "trashed.md", "---\nTrashed: Yes\n---\n# Gone\n");
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_normal_note() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
assert!(!is_file_trashed(&dir.path().join("normal.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_archived_not_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"archived.md",
|
||||
"---\nArchived: true\n---\n# Archived\n",
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("archived.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_nonexistent_file() {
|
||||
assert!(!is_file_trashed(Path::new("/nonexistent/path.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_underscore_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\n_trashed: true\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_underscore_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_false() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"active.md",
|
||||
"---\nTrashed: false\n---\n# Active\n",
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("active.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_delete_notes_removes_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -444,49 +108,4 @@ mod tests {
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(!dir.path().join("exists.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_deletes_all_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Recently trashed — should be deleted
|
||||
let recent = chrono::Utc::now()
|
||||
.date_naive()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"recent.md",
|
||||
&format!(
|
||||
"---\nTrashed: true\nTrashed at: \"{}\"\n---\n# Recent\n",
|
||||
recent
|
||||
),
|
||||
);
|
||||
// Old trashed — should be deleted
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"old.md",
|
||||
"---\nTrashed: true\nTrashed at: \"2025-01-01\"\n---\n# Old\n",
|
||||
);
|
||||
// Not trashed — should be kept
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
|
||||
let deleted = empty_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!dir.path().join("recent.md").exists());
|
||||
assert!(!dir.path().join("old.md").exists());
|
||||
assert!(dir.path().join("normal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_empty_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let deleted = empty_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert!(deleted.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_nonexistent_path() {
|
||||
let result = empty_trash("/nonexistent/path/that/does/not/exist");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +296,6 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
|
||||
// Boolean fields
|
||||
match field {
|
||||
"archived" => return evaluate_bool_field(entry.archived, &cond.op, &cond.value),
|
||||
"trashed" => return evaluate_bool_field(entry.trashed, &cond.op, &cond.value),
|
||||
"favorite" => return evaluate_bool_field(entry.favorite, &cond.op, &cond.value),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
16
src/App.tsx
16
src/App.tsx
@@ -51,7 +51,7 @@ import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { SidebarSelection, InboxPeriod } from './types'
|
||||
import type { SidebarSelection, InboxPeriod, VaultEntry } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
@@ -70,7 +70,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'inbox' }
|
||||
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function App() {
|
||||
@@ -83,6 +83,7 @@ function App() {
|
||||
setNoteListFilter('open')
|
||||
}, [])
|
||||
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
|
||||
const visibleNotesRef = useRef<VaultEntry[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
|
||||
@@ -338,8 +339,6 @@ function App() {
|
||||
})
|
||||
|
||||
const deleteActions = useDeleteActions({
|
||||
vaultPath: resolvedPath,
|
||||
entries: vault.entries,
|
||||
onDeselectNote: (path: string) => { if (notes.activeTabPath === path) notes.closeAllTabs() },
|
||||
removeEntry: vault.removeEntry,
|
||||
setToastMessage,
|
||||
@@ -455,6 +454,7 @@ function App() {
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
entries: vault.entries,
|
||||
visibleNotesRef,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
selection,
|
||||
@@ -465,7 +465,7 @@ function App() {
|
||||
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
||||
onSave: appSave.handleSave,
|
||||
onOpenSettings: dialogs.openSettings,
|
||||
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
|
||||
onDeleteNote: deleteActions.handleDeleteNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog,
|
||||
onPull: autoSync.triggerSync,
|
||||
@@ -493,8 +493,6 @@ function App() {
|
||||
onInstallMcp: installMcp,
|
||||
claudeCodeStatus: claudeCodeStatus ?? undefined,
|
||||
claudeCodeVersion: claudeCodeVersion ?? undefined,
|
||||
onEmptyTrash: deleteActions.handleEmptyTrash,
|
||||
trashedCount: deleteActions.trashedCount,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
onSetNoteIcon: handleSetNoteIconCommand,
|
||||
@@ -587,7 +585,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} views={vault.views} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} views={vault.views} visibleNotesRef={visibleNotesRef} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -622,8 +620,6 @@ function App() {
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onToggleFavorite={entryActions.handleToggleFavorite}
|
||||
onToggleOrganized={entryActions.handleToggleOrganized}
|
||||
onTrashNote={entryActions.handleTrashNote}
|
||||
onRestoreNote={entryActions.handleRestoreNote}
|
||||
onDeleteNote={deleteActions.handleDeleteNote}
|
||||
onArchiveNote={entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
|
||||
@@ -31,8 +31,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
|
||||
@@ -12,11 +12,7 @@ const baseEntry: VaultEntry = {
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
@@ -26,6 +22,18 @@ const baseEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
sort: null,
|
||||
sidebarLabel: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
properties: {},
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
hasH1: false,
|
||||
}
|
||||
|
||||
const archivedEntry: VaultEntry = {
|
||||
@@ -33,12 +41,6 @@ const archivedEntry: VaultEntry = {
|
||||
archived: true,
|
||||
}
|
||||
|
||||
const trashedEntry: VaultEntry = {
|
||||
...baseEntry,
|
||||
trashed: true,
|
||||
trashedAt: Date.now() / 1000 - 86400 * 5,
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
wordCount: 100,
|
||||
showDiffToggle: false,
|
||||
@@ -55,31 +57,17 @@ describe('BreadcrumbBar — drag region', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — trash/restore', () => {
|
||||
it('shows trash button for non-trashed note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onTrash={vi.fn()} onRestore={vi.fn()} />)
|
||||
expect(screen.getByTitle('Move to trash (Cmd+Delete)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Restore from trash')).not.toBeInTheDocument()
|
||||
describe('BreadcrumbBar — delete', () => {
|
||||
it('shows delete button', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={vi.fn()} />)
|
||||
expect(screen.getByTitle('Delete (Cmd+Delete)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows restore button for trashed note', () => {
|
||||
render(<BreadcrumbBar entry={trashedEntry} {...defaultProps} onTrash={vi.fn()} onRestore={vi.fn()} />)
|
||||
expect(screen.getByTitle('Restore from trash')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Move to trash (Cmd+Delete)')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onTrash when trash button is clicked', () => {
|
||||
const onTrash = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onTrash={onTrash} onRestore={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Move to trash (Cmd+Delete)'))
|
||||
expect(onTrash).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onRestore when restore button is clicked', () => {
|
||||
const onRestore = vi.fn()
|
||||
render(<BreadcrumbBar entry={trashedEntry} {...defaultProps} onTrash={vi.fn()} onRestore={onRestore} />)
|
||||
fireEvent.click(screen.getByTitle('Restore from trash'))
|
||||
expect(onRestore).toHaveBeenCalledOnce()
|
||||
it('calls onDelete when delete button is clicked', () => {
|
||||
const onDelete = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onDelete={onDelete} />)
|
||||
fireEvent.click(screen.getByTitle('Delete (Cmd+Delete)'))
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,13 +104,14 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
expect(screen.getByText('Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('›')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('test')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows emoji icon when entry has an emoji icon', () => {
|
||||
it('does not render emoji icon in breadcrumb (icon removed from breadcrumb title)', () => {
|
||||
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
|
||||
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
|
||||
expect(screen.getByText('🚀')).toBeInTheDocument()
|
||||
// BreadcrumbTitle now only shows type label and filename stem — no icon
|
||||
expect(screen.queryByText('🚀')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show icon when entry has a non-emoji icon', () => {
|
||||
@@ -174,4 +163,17 @@ describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
fireEvent.click(screen.getByTitle('Raw editor'))
|
||||
expect(onToggleRaw).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides raw toggle when forceRawMode is true (non-markdown file)', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} forceRawMode={true} />)
|
||||
expect(screen.queryByTitle('Raw editor')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Back to editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows raw toggle when forceRawMode is false (markdown file)', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} forceRawMode={false} />)
|
||||
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SlidersHorizontal,
|
||||
DotsThree,
|
||||
Trash,
|
||||
ArrowCounterClockwise,
|
||||
Archive,
|
||||
ArrowUUpLeft,
|
||||
Star,
|
||||
@@ -26,14 +25,15 @@ interface BreadcrumbBarProps {
|
||||
onToggleDiff: () => void
|
||||
rawMode?: boolean
|
||||
onToggleRaw?: () => void
|
||||
/** When true, raw mode is forced (non-markdown file) — hide the toggle. */
|
||||
forceRawMode?: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed?: boolean
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: () => void
|
||||
onToggleOrganized?: () => void
|
||||
onTrash?: () => void
|
||||
onRestore?: () => void
|
||||
onDelete?: () => void
|
||||
onArchive?: () => void
|
||||
onUnarchive?: () => void
|
||||
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
|
||||
@@ -58,9 +58,9 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
|
||||
}
|
||||
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
rawMode, onToggleRaw,
|
||||
rawMode, onToggleRaw, forceRawMode,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onToggleFavorite, onToggleOrganized, onTrash, onRestore, onArchive, onUnarchive,
|
||||
onToggleFavorite, onToggleOrganized, onDelete, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
|
||||
return (
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
@@ -112,7 +112,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
)}
|
||||
<RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
@@ -149,23 +149,13 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<Archive size={16} />
|
||||
</button>
|
||||
)}
|
||||
{entry.trashed ? (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onRestore}
|
||||
title="Restore from trash"
|
||||
>
|
||||
<ArrowCounterClockwise size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
|
||||
onClick={onTrash}
|
||||
title="Move to trash (Cmd+Delete)"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title="Delete (Cmd+Delete)"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
{inspectorCollapsed && (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
@@ -189,14 +179,12 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
|
||||
function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
|
||||
const typeLabel = entry.isA ?? 'Note'
|
||||
const icon = entry.icon
|
||||
const emojiIcon = icon && /^\p{Emoji}/u.test(icon) ? icon : null
|
||||
const filenameStem = entry.filename.replace(/\.md$/, '')
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">{typeLabel}</span>
|
||||
<span className="shrink-0 text-border">›</span>
|
||||
{emojiIcon && <span className="shrink-0">{emojiIcon}</span>}
|
||||
<span className="truncate font-medium text-foreground">{entry.title}</span>
|
||||
<span className="truncate font-medium text-foreground">{filenameStem}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -204,10 +192,14 @@ function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry, barRef, ...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
// In raw/diff mode the title section is not rendered — always show title in breadcrumb.
|
||||
// Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect.
|
||||
const titleAlwaysVisible = actionProps.rawMode || actionProps.diffMode
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
data-tauri-drag-region
|
||||
{...(titleAlwaysVisible ? { 'data-title-hidden': '' } : {})}
|
||||
className="breadcrumb-bar flex shrink-0 items-center"
|
||||
style={{
|
||||
height: 52,
|
||||
|
||||
@@ -6,41 +6,14 @@ describe('BulkActionBar', () => {
|
||||
const defaultProps = {
|
||||
count: 3,
|
||||
onArchive: vi.fn(),
|
||||
onTrash: vi.fn(),
|
||||
onRestore: vi.fn(),
|
||||
onDeletePermanently: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
isTrashView: false,
|
||||
}
|
||||
|
||||
it('shows Archive and Trash buttons in normal view', () => {
|
||||
it('shows Archive and Delete buttons in normal view', () => {
|
||||
render(<BulkActionBar {...defaultProps} />)
|
||||
expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-trash-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-restore-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Restore, Archive, and Delete permanently in trash view', () => {
|
||||
render(<BulkActionBar {...defaultProps} isTrashView={true} />)
|
||||
expect(screen.getByTestId('bulk-restore-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-trash-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRestore when Restore button clicked in trash view', () => {
|
||||
const onRestore = vi.fn()
|
||||
render(<BulkActionBar {...defaultProps} isTrashView={true} onRestore={onRestore} />)
|
||||
fireEvent.click(screen.getByTestId('bulk-restore-btn'))
|
||||
expect(onRestore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onDeletePermanently when Delete button clicked in trash view', () => {
|
||||
const onDeletePermanently = vi.fn()
|
||||
render(<BulkActionBar {...defaultProps} isTrashView={true} onDeletePermanently={onDeletePermanently} />)
|
||||
fireEvent.click(screen.getByTestId('bulk-delete-btn'))
|
||||
expect(onDeletePermanently).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows selected count', () => {
|
||||
@@ -48,13 +21,11 @@ describe('BulkActionBar', () => {
|
||||
expect(screen.getByText('5 selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Unarchive and Trash buttons in archived view', () => {
|
||||
it('shows Unarchive and Delete buttons in archived view', () => {
|
||||
render(<BulkActionBar {...defaultProps} isArchivedView={true} />)
|
||||
expect(screen.getByTestId('bulk-unarchive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-trash-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-restore-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onUnarchive when Unarchive button clicked in archived view', () => {
|
||||
|
||||
@@ -3,12 +3,9 @@ import { Archive, ArrowCounterClockwise, Trash, X } from '@phosphor-icons/react'
|
||||
|
||||
interface BulkActionBarProps {
|
||||
count: number
|
||||
isTrashView: boolean
|
||||
isArchivedView?: boolean
|
||||
onArchive: () => void
|
||||
onTrash: () => void
|
||||
onRestore: () => void
|
||||
onDeletePermanently: () => void
|
||||
onDelete: () => void
|
||||
onUnarchive?: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
@@ -16,49 +13,33 @@ interface BulkActionBarProps {
|
||||
const actionBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 } as const
|
||||
const destructiveBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 } as const
|
||||
|
||||
function renderTrashActions(onRestore: () => void, onArchive: () => void, onDeletePermanently: () => void) {
|
||||
return (
|
||||
<>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onRestore} title="Restore selected notes" data-testid="bulk-restore-btn">
|
||||
<ArrowCounterClockwise size={14} /> Restore
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onArchive} title="Archive selected notes" data-testid="bulk-archive-btn">
|
||||
<Archive size={14} /> Archive
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDeletePermanently} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
|
||||
<Trash size={14} /> Delete permanently
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderArchivedActions(onUnarchive: (() => void) | undefined, onTrash: () => void) {
|
||||
function renderArchivedActions(onUnarchive: (() => void) | undefined, onDelete: () => void) {
|
||||
return (
|
||||
<>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onUnarchive} title="Unarchive selected notes" data-testid="bulk-unarchive-btn">
|
||||
<ArrowCounterClockwise size={14} /> Unarchive
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onTrash} title="Move selected notes to trash" data-testid="bulk-trash-btn">
|
||||
<Trash size={14} /> Trash
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDelete} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
|
||||
<Trash size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefaultActions(onArchive: () => void, onTrash: () => void) {
|
||||
function renderDefaultActions(onArchive: () => void, onDelete: () => void) {
|
||||
return (
|
||||
<>
|
||||
<button className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer" style={actionBtnStyle} onClick={onArchive} title="Archive selected notes" data-testid="bulk-archive-btn">
|
||||
<Archive size={14} /> Archive
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onTrash} title="Move selected notes to trash" data-testid="bulk-trash-btn">
|
||||
<Trash size={14} /> Trash
|
||||
<button className="flex items-center gap-1.5 border-none cursor-pointer" style={destructiveBtnStyle} onClick={onDelete} title="Permanently delete selected notes" data-testid="bulk-delete-btn">
|
||||
<Trash size={14} /> Delete
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BulkActionBarInner({ count, isTrashView, isArchivedView, onArchive, onTrash, onRestore, onDeletePermanently, onUnarchive, onClear }: BulkActionBarProps) {
|
||||
function BulkActionBarInner({ count, isArchivedView, onArchive, onDelete, onUnarchive, onClear }: BulkActionBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
@@ -74,9 +55,8 @@ function BulkActionBarInner({ count, isTrashView, isArchivedView, onArchive, onT
|
||||
{count} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isTrashView ? renderTrashActions(onRestore, onArchive, onDeletePermanently)
|
||||
: isArchivedView ? renderArchivedActions(onUnarchive, onTrash)
|
||||
: renderDefaultActions(onArchive, onTrash)}
|
||||
{isArchivedView ? renderArchivedActions(onUnarchive, onDelete)
|
||||
: renderDefaultActions(onArchive, onDelete)}
|
||||
<button
|
||||
className="flex items-center border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}
|
||||
|
||||
@@ -28,8 +28,6 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
@@ -619,6 +617,43 @@ describe('DynamicPropertiesPanel', () => {
|
||||
fireEvent.click(screen.getByText('Status'))
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Status', '')
|
||||
})
|
||||
|
||||
it('auto-opens editor when property appears after clicking suggested slot', () => {
|
||||
const addProp = vi.fn()
|
||||
const { rerender } = render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{}}
|
||||
onAddProperty={addProp}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
// Click the Status suggested slot
|
||||
fireEvent.click(screen.getByText('Status'))
|
||||
expect(addProp).toHaveBeenCalledWith('Status', '')
|
||||
|
||||
// Simulate the frontmatter being updated (as if the backend wrote it)
|
||||
// The useEffect detects the new key in propertyEntries and sets editingKey
|
||||
rerender(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Status: '' }}
|
||||
onAddProperty={addProp}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
|
||||
// The suggested slot for Status should be gone
|
||||
const remainingSlots = screen.getAllByTestId('suggested-property')
|
||||
expect(remainingSlots.length).toBe(2) // Date and URL remain
|
||||
// Status dropdown is portaled to body — check for it there
|
||||
const dropdown = document.querySelector('[data-testid="status-dropdown-popover"]')
|
||||
expect(dropdown).toBeInTheDocument()
|
||||
const searchInput = document.querySelector('[data-testid="status-search-input"]')
|
||||
expect(searchInput).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL property rendering', () => {
|
||||
@@ -851,17 +886,15 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
|
||||
describe('system property filtering', () => {
|
||||
it('hides trashed, trashed_at, archived, archived_at, icon from properties panel', () => {
|
||||
it('hides archived, archived_at, icon from properties panel', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ trashed: true, trashed_at: '2026-01-01', archived: false, archived_at: '', icon: '📝', cadence: 'Weekly' }}
|
||||
frontmatter={{ archived: false, archived_at: '', icon: '📝', cadence: 'Weekly' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Trashed')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Trashed at')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived at')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
|
||||
@@ -874,11 +907,10 @@ describe('DynamicPropertiesPanel', () => {
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Trashed: true, Archived: false, Icon: '🎯', cadence: 'Daily' }}
|
||||
frontmatter={{ Archived: false, Icon: '🎯', cadence: 'Daily' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Trashed')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Cadence')).toBeInTheDocument()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user