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:
commit
531e7e757c
8 changed files with 4136 additions and 0 deletions
69
.gitea/workflows/ci.yml
Normal file
69
.gitea/workflows/ci.yml
Normal 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
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
2685
Cargo.lock
generated
Normal file
2685
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal 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
32
README.md
Normal 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
205
src/main.rs
Normal 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
600
src/ui.rs
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s).replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 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
527
src/vault.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue