From 237e28f08c96285c2d59828749e27238127f243b Mon Sep 17 00:00:00 2001 From: juprodh Date: Mon, 11 May 2026 11:06:48 +0800 Subject: [PATCH] First draft for history page --- src/lib/diff-render.ts | 42 ++++++++++++ src/lib/i18n/en.ts | 4 ++ src/lib/i18n/fr.ts | 4 ++ src/lib/i18n/index.ts | 4 ++ src/lib/urls.ts | 8 +++ src/server/db/queries/index.ts | 7 +- src/server/db/queries/profile-cache.ts | 29 +++++++++ src/server/db/queries/revision.ts | 21 ++++++ src/server/routes/note.ts | 66 ++++++++++++++++++- src/views/history.ts | 89 ++++++++++++++++++++++++++ src/views/icons.ts | 1 + src/views/layout.ts | 10 +++ 12 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 src/lib/diff-render.ts create mode 100644 src/views/history.ts diff --git a/src/lib/diff-render.ts b/src/lib/diff-render.ts new file mode 100644 index 0000000..325dd51 --- /dev/null +++ b/src/lib/diff-render.ts @@ -0,0 +1,42 @@ +import DiffMatchPatch from "diff-match-patch"; +import { escapeHtml } from "./html.ts"; + +const dmp = new DiffMatchPatch(); + +const INSERT = 1; +const DELETE = -1; + +/** + * Render a diff-match-patch patch text as inline HTML for the history page. + * + * Each patch becomes a hunk. Within a hunk, equal text is plain (context), + * inserts wrap in , deletes wrap in . Newlines are preserved with + *
. Returns "" for an empty patch (e.g. no-op revision). + */ +export function renderDiffInline(patchText: string): string { + if (!patchText.trim()) return ""; + let patches: { diffs: [number, string][] }[]; + try { + patches = dmp.patch_fromText(patchText) as unknown as { + diffs: [number, string][]; + }[]; + } catch { + return `
${escapeHtml(patchText)}
`; + } + + const hunks = patches.map((patch) => { + const parts = patch.diffs.map(([op, text]) => { + const html = escapeHtml(text).replace(/\n/g, "
"); + if (op === INSERT) + return `${html}`; + if (op === DELETE) + return `${html}`; + return `${html}`; + }); + return `
${parts.join("")}
`; + }); + + return hunks.join( + `
`, + ); +} diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 55956b7..f6d22f1 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -61,6 +61,10 @@ export const en: Messages = { exportNote: "Note (.md)", exportWiki: "Wiki (.zip)", share: "Share", + history: "History", + historyHeading: "Note history", + historyEmpty: "No revisions yet.", + historyDefaultMessage: "edit", }, editor: { title: "Title", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index fe0bac4..eba05a1 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -62,6 +62,10 @@ export const fr: PartialMessages = { exportNote: "Note (.md)", exportWiki: "Wiki (.zip)", share: "Partager", + history: "Historique", + historyHeading: "Historique de la note", + historyEmpty: "Aucune révision pour le moment.", + historyDefaultMessage: "modification", }, editor: { title: "Titre", diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 775a56b..2f46980 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -63,6 +63,10 @@ export interface Messages { exportNote: string; exportWiki: string; share: string; + history: string; + historyHeading: string; + historyEmpty: string; + historyDefaultMessage: string; }; editor: { title: string; diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 28e0758..2813477 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -42,6 +42,14 @@ export function exportNoteUrl( return `/@${handle}/${wikiSlug}/${noteSlug}/-/export`; } +export function historyNoteUrl( + handle: string, + wikiSlug: string, + noteSlug: string, +): string { + return `/@${handle}/${wikiSlug}/${noteSlug}/-/history`; +} + export function redirect(url: string): Response { return new Response(null, { status: 302, diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index 4c60d26..2ac0ae1 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -37,12 +37,17 @@ export { searchNotes, upsertNote, } from "./note.ts"; -export { getCachedProfile, setCachedProfile } from "./profile-cache.ts"; +export { + getCachedProfile, + getCachedProfilesByDids, + setCachedProfile, +} from "./profile-cache.ts"; export { applyRevisionFromFirehose, capSnapshots, getBacklinks, getSnapshots, + listRevisions, } from "./revision.ts"; export type { WikiSort, WikiWithNoteCount } from "./wiki.ts"; export { diff --git a/src/server/db/queries/profile-cache.ts b/src/server/db/queries/profile-cache.ts index 9c4ab49..7a9d5df 100644 --- a/src/server/db/queries/profile-cache.ts +++ b/src/server/db/queries/profile-cache.ts @@ -20,6 +20,35 @@ export function getCachedProfile(did: string): CachedProfile | null { return row; } +/** + * Batch lookup for history pages and similar — returns whatever's cached, + * regardless of staleness (history is for old data, fresh handles aren't critical). + */ +export function getCachedProfilesByDids( + dids: string[], +): Map { + const result = new Map< + string, + { handle: string | null; display_name: string | null } + >(); + if (dids.length === 0) return result; + const db = getDb(); + const placeholders = dids.map(() => "?").join(","); + const rows = db + .query( + `SELECT did, handle, display_name FROM profile_cache WHERE did IN (${placeholders})`, + ) + .all(...dids) as { + did: string; + handle: string | null; + display_name: string | null; + }[]; + for (const row of rows) { + result.set(row.did, { handle: row.handle, display_name: row.display_name }); + } + return result; +} + export function setCachedProfile( did: string, handle: string | null, diff --git a/src/server/db/queries/revision.ts b/src/server/db/queries/revision.ts index 3777b95..eb73986 100644 --- a/src/server/db/queries/revision.ts +++ b/src/server/db/queries/revision.ts @@ -93,6 +93,27 @@ export function getBacklinks( .all(wikiSlug, noteSlug) as BacklinkRow[]; } +export interface RevisionHistoryRow { + id: number; + did: string; + at_uri: string; + parent_revision_uri: string | null; + diff: string; + diff_format: string; + message: string | null; + created_at: string; +} + +export function listRevisions(noteAtUri: string): RevisionHistoryRow[] { + const db = getDb(); + return db + .query( + `SELECT id, did, at_uri, parent_revision_uri, diff, diff_format, message, created_at + FROM revisions WHERE note_at_uri = ? ORDER BY id DESC`, + ) + .all(noteAtUri) as RevisionHistoryRow[]; +} + export function getSnapshots(noteAtUri: string): SnapshotRow[] { const db = getDb(); return db diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 48546d1..3730d2c 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -20,10 +20,16 @@ import { htmlResponse } from "../../lib/response.ts"; import { noteUrl, redirect, wikiUrl } from "../../lib/urls.ts"; import { resolveBookmarkHtml } from "../../views/bookmark.ts"; import { editNotePage } from "../../views/edit-note.ts"; +import { type HistoryEntry, historyPage } from "../../views/history.ts"; import { newNotePage } from "../../views/new-note.ts"; import { notePage } from "../../views/note.ts"; import { shareOnBlueskyButton } from "../../views/share.ts"; -import { getNoteWithCurrent, getSidebarNotes } from "../db/queries/index.ts"; +import { + getCachedProfilesByDids, + getNoteWithCurrent, + getSidebarNotes, + listRevisions, +} from "../db/queries/index.ts"; interface HandleWikiNoteParams { handle: string; @@ -162,6 +168,64 @@ export const noteRoutes = new Elysia() }); }, ) + .get( + "/@:handle/:wikiSlug/:noteSlug/-/history", + async ({ params, request }) => { + const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); + const ctx = await resolveWikiContext( + request, + urlHandle, + wikiSlug, + "read", + ); + const handle = ctx.ownerHandle ?? urlHandle; + + const data = getNoteWithCurrent(ctx.wiki.at_uri, noteSlug); + if (!data) throw new NotFoundError("Note not found"); + + const revisions = listRevisions(data.note.at_uri); + const dids = Array.from(new Set(revisions.map((r) => r.did))); + const profiles = getCachedProfilesByDids(dids); + + const entries: HistoryEntry[] = revisions.map((r) => { + const p = profiles.get(r.did); + return { + at_uri: r.at_uri, + did: r.did, + handle: p?.handle ?? null, + displayName: p?.display_name ?? null, + message: r.message, + diff: r.diff, + diff_format: r.diff_format, + created_at: r.created_at, + }; + }); + + const sidebarNotes = getSidebarNotes(ctx.wiki.at_uri); + + return htmlResponse( + historyPage( + ctx.wiki.name, + handle, + ctx.wiki.slug, + noteSlug, + data.note.title, + entries, + { + session: ctx.session, + sidebarNotes, + locale: ctx.locale, + userTheme: ctx.userTheme, + accessLevel: ctx.access, + ownerHandle: handle, + wikiAtUri: ctx.wiki.at_uri, + wikiThemeMode: ctx.wiki.theme_mode, + wikiTheme: ctx.wiki.theme, + }, + ), + ); + }, + ) .post("/@:handle/:wikiSlug/:noteSlug/delete", async ({ params, request }) => { const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "edit"); diff --git a/src/views/history.ts b/src/views/history.ts new file mode 100644 index 0000000..1e4529b --- /dev/null +++ b/src/views/history.ts @@ -0,0 +1,89 @@ +import { renderDiffInline } from "../lib/diff-render.ts"; +import { escapeHtml } from "../lib/html.ts"; +import { type Locale, t } from "../lib/i18n/index.ts"; +import { profileUrl } from "../lib/urls.ts"; +import { type LayoutOptions, layout } from "./layout.ts"; +import { THEME } from "./theme/index.ts"; + +export interface HistoryEntry { + at_uri: string; + did: string; + handle: string | null; + displayName: string | null; + message: string | null; + diff: string; + diff_format: string; + created_at: string; +} + +function formatDate(iso: string, locale: Locale): string { + const d = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z"); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(locale, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function authorLabel(entry: HistoryEntry): string { + const display = entry.displayName || entry.handle || entry.did; + const handleSuffix = entry.handle ? `@${entry.handle}` : entry.did; + const href = profileUrl(entry.handle ?? entry.did); + return `${escapeHtml(display === handleSuffix ? handleSuffix : `@${entry.handle ?? entry.did}`)}`; +} + +export function historyPage( + wikiName: string, + ownerHandle: string, + wikiSlug: string, + noteSlug: string, + noteTitle: string, + entries: HistoryEntry[], + options: LayoutOptions, +): string { + const locale = options.locale ?? "en"; + const msg = t(locale); + + const heading = `
+

${msg.wiki.historyHeading}

+

${escapeHtml(noteTitle)}

+
`; + + const body = + entries.length === 0 + ? `

${msg.wiki.historyEmpty}

` + : entries + .map((entry) => { + const date = formatDate(entry.created_at, locale); + const message = + entry.message?.trim() || msg.wiki.historyDefaultMessage; + const summary = ` + ${escapeHtml(date)} + ${authorLabel(entry)}: ${escapeHtml(message)} + `; + const diffBody = + entry.diff_format === "diff-match-patch" + ? renderDiffInline(entry.diff) + : `
${escapeHtml(entry.diff)}
`; + return `
+ ${summary} +
${diffBody}
+
`; + }) + .join("\n"); + + const content = `
${heading}${body}
`; + + return layout(noteTitle, content, { + ...options, + wikiName, + wikiSlug, + ownerHandle, + currentNoteSlug: noteSlug, + pageTitle: `${msg.wiki.historyHeading} — ${noteTitle}`, + noindex: true, + }); +} diff --git a/src/views/icons.ts b/src/views/icons.ts index cfedd1c..ef9e8d1 100644 --- a/src/views/icons.ts +++ b/src/views/icons.ts @@ -6,6 +6,7 @@ export const ICONS = { login: ``, logout: ``, check: ``, + history: ``, download: ``, theme: ``, // Inlined so fill="currentColor" lets the nav logo follow theme accent. diff --git a/src/views/layout.ts b/src/views/layout.ts index 1367162..776aea2 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -5,6 +5,7 @@ import { editNoteUrl, exportNoteUrl, exportUrl, + historyNoteUrl, newNoteUrl, noteUrl, profileUrl, @@ -139,6 +140,13 @@ function renderWithSidebar(body: string, options: LayoutOptions): string { : ""; const wikiExportItem = `${msg.wiki.exportWiki}`; + const historyLink = options.currentNoteSlug + ? ` + ${ICONS.history} + ${msg.wiki.history} + ` + : ""; + const exportDropdown = `