Watch
1
0
Fork
You've already forked souveraine-lens
0

lens: wry mind-graph over a git-backed markdown vault

vault walker (frontmatter, wikilinks, graph), embedded page with a
hand-rolled force layout, ipc bridge with no server connection, autocommit
on save, house ci gates.
This commit is contained in:
Fimeg 2026-08-10 20:11:25 -04:00
commit 531e7e757c
8 changed files with 4136 additions and 0 deletions

69
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,69 @@
name: ci
# CI for souveraine-lens — Rust crate. Tests + clippy, then the gates. No
# artifact step yet: the lens is a desktop-glass app, nothing ships until it
# has been loved on hardware. Green on primary means it built and the gates
# passed.
on:
push:
branches: [primary]
pull_request:
branches: [primary]
jobs:
rust-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install system deps
run: sudo apt-get update -qq && sudo apt-get install -y -qq libgtk-3-dev libwebkit2gtk-4.1-dev
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c
with:
toolchain: "1.94"
components: clippy
- name: cargo test
run: cargo test
- name: cargo clippy
run: cargo clippy -- -D warnings
no-ai-attribution:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Check commit messages for AI attribution
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
RANGE="${{ github.event.pull_request.base.sha }}..${{ github.sha }}"
else
RANGE="${{ github.event.before }}..${{ github.sha }}"
if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
RANGE="HEAD~10..HEAD"
fi
fi
CO="Co-Authored""-By:"
PATTERNS="${CO}.*[Cc]laude|${CO}.*OpenAI|${CO}.*ChatGPT|${CO}.*Copilot|${CO}.*Letta|${CO}.*Cursor|Generated by|Generated with|AI-assisted|Auto-generated by"
FAIL=0
while IFS= read -r msg; do
if echo "$msg" | grep -qiE "$PATTERNS"; then
echo "::error::AI attribution found in commit: $msg"
FAIL=1
fi
done < <(git log --format='%s%n%b' $RANGE 2>/dev/null)
if [ "$FAIL" -eq 1 ]; then
echo "::error::Commits contain AI attribution lines. Remove them before merging."
exit 1
fi
echo "No AI attribution found in commits."
action-pins:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Check for floating action refs
run: |
if grep -rE 'uses:.*@(v[0-9]+|stable|main|master)(\s|$)' .gitea/workflows/; then
echo "::error::Floating action refs found — pin every action to a commit SHA"
exit 1
fi
echo "All action refs are SHA-pinned."

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2685
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

17
Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "souveraine-lens"
version = "0.1.0"
edition = "2021"
description = "A wry mind-graph lens over a git-backed markdown vault"
[dependencies]
anyhow = "1"
gtk = "0.18"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tao = "0.36"
wry = "0.56"
[profile.release]
lto = true
strip = true

32
README.md Normal file
View file

@ -0,0 +1,32 @@
# souveraine-lens
A wry mind-graph lens over a git-backed markdown vault. A sibling of
`souveraine-web`: the system webview (WebKitGTK), no second engine, no
connection to the server.
- Plain markdown files, `[[wikilinks]]`, optional YAML-ish frontmatter
(`description`, `tags`, anything else round-tripped verbatim).
- The vault is git — every save is a commit, the archive is the truth.
- The graph is a hand-rolled force layout on a canvas; no dependencies, no
network. A mirror should be hand-made.
## Run
```sh
cargo run --release -- --vault ~/vault
```
First run inits the vault as a git repo with an `index.md` home note. Point
any markdown directory at it — the lens asks no format of its own.
- drag nodes, wheel to zoom, drag the void to pan
- click a node to open the note, double-click the void to plant a new one
- `Notes` lists the garden; search filters it; `Sync` pushes to the remote
(ssh/gpg credentials are the user's own, like the substrate's memfs sync)
## The seam
This process holds no connection to the server. The page is one embedded
document; every piece of data arrives over the IPC bridge and the bridge is
the only conversation. Your thoughts live on your disk and in your git — the
lens is a surface over them, not a home for them.

205
src/main.rs Normal file
View file

@ -0,0 +1,205 @@
//! The lens — a wry mind-graph over a git-backed markdown vault.
//!
//! A sibling of souveraine-web: the same system webview (WebKitGTK, no second
//! engine), the same app_id discipline, the same rule from the rig — this
//! process holds no connection to the server. The vault is files and git;
//! the page is a single embedded document that talks to this process over
//! the IPC bridge and to nothing else.
mod ui;
mod vault;
use anyhow::{Context, Result};
use std::path::PathBuf;
use serde_json::json;
use tao::event::{Event, StartCause, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoopBuilder};
use tao::window::WindowBuilder;
use wry::WebViewBuilder;
/// What the embedded page asks for. One JSON object per message, the same
/// house grammar the rig and the shell speak.
enum LensEvent {
Ipc(String),
}
fn main() -> Result<()> {
run()
}
fn run() -> Result<()> {
let mut vault_dir = vault::default_root();
let mut app_id = String::from("org.souveraine.lens");
let mut title = String::from("Lens");
let mut width = 1280.0;
let mut height = 820.0;
let mut devtools = false;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--vault" => {
if let Some(v) = args.next() {
vault_dir = PathBuf::from(v);
}
}
"--app-id" => {
if let Some(v) = args.next() {
app_id = v;
}
}
"--title" => {
if let Some(v) = args.next() {
title = v;
}
}
"--size" => {
if let Some(v) = args.next() {
if let Some((w, h)) = v.split_once('x') {
width = w.parse().unwrap_or(width);
height = h.parse().unwrap_or(height);
}
}
}
"--devtools" => devtools = true,
"--help" | "-h" => {
eprintln!(
"souveraine-lens [--vault DIR] [--app-id ID] [--size WxH] [--title T] [--devtools]"
);
return Ok(());
}
other => anyhow::bail!("unknown argument {other}"),
}
}
// Same trap as the rig: the compositor reads the app_id while the surface
// is created, which happens inside the window build — so name the surface
// before the event loop exists.
#[cfg(target_os = "linux")]
gtk::glib::set_prgname(Some(&app_id));
#[cfg(target_os = "linux")]
gtk::gdk::set_program_class(&app_id);
let vault = vault::Vault::new(vault_dir);
vault::Vault::ensure_repo(&vault)
.with_context(|| format!("preparing vault at {}", vault.root.display()))?;
let event_loop = EventLoopBuilder::<LensEvent>::with_user_event().build();
let proxy = event_loop.create_proxy();
let window = WindowBuilder::new()
.with_title(&title)
.with_inner_size(tao::dpi::LogicalSize::new(width, height))
.build(&event_loop)
.context("creating the window")?;
// The page asks; this process answers. Replies ride back over
// evaluate_script into __lens.reply — the one transport, like the rig.
let builder = WebViewBuilder::new()
.with_devtools(devtools)
.with_html(ui::HTML)
.with_ipc_handler(move |req| {
let _ = proxy.send_event(LensEvent::Ipc(req.body().to_string()));
});
let webview = build_webview(builder, &window)?;
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::NewEvents(StartCause::Init) => {}
Event::UserEvent(LensEvent::Ipc(body)) => {
let _ = handle(&webview, &vault, &body);
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => *control_flow = ControlFlow::Exit,
_ => {}
}
});
}
/// Answer one page request. Every op answers, even with an error — a page that
/// waits on a promise that never settles is a mirror with its hand stuck out.
fn handle(webview: &wry::WebView, vault: &vault::Vault, body: &str) -> Result<()> {
let msg: serde_json::Value = match serde_json::from_str(body) {
Ok(m) => m,
Err(_) => return Ok(()),
};
let id = msg.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
let op = msg.get("op").and_then(|v| v.as_str()).unwrap_or("");
let result: serde_json::Value = match op {
"graph" => match vault.scan() {
Ok(g) => match vault.status() {
Ok(s) => json!({ "ok": true, "graph": g, "status": s }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
},
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
},
"note" => {
let rel = msg.get("rel").and_then(|v| v.as_str()).unwrap_or("");
match vault.read_note(rel) {
Ok(note) => json!({ "ok": true, "note": note }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
}
}
"save" => {
let rel = msg.get("rel").and_then(|v| v.as_str()).unwrap_or("");
let raw = msg.get("raw").and_then(|v| v.as_str()).unwrap_or("");
match vault.save_note(rel, raw) {
Ok(()) => json!({ "ok": true }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
}
}
"create" => {
let title = msg.get("title").and_then(|v| v.as_str()).unwrap_or("");
match vault.create_note(title) {
Ok(rel) => json!({ "ok": true, "rel": rel }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
}
}
"sync" => match vault.sync() {
Ok(out) => json!({ "ok": true, "out": out }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
},
"status" => match vault.status() {
Ok(s) => json!({ "ok": true, "status": s }),
Err(e) => json!({ "ok": false, "error": format!("{e:#}") }),
},
other => json!({ "ok": false, "error": format!("unknown op: {other}") }),
};
let script = format!(
"window.__lens.reply({}, {});",
id,
serde_json::to_string(&result).unwrap_or_else(|_| "{}".into())
);
webview.evaluate_script(&script).context("replying to the page")?;
Ok(())
}
/// wry on Linux draws into the window's GTK container rather than adopting the
/// surface — the rig keeps this in one place, so does the lens.
#[cfg(target_os = "linux")]
fn build_webview(
builder: WebViewBuilder<'_>,
window: &tao::window::Window,
) -> Result<wry::WebView> {
use tao::platform::unix::WindowExtUnix;
use wry::WebViewBuilderExtUnix;
let vbox = window
.default_vbox()
.context("the window has no gtk container")?;
builder.build_gtk(vbox).context("building the webview")
}
#[cfg(not(target_os = "linux"))]
fn build_webview(
builder: WebViewBuilder<'_>,
window: &tao::window::Window,
) -> Result<wry::WebView> {
builder.build(window).context("building the webview")
}

600
src/ui.rs Normal file
View file

@ -0,0 +1,600 @@
//! The lens's face — one embedded page, no dependencies, no network.
//!
//! Every piece of data the page needs arrives over the IPC bridge
//! (`window.ipc.postMessage` out, `evaluate_script` back), so the page holds
//! no connection to anything but the lens process — the same seam the
//! substrate's rig keeps. The force layout is hand-rolled: a few hundred
//! nodes settle fine at O(n^2) a frame, and a mirror this small deserves no
//! third-party gravity.
pub const HTML: &str = r###"<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
:root {
--bg: #0b0f14; --panel: #11161d; --edge: #1e2733;
--text: #e2e8f0; --dim: #64748b; --fresh: #34d399;
--mid: #fbbf24; --old: #475569; --accent: #7dd3fc;
--danger: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
background: var(--bg); color: var(--text);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 14px; display: flex; flex-direction: column;
}
header {
display: flex; align-items: center; gap: 12px;
padding: 8px 14px; background: var(--panel);
border-bottom: 1px solid var(--edge); flex: 0 0 auto;
user-select: none;
}
header .brand { font-weight: 700; letter-spacing: .08em; text-transform: uppercase; font-size: 12px; color: var(--accent); }
header #status { color: var(--dim); font-size: 12px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
header button {
background: transparent; color: var(--dim); border: 1px solid var(--edge);
border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer;
}
header button:hover { color: var(--text); border-color: var(--dim); }
main { flex: 1 1 auto; display: flex; position: relative; min-height: 0; }
canvas { width: 100%; height: 100%; display: block; cursor: grab; }
canvas.dragging { cursor: grabbing; }
/* left rail */
#rail {
position: absolute; left: 0; top: 0; bottom: 0; width: 240px;
background: var(--panel); border-right: 1px solid var(--edge);
display: flex; flex-direction: column; z-index: 5;
transform: translateX(-100%); transition: transform .18s ease;
}
#rail.open { transform: none; }
#rail input {
margin: 10px 10px 6px; padding: 6px 8px; background: var(--bg);
border: 1px solid var(--edge); border-radius: 6px; color: var(--text);
outline: none;
}
#rail input:focus { border-color: var(--accent); }
#rail ul { overflow-y: auto; flex: 1; list-style: none; padding: 0 6px 10px; }
#rail li {
padding: 7px 8px; border-radius: 6px; cursor: pointer;
display: flex; gap: 6px; align-items: baseline; font-size: 13px;
}
#rail li:hover { background: #1a2230; }
#rail li .t { color: var(--text); }
#rail li .d { color: var(--dim); font-size: 11px; }
#rail li .orphan { color: var(--mid); font-size: 11px; }
#rail .hint { color: var(--dim); font-size: 11px; padding: 0 10px 8px; }
/* note drawer */
#drawer {
position: absolute; right: 0; top: 0; bottom: 0; width: 420px;
background: var(--panel); border-left: 1px solid var(--edge);
display: none; flex-direction: column; z-index: 6;
}
#drawer.open { display: flex; }
#drawer .head {
display: flex; align-items: center; gap: 8px; padding: 10px 14px;
border-bottom: 1px solid var(--edge); flex: 0 0 auto;
}
#drawer .head h2 { font-size: 15px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#drawer .head .rel { color: var(--dim); font-size: 11px; }
#drawer button {
background: transparent; color: var(--dim); border: 1px solid var(--edge);
border-radius: 6px; padding: 3px 9px; font-size: 12px; cursor: pointer;
}
#drawer button:hover { color: var(--text); }
#drawer .body { flex: 1; overflow-y: auto; padding: 14px; line-height: 1.55; }
#drawer .body h1 { font-size: 20px; margin: 6px 0 10px; color: var(--accent); }
#drawer .body h2 { font-size: 16px; margin: 14px 0 6px; }
#drawer .body h3 { font-size: 14px; margin: 12px 0 4px; }
#drawer .body p { margin: 6px 0; }
#drawer .body ul { padding-left: 18px; margin: 6px 0; }
#drawer .body code { background: var(--bg); padding: 1px 5px; border-radius: 4px; font-size: 12px; color: var(--accent); }
#drawer .body pre { background: var(--bg); padding: 10px; border-radius: 6px; overflow-x: auto; margin: 8px 0; }
#drawer .body pre code { background: none; padding: 0; color: var(--text); }
#drawer .body blockquote { border-left: 3px solid var(--edge); padding-left: 10px; color: var(--dim); margin: 8px 0; }
#drawer .body hr { border: none; border-top: 1px solid var(--edge); margin: 12px 0; }
#drawer .body .wiki { color: var(--accent); cursor: pointer; border-bottom: 1px dotted var(--accent); }
#drawer .body .wiki.dead { color: var(--dim); cursor: default; border-bottom: 1px dotted var(--dim); }
#drawer textarea {
flex: 1; width: 100%; resize: none; background: var(--bg); color: var(--text);
border: none; border-top: 1px solid var(--edge); padding: 14px;
font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 13px;
line-height: 1.6; outline: none;
}
/* toasts */
#toasts { position: absolute; right: 14px; bottom: 14px; z-index: 20; display: flex; flex-direction: column; gap: 8px; }
.toast {
background: var(--panel); border: 1px solid var(--edge); border-radius: 8px;
padding: 8px 14px; font-size: 12px; color: var(--text); max-width: 320px;
animation: rise .2s ease;
}
.toast.err { border-color: var(--danger); color: var(--danger); }
@keyframes rise { from { opacity: 0; transform: translateY(6px); } }
</style>
</head>
<body>
<header>
<span class="brand">Lens</span>
<span id="status">looking for the garden…</span>
<button id="b-rail">Notes</button>
<button id="b-new">New</button>
<button id="b-sync">Sync</button>
<button id="b-refresh">Refresh</button>
</header>
<main>
<canvas id="graph"></canvas>
<nav id="rail">
<input id="search" placeholder="search thoughts…" autocomplete="off">
<p class="hint" id="list-hint"></p>
<ul id="list"></ul>
</nav>
<section id="drawer">
<div class="head">
<h2 id="note-title"></h2>
<span class="rel" id="note-rel"></span>
<button id="b-edit">Edit</button>
<button id="b-close">Close</button>
</div>
<div class="body" id="note-body"></div>
<textarea id="note-editor" spellcheck="false" style="display:none"></textarea>
</section>
</main>
<div id="toasts"></div>
<script>
(function () {
'use strict';
var ctx = document.getElementById('graph').getContext('2d');
var W = 0, H = 0, CSSW = 0, CSSH = 0;
function resize() {
var c = document.getElementById('graph');
var r = c.getBoundingClientRect();
CSSW = r.width; CSSH = r.height;
W = c.width = CSSW * devicePixelRatio;
H = c.height = CSSH * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}
window.addEventListener('resize', resize);
// ── IPC: postMessage out, evaluate_script back ──
var seq = 1;
var pending = Object.create(null);
function call(op, args) {
return new Promise(function (resolve, reject) {
var id = seq++;
pending[id] = { resolve: resolve, reject: reject };
var msg = { id: id, op: op };
if (args) for (var k in args) msg[k] = args[k];
try {
window.ipc.postMessage(JSON.stringify(msg));
} catch (e) {
delete pending[id];
reject(new Error('ipc unavailable: ' + e));
}
});
}
window.__lens = {
reply: function (id, payload) {
var p = pending[id];
if (!p) return;
delete pending[id];
if (payload && payload.ok === false) p.reject(new Error(payload.error || 'lens error'));
else p.resolve(payload);
}
};
// ── the graph ──
var nodes = [], byRel = Object.create(null), edges = [];
var cam = { x: 0, y: 0, k: 1 };
var hover = null, drag = null, pan = null, selected = null;
var move = { x: 0, y: 0 };
function layout(graph) {
nodes = [];
byRel = Object.create(null);
edges = [];
var n = graph.nodes.length;
graph.nodes.forEach(function (nd, i) {
var angle = (i / (n || 1)) * Math.PI * 2;
var r = Math.sqrt(n) * 42;
var node = {
rel: nd.rel, title: nd.title, tags: nd.tags, desc: nd.description,
mtime: nd.mtime, deg: 0, x: Math.cos(angle) * r, y: Math.sin(angle) * r,
vx: 0, vy: 0, fixed: false, fx: 0, fy: 0
};
nodes.push(node);
byRel[nd.rel] = node;
});
var deg = Object.create(null);
graph.links.forEach(function (l) {
var a = byRel[l.from], b = byRel[l.to];
if (!a || !b) return;
edges.push({ a: a, b: b, rest: 46 });
deg[a.rel] = (deg[a.rel] || 0) + 1;
deg[b.rel] = (deg[b.rel] || 0) + 1;
});
nodes.forEach(function (nd) { nd.deg = deg[nd.rel] || 0; });
}
var now = Date.now() / 1000;
function nodeColor(mtime) {
var age = now - mtime;
if (age < 30 * 86400) return '#34d399';
if (age < 180 * 86400) return '#fbbf24';
return '#475569';
}
function step(screen) {
// repulsion, O(n^2) — a garden this size settles fine
var krep = 900;
for (var i = 0; i < nodes.length; i++) {
var a = nodes[i];
for (var j = i + 1; j < nodes.length; j++) {
var b = nodes[j];
var dx = a.x - b.x, dy = a.y - b.y;
var d2 = dx * dx + dy * dy + 4;
var f = krep / d2;
var d = Math.sqrt(d2);
var ux = dx / d, uy = dy / d;
a.vx += ux * f; a.vy += uy * f;
b.vx -= ux * f; b.vy -= uy * f;
}
}
var kspring = 0.055;
for (var e = 0; e < edges.length; e++) {
var en = edges[e];
var ax = en.a.x - en.b.x, ay = en.a.y - en.b.y;
var ed = Math.sqrt(ax * ax + ay * ay + 1);
var fx = (ed - en.rest) * kspring * (ax / ed);
var fy = (ed - en.rest) * kspring * (ay / ed);
en.a.vx -= fx; en.a.vy -= fy;
en.b.vx += fx; en.b.vy += fy;
}
// gentle pull to the middle so strays find the conversation
for (var k = 0; k < nodes.length; k++) {
var nd = nodes[k];
nd.vx += (0 - nd.x) * 0.002;
nd.vy += (0 - nd.y) * 0.002;
nd.vx *= 0.84; nd.vy *= 0.84;
var sp = Math.min(8, Math.hypot(nd.vx, nd.vy));
if (sp > 8) { nd.vx *= 8 / sp; nd.vy *= 8 / sp; }
if (nd.fixed) { nd.x = nd.fx; nd.y = nd.fy; nd.vx = 0; nd.vy = 0; }
else { nd.x += nd.vx; nd.y += nd.vy; }
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.save();
ctx.translate(cam.x + CSSW / 2, cam.y + CSSH / 2);
ctx.scale(cam.k, cam.k);
var dimOthers = hover !== null;
var hot = hover !== null ? neighborsOf(hover) : null;
ctx.lineWidth = 1 / cam.k;
for (var e = 0; e < edges.length; e++) {
var en = edges[e];
var lit = hot && (hot[en.a.rel] || hot[en.b.rel]);
if (dimOthers && !lit) continue;
ctx.strokeStyle = lit ? 'rgba(125,211,252,0.5)' : 'rgba(100,116,139,0.22)';
ctx.beginPath();
ctx.moveTo(en.a.x, en.a.y);
ctx.lineTo(en.b.x, en.b.y);
ctx.stroke();
}
for (var i = 0; i < nodes.length; i++) {
var nd = nodes[i];
if (dimOthers && !(hot && (hot[nd.rel] || nd === hover))) continue;
var r = 3.5 + 2 * Math.sqrt(nd.deg);
var lit = hover && (hot && (hot[nd.rel] || nd === hover));
ctx.fillStyle = lit ? '#7dd3fc' : nodeColor(nd.mtime);
ctx.beginPath();
ctx.arc(nd.x, nd.y, r, 0, Math.PI * 2);
ctx.fill();
if (selected === nd.rel) {
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 2 / cam.k;
ctx.beginPath(); ctx.arc(nd.x, nd.y, r + 3, 0, Math.PI * 2); ctx.stroke();
}
if (nd.deg === 0) {
ctx.strokeStyle = 'rgba(251,191,36,0.6)'; ctx.lineWidth = 1 / cam.k;
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.arc(nd.x, nd.y, r + 4, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
}
}
if (cam.k > 0.7) {
ctx.font = '10px system-ui';
ctx.textAlign = 'center';
for (var l = 0; l < nodes.length; l++) {
var ln = nodes[l];
if (dimOthers && !(hot && (hot[ln.rel] || ln === hover))) continue;
if (ln.deg === 0 && !hover) continue;
ctx.fillStyle = hover === ln ? '#7dd3fc' : 'rgba(226,232,240,0.75)';
ctx.fillText(ln.title.length > 26 ? ln.title.slice(0, 25) + '…' : ln.title, ln.x, ln.y + 4);
}
}
ctx.restore();
}
function neighborsOf(nd) {
var set = Object.create(null);
for (var e = 0; e < edges.length; e++) {
var en = edges[e];
if (en.a === nd) set[en.b.rel] = true;
if (en.b === nd) set[en.a.rel] = true;
}
return set;
}
// ── interaction ──
function worldPos(mx, my) {
return {
x: (mx - cam.x - CSSW / 2) / cam.k,
y: (my - cam.y - CSSH / 2) / cam.k
};
}
function hit(mx, my) {
var p = worldPos(mx, my);
var best = null, bd = 14;
for (var i = 0; i < nodes.length; i++) {
var nd = nodes[i];
var r = 3.5 + 2 * Math.sqrt(nd.deg) + 6;
var d = Math.hypot(nd.x - p.x, nd.y - p.y);
if (d < bd && d < r) { bd = d; best = nd; }
}
return best;
}
document.getElementById('graph').addEventListener('mousemove', function (ev) {
var nd = hit(ev.clientX, ev.clientY);
hover = nd;
document.getElementById('graph').style.cursor = nd ? 'pointer' : (drag || pan ? 'grabbing' : 'grab');
if (drag) { drag.fx = worldPos(ev.clientX, ev.clientY).x; drag.fy = worldPos(ev.clientX, ev.clientY).y; }
if (pan) { cam.x += ev.movementX; cam.y += ev.movementY; }
});
document.getElementById('graph').addEventListener('mousedown', function (ev) {
var nd = hit(ev.clientX, ev.clientY);
if (nd) {
drag = nd; nd.fixed = true;
nd.fx = worldPos(ev.clientX, ev.clientY).x;
nd.fy = worldPos(ev.clientX, ev.clientY).y;
} else {
pan = true;
}
document.getElementById('graph').classList.add('dragging');
});
document.getElementById('graph').addEventListener('mouseup', function (ev) {
var wasDrag = drag;
var moved = wasDrag && Math.hypot(wasDrag.vx, wasDrag.vy) > 0.1;
if (wasDrag) { wasDrag.fixed = false; wasDrag.vx *= 0.2; wasDrag.vy *= 0.2; }
drag = null; pan = null;
document.getElementById('graph').classList.remove('dragging');
if (wasDrag && !moved && Math.hypot(ev.movementX, ev.movementY) < 4) openNote(wasDrag.rel);
});
document.getElementById('graph').addEventListener('wheel', function (ev) {
ev.preventDefault();
var p = worldPos(ev.clientX, ev.clientY);
var k = cam.k * (ev.deltaY < 0 ? 1.12 : 0.89);
cam.k = Math.min(6, Math.max(0.12, k));
});
document.getElementById('graph').addEventListener('dblclick', function (ev) {
if (hit(ev.clientX, ev.clientY)) return;
var title = prompt('New note:');
if (title) createNote(title);
});
// ── the drawer ──
var currentRel = null, editing = false;
var titleToRel = Object.create(null);
function openNote(rel) {
currentRel = rel;
document.getElementById('drawer').classList.add('open');
document.getElementById('note-title').textContent = byRel[rel] ? byRel[rel].title : rel;
document.getElementById('note-rel').textContent = rel;
document.getElementById('note-body').style.display = '';
document.getElementById('note-editor').style.display = 'none';
editing = false;
document.getElementById('b-edit').textContent = 'Edit';
selected = rel;
call('note', { rel: rel }).then(function (r) {
if (currentRel !== rel) return;
document.getElementById('note-title').textContent = r.note.title;
var el = document.getElementById('note-body');
el.innerHTML = renderMarkdown(r.note.body);
bindWikis(el);
document.getElementById('note-editor').value = r.note.raw;
}).catch(function (e) { toast(e.message, true); });
}
function startEdit() {
editing = true;
document.getElementById('b-edit').textContent = 'Save (Ctrl+S)';
document.getElementById('note-body').style.display = 'none';
document.getElementById('note-editor').style.display = '';
document.getElementById('note-editor').focus();
}
function saveEdit() {
if (!editing) return;
var raw = document.getElementById('note-editor').value;
call('save', { rel: currentRel, raw: raw }).then(function () {
toast('saved — committed to git');
refresh();
openNote(currentRel);
}).catch(function (e) { toast(e.message, true); });
}
document.getElementById('b-edit').addEventListener('click', function () { editing ? saveEdit() : startEdit(); });
document.getElementById('b-close').addEventListener('click', function () {
document.getElementById('drawer').classList.remove('open');
selected = null;
});
document.getElementById('note-editor').addEventListener('keydown', function (ev) {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') { ev.preventDefault(); saveEdit(); }
if (ev.key === 'Escape') { document.getElementById('b-close').click(); }
});
document.getElementById('note-body').addEventListener('click', function (ev) {
var w = ev.target.closest ? ev.target.closest('.wiki') : null;
if (!w || w.classList.contains('dead')) return;
var rel = titleToRel[w.dataset.t];
if (rel) openNote(rel);
});
function bindWikis(el) {
titleToRel = Object.create(null);
for (var i = 0; i < nodes.length; i++) {
titleToRel[nodes[i].title.toLowerCase()] = nodes[i].rel;
titleToRel[nodes[i].rel.toLowerCase()] = nodes[i].rel;
}
var wk = el.querySelectorAll('.wiki');
for (var w = 0; w < wk.length; w++) {
if (!titleToRel[wk[w].dataset.t.toLowerCase()]) wk[w].classList.add('dead');
}
}
function renderMarkdown(src) {
var lines = src.split('\n');
var out = '';
var list = null, pre = null;
function closeList() {
if (list) { out += '</ul>'; list = null; }
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (/^```/.test(line)) {
closeList();
if (pre === null) { pre = true; out += '<pre><code>'; }
else { out += '</code></pre>'; pre = null; }
continue;
}
if (pre !== null) { out += escapeHtml(line) + '\n'; continue; }
var h = line.match(/^(#{1,3})\s+(.*)/);
if (h) { closeList(); out += '<h' + h[1].length + '>' + inline(h[2]) + '</h' + h[1].length + '>'; continue; }
if (/^\s*([-*])\s+/.test(line)) {
if (!list) { out += '<ul>'; list = true; }
out += '<li>' + inline(line.replace(/^\s*([-*])\s+/, '')) + '</li>';
continue;
}
closeList();
if (/^---\s*$/.test(line)) { out += '<hr>'; continue; }
if (/^>\s?/.test(line)) { out += '<blockquote>' + inline(line.replace(/^>\s?/, '')) + '</blockquote>'; continue; }
if (/^\s*$/.test(line)) continue;
if (/^\d+\.\s+/.test(line)) { out += '<p>' + inline(line) + '</p>'; continue; }
out += '<p>' + inline(line) + '</p>';
}
closeList();
if (pre !== null) out += '</code></pre>';
return out;
}
function inline(s) {
s = escapeHtml(s);
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
s = s.replace(/\*([^*]+)\*/g, '<i>$1</i>');
s = s.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, function (_, t, a) {
return '<span class="wiki" data-t="' + escapeAttr(t) + '">' + (a || t) + '</span>';
});
return s;
}
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, '&quot;');
}
// ── buttons ──
var lastGraph = null;
function refresh() {
call('graph').then(function (r) {
lastGraph = r.graph;
layout(r.graph);
paintList(r.graph);
setStatus(r.status);
now = Date.now() / 1000;
}).catch(function (e) { toast(e.message, true); });
}
function paintList(graph) {
var list = document.getElementById('list');
list.innerHTML = '';
var q = document.getElementById('search').value.trim().toLowerCase();
var shown = 0;
graph.nodes.slice().sort(function (a, b) { return b.mtime - a.mtime; })
.forEach(function (nd) {
if (q && (nd.title + ' ' + (nd.tags || []).join(' ') + ' ' + nd.description).toLowerCase().indexOf(q) < 0) return;
shown++;
var li = document.createElement('li');
li.innerHTML = '<span class="t">' + escapeHtml(nd.title) + '</span>' +
(nd.tags && nd.tags.length ? '<span class="d">' + nd.tags.map(escapeHtml).join(' · ') + '</span>' : '') +
(byRel[nd.rel] && byRel[nd.rel].deg === 0 ? '<span class="orphan">orphan</span>' : '');
li.addEventListener('click', function () { openNote(nd.rel); });
list.appendChild(li);
});
document.getElementById('list-hint').textContent = shown + ' of ' + graph.nodes.length +
' · ' + graph.links.length + ' links · ' +
graph.nodes.filter(function (n) { return byRel[n.rel].deg === 0; }).length + ' orphan' +
(graph.nodes.filter(function (n) { return byRel[n.rel].deg === 0; }).length === 1 ? '' : 's');
}
document.getElementById('b-refresh').addEventListener('click', refresh);
document.getElementById('b-new').addEventListener('click', function () {
var title = prompt('New note:');
if (title) createNote(title);
});
document.getElementById('b-sync').addEventListener('click', function () {
call('sync').then(function (r) {
toast('synced', false);
refresh();
}).catch(function (e) { toast(e.message, true); refresh(); });
});
document.getElementById('b-rail').addEventListener('click', function () {
document.getElementById('rail').classList.toggle('open');
});
document.getElementById('search').addEventListener('input', function () {
if (lastGraph) paintList(lastGraph);
});
function createNote(title) {
call('create', { title: title }).then(function (r) {
toast('note born: ' + r.rel);
refresh();
openNote(r.rel);
}).catch(function (e) { toast(e.message, true); });
}
function setStatus(s) {
document.getElementById('status').textContent =
s.vault + ' · ' + s.note_count + ' notes · ' + (s.branch || '?') +
' @ ' + (s.head || '?') + (s.dirty ? ' · ' + s.dirty + ' uncommitted' : ' · clean') +
(s.remote ? '' : ' · no remote');
}
function toast(text, err) {
var box = document.getElementById('toasts');
var t = document.createElement('div');
t.className = 'toast' + (err ? ' err' : '');
t.textContent = text;
box.appendChild(t);
setTimeout(function () { t.style.opacity = '0'; t.style.transition = 'opacity .3s'; }, 3200);
setTimeout(function () { t.remove(); }, 3600);
}
// ── run ──
resize();
layout({ nodes: [], links: [] });
refresh();
function loop() {
step(0);
draw();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();
</script>
</body>
</html>
"###;

527
src/vault.rs Normal file
View file

@ -0,0 +1,527 @@
//! Vault — the git-backed markdown corpus and the graph walked out of it.
//!
//! The lens reads plain markdown, parses the same frontmatter grammar the
//! substrate's memfs uses (description, tags, plus anything else round-tripped
//! verbatim), walks [[wikilinks]] into nodes and edges, and leaves the truth
//! to git. No database, no format of its own: the vault is files, and git is
//! the archive.
use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
/// Directories the lens never treats as content.
const SKIP_DIRS: &[&str] = &[".git", ".obsidian", ".trash", "node_modules", "target"];
#[derive(Debug, Clone, Serialize)]
pub struct VaultNode {
pub rel: String,
pub title: String,
pub tags: Vec<String>,
pub description: String,
pub mtime: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct VaultLink {
pub from: String,
pub to: String,
}
#[derive(Debug, Default, Serialize)]
pub struct Graph {
pub nodes: Vec<VaultNode>,
pub links: Vec<VaultLink>,
}
#[derive(Debug, Default, Clone)]
pub struct Frontmatter {
pub description: String,
pub tags: Vec<String>,
/// Unknown keys, kept verbatim so a rewrite never destroys them. Same
/// contract as the substrate's memfs: tolerate, preserve, round-trip.
pub extra: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
pub struct Doc {
pub fm: Frontmatter,
pub title: String,
pub body: String,
pub links: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct NoteFile {
/// Raw file content — exactly what the editor shows and saves.
pub raw: String,
/// Body without frontmatter — what the viewer renders.
pub body: String,
pub title: String,
pub rel: String,
}
#[derive(Debug, Serialize)]
pub struct StatusInfo {
pub head: String,
pub dirty: usize,
pub remote: Option<String>,
pub branch: String,
pub vault: String,
pub note_count: usize,
}
// ── Parsing ────────────────────────────────────────────────────────────────
/// Split a leading `--- ... ---` frontmatter block off raw file content.
fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
let raw = raw.strip_prefix("\u{feff}").unwrap_or(raw);
let rest = raw.strip_prefix("---")?;
let end = rest.find("\n---")?;
let fm = &rest[..end];
let body = &rest[end + 4..];
Some((fm, body.trim_start_matches(['\r', '\n'])))
}
/// Parse the frontmatter block. Malformed blocks come back empty and the raw
/// text stays in the body — a file that exists must stay readable.
fn parse_frontmatter(fm: &str) -> Frontmatter {
let mut out = Frontmatter::default();
for line in fm.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim();
let value = value.trim();
match key {
"description" => out.description = value.to_string(),
"tags" => out.tags = parse_tags(value),
other => out.extra.push((other.to_string(), value.to_string())),
}
}
out
}
fn parse_tags(value: &str) -> Vec<String> {
let value = value.trim();
let value = value
.strip_prefix('[')
.and_then(|v| v.strip_suffix(']'))
.unwrap_or(value);
value
.split(',')
.map(|t| t.trim().trim_start_matches('-').trim().to_string())
.filter(|t| !t.is_empty())
.collect()
}
fn render_frontmatter(fm: &Frontmatter) -> Option<String> {
if fm.description.is_empty() && fm.tags.is_empty() && fm.extra.is_empty() {
return None;
}
let mut out = String::from("---\n");
if !fm.description.is_empty() {
out.push_str(&format!("description: {}\n", fm.description));
}
if !fm.tags.is_empty() {
out.push_str(&format!("tags: [{}]\n", fm.tags.join(", ")));
}
for (k, v) in &fm.extra {
out.push_str(&format!("{k}: {v}\n"));
}
out.push_str("---\n");
Some(out)
}
/// Parse a full markdown file into its parts.
fn parse_doc(raw: &str) -> Doc {
let (fm, body) = split_frontmatter(raw)
.map(|(fm, body)| (parse_frontmatter(fm), body))
.unwrap_or((Frontmatter::default(), raw));
let title = body
.lines()
.find(|l| l.trim_start().starts_with("# "))
.map(|l| l.trim_start_matches(['#', ' ']).trim().to_string())
.unwrap_or_default();
let links = extract_wikilinks(body);
Doc {
fm,
title,
body: body.to_string(),
links,
}
}
/// Every `[[target]]` (or `[[target|alias]]`) in the text, target only.
fn extract_wikilinks(text: &str) -> Vec<String> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
let Some(end) = text[i + 2..].find("]]") else {
break;
};
let inner = &text[i + 2..i + 2 + end];
let target = inner.split('|').next().unwrap_or_default().trim();
// A `#` suffix is a heading anchor, not a different note.
let target = target.split('#').next().unwrap_or_default().trim();
if !target.is_empty() {
out.push(target.to_string());
}
i += 2 + end + 2;
} else {
i += 1;
}
}
out
}
/// `[[Target Name]]` -> `target-name`. Empty input -> "note".
pub fn slug(title: &str) -> String {
let mut out = String::new();
let mut dash = false;
for c in title.to_lowercase().chars() {
if c.is_ascii_alphanumeric() {
out.push(c);
dash = false;
} else if !dash {
out.push('-');
dash = true;
}
}
let slug = out.trim_matches('-').to_string();
if slug.is_empty() {
"note".to_string()
} else {
slug
}
}
// ── The vault ──────────────────────────────────────────────────────────────
pub struct Vault {
pub root: PathBuf,
}
pub fn default_root() -> PathBuf {
std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
.join("vault")
}
impl Vault {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
/// Walk the vault and walk the links out of it.
pub fn scan(&self) -> Result<Graph> {
let mut docs: Vec<(String, Doc, u64)> = Vec::new();
self.walk(&self.root, &mut docs)?;
let mut titles: HashMap<String, String> = HashMap::new();
for (rel, doc, _) in &docs {
let stem = rel
.rsplit('/')
.next()
.and_then(|f| f.strip_suffix(".md"))
.unwrap_or(rel);
if !doc.title.is_empty() {
titles.insert(doc.title.to_lowercase(), rel.clone());
}
titles.insert(stem.to_lowercase(), rel.clone());
}
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut links = Vec::new();
for (rel, doc, _) in &docs {
for target in &doc.links {
let key = target.to_lowercase();
let resolved = titles
.get(&key)
.or_else(|| find_fuzzy(&titles, &key));
if let Some(to) = resolved {
if to != rel && seen.insert((rel.clone(), to.clone())) {
links.push(VaultLink {
from: rel.clone(),
to: to.clone(),
});
}
}
}
}
let mut nodes = docs
.into_iter()
.map(|(rel, doc, mtime)| VaultNode {
rel: rel.clone(),
title: if doc.title.is_empty() {
rel.rsplit('/').next().unwrap_or(&rel).to_string()
} else {
doc.title
},
tags: doc.fm.tags,
description: doc.fm.description,
mtime,
})
.collect::<Vec<_>>();
nodes.sort_by(|a, b| a.rel.cmp(&b.rel));
Ok(Graph { nodes, links })
}
fn walk(&self, dir: &Path, out: &mut Vec<(String, Doc, u64)>) -> Result<()> {
for entry in
std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref()) {
continue;
}
if path.is_dir() {
self.walk(&path, out)?;
} else if path.extension().is_some_and(|e| e == "md") {
let raw = std::fs::read_to_string(&path)?;
let rel = path
.strip_prefix(&self.root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let mtime = entry
.metadata()
.and_then(|m| m.modified())
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
})
.unwrap_or(0);
out.push((rel, parse_doc(&raw), mtime));
}
}
Ok(())
}
/// Read one note raw, for the editor.
pub fn read_note(&self, rel: &str) -> Result<NoteFile> {
let path = self.root.join(rel);
let raw = std::fs::read_to_string(&path).with_context(|| format!("reading note {rel}"))?;
let doc = parse_doc(&raw);
Ok(NoteFile {
raw: raw.clone(),
body: doc.body,
title: if doc.title.is_empty() {
rel.rsplit('/').next().unwrap_or(rel).to_string()
} else {
doc.title
},
rel: rel.to_string(),
})
}
/// Save raw editor content back to the vault.
pub fn save_note(&self, rel: &str, raw: &str) -> Result<()> {
let path = self.root.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let is_new = !path.exists();
let (fm, body) = split_frontmatter(raw)
.map(|(fm, body)| (parse_frontmatter(fm), body))
.unwrap_or((Frontmatter::default(), raw));
let out = match (render_frontmatter(&fm), body) {
(Some(rendered), body) => format!("{rendered}{body}"),
(None, body) => body.to_string(),
};
std::fs::write(&path, &out).with_context(|| format!("writing {rel}"))?;
let doc = parse_doc(&out);
let title = if doc.title.is_empty() {
rel.rsplit('/').next().unwrap_or(rel).trim_end_matches(".md").to_string()
} else {
doc.title
};
self.commit(
&[rel],
&if is_new {
format!("note (new): {title}")
} else {
format!("note: {title}")
},
)?;
Ok(())
}
/// Create a note from a title; returns the file's relative path.
pub fn create_note(&self, title: &str) -> Result<String> {
let stem = slug(title);
let stem = if stem.is_empty() { "note".to_string() } else { stem };
let rel = format!("{stem}.md");
let path = self.root.join(&rel);
let body = if title.trim().is_empty() {
"# Note\n".to_string()
} else {
format!("# {}\n", title.trim())
};
std::fs::write(&path, body)?;
self.commit(&[rel.as_str()], &format!("note: {title}"))?;
Ok(rel)
}
fn commit(&self, paths: &[&str], message: &str) -> Result<()> {
if !self.root.join(".git").exists() {
return Ok(());
}
let mut args = vec!["add", "--"];
args.extend(paths);
self.git(&args)?;
self.git(&["commit", "-q", "-m", message])?;
Ok(())
}
pub fn status(&self) -> Result<StatusInfo> {
let head = if self.root.join(".git").exists() {
self.git(&["log", "-1", "--oneline"])?.trim().to_string()
} else {
"(no repo)".to_string()
};
let dirty = if self.root.join(".git").exists() {
self.git(&["status", "--porcelain"])?.lines().count()
} else {
0
};
let branch = self.git(&["branch", "--show-current"]).unwrap_or_default().trim().to_string();
let note_count = self.scan()?.nodes.len();
Ok(StatusInfo {
head,
dirty,
remote: self.find_remote(),
branch,
vault: self.root.display().to_string(),
note_count,
})
}
/// Fetch and push, loud, with the user's own git credentials — the same
/// choice the substrate's memfs sync makes.
pub fn sync(&self) -> Result<String> {
let Some(remote) = self.find_remote() else {
anyhow::bail!(
"no remote configured — add one with:\n git -C {} remote add origin <url>",
self.root.display()
);
};
let mut out = String::new();
out.push_str(&self.git(&["fetch", "--prune", &remote])?);
out.push_str(&self.git(&["push", &remote])?);
Ok(out)
}
fn find_remote(&self) -> Option<String> {
for name in ["gitea", "origin"] {
if let Ok(url) = self.git(&["remote", "get-url", name]) {
return Some(url.trim().to_string());
}
}
None
}
fn git(&self, args: &[&str]) -> Result<String> {
let mut cmd = std::process::Command::new("git");
cmd.arg("-C").arg(&self.root).args(args);
let out = cmd.output().context("running git — the lens needs git on PATH")?;
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if !out.status.success() {
anyhow::bail!(
"git {} failed ({}):\n{}",
args.first().unwrap_or(&"?"),
out.status,
stderr.trim()
);
}
Ok(format!("{stdout}{stderr}"))
}
/// Make the vault a git repo with a first note, if it is not one already.
pub fn ensure_repo(&self) -> Result<()> {
std::fs::create_dir_all(&self.root)?;
if self.root.join(".git").exists() {
return Ok(());
}
self.git(&["init", "-q"])?;
let index = self.root.join("index.md");
if !index.exists() {
let first = "---\ndescription: The home note — where the garden is mapped.\ntags: [home]\n---\n\n# The Garden\n\nThis is your garden. Write freely here and link out with [[double brackets]] — the lens walks the links into the shape of your attention.\n";
std::fs::write(&index, first)?;
}
self.git(&["add", "--all"])?;
self.git(&["commit", "-q", "-m", "vault: first note"])?;
Ok(())
}
}
fn find_fuzzy<'a>(titles: &'a HashMap<String, String>, needle: &str) -> Option<&'a String> {
titles
.iter()
.find(|(k, _)| k.contains(needle) || needle.contains(k.as_str()))
.map(|(_, v)| v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_frontmatter_and_preserves_extra() {
let raw = "---\ndescription: hello world\ntags: [a, b, c]\nread_only: true\n---\n# Title\n\nBody\n";
let (fm, body) = split_frontmatter(raw).unwrap();
let fm = parse_frontmatter(fm);
assert_eq!(fm.description, "hello world");
assert_eq!(fm.tags, vec!["a", "b", "c"]);
assert!(fm.extra.iter().any(|(k, _)| k == "read_only"));
assert!(body.starts_with("# Title"));
let rendered = render_frontmatter(&fm).unwrap();
assert!(rendered.contains("read_only: true"));
}
#[test]
fn extracts_wikilinks_and_anchors() {
let text = "See [[Quickstep]] and [[Glacier Home|the home]] and [[saf/index#consolidation]] and not\n[[malformed";
let links = extract_wikilinks(text);
assert_eq!(links, vec!["Quickstep", "Glacier Home", "saf/index"]);
}
#[test]
fn no_frontmatter_is_tolerated() {
let raw = "# Only a title\n\nNo frontmatter here.\n";
let doc = parse_doc(raw);
assert_eq!(doc.title, "Only a title");
assert!(doc.body.contains("No frontmatter"));
assert!(doc.fm.description.is_empty());
}
#[test]
fn slug_is_kebab() {
assert_eq!(slug("Target Name"), "target-name");
assert_eq!(slug(" Weird / chars!! "), "weird-chars");
assert_eq!(slug("..."), "note");
}
#[test]
fn scans_and_resolves_links() {
let dir = std::env::temp_dir().join(format!("lens-test-{}", std::process::id()));
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("index.md"), "# The Garden\n\nSee [[Sub Note]].\n").unwrap();
std::fs::write(dir.join("sub").join("sub-note.md"), "# Sub Note\n\nBack to [[index]].\n").unwrap();
let vault = Vault::new(dir.clone());
let graph = vault.scan().unwrap();
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.links.len(), 2);
let _ = std::fs::remove_dir_all(dir);
}
}