🌿 Collaborative wiki on ATProto
0
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 + });