From d85d028a21cead1a31c583617660ecd07a4536af Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 18:45:01 +0200 Subject: [PATCH] add EPUB and PDF downloads --- .env.development | 2 + src/components/NoteOptions/useMenuActions.tsx | 104 +++++---- src/constants.ts | 7 + src/i18n/locales/de.ts | 7 + src/i18n/locales/en.ts | 7 + src/lib/asciidoctor-server-client.ts | 80 +++++++ .../publication-asciidoc-assembler.test.ts | 70 ++++++ src/lib/publication-asciidoc-assembler.ts | 205 ++++++++++++++++++ src/lib/publication-export.ts | 142 ++++++++++++ vite.config.ts | 13 +- 10 files changed, 589 insertions(+), 48 deletions(-) create mode 100644 src/lib/asciidoctor-server-client.ts create mode 100644 src/lib/publication-asciidoc-assembler.test.ts create mode 100644 src/lib/publication-asciidoc-assembler.ts create mode 100644 src/lib/publication-export.ts diff --git a/.env.development b/.env.development index b14b7e0e..79bd49de 100644 --- a/.env.development +++ b/.env.development @@ -4,3 +4,5 @@ VITE_PROXY_SERVER=/sites VITE_READ_ALOUD_TTS_URL=/api/piper-tts VITE_LANGUAGE_TOOL_URL=/api/languagetool VITE_TRANSLATE_URL=/api/translate +# Wikistr AsciiDoctor sidecar (EPUB/PDF); same API as unfold — proxy to localhost:8091 in dev. +VITE_ASCIIDOCTOR_SERVER_URL=/api/asciidoctor diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index c81ca49f..f759dc52 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -42,7 +42,10 @@ import { isEventInPinList } from '@/lib/replaceable-list-latest' import indexedDb from '@/services/indexed-db.service' -import { generateBech32IdFromATag } from '@/lib/tag' +import { + exportPublicationDownload, + isAsciiDoctorServerConfigured +} from '@/lib/publication-export' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useMuteList } from '@/contexts/mute-list-context' @@ -58,6 +61,7 @@ import { Bell, BellOff, Bookmark, + Download, Pin, Settings, Share2, @@ -837,55 +841,25 @@ export function useMenuActions({ const exportAsAsciidoc = async () => { if (!isArticleType) return - + try { - const title = articleMetadata?.title || 'Article' - let content = event.content - let filename = `${title}.adoc` - - // For publications (30040), export all referenced sections if (event.kind === ExtendedKind.PUBLICATION) { - const contentParts: string[] = [] - - // Extract all 'a' tag references - const aTags = event.tags.filter(tag => tag[0] === 'a' && tag[1]) - - // Fetch all referenced events - const fetchPromises = aTags.map(async (tag) => { - try { - const coordinate = tag[1] - const [kindStr] = coordinate.split(':') - const kind = parseInt(kindStr) - - if (isNaN(kind)) return null - - // Try to fetch the event - const aTag = ['a', coordinate, tag[2] || '', tag[3] || ''] - const bech32Id = generateBech32IdFromATag(aTag) - if (bech32Id) { - const fetchedEvent = await eventService.fetchEvent(bech32Id) - return fetchedEvent - } - return null - } catch (error) { - logger.warn('[NoteOptions] Error fetching referenced event for export:', error) - return null - } + closeDrawer() + await toast.promise(exportPublicationDownload(event, 'adoc', relayUrls), { + loading: t('Exporting publication…'), + success: () => t('Article exported as AsciiDoc'), + error: (err: unknown) => + t('Failed to export article') + + ': ' + + (err instanceof Error ? err.message : String(err)) }) - - const referencedEvents = (await Promise.all(fetchPromises)).filter((e): e is Event => e !== null) - - // Combine all events into one AsciiDoc document - for (const refEvent of referencedEvents) { - const refTitle = refEvent.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled' - contentParts.push(`= ${refTitle}\n\n${refEvent.content}\n\n`) - } - - if (contentParts.length > 0) { - content = contentParts.join('\n') - } + return } - + + const title = articleMetadata?.title || 'Article' + const content = event.content + const filename = `${title}.adoc` + const blob = new Blob([content], { type: 'text/plain' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') @@ -895,7 +869,7 @@ export function useMenuActions({ a.click() document.body.removeChild(a) URL.revokeObjectURL(url) - + logger.info('[NoteOptions] Exported article as AsciiDoc') toast.success(t('Article exported as AsciiDoc')) } catch (error) { @@ -904,6 +878,19 @@ export function useMenuActions({ } } + const exportPublicationAs = (format: 'epub' | 'pdf') => { + closeDrawer() + void toast.promise(exportPublicationDownload(event, format, relayUrls), { + loading: t('Exporting publication…'), + success: () => + format === 'epub' ? t('Publication exported as EPUB') : t('Publication exported as PDF'), + error: (err: unknown) => + t('Failed to export publication') + + ': ' + + (err instanceof Error ? err.message : String(err)) + }) + } + // View on external sites functions const handleViewOnAlexandria = () => { if (!naddr) return @@ -1441,6 +1428,29 @@ export function useMenuActions({ }) } + if (event.kind === ExtendedKind.PUBLICATION) { + actions.push({ + icon: Download, + label: t('Download as AsciiDoc'), + separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator, + onClick: () => { + void exportAsAsciidoc() + } + }) + if (isAsciiDoctorServerConfigured()) { + actions.push({ + icon: Download, + label: t('Download as EPUB'), + onClick: () => exportPublicationAs('epub') + }) + actions.push({ + icon: Download, + label: t('Download as PDF'), + onClick: () => exportPublicationAs('pdf') + }) + } + } + // Delete only when signed in as the author with a signing key (not read-only npub) if (canSignEvents && pubkey && event.pubkey === pubkey) { actions.push({ diff --git a/src/constants.ts b/src/constants.ts index 9e7601bf..52e1b8ca 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -29,6 +29,13 @@ export const LANGUAGE_TOOL_URL = export const TRANSLATE_URL = (import.meta.env.VITE_TRANSLATE_URL as string | undefined)?.trim() || '' +/** + * Wikistr/unfold AsciiDoctor sidecar for EPUB/PDF export (`POST /convert/{epub|pdf|html5}`). + * Dev default: `/api/asciidoctor` (Vite proxy → localhost:8091). Production: set full URL or same-origin path. + */ +export const ASCIIDOCTOR_SERVER_URL = + (import.meta.env.VITE_ASCIIDOCTOR_SERVER_URL as string | undefined)?.trim() || '' + /** HiveTalk (WebRTC video call) base URL; override with VITE_HIVETALK_BASE_URL for self-hosted instances. */ export const HIVETALK_BASE_URL = (import.meta.env.VITE_HIVETALK_BASE_URL as string | undefined) ?? 'https://honey.hivetalk.org' diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 129717fb..5349e0db 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1897,6 +1897,13 @@ export default { "Are you sure you want to unregister the service worker? This will clear this app's service worker caches and you will need to reload the page.": "Are you sure you want to unregister the service worker? This will clear this app's service worker caches and you will need to reload the page.", 'Article exported as AsciiDoc': 'Article exported as AsciiDoc', + 'Download as AsciiDoc': 'Als AsciiDoc herunterladen', + 'Download as EPUB': 'Als EPUB herunterladen', + 'Download as PDF': 'Als PDF herunterladen', + 'Exporting publication…': 'Publikation wird exportiert…', + 'Publication exported as EPUB': 'Publikation als EPUB exportiert', + 'Publication exported as PDF': 'Publikation als PDF exportiert', + 'Failed to export publication': 'Export der Publikation fehlgeschlagen', 'Article exported as Markdown': 'Article exported as Markdown', 'Article title (optional)': 'Article title (optional)', articleDTagDefaultHint: diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 0f532117..9abe6aea 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1924,6 +1924,13 @@ export default { "Are you sure you want to unregister the service worker? This will clear this app's service worker caches and you will need to reload the page.": "Are you sure you want to unregister the service worker? This will clear this app's service worker caches and you will need to reload the page.", 'Article exported as AsciiDoc': 'Article exported as AsciiDoc', + 'Download as AsciiDoc': 'Download as AsciiDoc', + 'Download as EPUB': 'Download as EPUB', + 'Download as PDF': 'Download as PDF', + 'Exporting publication…': 'Exporting publication…', + 'Publication exported as EPUB': 'Publication exported as EPUB', + 'Publication exported as PDF': 'Publication exported as PDF', + 'Failed to export publication': 'Failed to export publication', 'Article exported as Markdown': 'Article exported as Markdown', 'Article title (optional)': 'Article title (optional)', articleDTagDefaultHint: diff --git a/src/lib/asciidoctor-server-client.ts b/src/lib/asciidoctor-server-client.ts new file mode 100644 index 00000000..0f5eb152 --- /dev/null +++ b/src/lib/asciidoctor-server-client.ts @@ -0,0 +1,80 @@ +import { ASCIIDOCTOR_SERVER_URL } from '@/constants' + +export type AsciiDoctorExportFormat = 'epub3' | 'epub' | 'pdf' | 'html5' | 'html' + +const FORMATS: Record< + AsciiDoctorExportFormat, + { endpoint: string; mimeType: string; extension: string } +> = { + epub3: { endpoint: 'epub', mimeType: 'application/epub+zip', extension: 'epub' }, + epub: { endpoint: 'epub', mimeType: 'application/epub+zip', extension: 'epub' }, + pdf: { endpoint: 'pdf', mimeType: 'application/pdf', extension: 'pdf' }, + html5: { endpoint: 'html5', mimeType: 'text/html; charset=utf-8', extension: 'html' }, + html: { endpoint: 'html5', mimeType: 'text/html; charset=utf-8', extension: 'html' } +} + +const CONVERT_TIMEOUT_MS = 120_000 + +export function isAsciiDoctorServerConfigured(): boolean { + return ASCIIDOCTOR_SERVER_URL.length > 0 +} + +export function normalizeAsciiDoctorFormat(format: string): AsciiDoctorExportFormat { + const key = format.trim().toLowerCase() + if (key === '' || key === 'epub3') return 'epub3' + if (key in FORMATS) return key as AsciiDoctorExportFormat + throw new Error(`Unsupported export format: ${format}`) +} + +export async function convertAsciiDocViaServer( + format: AsciiDoctorExportFormat, + content: string, + title: string, + author: string, + image?: string | null +): Promise<{ blob: Blob; mimeType: string; extension: string }> { + if (!isAsciiDoctorServerConfigured()) { + throw new Error('AsciiDoctor server URL is not configured.') + } + + const normalized = normalizeAsciiDoctorFormat(format) + const info = FORMATS[normalized] + const url = `${ASCIIDOCTOR_SERVER_URL.replace(/\/$/, '')}/convert/${info.endpoint}` + + const payload: Record = { + content, + title: title.trim() || 'Publication', + author: author.trim() + } + const cover = image?.trim() + if (cover) payload.image = cover + + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), CONVERT_TIMEOUT_MS) + + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: '*/*' }, + body: JSON.stringify(payload), + signal: controller.signal + }) + } finally { + window.clearTimeout(timeoutId) + } + + if (!response.ok) { + const detail = (await response.text()).slice(0, 400) + throw new Error( + detail ? `AsciiDoctor server returned HTTP ${response.status}: ${detail}` : `AsciiDoctor server returned HTTP ${response.status}` + ) + } + + const blob = await response.blob() + if (blob.size === 0) { + throw new Error('AsciiDoctor server returned an empty file.') + } + + return { blob, mimeType: info.mimeType, extension: info.extension } +} diff --git a/src/lib/publication-asciidoc-assembler.test.ts b/src/lib/publication-asciidoc-assembler.test.ts new file mode 100644 index 00000000..1d40318c --- /dev/null +++ b/src/lib/publication-asciidoc-assembler.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { ExtendedKind } from '@/constants' +import { assemblePublicationAsciidoc } from '@/lib/publication-asciidoc-assembler' +import type { Event } from 'nostr-tools' + +const PK = 'a'.repeat(64) + +function indexEvent(d: string, aTags: string[], id = d.padEnd(64, '0').slice(0, 64)): Event { + return { + id, + kind: ExtendedKind.PUBLICATION, + pubkey: PK, + created_at: 100, + content: '', + tags: [ + ['d', d], + ['title', `Book ${d}`], + ['author', 'Jane Author', 'writer'], + ['image', 'https://example.com/cover.jpg'], + ['version', '1.2'], + ['summary', 'A short summary.'], + ...aTags.map((a) => ['a', a] as [string, string]) + ], + sig: 'c'.repeat(128) + } +} + +function sectionEvent(d: string, title: string, content: string): Event { + return { + id: `${d}-id`.padEnd(64, '0').slice(0, 64), + kind: ExtendedKind.PUBLICATION_CONTENT, + pubkey: PK, + created_at: 50, + content, + tags: [['d', d], ['title', title]], + sig: 'd'.repeat(128) + } +} + +describe('assemblePublicationAsciidoc', () => { + it('builds document header with cover and metadata', () => { + const coord = `30041:${PK}:intro` + const root = indexEvent('my-book', [coord]) + const intro = sectionEvent('intro', 'Introduction', 'Hello world.') + const fetched = new Map([ + [root.id, root], + [`30040:${PK}:my-book`, root], + [coord, intro], + [intro.id, intro] + ]) + const byAddress = new Map([ + [`30040:${PK}:my-book`, root], + [coord, intro] + ]) + + const assembled = assemblePublicationAsciidoc(root, fetched, byAddress) + + expect(assembled.title).toBe('Book my-book') + expect(assembled.author).toBe('Jane Author (writer)') + expect(assembled.image).toBe('https://example.com/cover.jpg') + expect(assembled.content).toContain('= Book my-book') + expect(assembled.content).toContain('Jane Author (writer)') + expect(assembled.content).toContain(':doctype: book') + expect(assembled.content).toContain(':epub-cover-image: https://example.com/cover.jpg') + expect(assembled.content).toContain('[abstract]') + expect(assembled.content).toContain('A short summary.') + expect(assembled.content).toContain('== Introduction') + expect(assembled.content).toContain('Hello world.') + }) +}) diff --git a/src/lib/publication-asciidoc-assembler.ts b/src/lib/publication-asciidoc-assembler.ts new file mode 100644 index 00000000..084505ee --- /dev/null +++ b/src/lib/publication-asciidoc-assembler.ts @@ -0,0 +1,205 @@ +import { ExtendedKind } from '@/constants' +import { + getPublicationIndexMetadataFromEvent, + type PublicationIndexMetadata +} from '@/lib/event-metadata' +import { eventTagAddress } from '@/lib/publication-index' +import { pubkeyToNpub } from '@/lib/pubkey' +import { + parsePublicationATagCoordinate, + publicationRefKey, + type PublicationSectionRef +} from '@/lib/publication-section-fetch' +import type { Event } from 'nostr-tools' + +const MAX_NEST_DEPTH = 8 + +export type AssembledPublicationAsciidoc = { + content: string + title: string + author: string + image: string +} + +function escapeInline(value: string): string { + return value.replace(/\n/g, ' ') +} + +function heading(level: number, title: string): string { + const marks = '='.repeat(Math.max(2, Math.min(6, level))) + return `${marks} ${escapeInline(title)}\n\n` +} + +function tagValue(event: Event, name: string): string | undefined { + for (const tag of event.tags) { + if ((tag[0] || '').trim().toLowerCase() !== name) continue + const value = tag[1]?.trim() + if (value) return value + } + return undefined +} + +function titleFromIndex(event: Event): string { + return tagValue(event, 'title') || tagValue(event, 'd') || 'Publication' +} + +function authorFromMetadata(metadata: PublicationIndexMetadata, pubkey: string): string { + if (metadata.authors.length > 0) { + return metadata.authors + .map((a) => (a.role ? `${a.name} (${a.role})` : a.name)) + .join('; ') + } + return pubkeyToNpub(pubkey) ?? pubkey +} + +/** Ordered `a` / `e` refs from index tags (NKBIP-01 section order). */ +export function orderedPublicationRefsFromIndex(event: Event): PublicationSectionRef[] { + const refs: PublicationSectionRef[] = [] + for (const tag of event.tags) { + const name = (tag[0] || '').trim().toLowerCase() + if (name === 'a' && tag[1]) { + const parsed = parsePublicationATagCoordinate(tag[1]) + if (!parsed) continue + refs.push({ + type: 'a', + coordinate: parsed.coordinate, + kind: parsed.kind, + pubkey: parsed.pubkey, + identifier: parsed.identifier, + relay: tag[2] + }) + } else if (name === 'e' && tag[1]) { + refs.push({ type: 'e', eventId: tag[1], relay: tag[2] }) + } + } + return refs +} + +function resolveRefEvent( + ref: PublicationSectionRef, + fetched: Map +): Event | undefined { + return fetched.get(publicationRefKey(ref)) +} + +function appendIndexBody( + index: Event, + eventsByAddress: Map, + fetched: Map, + headingLevel: number, + parts: string[] +): void { + if (headingLevel > MAX_NEST_DEPTH + 1) return + + for (const ref of orderedPublicationRefsFromIndex(index)) { + if (ref.type === 'a' && ref.coordinate) { + const partsCoord = ref.coordinate.split(':') + const kind = ref.kind ?? parseInt(partsCoord[0], 10) + + if (kind === ExtendedKind.PUBLICATION) { + const child = + eventsByAddress.get(ref.coordinate) ?? resolveRefEvent(ref, fetched) + if (!child) continue + + const sectionTitle = titleFromIndex(child) + if (sectionTitle) parts.push(heading(headingLevel, sectionTitle)) + + const indexContent = child.content.trim() + if (indexContent) parts.push(`${indexContent}\n\n`) + + appendIndexBody(child, eventsByAddress, fetched, headingLevel + 1, parts) + } else if ( + kind === ExtendedKind.PUBLICATION_CONTENT || + kind === ExtendedKind.WIKI_ARTICLE + ) { + const article = resolveRefEvent(ref, fetched) + if (!article) continue + + let sectionTitle = tagValue(article, 'title')?.trim() || ref.identifier || '' + if (!sectionTitle && ref.coordinate) { + sectionTitle = ref.coordinate.split(':').slice(2).join(':') + } + if (sectionTitle) parts.push(heading(headingLevel, sectionTitle)) + + const body = article.content.trim() + if (body) parts.push(`${body}\n\n`) + } + } else if (ref.type === 'e') { + const article = resolveRefEvent(ref, fetched) + if (!article) continue + const sectionTitle = tagValue(article, 'title')?.trim() || 'Section' + parts.push(heading(headingLevel, sectionTitle)) + const body = article.content.trim() + if (body) parts.push(`${body}\n\n`) + } + } +} + +/** Build a single AsciiDoc book document (matches unfold {@link PublicationAsciidocAssembler}). */ +export function assemblePublicationAsciidoc( + rootIndex: Event, + fetched: Map, + eventsByAddress: Map +): AssembledPublicationAsciidoc { + const metadata = getPublicationIndexMetadataFromEvent(rootIndex) + const title = metadata.title?.trim() || titleFromIndex(rootIndex) + const author = authorFromMetadata(metadata, rootIndex.pubkey) + const image = metadata.image?.trim() ?? '' + const version = + metadata.version?.trim() || tagValue(rootIndex, 'V')?.trim() || 'first edition' + + const header: string[] = [`= ${escapeInline(title)}`] + if (author) header.push(escapeInline(author)) + header.push(':doctype: book') + header.push(':toc:') + header.push(':toclevels: 2') + header.push(':stem:') + header.push(':page-break-mode: auto') + header.push(':sectnums!:') + header.push(':imagesdir:') + header.push(':image-width: 1000px') + header.push(':max-width: 1000px') + if (author) header.push(`:author: ${escapeInline(author)}`) + header.push(`:version: ${escapeInline(version)}`) + header.push(`:revnumber: ${escapeInline(version)}`) + if (metadata.releaseDate?.trim()) { + header.push(`:revdate: ${escapeInline(metadata.releaseDate.trim())}`) + } + if (metadata.source?.trim()) { + header.push(`:source: ${escapeInline(metadata.source.trim())}`) + } + if (metadata.type?.trim()) { + header.push(`:publication-type: ${escapeInline(metadata.type.trim())}`) + } + if (metadata.language?.trim()) { + header.push(`:lang: ${escapeInline(metadata.language.trim())}`) + } + if (image) { + header.push(`:front-cover-image: ${image}`) + header.push(`:epub-cover-image: ${image}`) + header.push(`:ebook-cover-image: ${image}`) + } + + const bodyParts: string[] = [] + if (metadata.summary?.trim()) { + bodyParts.push('[abstract]', '____', metadata.summary.trim(), '____', '') + } + + appendIndexBody(rootIndex, eventsByAddress, fetched, 2, bodyParts) + + const content = `${header.join('\n')}\n\n${bodyParts.join('\n')}`.trimEnd() + '\n' + + return { content, title, author, image } +} + +/** Register events from a batch fetch into address/id maps. */ +export function indexPublicationEvents( + target: Map, + events: Iterable +): void { + for (const event of events) { + target.set(event.id, event) + const addr = eventTagAddress(event) + if (addr) target.set(addr, event) + } +} diff --git a/src/lib/publication-export.ts b/src/lib/publication-export.ts new file mode 100644 index 00000000..9add9963 --- /dev/null +++ b/src/lib/publication-export.ts @@ -0,0 +1,142 @@ +import { ExtendedKind } from '@/constants' +import { convertAsciiDocViaServer, isAsciiDoctorServerConfigured } from '@/lib/asciidoctor-server-client' +import { eventTagAddress } from '@/lib/publication-index' +import { + assemblePublicationAsciidoc, + indexPublicationEvents, + orderedPublicationRefsFromIndex +} from '@/lib/publication-asciidoc-assembler' +import { + batchFetchPublicationSectionEvents, + buildPublicationSectionRelayUrls, + publicationRefKey +} from '@/lib/publication-section-fetch' +import type { Event } from 'nostr-tools' + +export type PublicationDownloadFormat = 'adoc' | 'epub' | 'pdf' + +const MAX_NESTED_PUBLICATIONS = 128 +const MAX_TOTAL_EVENTS = 5000 + +export { isAsciiDoctorServerConfigured } + +function safeFilename(title: string, extension: string): string { + const safe = title.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^[\W_]+|[\W_]+$/g, '') + const base = (safe || 'publication').slice(0, 80) + return `${base}.${extension}` +} + +function downloadBlob(filename: string, blob: Blob): void { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} + +async function mergeRelayUrls(index: Event, relayUrls: string[]): Promise { + const refs = orderedPublicationRefsFromIndex(index) + const primary = refs.length + ? await buildPublicationSectionRelayUrls(index, refs, 40, false) + : [] + const fallback = refs.length + ? await buildPublicationSectionRelayUrls(index, refs, 80, true) + : [] + return [...new Set([...primary, ...fallback, ...relayUrls])] +} + +/** Fetch nested publication index + section events for export. */ +export async function fetchPublicationTreeForExport( + rootIndex: Event, + relayUrls: string[] +): Promise> { + const fetched = new Map() + indexPublicationEvents(fetched, [rootIndex]) + + const queue: Event[] = [rootIndex] + const visitedPublicationIds = new Set() + let traversedPublications = 0 + + while (queue.length > 0) { + const publication = queue.shift()! + if (visitedPublicationIds.has(publication.id)) continue + visitedPublicationIds.add(publication.id) + traversedPublications++ + + if (traversedPublications > MAX_NESTED_PUBLICATIONS) break + + const refs = orderedPublicationRefsFromIndex(publication) + if (refs.length === 0) continue + + const relays = await mergeRelayUrls(publication, relayUrls) + const resolved = await batchFetchPublicationSectionEvents(refs, relays) + + for (const ref of refs) { + if (fetched.size >= MAX_TOTAL_EVENTS) break + const ev = resolved.get(publicationRefKey(ref)) + if (!ev) continue + indexPublicationEvents(fetched, [ev]) + if (ev.kind === ExtendedKind.PUBLICATION && !visitedPublicationIds.has(ev.id)) { + queue.push(ev) + } + } + + if (fetched.size >= MAX_TOTAL_EVENTS) break + } + + return fetched +} + +export async function assemblePublicationForExport( + rootIndex: Event, + relayUrls: string[] +): Promise> { + const fetched = await fetchPublicationTreeForExport(rootIndex, relayUrls) + const eventsByAddress = new Map() + const seenIds = new Set() + for (const ev of fetched.values()) { + if (seenIds.has(ev.id)) continue + seenIds.add(ev.id) + const addr = eventTagAddress(ev) + if (addr) eventsByAddress.set(addr, ev) + } + const assembled = assemblePublicationAsciidoc(rootIndex, fetched, eventsByAddress) + if (!assembled.content.trim()) { + throw new Error('Publication has no exportable content.') + } + return assembled +} + +export async function exportPublicationDownload( + rootIndex: Event, + format: PublicationDownloadFormat, + relayUrls: string[] +): Promise<{ filename: string }> { + const assembled = await assemblePublicationForExport(rootIndex, relayUrls) + + if (format === 'adoc') { + const blob = new Blob([assembled.content], { type: 'text/plain;charset=utf-8' }) + const filename = safeFilename(assembled.title, 'adoc') + downloadBlob(filename, blob) + return { filename } + } + + if (!isAsciiDoctorServerConfigured()) { + throw new Error('Publication export server is not configured (VITE_ASCIIDOCTOR_SERVER_URL).') + } + + const serverFormat = format === 'epub' ? 'epub3' : 'pdf' + const converted = await convertAsciiDocViaServer( + serverFormat, + assembled.content, + assembled.title, + assembled.author, + assembled.image || null + ) + const filename = safeFilename(assembled.title, converted.extension) + downloadBlob(filename, converted.blob) + return { filename } +} diff --git a/vite.config.ts b/vite.config.ts index 208e2d8c..0cf4dca4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,7 +50,7 @@ function fullReloadOnProvidersAndPages(): Plugin { } /** Loopback targets in `server.proxy` — optional in dev; see PROXY_SETUP.md. */ -const OPTIONAL_DEV_PROXY_LOOPBACK_PORTS = [4000, 5000, 8010, 8090, 9876] as const +const OPTIONAL_DEV_PROXY_LOOPBACK_PORTS = [4000, 5000, 8010, 8090, 8091, 9876] as const function blobFromLogArgs(args: unknown[]): string { return args @@ -69,6 +69,7 @@ const DEV_INDEX_RELAY_PROXY_PATH_MARKERS = [ '/api/languagetool', '/v2/', '/api/piper-tts', + '/api/asciidoctor', '/api/translate', '/sites', '/dev-index-relay', @@ -263,6 +264,16 @@ export default defineConfig(({ mode }) => { hint: 'Start LibreTranslate (or compatible API) on :5000 — see PROXY_SETUP.md' }) }, + '/api/asciidoctor': { + target: 'http://127.0.0.1:8091', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api\/asciidoctor/u, '') || '/', + configure: jsonProxyErrorHandler(503, { + ok: false, + error: 'asciidoctor_proxy_unreachable', + hint: 'Start the Wikistr AsciiDoctor server on :8091 (see ../wikistr/deployment)' + }) + }, '/sites': { target: 'http://127.0.0.1:8090', changeOrigin: true,