579 lines
17 KiB
TypeScript
579 lines
17 KiB
TypeScript
/**
|
|
* rNotes module — vault sync and browse for Obsidian and Logseq.
|
|
*
|
|
* Replaces the old full-editor module (~1784 lines).
|
|
* Rich editing is now in rDocs. This module handles:
|
|
* - ZIP vault uploads (Obsidian / Logseq)
|
|
* - Automerge metadata storage (titles, tags, hashes, wikilinks)
|
|
* - On-demand note content served from ZIP on disk
|
|
* - Graph data for wikilink visualization
|
|
* - Browser-extension compat shims → rdocs
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import * as Automerge from "@automerge/automerge";
|
|
import JSZip from "jszip";
|
|
import { createHash } from "crypto";
|
|
import { mkdir, writeFile, readFile, unlink } from "fs/promises";
|
|
import { join } from "path";
|
|
import { renderShell } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
|
|
import { resolveDataSpace } from "../../shared/scope-resolver";
|
|
import { verifyToken, extractToken } from "../../server/auth";
|
|
import { resolveCallerRole } from "../../server/spaces";
|
|
import type { SpaceRoleString } from "../../server/spaces";
|
|
import { filterArrayByVisibility } from "../../shared/membrane";
|
|
import { renderLanding } from "./landing";
|
|
import { vaultSchema, vaultDocId } from "./schemas";
|
|
import type { VaultDoc, VaultNoteMeta } from "./schemas";
|
|
import type { SyncServer } from "../../server/local-first/sync-server";
|
|
|
|
// ── SyncServer ref (set during onInit) ──
|
|
|
|
let _syncServer: SyncServer | null = null;
|
|
|
|
// ── Constants ──
|
|
|
|
const VAULT_UPLOAD_DIR = "/data/files/uploads/vaults";
|
|
|
|
// ── Helpers ──
|
|
|
|
/** Find all vault docIds for a space by scanning known prefix. */
|
|
function findVaultDocIds(space: string): string[] {
|
|
if (!_syncServer) return [];
|
|
const prefix = `${space}:rnotes:vaults:`;
|
|
return _syncServer.getDocIds().filter((id) => id.startsWith(prefix));
|
|
}
|
|
|
|
interface ParsedNote {
|
|
path: string;
|
|
title: string;
|
|
tags: string[];
|
|
frontmatter: Record<string, unknown>;
|
|
contentHash: string;
|
|
sizeBytes: number;
|
|
wikilinks: string[];
|
|
}
|
|
|
|
/**
|
|
* Parse a ZIP buffer and extract metadata for each .md file.
|
|
* Returns an array of parsed note metadata objects.
|
|
*/
|
|
async function parseVaultZip(
|
|
buffer: ArrayBuffer,
|
|
source: "obsidian" | "logseq",
|
|
): Promise<ParsedNote[]> {
|
|
const zip = await JSZip.loadAsync(buffer);
|
|
const results: ParsedNote[] = [];
|
|
|
|
for (const [relativePath, zipEntry] of Object.entries(zip.files)) {
|
|
if (zipEntry.dir) continue;
|
|
if (!relativePath.endsWith(".md") && !relativePath.endsWith(".markdown")) continue;
|
|
// Skip hidden / system files
|
|
if (relativePath.includes("/.") || relativePath.startsWith(".")) continue;
|
|
|
|
const content = await zipEntry.async("string");
|
|
const sizeBytes = content.length;
|
|
const contentHash = createHash("sha256").update(content).digest("hex");
|
|
|
|
// Parse frontmatter (YAML between --- delimiters)
|
|
const frontmatter: Record<string, unknown> = {};
|
|
let bodyStart = 0;
|
|
if (content.startsWith("---")) {
|
|
const end = content.indexOf("\n---", 3);
|
|
if (end !== -1) {
|
|
const fmBlock = content.slice(3, end).trim();
|
|
bodyStart = end + 4;
|
|
// Simple YAML key: value parser (no arrays/nested)
|
|
for (const line of fmBlock.split("\n")) {
|
|
const colon = line.indexOf(":");
|
|
if (colon === -1) continue;
|
|
const key = line.slice(0, colon).trim();
|
|
const val = line.slice(colon + 1).trim();
|
|
if (key) frontmatter[key] = val;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract tags from frontmatter
|
|
let tags: string[] = [];
|
|
if (typeof frontmatter.tags === "string") {
|
|
tags = frontmatter.tags
|
|
.replace(/[\[\]]/g, "")
|
|
.split(",")
|
|
.map((t) => t.trim().replace(/^#/, ""))
|
|
.filter(Boolean);
|
|
}
|
|
// Also pick up inline #tags from body
|
|
const body = content.slice(bodyStart);
|
|
const inlineTags = [...body.matchAll(/#([\w/-]+)/g)].map((m) => m[1]);
|
|
tags = [...new Set([...tags, ...inlineTags])];
|
|
|
|
// Derive title: first H1 heading, or frontmatter title, or filename
|
|
let title = (frontmatter.title as string) ?? "";
|
|
if (!title) {
|
|
const h1 = body.match(/^#\s+(.+)/m);
|
|
title = h1 ? h1[1].trim() : relativePath.split("/").pop()!.replace(/\.md$/i, "");
|
|
}
|
|
|
|
// Extract wikilinks [[Target]] and [[Target|Alias]]
|
|
const wikilinks = [...body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)].map((m) =>
|
|
m[1].trim(),
|
|
);
|
|
|
|
// Logseq: strip "pages/" prefix if present
|
|
const normalizedPath =
|
|
source === "logseq" && relativePath.startsWith("pages/")
|
|
? relativePath.slice(6)
|
|
: relativePath;
|
|
|
|
results.push({
|
|
path: normalizedPath,
|
|
title,
|
|
tags,
|
|
frontmatter,
|
|
contentHash,
|
|
sizeBytes,
|
|
wikilinks,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ── Routes ──
|
|
|
|
const routes = new Hono();
|
|
|
|
// GET / — render landing or app shell
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
if (!space || space === "rnotes.online") {
|
|
return c.html(renderLanding());
|
|
}
|
|
return c.html(
|
|
renderShell({
|
|
title: `${space} — rNotes | rSpace`,
|
|
moduleId: "rnotes",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
|
|
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=10"></script>`,
|
|
}),
|
|
);
|
|
});
|
|
|
|
// POST /api/vault/upload — accept ZIP + metadata, parse, store
|
|
routes.post("/api/vault/upload", async (c) => {
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Unauthorized" }, 401);
|
|
try {
|
|
await verifyToken(token);
|
|
} catch {
|
|
return c.json({ error: "Invalid token" }, 401);
|
|
}
|
|
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = resolveDataSpace("rnotes", space);
|
|
|
|
let formData: FormData;
|
|
try {
|
|
formData = await c.req.formData();
|
|
} catch {
|
|
return c.json({ error: "Expected multipart/form-data" }, 400);
|
|
}
|
|
|
|
const file = formData.get("file") as File | null;
|
|
const name = (formData.get("name") as string | null) ?? "My Vault";
|
|
const source = ((formData.get("source") as string | null) ?? "obsidian") as
|
|
| "obsidian"
|
|
| "logseq";
|
|
|
|
if (!file) return c.json({ error: "Missing file" }, 400);
|
|
if (!file.name.endsWith(".zip")) return c.json({ error: "File must be a .zip" }, 400);
|
|
|
|
const buffer = await file.arrayBuffer();
|
|
const vaultId = crypto.randomUUID();
|
|
|
|
// Parse ZIP metadata
|
|
let notes: ParsedNote[];
|
|
try {
|
|
notes = await parseVaultZip(buffer, source);
|
|
} catch (err) {
|
|
return c.json({ error: `Failed to parse ZIP: ${(err as Error).message}` }, 400);
|
|
}
|
|
|
|
if (notes.length === 0) {
|
|
return c.json({ error: "No markdown files found in ZIP" }, 400);
|
|
}
|
|
|
|
// Store ZIP to disk
|
|
await mkdir(VAULT_UPLOAD_DIR, { recursive: true });
|
|
const zipPath = join(VAULT_UPLOAD_DIR, `${vaultId}.zip`);
|
|
await writeFile(zipPath, Buffer.from(buffer));
|
|
|
|
// Build Automerge doc
|
|
const docId = vaultDocId(dataSpace, vaultId);
|
|
const now = Date.now();
|
|
const totalSize = notes.reduce((acc, n) => acc + n.sizeBytes, 0);
|
|
|
|
const doc = Automerge.change(Automerge.init<VaultDoc>(), "init vault", (d) => {
|
|
d.meta = {
|
|
module: "rnotes",
|
|
collection: "vaults",
|
|
version: 1,
|
|
spaceSlug: dataSpace,
|
|
createdAt: now,
|
|
};
|
|
d.vault = {
|
|
id: vaultId,
|
|
name,
|
|
source,
|
|
totalNotes: notes.length,
|
|
totalSizeBytes: totalSize,
|
|
lastSyncedAt: now,
|
|
createdAt: now,
|
|
};
|
|
d.notes = {};
|
|
d.wikilinks = {};
|
|
for (const n of notes) {
|
|
d.notes[n.path] = {
|
|
path: n.path,
|
|
title: n.title,
|
|
tags: n.tags,
|
|
contentHash: n.contentHash,
|
|
sizeBytes: n.sizeBytes,
|
|
lastModifiedAt: now,
|
|
syncStatus: "synced",
|
|
frontmatter: n.frontmatter as Record<string, any>,
|
|
} satisfies VaultNoteMeta;
|
|
if (n.wikilinks.length > 0) {
|
|
d.wikilinks[n.path] = n.wikilinks;
|
|
}
|
|
}
|
|
});
|
|
|
|
_syncServer!.setDoc(docId, doc);
|
|
|
|
return c.json({
|
|
vaultId,
|
|
name,
|
|
source,
|
|
totalNotes: notes.length,
|
|
totalSizeBytes: totalSize,
|
|
});
|
|
});
|
|
|
|
// GET /api/vault/list — list all vaults for a space
|
|
routes.get("/api/vault/list", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = resolveDataSpace("rnotes", space);
|
|
|
|
const docIds = findVaultDocIds(dataSpace);
|
|
const vaults = docIds
|
|
.map((id) => _syncServer!.getDoc<VaultDoc>(id))
|
|
.filter((d): d is VaultDoc => !!d)
|
|
.map((d) => d.vault);
|
|
|
|
return c.json({ vaults });
|
|
});
|
|
|
|
// GET /api/vault/:vaultId/status — sync status for a vault
|
|
routes.get("/api/vault/:vaultId/status", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = resolveDataSpace("rnotes", space);
|
|
const vaultId = c.req.param("vaultId");
|
|
|
|
const docId = vaultDocId(dataSpace, vaultId);
|
|
const doc = _syncServer!.getDoc<VaultDoc>(docId);
|
|
if (!doc) return c.json({ error: "Vault not found" }, 404);
|
|
|
|
const notes = Object.values(doc.notes);
|
|
const synced = notes.filter((n) => n.syncStatus === "synced").length;
|
|
const conflicts = notes.filter((n) => n.syncStatus === "conflict").length;
|
|
const localModified = notes.filter((n) => n.syncStatus === "local-modified").length;
|
|
|
|
return c.json({
|
|
vaultId,
|
|
name: doc.vault.name,
|
|
source: doc.vault.source,
|
|
totalNotes: doc.vault.totalNotes,
|
|
lastSyncedAt: doc.vault.lastSyncedAt,
|
|
synced,
|
|
conflicts,
|
|
localModified,
|
|
});
|
|
});
|
|
|
|
// GET /api/vault/:vaultId/notes — list notes (with ?folder= and ?search=)
|
|
routes.get("/api/vault/:vaultId/notes", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = resolveDataSpace("rnotes", space);
|
|
const vaultId = c.req.param("vaultId");
|
|
const { folder, search } = c.req.query();
|
|
|
|
const docId = vaultDocId(dataSpace, vaultId);
|
|
const doc = _syncServer!.getDoc<VaultDoc>(docId);
|
|
if (!doc) return c.json({ error: "Vault not found" }, 404);
|
|
|
|
// Resolve caller role for membrane filtering
|
|
let callerRole: SpaceRoleString = 'viewer';
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (token) {
|
|
try {
|
|
const claims = await verifyToken(token);
|
|
const resolved = await resolveCallerRole(space, claims);
|
|
if (resolved) callerRole = resolved.role;
|
|
} catch {}
|
|
}
|
|
|
|
let notes = filterArrayByVisibility(Object.values(doc.notes), callerRole);
|
|
|
|
if (folder) {
|
|
const prefix = folder.endsWith("/") ? folder : `${folder}/`;
|
|
notes = notes.filter((n) => n.path.startsWith(prefix));
|
|
}
|
|
if (search) {
|
|
const term = search.toLowerCase();
|
|
notes = notes.filter(
|
|
(n) =>
|
|
n.title.toLowerCase().includes(term) ||
|
|
n.path.toLowerCase().includes(term) ||
|
|
n.tags.some((t) => t.toLowerCase().includes(term)),
|
|
);
|
|
}
|
|
|
|
return c.json({
|
|
vaultId,
|
|
total: notes.length,
|
|
notes: notes.sort((a, b) => b.lastModifiedAt - a.lastModifiedAt),
|
|
});
|
|
});
|
|
|
|
// GET /api/vault/:vaultId/note/:path+ — read note content from ZIP
|
|
routes.get("/api/vault/:vaultId/note/*", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = resolveDataSpace("rnotes", space);
|
|
const vaultId = c.req.param("vaultId");
|
|
|
|
// Extract wildcard path segment after /note/
|
|
const url = new URL(c.req.url);
|
|
const prefix = `/rnotes/api/vault/${vaultId}/note/`;
|
|
const altPrefix = `/api/vault/${vaultId}/note/`;
|
|
let notePath = url.pathname;
|
|
if (notePath.includes(prefix)) {
|
|
notePath = decodeURIComponent(notePath.slice(notePath.indexOf(prefix) + prefix.length));
|
|
} else if (notePath.includes(altPrefix)) {
|
|
notePath = decodeURIComponent(notePath.slice(notePath.indexOf(altPrefix) + altPrefix.length));
|
|
}
|
|
|
|
const docId = vaultDocId(dataSpace, vaultId);
|
|
const doc = _syncServer!.getDoc<VaultDoc>(docId);
|
|
if (!doc) return c.json({ error: "Vault not found" }, 404);
|
|
if (!doc.notes[notePath]) return c.json({ error: "Note not found" }, 404);
|
|
|
|
const zipPath = join(VAULT_UPLOAD_DIR, `${vaultId}.zip`);
|
|
let zipBuffer: Buffer;
|
|
try {
|
|
zipBuffer = await readFile(zipPath);
|
|
} catch {
|
|
return c.json({ error: "Vault ZIP not found on disk" }, 404);
|
|
}
|
|
|
|
const zip = await JSZip.loadAsync(zipBuffer);
|
|
const zipEntry = zip.file(notePath);
|
|
if (!zipEntry) {
|
|
// Logseq may store under pages/ prefix
|
|
const altEntry = zip.file(`pages/${notePath}`);
|
|
if (!altEntry) return c.json({ error: "File not in ZIP" }, 404);
|
|
const content = await altEntry.async("string");
|
|
return new Response(content, {
|
|
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
|
});
|
|
}
|
|
const content = await zipEntry.async("string");
|
|
return new Response(content, {
|
|
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
|
});
|
|
});
|
|
|
|
// GET /api/vault/:vaultId/graph — wikilinks for graph visualization
|
|
routes.get("/api/vault/:vaultId/graph", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = resolveDataSpace("rnotes", space);
|
|
const vaultId = c.req.param("vaultId");
|
|
|
|
const docId = vaultDocId(dataSpace, vaultId);
|
|
const doc = _syncServer!.getDoc<VaultDoc>(docId);
|
|
if (!doc) return c.json({ error: "Vault not found" }, 404);
|
|
|
|
// Build nodes + edges for graph viz
|
|
const notes = Object.values(doc.notes);
|
|
const nodeSet = new Set(notes.map((n) => n.path));
|
|
const nodes = notes.map((n) => ({
|
|
id: n.path,
|
|
label: n.title,
|
|
tags: n.tags,
|
|
}));
|
|
const edges: Array<{ source: string; target: string }> = [];
|
|
|
|
for (const [sourcePath, targets] of Object.entries(doc.wikilinks)) {
|
|
for (const target of targets) {
|
|
// Try to find the matching note path (title match or path match)
|
|
const targetPath =
|
|
[...nodeSet].find(
|
|
(p) =>
|
|
p === target ||
|
|
p.replace(/\.md$/i, "") === target ||
|
|
p.split("/").pop()?.replace(/\.md$/i, "") === target,
|
|
) ?? target;
|
|
edges.push({ source: sourcePath, target: targetPath });
|
|
}
|
|
}
|
|
|
|
return c.json({ vaultId, nodes, edges });
|
|
});
|
|
|
|
// DELETE /api/vault/:vaultId — remove vault doc + ZIP
|
|
routes.delete("/api/vault/:vaultId", async (c) => {
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Unauthorized" }, 401);
|
|
try {
|
|
await verifyToken(token);
|
|
} catch {
|
|
return c.json({ error: "Invalid token" }, 401);
|
|
}
|
|
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = resolveDataSpace("rnotes", space);
|
|
const vaultId = c.req.param("vaultId");
|
|
|
|
const docId = vaultDocId(dataSpace, vaultId);
|
|
const doc = _syncServer!.getDoc<VaultDoc>(docId);
|
|
if (!doc) return c.json({ error: "Vault not found" }, 404);
|
|
|
|
// Remove Automerge doc
|
|
(_syncServer as any).deleteDoc?.(docId);
|
|
|
|
// Remove ZIP from disk (best-effort)
|
|
const zipPath = join(VAULT_UPLOAD_DIR, `${vaultId}.zip`);
|
|
try {
|
|
await unlink(zipPath);
|
|
} catch {
|
|
// Ignore if already gone
|
|
}
|
|
|
|
return c.json({ ok: true, vaultId });
|
|
});
|
|
|
|
// ── Browser extension compat shims ──
|
|
// Old extension POSTed to /api/notes and GET /api/notebooks.
|
|
// Redirect to rdocs equivalents so existing installs keep working.
|
|
|
|
routes.post("/api/notes", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.redirect(`/${space}/rdocs/api/notes`, 301);
|
|
});
|
|
|
|
routes.get("/api/notebooks", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.redirect(`/${space}/rdocs/api/notebooks`, 301);
|
|
});
|
|
|
|
// ── Module definition ──
|
|
|
|
export const notesModule: RSpaceModule = {
|
|
id: "rnotes",
|
|
name: "rNotes",
|
|
icon: "🔗",
|
|
description: "Vault sync and browse for Obsidian and Logseq",
|
|
standaloneDomain: "rnotes.online",
|
|
routes,
|
|
|
|
scoping: {
|
|
defaultScope: "space",
|
|
userConfigurable: false,
|
|
},
|
|
|
|
docSchemas: [
|
|
{
|
|
pattern: "{space}:rnotes:vaults:{vaultId}",
|
|
description: "Vault metadata — notes, tags, wikilinks (content in ZIP on disk)",
|
|
init: vaultSchema.init,
|
|
},
|
|
],
|
|
|
|
landingPage: renderLanding,
|
|
|
|
onboardingActions: [
|
|
{
|
|
label: "Upload Vault ZIP",
|
|
icon: "📦",
|
|
description: "Export your Obsidian or Logseq vault as a ZIP and upload it here",
|
|
type: "upload",
|
|
upload: { accept: ".zip", endpoint: "/api/vault/upload" },
|
|
},
|
|
],
|
|
|
|
outputPaths: [
|
|
{
|
|
path: "vaults",
|
|
name: "Vaults",
|
|
icon: "🗂️",
|
|
description: "Synced Obsidian and Logseq vaults",
|
|
},
|
|
],
|
|
|
|
async onInit({ syncServer }) {
|
|
_syncServer = syncServer;
|
|
},
|
|
|
|
async onSpaceCreate(_ctx: SpaceLifecycleContext) {
|
|
// Vaults are user-uploaded — no auto-create needed
|
|
},
|
|
};
|
|
|
|
export default notesModule;
|
|
|
|
// ── MI Integration ──
|
|
|
|
export interface MIVaultNote {
|
|
title: string;
|
|
path: string;
|
|
vaultName: string;
|
|
tags: string[];
|
|
lastModifiedAt: number;
|
|
}
|
|
|
|
/**
|
|
* Return recently modified vault notes for the MI system prompt.
|
|
* Queries all vault docs for the space and returns the most recent entries.
|
|
*/
|
|
export function getRecentVaultNotesForMI(space: string, limit = 10): MIVaultNote[] {
|
|
if (!_syncServer) return [];
|
|
|
|
const docIds = findVaultDocIds(space);
|
|
const allNotes: MIVaultNote[] = [];
|
|
|
|
for (const docId of docIds) {
|
|
const doc = _syncServer.getDoc<VaultDoc>(docId);
|
|
if (!doc) continue;
|
|
const vaultName = doc.vault.name;
|
|
for (const note of Object.values(doc.notes)) {
|
|
allNotes.push({
|
|
title: note.title,
|
|
path: note.path,
|
|
vaultName,
|
|
tags: Array.from(note.tags),
|
|
lastModifiedAt: note.lastModifiedAt,
|
|
});
|
|
}
|
|
}
|
|
|
|
return allNotes
|
|
.sort((a, b) => b.lastModifiedAt - a.lastModifiedAt)
|
|
.slice(0, limit);
|
|
}
|