diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7271f65..5b66d03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ bun run dev # appview on :3000 (watch mode) bun run test:unit # unit tests (no Node needed) mise run test # full suite (unit + integration, needs Node 22) bun run lint # check with Biome -bun run typecheck # tsc --noEmit +bun run typecheck # tsgo --noEmit ``` The database auto-seeds with sample data on first run. To reset, delete `lichen.db` and restart. diff --git a/src/atproto/routes.ts b/src/atproto/routes.ts index ac86d51..012e495 100644 --- a/src/atproto/routes.ts +++ b/src/atproto/routes.ts @@ -120,7 +120,7 @@ export function atprotoRoutes() { ["Location", returnToUrl], [ "Set-Cookie", - `did=${encodeURIComponent(did)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`, + `did=${encodeURIComponent(did)}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=2592000`, ], [ "Set-Cookie", @@ -172,7 +172,7 @@ export function atprotoRoutes() { ["Location", returnToUrl], [ "Set-Cookie", - `did=${encodeURIComponent(account.did)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`, + `did=${encodeURIComponent(account.did)}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=2592000`, ], [ "Set-Cookie", diff --git a/src/firehose/handlers.ts b/src/firehose/handlers.ts index fa5dded..2e4bc32 100644 --- a/src/firehose/handlers.ts +++ b/src/firehose/handlers.ts @@ -1,6 +1,6 @@ import type { CommitEvt } from "@atproto/sync"; import { didOwnsUri } from "../lib/at-uri.ts"; -import { COLLECTIONS } from "../lib/constants.ts"; +import { COLLECTIONS, normalizeRole } from "../lib/constants.ts"; import { applyRevisionFromFirehose, deleteBookmarkByUri, @@ -70,7 +70,7 @@ function hasString(r: Rec, key: string): boolean { function isWikiRecord(r: Rec): r is Rec & WikiRecord { return ( hasString(r, "name") && - hasString(r, "visibility") && + (r["visibility"] === "public" || r["visibility"] === "private") && hasString(r, "createdAt") ); } @@ -215,7 +215,7 @@ function handleMembership( upsertMembership( wiki.slug, record.memberDid, - record.role, + normalizeRole(record.role), atUri, record.createdAt, ); diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 48247d2..5960378 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -52,6 +52,8 @@ export const en: Messages = { pages: "Pages", edit: "Edit", export: "Export", + exportNote: "Note (.md)", + exportWiki: "Wiki (.zip)", }, editor: { title: "Title", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 162446b..01f293a 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -53,6 +53,8 @@ export const fr: PartialMessages = { pages: "Pages", edit: "Modifier", export: "Exporter", + exportNote: "Note (.md)", + exportWiki: "Wiki (.zip)", }, editor: { title: "Titre", @@ -147,8 +149,8 @@ export const fr: PartialMessages = { wikiLanguageRequired: "La langue est requise.", invalidZip: "Fichier zip invalide ou corrompu.", tooManyFiles: "Trop de fichiers dans le zip (max 100 fichiers markdown).", - zipTooLarge: "Le contenu du zip depasse la limite de 50 Mo.", - noMarkdownFiles: "Aucun fichier markdown trouve dans le zip.", - importFailed: "Echec de l'importation : {error}", + zipTooLarge: "Le contenu du zip dépasse la limite de 50 Mo.", + noMarkdownFiles: "Aucun fichier markdown trouvé dans le zip.", + importFailed: "Échec de l'importation : {error}", }, }; diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index fd9c6a8..20acd38 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -54,6 +54,8 @@ export interface Messages { pages: string; edit: string; export: string; + exportNote: string; + exportWiki: string; }; editor: { title: string; diff --git a/src/lib/identity.ts b/src/lib/identity.ts index 5682506..33e71ff 100644 --- a/src/lib/identity.ts +++ b/src/lib/identity.ts @@ -10,3 +10,17 @@ export function getIdResolver(): IdResolver { } return instance; } + +/** + * Resolve a DID to its PDS service endpoint URL. + * Returns null if the DID or PDS service cannot be found. + */ +export async function resolvePdsEndpoint(did: string): Promise { + const didDoc = await getIdResolver().did.resolve(did); + if (!didDoc) return null; + const service = didDoc.service?.find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", + ); + if (!service || typeof service.serviceEndpoint !== "string") return null; + return service.serviceEndpoint; +} diff --git a/src/lib/import-export/export.ts b/src/lib/import-export/export.ts index a08cad2..73fcf0d 100644 --- a/src/lib/import-export/export.ts +++ b/src/lib/import-export/export.ts @@ -2,7 +2,7 @@ import { zipSync } from "fflate"; import { getBlobsByCids } from "../../server/db/queries/blob.ts"; import { getCurrentNote, listNotes } from "../../server/db/queries/index.ts"; import { MIME_TO_EXT } from "../constants.ts"; -import { getIdResolver } from "../identity.ts"; +import { resolvePdsEndpoint } from "../identity.ts"; import { rewriteForExport } from "./markdown-transform.ts"; const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/g; @@ -102,15 +102,10 @@ async function fetchBlobFromPds( did: string, cid: string, ): Promise { - const didDoc = await getIdResolver().did.resolve(did); - if (!didDoc) return null; + const pdsEndpoint = await resolvePdsEndpoint(did); + if (!pdsEndpoint) return null; - const service = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - if (!service || typeof service.serviceEndpoint !== "string") return null; - - const url = `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; + const url = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; const response = await fetch(url); if (!response.ok) return null; @@ -131,3 +126,25 @@ function deduplicateFilename(name: string, used: Set): string { } return `${name}-${counter}`; } + +/** + * Export a single note as markdown text. + * Wikilinks are converted to Obsidian-compatible [[Note Title]] format. + * Blob image refs are left as-is (no attachments folder for single-note export). + */ +export function exportNoteMd( + wikiSlug: string, + noteSlug: string, +): string | null { + const current = getCurrentNote(wikiSlug, noteSlug); + if (!current) return null; + + const notes = listNotes(wikiSlug); + const slugToTitle = new Map(); + for (const note of notes) { + slugToTitle.set(note.slug, note.title); + } + + const { content } = rewriteForExport(current.content, slugToTitle); + return content; +} diff --git a/src/lib/orchestrators/bookmark.ts b/src/lib/orchestrators/bookmark.ts index a63eef8..410f8e6 100644 --- a/src/lib/orchestrators/bookmark.ts +++ b/src/lib/orchestrators/bookmark.ts @@ -2,6 +2,7 @@ import { deleteRecord, writeBookmarkRecord } from "../../atproto/pds.ts"; import { getAgent, type Session } from "../../atproto/session.ts"; import { deleteBookmarkByWiki, + getBookmarkAtUri, upsertBookmark, } from "../../server/db/queries/index.ts"; import { parseAtUri } from "../at-uri.ts"; @@ -33,7 +34,7 @@ export async function deleteBookmarkAction( wikiAtUri: string, session: Session | null, ): Promise { - const bookmarkAtUri = deleteBookmarkByWiki(did, wikiAtUri); + const bookmarkAtUri = getBookmarkAtUri(did, wikiAtUri); if (!bookmarkAtUri) return; if (session) { @@ -50,4 +51,6 @@ export async function deleteBookmarkAction( }); } } + + deleteBookmarkByWiki(did, wikiAtUri); } diff --git a/src/lib/orchestrators/membership.ts b/src/lib/orchestrators/membership.ts index 22df03d..a6d172b 100644 --- a/src/lib/orchestrators/membership.ts +++ b/src/lib/orchestrators/membership.ts @@ -8,6 +8,7 @@ import { deleteMembership, deleteRequest, getMembership, + getMembershipAtUri, upsertMembership, upsertRequest, } from "../../server/db/queries/index.ts"; @@ -196,7 +197,7 @@ export async function deleteMemberAction( throw new ValidationError(msg.access.cannotRemoveOwner); } - const atUri = deleteMembership(ctx.wiki.slug, memberDid); + const atUri = getMembershipAtUri(ctx.wiki.slug, memberDid); if (!atUri) { throw new NotFoundError("Membership not found"); } @@ -216,4 +217,6 @@ export async function deleteMemberAction( }); } } + + deleteMembership(ctx.wiki.slug, memberDid); } diff --git a/src/lib/profile.ts b/src/lib/profile.ts index e07659e..c9f342f 100644 --- a/src/lib/profile.ts +++ b/src/lib/profile.ts @@ -85,7 +85,8 @@ export async function resolveProfile( } return result; - } catch { + } catch (err) { + console.warn(`Failed to resolve profile for ${did}:`, err); return { handle: null, displayName: null, avatar: null }; } } diff --git a/src/lib/query-params.ts b/src/lib/query-params.ts new file mode 100644 index 0000000..112ec8b --- /dev/null +++ b/src/lib/query-params.ts @@ -0,0 +1,10 @@ +import type { WikiSort } from "../server/db/queries/wiki.ts"; + +export function parseSort(value: unknown): WikiSort { + return value === "created" ? "created" : "updated"; +} + +export function parsePage(value: unknown): number { + const n = Number(value); + return Number.isInteger(n) && n >= 1 ? n : 1; +} diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 81ecc4c..635aea6 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -26,6 +26,10 @@ export function exportUrl(wikiSlug: string): string { return `/wiki/${wikiSlug}/-/export`; } +export function exportNoteUrl(wikiSlug: string, noteSlug: string): string { + return `/wiki/${wikiSlug}/${noteSlug}/-/export`; +} + export function redirect(url: string): Response { return new Response(null, { status: 302, diff --git a/src/server/db/queries/bookmark.ts b/src/server/db/queries/bookmark.ts index 9d37d6e..0cdf286 100644 --- a/src/server/db/queries/bookmark.ts +++ b/src/server/db/queries/bookmark.ts @@ -22,7 +22,7 @@ export function deleteBookmarkByUri(atUri: string): void { db.run("DELETE FROM bookmarks WHERE at_uri = ?", [atUri]); } -export function deleteBookmarkByWiki( +export function getBookmarkAtUri( did: string, wikiAtUri: string, ): string | null { @@ -30,12 +30,15 @@ export function deleteBookmarkByWiki( const row = db .query("SELECT at_uri FROM bookmarks WHERE did = ? AND wiki_at_uri = ?") .get(did, wikiAtUri) as { at_uri: string } | null; - if (!row) return null; + return row?.at_uri ?? null; +} + +export function deleteBookmarkByWiki(did: string, wikiAtUri: string): void { + const db = getDb(); db.run("DELETE FROM bookmarks WHERE did = ? AND wiki_at_uri = ?", [ did, wikiAtUri, ]); - return row.at_uri; } export function isBookmarked(did: string, wikiAtUri: string): boolean { diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index f1e61d9..6620320 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -4,6 +4,7 @@ export { insertBlob } from "./blob.ts"; export { deleteBookmarkByUri, deleteBookmarkByWiki, + getBookmarkAtUri, getBookmarksForUser, isBookmarked, upsertBookmark, @@ -16,6 +17,7 @@ export { deleteRequestByUri, getMemberRole, getMembership, + getMembershipAtUri, getRequest, listMembers, listRequests, diff --git a/src/server/db/queries/membership.ts b/src/server/db/queries/membership.ts index 0804eb3..0e0b8ef 100644 --- a/src/server/db/queries/membership.ts +++ b/src/server/db/queries/membership.ts @@ -87,17 +87,23 @@ export function getRequest(wikiSlug: string, did: string): RequestRow | null { ); } -export function deleteMembership(wikiSlug: string, did: string): string | null { +export function getMembershipAtUri( + wikiSlug: string, + did: string, +): string | null { const db = getDb(); const row = db .query("SELECT at_uri FROM memberships WHERE wiki_slug = ? AND did = ?") .get(wikiSlug, did) as { at_uri: string } | null; - if (!row) return null; + return row?.at_uri ?? null; +} + +export function deleteMembership(wikiSlug: string, did: string): void { + const db = getDb(); db.run("DELETE FROM memberships WHERE wiki_slug = ? AND did = ?", [ wikiSlug, did, ]); - return row.at_uri; } export function deleteRequest(wikiSlug: string, did: string): void { diff --git a/src/server/db/queries/revision.ts b/src/server/db/queries/revision.ts index b909895..d8b331e 100644 --- a/src/server/db/queries/revision.ts +++ b/src/server/db/queries/revision.ts @@ -112,10 +112,17 @@ export function applyRevisionFromFirehose( revisionAtUri: string, parentRevisionUri: string | null, diff: string, - _diffFormat: string, + diffFormat: string, message: string | null, blobs: { cid: string; mimeType: string }[], ): void { + if (diffFormat !== "diff-match-patch") { + console.warn( + `Unsupported diff format "${diffFormat}" for revision ${revisionAtUri}, skipping`, + ); + return; + } + const db = getDb(); const current = db diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index b2bd735..81c1e4c 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -169,4 +169,5 @@ export function initSchema(db: Database): void { "CREATE INDEX IF NOT EXISTS idx_blobs_revision ON blobs(revision_at_uri)", ); db.run("CREATE INDEX IF NOT EXISTS idx_bookmarks_did ON bookmarks(did)"); + db.run("CREATE INDEX IF NOT EXISTS idx_wikis_did ON wikis(did)"); } diff --git a/src/server/routes/blob.ts b/src/server/routes/blob.ts index 773a339..bd6546e 100644 --- a/src/server/routes/blob.ts +++ b/src/server/routes/blob.ts @@ -1,10 +1,11 @@ import { existsSync, mkdirSync } from "node:fs"; import { join, resolve } from "node:path"; import { Elysia } from "elysia"; +import { isAuthEnabled } from "../../atproto/env.ts"; import { getAgent, getSessionFromRequest } from "../../atproto/session.ts"; import { MIME_TO_EXT } from "../../lib/constants.ts"; import { formatError } from "../../lib/errors.ts"; -import { getIdResolver } from "../../lib/identity.ts"; +import { resolvePdsEndpoint } from "../../lib/identity.ts"; import { processImage } from "../../lib/image.ts"; const LOCAL_BLOB_DIR = "data/blobs"; @@ -42,6 +43,16 @@ export const blobRoutes = new Elysia() const session = await getSessionFromRequest(request); + if (!session && isAuthEnabled()) { + return new Response( + JSON.stringify({ error: "Authentication required" }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + }, + ); + } + if (session) { // ATProto mode: upload to PDS const agent = await getAgent(session); @@ -100,18 +111,11 @@ export const blobRoutes = new Elysia() let pdsEndpoint: string; try { - const didDoc = await getIdResolver().did.resolve(did); - if (!didDoc) { - return new Response("DID not found", { status: 404 }); - } - const service = didDoc.service?.find( - (s) => - s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - if (!service || typeof service.serviceEndpoint !== "string") { + const resolved = await resolvePdsEndpoint(did); + if (!resolved) { return new Response("PDS not found for DID", { status: 404 }); } - pdsEndpoint = service.serviceEndpoint; + pdsEndpoint = resolved; } catch { return new Response("Failed to resolve DID", { status: 502 }); } diff --git a/src/server/routes/explore.ts b/src/server/routes/explore.ts index 3de6017..9689ada 100644 --- a/src/server/routes/explore.ts +++ b/src/server/routes/explore.ts @@ -1,8 +1,8 @@ import { Elysia } from "elysia"; import { resolveRequestContext } from "../../lib/access.ts"; +import { parsePage, parseSort } from "../../lib/query-params.ts"; import { htmlResponse } from "../../lib/response.ts"; import { explorePage } from "../../views/explore.ts"; -import type { WikiSort } from "../db/queries/index.ts"; import { getWikiLanguages, listPublicWikisPaginated, @@ -10,15 +10,6 @@ import { const EXPLORE_LIMIT = 12; -function parseSort(value: unknown): WikiSort { - return value === "created" ? "created" : "updated"; -} - -function parsePage(value: unknown): number { - const n = Number(value); - return Number.isInteger(n) && n >= 1 ? n : 1; -} - export const exploreRoutes = new Elysia() .get("/explore", async ({ query, request }) => { const ctx = await resolveRequestContext(request); diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 49adb12..5e9e309 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -3,6 +3,7 @@ import { resolveWikiContext } from "../../lib/access.ts"; import { EDITOR_SCRIPTS, VIZ_SCRIPTS } from "../../lib/constants.ts"; import { NotFoundError, ValidationError } from "../../lib/errors.ts"; import { t } from "../../lib/i18n/index.ts"; +import { exportNoteMd } from "../../lib/import-export/export.ts"; import { renderMarkdown } from "../../lib/markdown.ts"; import { createNoteAction, @@ -26,14 +27,11 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) .get("/:wikiSlug/new", async ({ params, request }) => { const ctx = await resolveWikiContext(request, params.wikiSlug, "edit"); - const sidebarNotes = getSidebarNotes(params.wikiSlug); return htmlResponse( newNotePage(ctx.wiki.name, params.wikiSlug, { session: ctx.session, scripts: EDITOR_SCRIPTS, - sidebarNotes, locale: ctx.locale, - accessLevel: ctx.access, }), ); }) @@ -49,12 +47,10 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) return redirect(noteUrl(params.wikiSlug, noteSlug)); } catch (err) { if (err instanceof ValidationError) { - const sidebarNotes = getSidebarNotes(params.wikiSlug); return htmlResponse( newNotePage(ctx.wiki.name, params.wikiSlug, { session: ctx.session, scripts: EDITOR_SCRIPTS, - sidebarNotes, locale: ctx.locale, error: err.message, titleValue: fields.title, @@ -99,6 +95,22 @@ export const noteRoutes = new Elysia({ prefix: "/wiki" }) return redirect(noteUrl(params.wikiSlug, params.noteSlug)); }) + .get("/:wikiSlug/:noteSlug/-/export", async ({ params, request }) => { + await resolveWikiContext(request, params.wikiSlug, "read"); + + const content = exportNoteMd(params.wikiSlug, params.noteSlug); + if (!content) { + throw new NotFoundError("Note not found"); + } + + const filename = `${params.noteSlug}.md`; + return new Response(content, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + }) .post("/:wikiSlug/:noteSlug/delete", async ({ params, request }) => { const ctx = await resolveWikiContext(request, params.wikiSlug, "edit"); diff --git a/src/server/routes/search.ts b/src/server/routes/search.ts index 45d73e7..2bae03e 100644 --- a/src/server/routes/search.ts +++ b/src/server/routes/search.ts @@ -1,26 +1,17 @@ import { Elysia } from "elysia"; import { canRead, resolveRequestContext } from "../../lib/access.ts"; +import { parsePage, parseSort } from "../../lib/query-params.ts"; import { htmlResponse } from "../../lib/response.ts"; import { renderNoteResults } from "../../views/search-results.ts"; import { wikiGridWithExploreLink, wikiGridWithPagination, } from "../../views/wiki-list.ts"; -import type { WikiSort } from "../db/queries/index.ts"; import { listPublicWikisPaginated, searchNotes } from "../db/queries/index.ts"; const MAX_LIMIT = 12; const DEFAULT_LIMIT = 6; -function parseSort(value: unknown): WikiSort { - return value === "created" ? "created" : "updated"; -} - -function parsePage(value: unknown): number { - const n = Number(value); - return Number.isInteger(n) && n >= 1 ? n : 1; -} - function parseLimit(value: unknown): number { const n = Number(value); if (Number.isInteger(n) && n >= 1 && n <= MAX_LIMIT) return n; diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index 997ec04..ce59385 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -32,6 +32,14 @@ import { listRequests, } from "../db/queries/index.ts"; +async function loadSettingsData(wikiSlug: string) { + const members = listMembers(wikiSlug); + const requests = listRequests(wikiSlug); + const allDids = [...members.map((m) => m.did), ...requests.map((r) => r.did)]; + const profiles = await resolveProfiles(allDids); + return { members, requests, profiles }; +} + export const wikiRoutes = new Elysia({ prefix: "/wiki" }) .get("/new", async ({ request }) => { const ctx = await resolveRequestContext(request); @@ -90,14 +98,9 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) .get("/:wikiSlug/-/settings", async ({ params, request }) => { const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); const isOwner = ctx.effectiveDid === ctx.wiki.did; - - const members = listMembers(params.wikiSlug); - const requests = listRequests(params.wikiSlug); - const allDids = [ - ...members.map((m) => m.did), - ...requests.map((r) => r.did), - ]; - const profiles = await resolveProfiles(allDids); + const { members, requests, profiles } = await loadSettingsData( + params.wikiSlug, + ); return htmlResponse( settingsPage( @@ -122,13 +125,9 @@ export const wikiRoutes = new Elysia({ prefix: "/wiki" }) const confirm = (formData.get("confirm") as string | null) ?? ""; if (confirm !== ctx.wiki.name) { const isOwner = ctx.effectiveDid === ctx.wiki.did; - const members = listMembers(params.wikiSlug); - const requests = listRequests(params.wikiSlug); - const allDids = [ - ...members.map((m) => m.did), - ...requests.map((r) => r.did), - ]; - const profiles = await resolveProfiles(allDids); + const { members, requests, profiles } = await loadSettingsData( + params.wikiSlug, + ); return htmlResponse( settingsPage( ctx.wiki.name, diff --git a/src/views/layout.ts b/src/views/layout.ts index 8746307..cfc58f8 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -1,7 +1,7 @@ import { type AccessLevel, canEdit, canManage } from "../lib/access.ts"; import { escapeHtml } from "../lib/html.ts"; import { type Locale, SUPPORTED_LOCALES, t } from "../lib/i18n/index.ts"; -import { exportUrl, profileUrl } from "../lib/urls.ts"; +import { exportNoteUrl, exportUrl, profileUrl } from "../lib/urls.ts"; import { ICONS } from "./icons.ts"; import { kbdClass, @@ -64,21 +64,33 @@ function renderWithSidebar(body: string, options: LayoutOptions): string { ? `${msg.wiki.newNote}` : ""; - const exportLink = ` + const dropdownItemClass = `block px-3 py-1.5 text-sm ${THEME.textSecondary} ${THEME.accentLightHoverBg} rounded`; + const noteExportItem = options.currentNoteSlug + ? `${msg.wiki.exportNote}` + : ""; + const wikiExportItem = `${msg.wiki.exportWiki}`; + + const exportDropdown = `
+ + +
`; const adminLinks = canManage(level) ? `
- ${exportLink} + ${exportDropdown} ${ICONS.settings} ${msg.settings.heading}
` : `
- ${exportLink} + ${exportDropdown}
`; return `
@@ -183,7 +195,7 @@ ${extraScripts}