🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Add wiki creation import interface

juprodh ea4d1031 321beefd

+104 -10
+3
bun.lock
··· 17 17 "codemirror": "^6.0.2", 18 18 "diff-match-patch": "^1.0.5", 19 19 "elysia": "^1.4.27", 20 + "fflate": "^0.8.2", 20 21 "markdown-it": "^14.1.1", 21 22 "sharp": "^0.34.5", 22 23 "ws": "^8.0.0", ··· 992 993 "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], 993 994 994 995 "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], 996 + 997 + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], 995 998 996 999 "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], 997 1000
+1
package.json
··· 60 60 "codemirror": "^6.0.2", 61 61 "diff-match-patch": "^1.0.5", 62 62 "elysia": "^1.4.27", 63 + "fflate": "^0.8.2", 63 64 "markdown-it": "^14.1.1", 64 65 "sharp": "^0.34.5", 65 66 "ws": "^8.0.0"
+6
src/lib/errors.ts
··· 34 34 super(message, 400); 35 35 } 36 36 } 37 + 38 + export class ImportError extends AppError { 39 + constructor(message: string) { 40 + super(message, 400); 41 + } 42 + }
+11
src/lib/i18n/en.ts
··· 51 51 noNotesYet: "No notes yet.", 52 52 pages: "Pages", 53 53 edit: "Edit", 54 + export: "Export", 54 55 }, 55 56 editor: { 56 57 title: "Title", ··· 65 66 "Are you sure you want to delete this note? This cannot be undone.", 66 67 newNotePlaceholder: "# My Note Title\n\nWrite your content here...", 67 68 titlePlaceholder: "My Note Title", 69 + importFile: "Import from file", 70 + importFileHelp: "Select a .md file to pre-fill the editor.", 68 71 }, 69 72 createWiki: { 70 73 heading: "Create a Wiki", ··· 78 81 "Your wiki will be hidden on Lichen, but the underlying records on your PDS remain publicly accessible. Private data on ATProto is not yet available, fully private wikis will come when the protocol supports it.", 79 82 create: "Create Wiki", 80 83 cancel: "Cancel", 84 + importZip: "Import from zip (optional)", 85 + importZipHelp: 86 + "Upload a .zip of .md files (max 100). Obsidian vaults work.", 81 87 }, 82 88 access: { 83 89 noAccess: "You don't have access to this wiki.", ··· 137 143 wikiNameRequired: "Wiki name is required.", 138 144 wikiSlugExists: 'A wiki with slug "{slug}" already exists.', 139 145 wikiLanguageRequired: "Language is required.", 146 + invalidZip: "Invalid or corrupt zip file.", 147 + tooManyFiles: "Too many files in zip (max 100 markdown files).", 148 + zipTooLarge: "Zip content exceeds the 50MB limit.", 149 + noMarkdownFiles: "No markdown files found in zip.", 150 + importFailed: "Import failed: {error}", 140 151 }, 141 152 };
+11
src/lib/i18n/fr.ts
··· 52 52 noNotesYet: "Aucune note pour le moment.", 53 53 pages: "Pages", 54 54 edit: "Modifier", 55 + export: "Exporter", 55 56 }, 56 57 editor: { 57 58 title: "Titre", ··· 66 67 "Voulez-vous vraiment supprimer cette note ? Cette action est irréversible.", 67 68 newNotePlaceholder: "# Titre de ma note\n\nÉcrivez votre contenu ici...", 68 69 titlePlaceholder: "Titre de ma note", 70 + importFile: "Importer depuis un fichier", 71 + importFileHelp: "Selectionnez un fichier .md pour pre-remplir l'editeur.", 69 72 }, 70 73 createWiki: { 71 74 heading: "Créer un wiki", ··· 79 82 "Votre wiki sera masqué sur Lichen, mais les enregistrements sur votre PDS restent accessibles publiquement. Les données privées sur ATProto ne sont pas encore disponibles, les wikis entièrement privés arriveront quand le protocole le permettra.", 80 83 create: "Créer le wiki", 81 84 cancel: "Annuler", 85 + importZip: "Importer depuis un zip (optionnel)", 86 + importZipHelp: 87 + "Envoyez un .zip de fichiers .md (max 100). Les coffres Obsidian fonctionnent.", 82 88 }, 83 89 access: { 84 90 noAccess: "Vous n'avez pas accès à ce wiki.", ··· 138 144 wikiNameRequired: "Le nom du wiki est requis.", 139 145 wikiSlugExists: 'Un wiki avec le slug "{slug}" existe déjà.', 140 146 wikiLanguageRequired: "La langue est requise.", 147 + invalidZip: "Fichier zip invalide ou corrompu.", 148 + tooManyFiles: "Trop de fichiers dans le zip (max 100 fichiers markdown).", 149 + zipTooLarge: "Le contenu du zip depasse la limite de 50 Mo.", 150 + noMarkdownFiles: "Aucun fichier markdown trouve dans le zip.", 151 + importFailed: "Echec de l'importation : {error}", 141 152 }, 142 153 };
+10
src/lib/i18n/index.ts
··· 53 53 noNotesYet: string; 54 54 pages: string; 55 55 edit: string; 56 + export: string; 56 57 }; 57 58 editor: { 58 59 title: string; ··· 66 67 confirmDeleteNote: string; 67 68 newNotePlaceholder: string; 68 69 titlePlaceholder: string; 70 + importFile: string; 71 + importFileHelp: string; 69 72 }; 70 73 createWiki: { 71 74 heading: string; ··· 78 81 privateWarning: string; 79 82 create: string; 80 83 cancel: string; 84 + importZip: string; 85 + importZipHelp: string; 81 86 }; 82 87 access: { 83 88 noAccess: string; ··· 135 140 wikiNameRequired: string; 136 141 wikiSlugExists: string; 137 142 wikiLanguageRequired: string; 143 + invalidZip: string; 144 + tooManyFiles: string; 145 + zipTooLarge: string; 146 + noMarkdownFiles: string; 147 + importFailed: string; 138 148 }; 139 149 } 140 150
+51 -9
src/lib/orchestrators/wiki.ts
··· 25 25 import { currentTimestamp, generateTid } from "../tid.ts"; 26 26 import { withPdsError } from "./helpers.ts"; 27 27 28 - interface WikiFormFields { 28 + export interface WikiFormFields { 29 29 name: string; 30 30 language: string; 31 31 visibility: string; 32 32 description: string; 33 + } 34 + 35 + interface WikiCoreResult { 36 + wikiSlug: string; 37 + wikiAtUri: string; 38 + agent: Awaited<ReturnType<typeof getAgent>> | null; 39 + did: string; 40 + now: string; 33 41 } 34 42 35 43 /** 36 - * Full lifecycle for creating a wiki: validate → PDS write → DB write → auto-admin. 37 - * Throws ValidationError, PdsWriteError on failure. 38 - * Returns the created wiki slug. 44 + * Core wiki creation: validate → PDS write wiki + membership → DB write. 45 + * Does NOT create the home note. Used by both createWikiAction and importWikiAction. 39 46 */ 40 - export async function createWikiAction( 47 + export async function createWikiCore( 41 48 ctx: RequestContext, 42 49 fields: WikiFormFields, 43 50 msg: Messages, 44 - ): Promise<{ wikiSlug: string }> { 51 + ): Promise<WikiCoreResult> { 45 52 if (!fields.name.trim()) { 46 53 throw new ValidationError(msg.error.wikiNameRequired); 47 54 } ··· 119 126 }); 120 127 } 121 128 129 + return { wikiSlug: slug, wikiAtUri: atUri, agent, did, now }; 130 + } 131 + 132 + /** 133 + * Full lifecycle for creating a wiki: validate → PDS write → DB write → auto-admin → home note. 134 + * Throws ValidationError, PdsWriteError on failure. 135 + * Returns the created wiki slug. 136 + */ 137 + export async function createWikiAction( 138 + ctx: RequestContext, 139 + fields: WikiFormFields, 140 + msg: Messages, 141 + ): Promise<{ wikiSlug: string }> { 142 + const { wikiSlug, wikiAtUri, agent, did, now } = await createWikiCore( 143 + ctx, 144 + fields, 145 + msg, 146 + ); 147 + 122 148 // Create home note — TIDs shared between PDS and DB writes 123 149 const homeContent = `# Welcome to ${fields.name}\n\nThis is the home page of your wiki. Edit it to get started.`; 124 150 const noteTid = generateTid(); ··· 129 155 if (agent) { 130 156 const diff = createDiff("", homeContent); 131 157 await withPdsError("create home note", async () => { 132 - await writeNoteRecord(agent, did, noteTid, "home", "Home", atUri, now); 158 + await writeNoteRecord( 159 + agent, 160 + did, 161 + noteTid, 162 + "home", 163 + "Home", 164 + wikiAtUri, 165 + now, 166 + ); 133 167 await writeRevisionRecord( 134 168 agent, 135 169 did, ··· 143 177 }); 144 178 } 145 179 146 - createNote(noteAtUri, revisionAtUri, slug, "home", "Home", did, homeContent); 180 + createNote( 181 + noteAtUri, 182 + revisionAtUri, 183 + wikiSlug, 184 + "home", 185 + "Home", 186 + did, 187 + homeContent, 188 + ); 147 189 148 - return { wikiSlug: slug }; 190 + return { wikiSlug }; 149 191 } 150 192 151 193 /**
+4
src/lib/urls.ts
··· 22 22 return `/@${didOrHandle}`; 23 23 } 24 24 25 + export function exportUrl(wikiSlug: string): string { 26 + return `/wiki/${wikiSlug}/-/export`; 27 + } 28 + 25 29 export function redirect(url: string): Response { 26 30 return new Response(null, { 27 31 status: 302,
+7 -1
src/views/wiki.ts
··· 1 1 import { canEdit } from "../lib/access.ts"; 2 2 import { t } from "../lib/i18n/index.ts"; 3 + import { exportUrl } from "../lib/urls.ts"; 3 4 import { type LayoutOptions, layout } from "./layout.ts"; 4 5 import { primarySmallButtonClass, THEME } from "./theme.ts"; 5 6 ··· 35 36 ? `<a href="/wiki/${wikiSlug}/new" class="${primarySmallButtonClass}">${msg.wiki.newNote}</a>` 36 37 : ""; 37 38 39 + const exportButton = `<a href="${exportUrl(wikiSlug)}" class="text-sm ${THEME.textMuted} hover:underline">${msg.wiki.export}</a>`; 40 + 38 41 return layout( 39 42 wikiName, 40 43 ` ··· 42 45 <p class="${THEME.textMuted} mb-6">/${wikiSlug}</p> 43 46 <div class="flex items-center justify-between mb-3"> 44 47 <h2 class="text-lg font-semibold">${msg.wiki.notes}</h2> 45 - ${newNoteButton} 48 + <div class="flex items-center gap-3"> 49 + ${exportButton} 50 + ${newNoteButton} 51 + </div> 46 52 </div> 47 53 <ul class="space-y-2"> 48 54 ${noteList || `<li class="${THEME.textMuted}">${msg.wiki.noNotesYet}</li>`}