🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Add possibility to change wiki name and description in settings

juprodh f613e072 72929e48

+222 -5
+5
src/lib/i18n/en.ts
··· 128 128 }, 129 129 settings: { 130 130 heading: "Settings", 131 + details: "Wiki details", 132 + nameLabel: "Name", 133 + descriptionLabel: "Description", 134 + save: "Save changes", 135 + detailsSaved: "Wiki details saved.", 131 136 dangerZone: "Delete this wiki", 132 137 deleteWikiDescription: 133 138 "This will permanently delete the wiki and all its notes. This action cannot be undone.",
+5
src/lib/i18n/fr.ts
··· 130 130 }, 131 131 settings: { 132 132 heading: "Paramètres", 133 + details: "Détails du wiki", 134 + nameLabel: "Nom", 135 + descriptionLabel: "Description", 136 + save: "Enregistrer les modifications", 137 + detailsSaved: "Détails du wiki enregistrés.", 133 138 dangerZone: "Supprimer ce wiki", 134 139 deleteWikiDescription: 135 140 "Cela supprimera définitivement le wiki et toutes ses notes. Cette action est irréversible.",
+5
src/lib/i18n/index.ts
··· 128 128 }; 129 129 settings: { 130 130 heading: string; 131 + details: string; 132 + nameLabel: string; 133 + descriptionLabel: string; 134 + save: string; 135 + detailsSaved: string; 131 136 dangerZone: string; 132 137 deleteWikiDescription: string; 133 138 deleteWikiOwnerOnly: string;
+53
src/lib/orchestrators/wiki.ts
··· 200 200 } 201 201 202 202 /** 203 + * Update a wiki's name and description. Admin only (route-gated). 204 + * Slug, visibility, language, and createdAt are preserved. 205 + */ 206 + export async function editWikiAction( 207 + ctx: WikiRequestContext, 208 + fields: { name: string; description: string }, 209 + msg: Messages, 210 + ): Promise<void> { 211 + if (!fields.name.trim()) { 212 + throw new ValidationError(msg.error.wikiNameRequired); 213 + } 214 + if (fields.name.length > LIMITS.wiki.name) { 215 + throw new ValidationError( 216 + fmt(msg.error.wikiNameTooLong, { max: String(LIMITS.wiki.name) }), 217 + ); 218 + } 219 + 220 + const did = ctx.did; 221 + if (!did) throw new ForbiddenError(); 222 + 223 + const description = fields.description 224 + .trim() 225 + .slice(0, LIMITS.wiki.description); 226 + 227 + if (ctx.session) { 228 + const agent = await getAgent(ctx.session); 229 + await withPdsError("edit wiki", async () => { 230 + await writeWikiRecord( 231 + agent, 232 + did, 233 + ctx.wiki.slug, 234 + fields.name, 235 + ctx.wiki.visibility as "public" | "private", 236 + ctx.wiki.created_at, 237 + ctx.wiki.language, 238 + description || undefined, 239 + ); 240 + }); 241 + } 242 + 243 + upsertWiki( 244 + ctx.wiki.slug, 245 + ctx.wiki.did, 246 + fields.name, 247 + ctx.wiki.visibility, 248 + ctx.wiki.at_uri, 249 + ctx.wiki.created_at, 250 + ctx.wiki.language, 251 + description, 252 + ); 253 + } 254 + 255 + /** 203 256 * Delete a wiki. Owner only. 204 257 * PDS cleanup (wiki record + membership records) → DB cascade delete. 205 258 * Throws ForbiddenError if caller is not the wiki owner.
+44 -1
src/server/routes/wiki.ts
··· 19 19 import { 20 20 createWikiAction, 21 21 deleteWikiAction, 22 + editWikiAction, 22 23 } from "../../lib/orchestrators/wiki.ts"; 23 24 import { resolveProfiles } from "../../lib/profile.ts"; 24 25 import { htmlResponse } from "../../lib/response.ts"; ··· 104 105 throw err; 105 106 } 106 107 }) 107 - .get("/:wikiSlug/-/settings", async ({ params, request }) => { 108 + .get("/:wikiSlug/-/settings", async ({ params, request, query }) => { 108 109 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 109 110 const isOwner = ctx.did === ctx.wiki.did; 110 111 const { members, requests, profiles } = await loadSettingsData( ··· 124 125 locale: ctx.locale, 125 126 accessLevel: ctx.access, 126 127 wikiDid: ctx.wiki.did, 128 + wikiDescription: ctx.wiki.description, 129 + detailsSaved: query["saved"] === "1", 127 130 }, 128 131 ), 129 132 ); 130 133 }) 134 + .post("/:wikiSlug/-/edit", async ({ params, request }) => { 135 + const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 136 + const msg = t(ctx.locale); 137 + const formData = await request.formData(); 138 + const name = (formData.get("name") as string | null) ?? ""; 139 + const description = (formData.get("description") as string | null) ?? ""; 140 + 141 + try { 142 + await editWikiAction(ctx, { name, description }, msg); 143 + return redirect(`/wiki/${params.wikiSlug}/-/settings?saved=1`); 144 + } catch (err) { 145 + if (err instanceof ValidationError) { 146 + const isOwner = ctx.did === ctx.wiki.did; 147 + const { members, requests, profiles } = await loadSettingsData( 148 + params.wikiSlug, 149 + ); 150 + return htmlResponse( 151 + settingsPage( 152 + name || ctx.wiki.name, 153 + params.wikiSlug, 154 + isOwner, 155 + members, 156 + requests, 157 + profiles, 158 + { 159 + session: ctx.session, 160 + locale: ctx.locale, 161 + accessLevel: ctx.access, 162 + wikiDid: ctx.wiki.did, 163 + wikiDescription: description, 164 + error: err.message, 165 + }, 166 + ), 167 + 400, 168 + ); 169 + } 170 + throw err; 171 + } 172 + }) 131 173 .post("/:wikiSlug/-/delete", async ({ params, request }) => { 132 174 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 133 175 const formData = await request.formData(); ··· 150 192 locale: ctx.locale, 151 193 accessLevel: ctx.access, 152 194 wikiDid: ctx.wiki.did, 195 + wikiDescription: ctx.wiki.description, 153 196 error: "Wiki name does not match.", 154 197 }, 155 198 ),
+45 -3
src/views/settings.ts
··· 14 14 15 15 interface SettingsPageOptions extends LayoutOptions { 16 16 wikiDid: string; 17 + wikiDescription: string; 17 18 error?: string; 19 + detailsSaved?: boolean; 18 20 } 19 21 20 22 function renderIdentity(did: string, profile: ProfileInfo | undefined): string { ··· 192 194 </section>`; 193 195 } 194 196 197 + function renderDetailsSection( 198 + wikiSlug: string, 199 + wikiName: string, 200 + wikiDescription: string, 201 + detailsSaved: boolean, 202 + locale: string, 203 + ): string { 204 + const msg = t(locale as "en" | "fr"); 205 + const savedBanner = detailsSaved 206 + ? `<div class="mb-4 px-3 py-2 text-sm rounded ${THEME.accentLightHoverBg} ${THEME.accentText}">${msg.settings.detailsSaved}</div>` 207 + : ""; 208 + 209 + return `<section class="mb-10"> 210 + <h2 class="text-lg font-semibold mb-4">${msg.settings.details}</h2> 211 + ${savedBanner} 212 + <form method="POST" action="/wiki/${wikiSlug}/-/edit" class="flex flex-col gap-3 max-w-xl"> 213 + <div class="flex flex-col gap-1"> 214 + <label class="text-xs font-medium ${THEME.textMuted}">${msg.settings.nameLabel}</label> 215 + <input type="text" name="name" required autocomplete="off" value="${escapeHtml(wikiName)}" 216 + class="px-3 py-1.5 text-sm border ${THEME.borderInput} rounded focus:outline-none ${THEME.accentInputFocusBorder}" /> 217 + </div> 218 + <div class="flex flex-col gap-1"> 219 + <label class="text-xs font-medium ${THEME.textMuted}">${msg.settings.descriptionLabel}</label> 220 + <textarea name="description" rows="3" 221 + class="px-3 py-1.5 text-sm border ${THEME.borderInput} rounded focus:outline-none ${THEME.accentInputFocusBorder}">${escapeHtml(wikiDescription)}</textarea> 222 + </div> 223 + <div> 224 + <button type="submit" class="${primarySmallButtonClass}">${msg.settings.save}</button> 225 + </div> 226 + </form> 227 + </section>`; 228 + } 229 + 195 230 export function settingsPage( 196 231 wikiName: string, 197 232 wikiSlug: string, ··· 202 237 options: SettingsPageOptions, 203 238 ): string { 204 239 const errorHtml = errorBanner(options.error); 240 + const locale = options.locale ?? "en"; 241 + const detailsHtml = renderDetailsSection( 242 + wikiSlug, 243 + wikiName, 244 + options.wikiDescription, 245 + options.detailsSaved ?? false, 246 + locale, 247 + ); 205 248 const membersHtml = renderMembersSection( 206 249 wikiSlug, 207 250 members, 208 251 requests, 209 252 profiles, 210 253 options.wikiDid, 211 - options.locale ?? "en", 254 + locale, 212 255 ); 213 - const locale = options.locale ?? "en"; 214 256 const msg = t(locale); 215 257 const dangerHtml = renderDangerZone(wikiName, wikiSlug, isOwner, locale); 216 258 217 259 return layout( 218 260 `${msg.settings.heading} — ${wikiName}`, 219 - `<h1 class="text-2xl font-bold mb-8">${msg.settings.heading}</h1>${errorHtml}${membersHtml}${dangerHtml}`, 261 + `<h1 class="text-2xl font-bold mb-8">${msg.settings.heading}</h1>${errorHtml}${detailsHtml}${membersHtml}${dangerHtml}`, 220 262 { ...options, wikiName, wikiSlug }, 221 263 ); 222 264 }
+65 -1
tests/lib/orchestrators/wiki.test.ts
··· 42 42 getAgent: mockGetAgent, 43 43 })); 44 44 45 - const { createWikiAction, deleteWikiAction } = await import( 45 + const { createWikiAction, deleteWikiAction, editWikiAction } = await import( 46 46 "../../../src/lib/orchestrators/wiki.ts" 47 47 ); 48 48 ··· 56 56 wikiLanguageRequired: "Language required", 57 57 invalidSlug: "Invalid slug: {title}", 58 58 wikiSlugExists: "Slug exists: {slug}", 59 + wikiNameTooLong: "Too long (max {max})", 59 60 }, 60 61 } as never; 61 62 ··· 321 322 if (idx !== -1) createdSlugs.splice(idx, 1); 322 323 }); 323 324 }); 325 + 326 + describe("editWikiAction", () => { 327 + async function createTestWiki(name: string): Promise<WikiRow> { 328 + const result = await createWikiAction( 329 + makeCtx({ session: null }), 330 + { name, language: "en", visibility: "public", description: "orig" }, 331 + dummyMsg, 332 + ); 333 + createdSlugs.push(result.wikiSlug); 334 + const db = getDb(); 335 + return db 336 + .query("SELECT * FROM wikis WHERE slug = ?") 337 + .get(result.wikiSlug) as WikiRow; 338 + } 339 + 340 + test("updates name and description in DB, writes to PDS", async () => { 341 + const wiki = await createTestWiki("Orch Edit Test"); 342 + mockWriteWikiRecord.mockClear(); 343 + const ctx = makeWikiCtx(wiki); 344 + 345 + await editWikiAction( 346 + ctx, 347 + { name: "Renamed Wiki", description: "new description" }, 348 + dummyMsg, 349 + ); 350 + 351 + const db = getDb(); 352 + const row = db 353 + .query("SELECT name, description FROM wikis WHERE slug = ?") 354 + .get(wiki.slug) as { name: string; description: string }; 355 + expect(row.name).toBe("Renamed Wiki"); 356 + expect(row.description).toBe("new description"); 357 + expect(mockWriteWikiRecord).toHaveBeenCalledTimes(1); 358 + }); 359 + 360 + test("skips PDS when no session", async () => { 361 + const wiki = await createTestWiki("Orch Edit No Session"); 362 + mockWriteWikiRecord.mockClear(); 363 + const ctx = makeWikiCtx(wiki, { session: null }); 364 + 365 + await editWikiAction(ctx, { name: "Renamed", description: "" }, dummyMsg); 366 + 367 + expect(mockWriteWikiRecord).not.toHaveBeenCalled(); 368 + }); 369 + 370 + test("throws ValidationError on empty name", async () => { 371 + const wiki = await createTestWiki("Orch Edit Empty Name"); 372 + const ctx = makeWikiCtx(wiki); 373 + 374 + expect( 375 + editWikiAction(ctx, { name: "", description: "x" }, dummyMsg), 376 + ).rejects.toBeInstanceOf(ValidationError); 377 + }); 378 + 379 + test("throws ValidationError on oversize name", async () => { 380 + const wiki = await createTestWiki("Orch Edit Oversize Name"); 381 + const ctx = makeWikiCtx(wiki); 382 + 383 + expect( 384 + editWikiAction(ctx, { name: "x".repeat(257), description: "" }, dummyMsg), 385 + ).rejects.toBeInstanceOf(ValidationError); 386 + }); 387 + });