/**
* A shared folder and its repository, kept in step. The notes live here,
* so the page is as quick as a personal vault; the repository is where
* they are kept, so they are plain markdown files anyone can clone, push
* to, or open in another editor.
*
* One pass (`syncSpace`) reads, then writes:
*
* read when the branch has moved since the last read, walk the folder
* and take every file that changed. A note nobody has touched
* here takes the repository's words; a note edited here *and*
* there keeps ours, and the repository's version is kept beside
* it as "
(repo copy)" — nothing is ever lost.
* write whatever was written, renamed, moved or removed here since,
* as one commit.
*
* A note knows what the repository last had (synced_path and the git blob
* of its words, synced_oid), so "changed here" is a comparison, never a
* flag to forget. Commits go through the forge's API with a token
* (NOTES_REPO_TOKEN): the app's own token reads only.
*
* Writes are owed, not immediate: a change waits a few seconds for the
* next (typing is many saves), and a slow trickle still commits within
* MAX_WAIT_MS. Every space is also read once a minute.
*/
import type { Database } from "bun:sqlite";
import { createHash } from "node:crypto";
import type { Note } from "./actions.ts";
import { BODY_MAX } from "./actions.ts";
import { findSpace, spaceVault, tellSpace, titleOfPath, whenEdited, type Space } from "./spaces.ts";
/** The git blob id of some words: what `git hash-object` says of the file. */
export function blobOid(text: string): string {
const bytes = Buffer.from(text, "utf8");
return createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
}
export interface Entry {
readonly name: string;
readonly type: "blob" | "tree" | string;
readonly oid: string;
readonly size: number;
}
/** The forge, as much of it as a shared folder needs. */
export interface Forge {
/** The commit at the tip of a branch; null when there is none yet. */
head(repo: string, branch: string): Promise;
/** A folder's entries at a commit; null when it doesn't exist. */
list(repo: string, ref: string, path: string): Promise;
/** A file's words at a commit. */
read(repo: string, ref: string, path: string): Promise;
/** Write and remove files (`content: null`) as one commit on `branch`. */
commit(
repo: string,
branch: string,
message: string,
files: { path: string; content: string | null }[],
): Promise;
}
type Fetch = (input: string, init?: RequestInit) => Promise;
/** The forge's JSON API (`/api/v1`), as a token that may write the repositories. */
export function httpForge(api: string, token: string, fetch: Fetch = globalThis.fetch): Forge {
const base = api.replace(/\/+$/, "");
const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
const at = (repo: string, rest: string) => `${base}/repos/${repo}/${rest}`;
const path = (p: string) => p.split("/").map(encodeURIComponent).join("/");
const failed = async (r: Response, what: string): Promise => {
throw new Error(`${what}: HTTP ${r.status} ${(await r.text()).slice(0, 200)}`);
};
return {
async head(repo, branch) {
const r = await fetch(at(repo, `commits?ref=${encodeURIComponent(branch)}&limit=1`), { headers });
if (r.status === 404) throw new Error(`No repository ${repo} — or the token can't see it.`);
if (!r.ok) return failed(r, "read the branch");
const log = (await r.json()) as { oid?: string }[];
return log[0]?.oid ?? null;
},
async list(repo, ref, p) {
const r = await fetch(at(repo, `contents${p === "" ? "" : `/${path(p)}`}?ref=${encodeURIComponent(ref)}`), {
headers,
});
if (r.status === 404) return null;
if (!r.ok) return failed(r, `list ${p || "/"}`);
const answer = (await r.json()) as { type?: string; entries?: Entry[] };
return answer.type === "dir" ? (answer.entries ?? []) : null;
},
async read(repo, ref, p) {
const r = await fetch(at(repo, `contents/${path(p)}?ref=${encodeURIComponent(ref)}`), { headers });
if (!r.ok) return failed(r, `read ${p}`);
const answer = (await r.json()) as { encoding?: string; content?: string };
const content = answer.content ?? "";
return answer.encoding === "base64" ? Buffer.from(content, "base64").toString("utf8") : content;
},
async commit(repo, branch, message, files) {
const r = await fetch(at(repo, "edits"), {
method: "POST",
headers,
body: JSON.stringify({ branch, message, files }),
});
if (!r.ok) return failed(r, "commit");
const answer = (await r.json()) as { commit?: { commitOid?: string; oid?: string } | string };
const c = answer.commit;
return typeof c === "string" ? c : (c?.commitOid ?? c?.oid ?? "");
},
};
}
/** Folders a vault never holds: git's, and editors' own settings (`.obsidian`, `.vscode`). */
const hidden = (name: string): boolean => name.startsWith(".") || name === "node_modules";
/** Every markdown file under `folder`, path → blob id. */
async function walk(forge: Forge, space: Space, ref: string): Promise