🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add export and import single notes

juprodh da06b309 704dfec7

+200 -94
+1 -1
CONTRIBUTING.md
··· 10 10 bun run test:unit # unit tests (no Node needed) 11 11 mise run test # full suite (unit + integration, needs Node 22) 12 12 bun run lint # check with Biome 13 - bun run typecheck # tsc --noEmit 13 + bun run typecheck # tsgo --noEmit 14 14 ``` 15 15 16 16 The database auto-seeds with sample data on first run. To reset, delete `lichen.db` and restart.
+2 -2
src/atproto/routes.ts
··· 120 120 ["Location", returnToUrl], 121 121 [ 122 122 "Set-Cookie", 123 - `did=${encodeURIComponent(did)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`, 123 + `did=${encodeURIComponent(did)}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=2592000`, 124 124 ], 125 125 [ 126 126 "Set-Cookie", ··· 172 172 ["Location", returnToUrl], 173 173 [ 174 174 "Set-Cookie", 175 - `did=${encodeURIComponent(account.did)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`, 175 + `did=${encodeURIComponent(account.did)}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=2592000`, 176 176 ], 177 177 [ 178 178 "Set-Cookie",
+3 -3
src/firehose/handlers.ts
··· 1 1 import type { CommitEvt } from "@atproto/sync"; 2 2 import { didOwnsUri } from "../lib/at-uri.ts"; 3 - import { COLLECTIONS } from "../lib/constants.ts"; 3 + import { COLLECTIONS, normalizeRole } from "../lib/constants.ts"; 4 4 import { 5 5 applyRevisionFromFirehose, 6 6 deleteBookmarkByUri, ··· 70 70 function isWikiRecord(r: Rec): r is Rec & WikiRecord { 71 71 return ( 72 72 hasString(r, "name") && 73 - hasString(r, "visibility") && 73 + (r["visibility"] === "public" || r["visibility"] === "private") && 74 74 hasString(r, "createdAt") 75 75 ); 76 76 } ··· 215 215 upsertMembership( 216 216 wiki.slug, 217 217 record.memberDid, 218 - record.role, 218 + normalizeRole(record.role), 219 219 atUri, 220 220 record.createdAt, 221 221 );
+2
src/lib/i18n/en.ts
··· 52 52 pages: "Pages", 53 53 edit: "Edit", 54 54 export: "Export", 55 + exportNote: "Note (.md)", 56 + exportWiki: "Wiki (.zip)", 55 57 }, 56 58 editor: { 57 59 title: "Title",
+5 -3
src/lib/i18n/fr.ts
··· 53 53 pages: "Pages", 54 54 edit: "Modifier", 55 55 export: "Exporter", 56 + exportNote: "Note (.md)", 57 + exportWiki: "Wiki (.zip)", 56 58 }, 57 59 editor: { 58 60 title: "Titre", ··· 147 149 wikiLanguageRequired: "La langue est requise.", 148 150 invalidZip: "Fichier zip invalide ou corrompu.", 149 151 tooManyFiles: "Trop de fichiers dans le zip (max 100 fichiers markdown).", 150 - zipTooLarge: "Le contenu du zip depasse la limite de 50 Mo.", 151 - noMarkdownFiles: "Aucun fichier markdown trouve dans le zip.", 152 - importFailed: "Echec de l'importation : {error}", 152 + zipTooLarge: "Le contenu du zip dépasse la limite de 50 Mo.", 153 + noMarkdownFiles: "Aucun fichier markdown trouvé dans le zip.", 154 + importFailed: "Échec de l'importation : {error}", 153 155 }, 154 156 };
+2
src/lib/i18n/index.ts
··· 54 54 pages: string; 55 55 edit: string; 56 56 export: string; 57 + exportNote: string; 58 + exportWiki: string; 57 59 }; 58 60 editor: { 59 61 title: string;
+14
src/lib/identity.ts
··· 10 10 } 11 11 return instance; 12 12 } 13 + 14 + /** 15 + * Resolve a DID to its PDS service endpoint URL. 16 + * Returns null if the DID or PDS service cannot be found. 17 + */ 18 + export async function resolvePdsEndpoint(did: string): Promise<string | null> { 19 + const didDoc = await getIdResolver().did.resolve(did); 20 + if (!didDoc) return null; 21 + const service = didDoc.service?.find( 22 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 23 + ); 24 + if (!service || typeof service.serviceEndpoint !== "string") return null; 25 + return service.serviceEndpoint; 26 + }
+26 -9
src/lib/import-export/export.ts
··· 2 2 import { getBlobsByCids } from "../../server/db/queries/blob.ts"; 3 3 import { getCurrentNote, listNotes } from "../../server/db/queries/index.ts"; 4 4 import { MIME_TO_EXT } from "../constants.ts"; 5 - import { getIdResolver } from "../identity.ts"; 5 + import { resolvePdsEndpoint } from "../identity.ts"; 6 6 import { rewriteForExport } from "./markdown-transform.ts"; 7 7 8 8 const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/g; ··· 102 102 did: string, 103 103 cid: string, 104 104 ): Promise<Uint8Array | null> { 105 - const didDoc = await getIdResolver().did.resolve(did); 106 - if (!didDoc) return null; 105 + const pdsEndpoint = await resolvePdsEndpoint(did); 106 + if (!pdsEndpoint) return null; 107 107 108 - const service = didDoc.service?.find( 109 - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 110 - ); 111 - if (!service || typeof service.serviceEndpoint !== "string") return null; 112 - 113 - const url = `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 108 + const url = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 114 109 const response = await fetch(url); 115 110 if (!response.ok) return null; 116 111 ··· 131 126 } 132 127 return `${name}-${counter}`; 133 128 } 129 + 130 + /** 131 + * Export a single note as markdown text. 132 + * Wikilinks are converted to Obsidian-compatible [[Note Title]] format. 133 + * Blob image refs are left as-is (no attachments folder for single-note export). 134 + */ 135 + export function exportNoteMd( 136 + wikiSlug: string, 137 + noteSlug: string, 138 + ): string | null { 139 + const current = getCurrentNote(wikiSlug, noteSlug); 140 + if (!current) return null; 141 + 142 + const notes = listNotes(wikiSlug); 143 + const slugToTitle = new Map<string, string>(); 144 + for (const note of notes) { 145 + slugToTitle.set(note.slug, note.title); 146 + } 147 + 148 + const { content } = rewriteForExport(current.content, slugToTitle); 149 + return content; 150 + }
+4 -1
src/lib/orchestrators/bookmark.ts
··· 2 2 import { getAgent, type Session } from "../../atproto/session.ts"; 3 3 import { 4 4 deleteBookmarkByWiki, 5 + getBookmarkAtUri, 5 6 upsertBookmark, 6 7 } from "../../server/db/queries/index.ts"; 7 8 import { parseAtUri } from "../at-uri.ts"; ··· 33 34 wikiAtUri: string, 34 35 session: Session | null, 35 36 ): Promise<void> { 36 - const bookmarkAtUri = deleteBookmarkByWiki(did, wikiAtUri); 37 + const bookmarkAtUri = getBookmarkAtUri(did, wikiAtUri); 37 38 if (!bookmarkAtUri) return; 38 39 39 40 if (session) { ··· 50 51 }); 51 52 } 52 53 } 54 + 55 + deleteBookmarkByWiki(did, wikiAtUri); 53 56 }
+4 -1
src/lib/orchestrators/membership.ts
··· 8 8 deleteMembership, 9 9 deleteRequest, 10 10 getMembership, 11 + getMembershipAtUri, 11 12 upsertMembership, 12 13 upsertRequest, 13 14 } from "../../server/db/queries/index.ts"; ··· 196 197 throw new ValidationError(msg.access.cannotRemoveOwner); 197 198 } 198 199 199 - const atUri = deleteMembership(ctx.wiki.slug, memberDid); 200 + const atUri = getMembershipAtUri(ctx.wiki.slug, memberDid); 200 201 if (!atUri) { 201 202 throw new NotFoundError("Membership not found"); 202 203 } ··· 216 217 }); 217 218 } 218 219 } 220 + 221 + deleteMembership(ctx.wiki.slug, memberDid); 219 222 }
+2 -1
src/lib/profile.ts
··· 85 85 } 86 86 87 87 return result; 88 - } catch { 88 + } catch (err) { 89 + console.warn(`Failed to resolve profile for ${did}:`, err); 89 90 return { handle: null, displayName: null, avatar: null }; 90 91 } 91 92 }
+10
src/lib/query-params.ts
··· 1 + import type { WikiSort } from "../server/db/queries/wiki.ts"; 2 + 3 + export function parseSort(value: unknown): WikiSort { 4 + return value === "created" ? "created" : "updated"; 5 + } 6 + 7 + export function parsePage(value: unknown): number { 8 + const n = Number(value); 9 + return Number.isInteger(n) && n >= 1 ? n : 1; 10 + }
+4
src/lib/urls.ts
··· 26 26 return `/wiki/${wikiSlug}/-/export`; 27 27 } 28 28 29 + export function exportNoteUrl(wikiSlug: string, noteSlug: string): string { 30 + return `/wiki/${wikiSlug}/${noteSlug}/-/export`; 31 + } 32 + 29 33 export function redirect(url: string): Response { 30 34 return new Response(null, { 31 35 status: 302,
+6 -3
src/server/db/queries/bookmark.ts
··· 22 22 db.run("DELETE FROM bookmarks WHERE at_uri = ?", [atUri]); 23 23 } 24 24 25 - export function deleteBookmarkByWiki( 25 + export function getBookmarkAtUri( 26 26 did: string, 27 27 wikiAtUri: string, 28 28 ): string | null { ··· 30 30 const row = db 31 31 .query("SELECT at_uri FROM bookmarks WHERE did = ? AND wiki_at_uri = ?") 32 32 .get(did, wikiAtUri) as { at_uri: string } | null; 33 - if (!row) return null; 33 + return row?.at_uri ?? null; 34 + } 35 + 36 + export function deleteBookmarkByWiki(did: string, wikiAtUri: string): void { 37 + const db = getDb(); 34 38 db.run("DELETE FROM bookmarks WHERE did = ? AND wiki_at_uri = ?", [ 35 39 did, 36 40 wikiAtUri, 37 41 ]); 38 - return row.at_uri; 39 42 } 40 43 41 44 export function isBookmarked(did: string, wikiAtUri: string): boolean {
+2
src/server/db/queries/index.ts
··· 4 4 export { 5 5 deleteBookmarkByUri, 6 6 deleteBookmarkByWiki, 7 + getBookmarkAtUri, 7 8 getBookmarksForUser, 8 9 isBookmarked, 9 10 upsertBookmark, ··· 16 17 deleteRequestByUri, 17 18 getMemberRole, 18 19 getMembership, 20 + getMembershipAtUri, 19 21 getRequest, 20 22 listMembers, 21 23 listRequests,
+9 -3
src/server/db/queries/membership.ts
··· 87 87 ); 88 88 } 89 89 90 - export function deleteMembership(wikiSlug: string, did: string): string | null { 90 + export function getMembershipAtUri( 91 + wikiSlug: string, 92 + did: string, 93 + ): string | null { 91 94 const db = getDb(); 92 95 const row = db 93 96 .query("SELECT at_uri FROM memberships WHERE wiki_slug = ? AND did = ?") 94 97 .get(wikiSlug, did) as { at_uri: string } | null; 95 - if (!row) return null; 98 + return row?.at_uri ?? null; 99 + } 100 + 101 + export function deleteMembership(wikiSlug: string, did: string): void { 102 + const db = getDb(); 96 103 db.run("DELETE FROM memberships WHERE wiki_slug = ? AND did = ?", [ 97 104 wikiSlug, 98 105 did, 99 106 ]); 100 - return row.at_uri; 101 107 } 102 108 103 109 export function deleteRequest(wikiSlug: string, did: string): void {
+8 -1
src/server/db/queries/revision.ts
··· 112 112 revisionAtUri: string, 113 113 parentRevisionUri: string | null, 114 114 diff: string, 115 - _diffFormat: string, 115 + diffFormat: string, 116 116 message: string | null, 117 117 blobs: { cid: string; mimeType: string }[], 118 118 ): void { 119 + if (diffFormat !== "diff-match-patch") { 120 + console.warn( 121 + `Unsupported diff format "${diffFormat}" for revision ${revisionAtUri}, skipping`, 122 + ); 123 + return; 124 + } 125 + 119 126 const db = getDb(); 120 127 121 128 const current = db
+1
src/server/db/schema.ts
··· 169 169 "CREATE INDEX IF NOT EXISTS idx_blobs_revision ON blobs(revision_at_uri)", 170 170 ); 171 171 db.run("CREATE INDEX IF NOT EXISTS idx_bookmarks_did ON bookmarks(did)"); 172 + db.run("CREATE INDEX IF NOT EXISTS idx_wikis_did ON wikis(did)"); 172 173 }
+15 -11
src/server/routes/blob.ts
··· 1 1 import { existsSync, mkdirSync } from "node:fs"; 2 2 import { join, resolve } from "node:path"; 3 3 import { Elysia } from "elysia"; 4 + import { isAuthEnabled } from "../../atproto/env.ts"; 4 5 import { getAgent, getSessionFromRequest } from "../../atproto/session.ts"; 5 6 import { MIME_TO_EXT } from "../../lib/constants.ts"; 6 7 import { formatError } from "../../lib/errors.ts"; 7 - import { getIdResolver } from "../../lib/identity.ts"; 8 + import { resolvePdsEndpoint } from "../../lib/identity.ts"; 8 9 import { processImage } from "../../lib/image.ts"; 9 10 10 11 const LOCAL_BLOB_DIR = "data/blobs"; ··· 41 42 } 42 43 43 44 const session = await getSessionFromRequest(request); 45 + 46 + if (!session && isAuthEnabled()) { 47 + return new Response( 48 + JSON.stringify({ error: "Authentication required" }), 49 + { 50 + status: 401, 51 + headers: { "Content-Type": "application/json" }, 52 + }, 53 + ); 54 + } 44 55 45 56 if (session) { 46 57 // ATProto mode: upload to PDS ··· 100 111 101 112 let pdsEndpoint: string; 102 113 try { 103 - const didDoc = await getIdResolver().did.resolve(did); 104 - if (!didDoc) { 105 - return new Response("DID not found", { status: 404 }); 106 - } 107 - const service = didDoc.service?.find( 108 - (s) => 109 - s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 110 - ); 111 - if (!service || typeof service.serviceEndpoint !== "string") { 114 + const resolved = await resolvePdsEndpoint(did); 115 + if (!resolved) { 112 116 return new Response("PDS not found for DID", { status: 404 }); 113 117 } 114 - pdsEndpoint = service.serviceEndpoint; 118 + pdsEndpoint = resolved; 115 119 } catch { 116 120 return new Response("Failed to resolve DID", { status: 502 }); 117 121 }
+1 -10
src/server/routes/explore.ts
··· 1 1 import { Elysia } from "elysia"; 2 2 import { resolveRequestContext } from "../../lib/access.ts"; 3 + import { parsePage, parseSort } from "../../lib/query-params.ts"; 3 4 import { htmlResponse } from "../../lib/response.ts"; 4 5 import { explorePage } from "../../views/explore.ts"; 5 - import type { WikiSort } from "../db/queries/index.ts"; 6 6 import { 7 7 getWikiLanguages, 8 8 listPublicWikisPaginated, 9 9 } from "../db/queries/index.ts"; 10 10 11 11 const EXPLORE_LIMIT = 12; 12 - 13 - function parseSort(value: unknown): WikiSort { 14 - return value === "created" ? "created" : "updated"; 15 - } 16 - 17 - function parsePage(value: unknown): number { 18 - const n = Number(value); 19 - return Number.isInteger(n) && n >= 1 ? n : 1; 20 - } 21 12 22 13 export const exploreRoutes = new Elysia() 23 14 .get("/explore", async ({ query, request }) => {
+17 -5
src/server/routes/note.ts
··· 3 3 import { EDITOR_SCRIPTS, VIZ_SCRIPTS } from "../../lib/constants.ts"; 4 4 import { NotFoundError, ValidationError } from "../../lib/errors.ts"; 5 5 import { t } from "../../lib/i18n/index.ts"; 6 + import { exportNoteMd } from "../../lib/import-export/export.ts"; 6 7 import { renderMarkdown } from "../../lib/markdown.ts"; 7 8 import { 8 9 createNoteAction, ··· 26 27 .get("/:wikiSlug/new", async ({ params, request }) => { 27 28 const ctx = await resolveWikiContext(request, params.wikiSlug, "edit"); 28 29 29 - const sidebarNotes = getSidebarNotes(params.wikiSlug); 30 30 return htmlResponse( 31 31 newNotePage(ctx.wiki.name, params.wikiSlug, { 32 32 session: ctx.session, 33 33 scripts: EDITOR_SCRIPTS, 34 - sidebarNotes, 35 34 locale: ctx.locale, 36 - accessLevel: ctx.access, 37 35 }), 38 36 ); 39 37 }) ··· 49 47 return redirect(noteUrl(params.wikiSlug, noteSlug)); 50 48 } catch (err) { 51 49 if (err instanceof ValidationError) { 52 - const sidebarNotes = getSidebarNotes(params.wikiSlug); 53 50 return htmlResponse( 54 51 newNotePage(ctx.wiki.name, params.wikiSlug, { 55 52 session: ctx.session, 56 53 scripts: EDITOR_SCRIPTS, 57 - sidebarNotes, 58 54 locale: ctx.locale, 59 55 error: err.message, 60 56 titleValue: fields.title, ··· 98 94 await editNoteAction(ctx, params.noteSlug, fields, msg); 99 95 100 96 return redirect(noteUrl(params.wikiSlug, params.noteSlug)); 97 + }) 98 + .get("/:wikiSlug/:noteSlug/-/export", async ({ params, request }) => { 99 + await resolveWikiContext(request, params.wikiSlug, "read"); 100 + 101 + const content = exportNoteMd(params.wikiSlug, params.noteSlug); 102 + if (!content) { 103 + throw new NotFoundError("Note not found"); 104 + } 105 + 106 + const filename = `${params.noteSlug}.md`; 107 + return new Response(content, { 108 + headers: { 109 + "Content-Type": "text/markdown; charset=utf-8", 110 + "Content-Disposition": `attachment; filename="${filename}"`, 111 + }, 112 + }); 101 113 }) 102 114 .post("/:wikiSlug/:noteSlug/delete", async ({ params, request }) => { 103 115 const ctx = await resolveWikiContext(request, params.wikiSlug, "edit");
+1 -10
src/server/routes/search.ts
··· 1 1 import { Elysia } from "elysia"; 2 2 import { canRead, resolveRequestContext } from "../../lib/access.ts"; 3 + import { parsePage, parseSort } from "../../lib/query-params.ts"; 3 4 import { htmlResponse } from "../../lib/response.ts"; 4 5 import { renderNoteResults } from "../../views/search-results.ts"; 5 6 import { 6 7 wikiGridWithExploreLink, 7 8 wikiGridWithPagination, 8 9 } from "../../views/wiki-list.ts"; 9 - import type { WikiSort } from "../db/queries/index.ts"; 10 10 import { listPublicWikisPaginated, searchNotes } from "../db/queries/index.ts"; 11 11 12 12 const MAX_LIMIT = 12; 13 13 const DEFAULT_LIMIT = 6; 14 - 15 - function parseSort(value: unknown): WikiSort { 16 - return value === "created" ? "created" : "updated"; 17 - } 18 - 19 - function parsePage(value: unknown): number { 20 - const n = Number(value); 21 - return Number.isInteger(n) && n >= 1 ? n : 1; 22 - } 23 14 24 15 function parseLimit(value: unknown): number { 25 16 const n = Number(value);
+14 -15
src/server/routes/wiki.ts
··· 32 32 listRequests, 33 33 } from "../db/queries/index.ts"; 34 34 35 + async function loadSettingsData(wikiSlug: string) { 36 + const members = listMembers(wikiSlug); 37 + const requests = listRequests(wikiSlug); 38 + const allDids = [...members.map((m) => m.did), ...requests.map((r) => r.did)]; 39 + const profiles = await resolveProfiles(allDids); 40 + return { members, requests, profiles }; 41 + } 42 + 35 43 export const wikiRoutes = new Elysia({ prefix: "/wiki" }) 36 44 .get("/new", async ({ request }) => { 37 45 const ctx = await resolveRequestContext(request); ··· 90 98 .get("/:wikiSlug/-/settings", async ({ params, request }) => { 91 99 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 92 100 const isOwner = ctx.effectiveDid === ctx.wiki.did; 93 - 94 - const members = listMembers(params.wikiSlug); 95 - const requests = listRequests(params.wikiSlug); 96 - const allDids = [ 97 - ...members.map((m) => m.did), 98 - ...requests.map((r) => r.did), 99 - ]; 100 - const profiles = await resolveProfiles(allDids); 101 + const { members, requests, profiles } = await loadSettingsData( 102 + params.wikiSlug, 103 + ); 101 104 102 105 return htmlResponse( 103 106 settingsPage( ··· 122 125 const confirm = (formData.get("confirm") as string | null) ?? ""; 123 126 if (confirm !== ctx.wiki.name) { 124 127 const isOwner = ctx.effectiveDid === ctx.wiki.did; 125 - const members = listMembers(params.wikiSlug); 126 - const requests = listRequests(params.wikiSlug); 127 - const allDids = [ 128 - ...members.map((m) => m.did), 129 - ...requests.map((r) => r.did), 130 - ]; 131 - const profiles = await resolveProfiles(allDids); 128 + const { members, requests, profiles } = await loadSettingsData( 129 + params.wikiSlug, 130 + ); 132 131 return htmlResponse( 133 132 settingsPage( 134 133 ctx.wiki.name,
+18 -6
src/views/layout.ts
··· 1 1 import { type AccessLevel, canEdit, canManage } from "../lib/access.ts"; 2 2 import { escapeHtml } from "../lib/html.ts"; 3 3 import { type Locale, SUPPORTED_LOCALES, t } from "../lib/i18n/index.ts"; 4 - import { exportUrl, profileUrl } from "../lib/urls.ts"; 4 + import { exportNoteUrl, exportUrl, profileUrl } from "../lib/urls.ts"; 5 5 import { ICONS } from "./icons.ts"; 6 6 import { 7 7 kbdClass, ··· 64 64 ? `<a href="/wiki/${options.wikiSlug}/new" class="block mb-2 ${outlineSmallButtonClass} text-center">${msg.wiki.newNote}</a>` 65 65 : ""; 66 66 67 - const exportLink = `<a href="${exportUrl(options.wikiSlug ?? "")}" class="${sidebarLinkClass}"> 67 + const dropdownItemClass = `block px-3 py-1.5 text-sm ${THEME.textSecondary} ${THEME.accentLightHoverBg} rounded`; 68 + const noteExportItem = options.currentNoteSlug 69 + ? `<a href="${exportNoteUrl(options.wikiSlug ?? "", options.currentNoteSlug)}" class="${dropdownItemClass}">${msg.wiki.exportNote}</a>` 70 + : ""; 71 + const wikiExportItem = `<a href="${exportUrl(options.wikiSlug ?? "")}" class="${dropdownItemClass}">${msg.wiki.exportWiki}</a>`; 72 + 73 + const exportDropdown = `<div class="relative" id="export-picker"> 74 + <button type="button" onclick="document.getElementById('export-menu').classList.toggle('hidden')" class="${sidebarLinkClass}"> 68 75 ${ICONS.download} 69 76 ${msg.wiki.export} 70 - </a>`; 77 + </button> 78 + <div id="export-menu" class="hidden absolute left-0 bottom-full mb-1 ${THEME.bgSurface} border ${THEME.borderDefault} rounded-lg shadow-lg py-1.5 z-20 w-max"> 79 + ${noteExportItem} 80 + ${wikiExportItem} 81 + </div> 82 + </div>`; 71 83 72 84 const adminLinks = canManage(level) 73 85 ? `<div class="pt-3 border-t ${THEME.borderSubtle} shrink-0"> 74 - ${exportLink} 86 + ${exportDropdown} 75 87 <a href="/wiki/${options.wikiSlug}/-/settings" class="${sidebarLinkClass}"> 76 88 ${ICONS.settings} 77 89 ${msg.settings.heading} 78 90 </a> 79 91 </div>` 80 92 : `<div class="pt-3 border-t ${THEME.borderSubtle} shrink-0"> 81 - ${exportLink} 93 + ${exportDropdown} 82 94 </div>`; 83 95 84 96 return `<div class="w-full max-w-6xl mx-auto px-6 py-8 flex flex-col md:flex-row md:flex-1 gap-8"> ··· 183 195 </dialog> 184 196 <script> 185 197 document.addEventListener('click', (e) => { 186 - for (const [pickerId, menuId] of [['locale-picker', 'locale-menu'], ['profile-picker', 'profile-menu']]) { 198 + for (const [pickerId, menuId] of [['locale-picker', 'locale-menu'], ['profile-picker', 'profile-menu'], ['export-picker', 'export-menu']]) { 187 199 const el = document.getElementById(pickerId); 188 200 if (el && !el.contains(e.target)) document.getElementById(menuId)?.classList.add('hidden'); 189 201 }
+3 -4
src/views/settings.ts
··· 179 179 <p class="text-sm ${THEME.textSecondary} mb-4"> 180 180 ${msg.settings.deleteWikiDescription} 181 181 </p> 182 - <form method="POST" action="/wiki/${wikiSlug}/-/delete" 183 - onsubmit="return document.getElementById('confirm-name').value === '${escapeHtml(wikiName)}' 184 - || (alert('${msg.settings.nameDoesNotMatch}'), false)"> 182 + <form method="POST" action="/wiki/${wikiSlug}/-/delete" data-confirm-name="${escapeHtml(wikiName)}" data-mismatch-msg="${escapeHtml(msg.settings.nameDoesNotMatch)}" 183 + onsubmit="var f=this;var v=f.querySelector('[name=confirm]').value;if(v!==f.dataset.confirmName){alert(f.dataset.mismatchMsg);return false}"> 185 184 <label class="block text-sm font-medium ${THEME.textSecondary} mb-1"> 186 185 ${fmt(msg.settings.typeToConfirm, { name: `<strong>${escapeHtml(wikiName)}</strong>` })} 187 186 </label> 188 - <input id="confirm-name" type="text" name="confirm" autocomplete="off" 187 + <input type="text" name="confirm" autocomplete="off" 189 188 class="block w-full max-w-sm px-3 py-1.5 text-sm border ${THEME.borderInput} rounded mb-3 focus:outline-none ${THEME.accentInputFocusBorder}" 190 189 placeholder="${escapeHtml(wikiName)}" /> 191 190 <button type="submit" class="${dangerButtonClass}">${msg.settings.deleteWiki}</button>
+4
tests/integration/helpers.ts
··· 94 94 filterCollections: Object.values(COLLECTIONS), 95 95 excludeIdentity: true, 96 96 excludeAccount: true, 97 + // Skip signature verification -- Bun's crypto/ws produces "Invalid 98 + // signature on commit" locally (Arch Linux) while CI (nixery) passes. 99 + // Root cause unclear; likely Bun binary WebSocket or libcrypto difference. 100 + unauthenticatedCommits: true, 97 101 handleEvent: (evt: Event) => { 98 102 if ( 99 103 evt.event === "create" ||
+4
tests/lib/urls.test.ts
··· 1 1 import { describe, expect, test } from "bun:test"; 2 2 import { 3 3 editNoteUrl, 4 + exportNoteUrl, 4 5 newNoteUrl, 5 6 noteUrl, 6 7 redirect, ··· 15 16 expect(editNoteUrl("cooking", "pasta")).toBe("/wiki/cooking/pasta/edit"); 16 17 expect(newNoteUrl("cooking")).toBe("/wiki/cooking/new"); 17 18 expect(settingsUrl("cooking")).toBe("/wiki/cooking/-/settings"); 19 + expect(exportNoteUrl("cooking", "pasta")).toBe( 20 + "/wiki/cooking/pasta/-/export", 21 + ); 18 22 }); 19 23 20 24 test("redirect returns 302 with Location header", () => {
+18 -5
tests/server/db/queries/bookmark.test.ts
··· 3 3 import { 4 4 deleteBookmarkByUri, 5 5 deleteBookmarkByWiki, 6 + getBookmarkAtUri, 6 7 getBookmarksForUser, 7 8 isBookmarked, 8 9 upsertBookmark, ··· 99 100 }); 100 101 }); 101 102 102 - describe("deleteBookmarkByWiki", () => { 103 - test("deletes bookmark and returns at_uri", () => { 103 + describe("getBookmarkAtUri", () => { 104 + test("returns at_uri for existing bookmark", () => { 104 105 upsertBookmark( 105 106 OTHER_DID, 106 107 WIKI_AT_URI, 107 108 OTHER_BOOKMARK_AT_URI, 108 109 "2026-01-01T00:00:00.000Z", 109 110 ); 110 - const atUri = deleteBookmarkByWiki(OTHER_DID, WIKI_AT_URI); 111 + const atUri = getBookmarkAtUri(OTHER_DID, WIKI_AT_URI); 111 112 expect(atUri).toBe(OTHER_BOOKMARK_AT_URI); 112 - expect(isBookmarked(OTHER_DID, WIKI_AT_URI)).toBe(false); 113 113 }); 114 114 115 115 test("returns null when no bookmark exists", () => { 116 - const atUri = deleteBookmarkByWiki("did:plc:nobody", WIKI_AT_URI); 116 + const atUri = getBookmarkAtUri("did:plc:nobody", WIKI_AT_URI); 117 117 expect(atUri).toBeNull(); 118 118 }); 119 119 }); 120 + 121 + describe("deleteBookmarkByWiki", () => { 122 + test("deletes bookmark", () => { 123 + upsertBookmark( 124 + OTHER_DID, 125 + WIKI_AT_URI, 126 + OTHER_BOOKMARK_AT_URI, 127 + "2026-01-01T00:00:00.000Z", 128 + ); 129 + deleteBookmarkByWiki(OTHER_DID, WIKI_AT_URI); 130 + expect(isBookmarked(OTHER_DID, WIKI_AT_URI)).toBe(false); 131 + }); 132 + });