mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
e3b0cf60c5
- Sync scheduler in the content service with a per-job cache (sync_cache), sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek, cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading progress, Bambuddy filament and Homebox material stock. A note's own value always wins over a synced one. - PersonalReader: updates only Library notes with a matching anilistId, changes to Greg's notes are held for review; Reading series with no note show as suggestions on the admin Overview. - /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the content service (intake token) plus email over SMTP. Orders are re-checked against the live notes: accepted only when the category's ordersOpen is true and the line has capacity. - Admin Inbox for saved submissions. Options' inStock is optional so synced stock can fill it. All settings from process.env; .env.example per service. - Sources could not be reached from the build environment; parsers are tested against fixtures labelled as not live. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
348 lines
14 KiB
TypeScript
348 lines
14 KiB
TypeScript
/**
|
|
* The content service core. Every change, from the vault or from the write
|
|
* API, goes through `propose`, which runs the rules and either accepts the
|
|
* change into the store or holds it for review.
|
|
*
|
|
* The HTTP API, the MCP server, the admin page and any later editing tool all
|
|
* call these methods; none of them write notes any other way.
|
|
*/
|
|
import { basename } from "node:path";
|
|
import { checkNote, checkVault, formatNote, notePath, outgoingLinks, slugify, typeDef, type CheckedNote } from "@gk/schema";
|
|
import { defaultRules, evaluate, isLimited, type RuleSettings } from "./rules.ts";
|
|
import type { Author, HeldChange, HeldReason, StoredNote, Store } from "./store.ts";
|
|
import { hashText, isNotePath, type Vault } from "./vault.ts";
|
|
|
|
export type Outcome =
|
|
| { status: "accepted"; path: string; note: StoredNote }
|
|
| { status: "held"; path: string; held: HeldChange }
|
|
| { status: "rejected"; path: string | null; reasons: HeldReason[] }
|
|
| { status: "unchanged"; path: string };
|
|
|
|
export type Change =
|
|
| { op: "create"; data: Record<string, unknown>; body?: string }
|
|
| { op: "update"; path: string; fields?: Record<string, unknown>; body?: string };
|
|
|
|
export interface ServiceOptions {
|
|
store: Store;
|
|
vault: Vault;
|
|
rules?: Partial<RuleSettings>;
|
|
/** Injectable clock, for tests. */
|
|
now?: () => Date;
|
|
/** Called whenever a change is held, for notifications. */
|
|
onHold?: (held: HeldChange) => void;
|
|
}
|
|
|
|
/** How long after a note disappears a new note can still claim its old address. */
|
|
const MOVE_WINDOW_MS = 10 * 60_000;
|
|
|
|
export class ContentService {
|
|
readonly store: Store;
|
|
readonly vault: Vault;
|
|
readonly rules: RuleSettings;
|
|
private readonly now: () => Date;
|
|
private readonly onHold?: (held: HeldChange) => void;
|
|
|
|
constructor(o: ServiceOptions) {
|
|
this.store = o.store;
|
|
this.vault = o.vault;
|
|
this.rules = { ...defaultRules, ...o.rules };
|
|
this.now = o.now ?? (() => new Date());
|
|
this.onHold = o.onHold;
|
|
}
|
|
|
|
/** What the admin page shows as the service's own health. */
|
|
readonly health: {
|
|
startedAt: string;
|
|
lastScan: { at: string; changed: number; held: number } | null;
|
|
lastError: { at: string; message: string } | null;
|
|
} = { startedAt: new Date().toISOString(), lastScan: null, lastError: null };
|
|
|
|
noteError(err: unknown) {
|
|
this.health.lastError = { at: this.iso(), message: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
|
|
private iso = () => this.now().toISOString();
|
|
private today = () => this.iso().slice(0, 10);
|
|
|
|
// ------------------------------------------------------------ from the vault
|
|
|
|
/** Read one file from the vault and take it in if it changed. */
|
|
ingest(path: string): Outcome {
|
|
const text = this.vault.read(path);
|
|
if (text === null) return this.removed(path);
|
|
const hash = hashText(text);
|
|
if (this.store.seenHash(path) === hash) return { status: "unchanged", path };
|
|
this.store.markSeen(path, hash);
|
|
const current = this.store.get(path);
|
|
if (current && !current.removed && current.hash === hash) return { status: "unchanged", path };
|
|
return this.propose(path, text, "greg", "vault");
|
|
}
|
|
|
|
/** The file is gone. The note leaves the site; its history stays. */
|
|
removed(path: string): Outcome {
|
|
this.store.markSeen(path, null);
|
|
this.store.supersede(path, this.iso());
|
|
const note = this.store.remove(path, "greg", this.iso());
|
|
return note ? { status: "accepted", path, note } : { status: "unchanged", path };
|
|
}
|
|
|
|
/**
|
|
* Compare the whole folder with what was seen before. Removals go first, so
|
|
* a note moved between folders frees its name before it is read again.
|
|
*/
|
|
scan(): { changed: number; held: number } {
|
|
const present = new Set(this.vault.listNotes());
|
|
let changed = 0;
|
|
let held = 0;
|
|
for (const path of this.store.seenPaths()) {
|
|
if (!present.has(path)) {
|
|
this.removed(path);
|
|
changed++;
|
|
}
|
|
}
|
|
for (const path of present) {
|
|
const o = this.ingest(path);
|
|
if (o.status !== "unchanged") changed++;
|
|
if (o.status === "held") held++;
|
|
}
|
|
this.health.lastScan = { at: this.iso(), changed, held };
|
|
return { changed, held };
|
|
}
|
|
|
|
// ------------------------------------------------------------ the write API
|
|
|
|
/** One change from the API, made as `author`. Accepted changes are written into the vault. */
|
|
apply(change: Change, author: Author): Outcome {
|
|
let data: Record<string, unknown>;
|
|
let body: string;
|
|
let path: string;
|
|
|
|
if (change.op === "create") {
|
|
data = { ...change.data, publish: change.data.publish ?? false, created: change.data.created ?? this.today() };
|
|
body = change.body ?? "";
|
|
if (!typeDef(String(data.type))) return this.reject(null, "invalid", `Unknown type "${String(data.type)}"`);
|
|
if (typeof data.title !== "string" || !data.title.trim()) return this.reject(null, "invalid", "title: A title is required");
|
|
path = notePath(data);
|
|
if (this.vault.exists(path) || (this.store.get(path) && !this.store.get(path)!.removed)) {
|
|
return this.reject(path, "exists", `A note already exists at ${path}; update it instead`);
|
|
}
|
|
} else {
|
|
path = change.path;
|
|
if (!isNotePath(path) || path.startsWith("/") || path.split("/").includes("..")) {
|
|
return this.reject(null, "invalid", "path: Not a note path inside the Site folder");
|
|
}
|
|
// Take in any edit made in Obsidian first, so it is never overwritten.
|
|
this.ingest(path);
|
|
const current = this.store.get(path);
|
|
if (!current || current.removed) return this.reject(path, "missing", `No note at ${path}`);
|
|
if (this.store.pendingFor(path)) {
|
|
return this.reject(path, "waiting", "This note has a change waiting for review; decide on that first");
|
|
}
|
|
data = { ...current.data };
|
|
for (const [k, v] of Object.entries(change.fields ?? {})) {
|
|
if (k === "type") return this.reject(path, "invalid", "type: A note's type cannot change");
|
|
if (v === null) delete data[k];
|
|
else data[k] = v;
|
|
}
|
|
body = change.body ?? current.body;
|
|
}
|
|
|
|
data.updated = this.today();
|
|
data.updatedBy = author;
|
|
const text = formatNote(data, body);
|
|
return this.propose(path, text, author, "api");
|
|
}
|
|
|
|
// ------------------------------------------------------------ review
|
|
|
|
acceptHeld(id: number): Outcome {
|
|
const h = this.store.heldById(id);
|
|
if (!h || h.status !== "pending") return this.reject(h?.path ?? null, "missing", "Nothing is waiting with that id");
|
|
const checked = checkNote(h.text);
|
|
if (!checked.ok) {
|
|
return this.reject(h.path, "invalid", "This change is not a valid note, so it cannot go live. Fix the note in Obsidian.");
|
|
}
|
|
const clash = this.clashes(h.path, checked);
|
|
if (clash.length) return { status: "rejected", path: h.path, reasons: clash };
|
|
if (h.source === "api") {
|
|
const onDisk = this.vault.read(h.path);
|
|
const current = this.store.get(h.path);
|
|
if (onDisk !== null && hashText(onDisk) !== (current && !current.removed ? current.hash : null)) {
|
|
return this.reject(h.path, "conflict", "The note changed in Obsidian since this was proposed");
|
|
}
|
|
this.writeToVault(h.path, h.text);
|
|
}
|
|
const note = this.acceptText(h.path, h.text, checked, h.author, "accept");
|
|
this.store.decide(id, "accepted", this.iso());
|
|
return { status: "accepted", path: h.path, note };
|
|
}
|
|
|
|
/**
|
|
* Turn a held change down. With `restore`, a held edit from Obsidian is
|
|
* replaced in the vault by the version the site is showing.
|
|
*/
|
|
rejectHeld(id: number, opts: { restore?: boolean } = {}): Outcome {
|
|
const h = this.store.heldById(id);
|
|
if (!h || h.status !== "pending") return this.reject(h?.path ?? null, "missing", "Nothing is waiting with that id");
|
|
this.store.decide(id, "rejected", this.iso());
|
|
const current = this.store.get(h.path);
|
|
if (opts.restore && h.source === "vault" && current && !current.removed) this.writeToVault(h.path, current.text);
|
|
return current ? { status: "accepted", path: h.path, note: current } : { status: "unchanged", path: h.path };
|
|
}
|
|
|
|
/** Put an earlier accepted version back, in the vault and on the site. */
|
|
rollback(path: string, rev: number): Outcome {
|
|
const r = this.store.revision(path, rev);
|
|
if (!r) return this.reject(path, "missing", `No revision ${rev} for ${path}`);
|
|
const checked = checkNote(r.text);
|
|
if (!checked.ok) return this.reject(path, "invalid", "That version no longer passes the note rules");
|
|
const clash = this.clashes(path, checked);
|
|
if (clash.length) return { status: "rejected", path, reasons: clash };
|
|
this.store.supersede(path, this.iso());
|
|
this.writeToVault(path, r.text);
|
|
const note = this.acceptText(path, r.text, checked, "admin", "rollback");
|
|
return { status: "accepted", path, note };
|
|
}
|
|
|
|
// ------------------------------------------------------------ reading
|
|
|
|
/** Problems across the whole folder: links to notes that do not exist, and the like. */
|
|
problems() {
|
|
const notes = this.store.list().map((n) => ({ path: n.path, type: n.type, data: n.data, body: n.body }));
|
|
return checkVault(notes, this.vault.listAttachments());
|
|
}
|
|
|
|
// ------------------------------------------------------------ internals
|
|
|
|
private reject(path: string | null, code: string, message: string): Outcome {
|
|
return { status: "rejected", path, reasons: [{ code, message }] };
|
|
}
|
|
|
|
private slugOf(checked: CheckedNote): string {
|
|
if (!checked.ok) return "";
|
|
return String(checked.data.slug ?? slugify(String(checked.data.title)));
|
|
}
|
|
|
|
/** Name and slug clashes with other live notes. */
|
|
private clashes(path: string, checked: CheckedNote): HeldReason[] {
|
|
const reasons: HeldReason[] = [];
|
|
if (!checked.ok) return reasons;
|
|
const other = this.liveOther(this.store.byName(basename(path, ".md")), path);
|
|
if (other) reasons.push({ code: "duplicate-name", message: `Another note already uses this file name: ${other}` });
|
|
const def = typeDef(checked.type);
|
|
if (!def?.singleton) {
|
|
const s = this.liveOther(this.store.bySlug(checked.type, this.slugOf(checked)), path);
|
|
if (s) reasons.push({ code: "duplicate-slug", message: `Another ${checked.type} already uses this address: ${s}` });
|
|
}
|
|
return reasons;
|
|
}
|
|
|
|
/** Another live note's path, after making sure that note's file still exists. */
|
|
private liveOther(note: StoredNote | null, path: string): string | null {
|
|
if (!note || note.path === path) return null;
|
|
if (!this.vault.exists(note.path)) {
|
|
// Its file is gone (moved or deleted) and the watcher has not caught up.
|
|
this.removed(note.path);
|
|
return null;
|
|
}
|
|
return note.path;
|
|
}
|
|
|
|
private propose(path: string, text: string, author: Author, source: "vault" | "api"): Outcome {
|
|
const checked = checkNote(text);
|
|
const name = basename(path, ".md");
|
|
const slug = this.slugOf(checked);
|
|
const window = new Date(this.now().getTime() - this.rules.agentBatchWindowMinutes * 60_000).toISOString();
|
|
const current = this.store.get(path);
|
|
const nameTakenBy = checked.ok ? this.liveOther(this.store.byName(name), path) : null;
|
|
const slugTakenBy =
|
|
checked.ok && !typeDef(checked.type)?.singleton ? this.liveOther(this.store.bySlug(checked.type, slug), path) : null;
|
|
|
|
const verdict = evaluate(
|
|
{ path, name, slug, text, author, checked },
|
|
{
|
|
current,
|
|
nameTakenBy,
|
|
slugTakenBy,
|
|
recentAgentChanges: isLimited(author) ? this.store.changesSince(author, window) : 0,
|
|
settings: this.rules,
|
|
},
|
|
);
|
|
|
|
if (verdict.decision === "accept" && checked.ok) {
|
|
if (source === "api") this.writeToVault(path, text);
|
|
const note = this.acceptText(path, text, checked, author, "accept");
|
|
return { status: "accepted", path, note };
|
|
}
|
|
if (verdict.decision === "hold" && source === "api" && verdict.reasons.some((r) => r.code === "invalid")) {
|
|
// An API caller gets the problems back at once instead of a broken note in the queue.
|
|
return { status: "rejected", path, reasons: verdict.reasons };
|
|
}
|
|
const reasons = verdict.decision === "hold" ? verdict.reasons : [];
|
|
const held = this.store.hold({
|
|
path,
|
|
author,
|
|
source,
|
|
text,
|
|
baseHash: current && !current.removed ? current.hash : null,
|
|
reasons,
|
|
at: this.iso(),
|
|
});
|
|
this.onHold?.(held);
|
|
return { status: "held", path, held };
|
|
}
|
|
|
|
private writeToVault(path: string, text: string) {
|
|
// Mark it seen first, so the watcher recognises the service's own write.
|
|
this.store.markSeen(path, hashText(text));
|
|
this.vault.write(path, text);
|
|
}
|
|
|
|
private acceptText(path: string, text: string, checked: CheckedNote & { ok: true }, author: Author, action: "accept" | "rollback"): StoredNote {
|
|
const at = this.iso();
|
|
const data = checked.data;
|
|
const slug = this.slugOf(checked);
|
|
const hash = hashText(text);
|
|
const vaultNote = { path, type: checked.type, data, body: checked.body };
|
|
|
|
return this.store.transaction(() => {
|
|
const prev = this.store.get(path);
|
|
// The old address keeps working after a rename or a slug change.
|
|
if (prev && !prev.removed && prev.type === checked.type && prev.slug !== slug) {
|
|
this.store.addRedirect(prev.type, prev.slug, path, at);
|
|
}
|
|
if (!prev || prev.removed) {
|
|
const since = new Date(this.now().getTime() - MOVE_WINDOW_MS).toISOString();
|
|
const moved = this.store
|
|
.recentlyRemoved(since)
|
|
.find((n) => n.path !== path && n.type === checked.type && (n.hash === hash || n.title === data.title));
|
|
if (moved) {
|
|
this.store.retarget(moved.path, path);
|
|
if (moved.slug !== slug) this.store.addRedirect(moved.type, moved.slug, path, at);
|
|
}
|
|
}
|
|
// A slug in use again belongs to its note, not to a redirect.
|
|
this.store.dropRedirect(checked.type, slug);
|
|
this.store.supersede(path, at);
|
|
return this.store.accept(
|
|
{
|
|
path,
|
|
type: checked.type,
|
|
name: basename(path, ".md"),
|
|
slug,
|
|
title: String(data.title),
|
|
publish: data.publish === true,
|
|
data,
|
|
body: checked.body,
|
|
text,
|
|
hash,
|
|
author,
|
|
acceptedAt: at,
|
|
},
|
|
action,
|
|
outgoingLinks(vaultNote),
|
|
);
|
|
});
|
|
}
|
|
}
|