Browse Source

add EPUB and PDF downloads

imwald
Silberengel 1 week ago
parent
commit
d85d028a21
  1. 2
      .env.development
  2. 104
      src/components/NoteOptions/useMenuActions.tsx
  3. 7
      src/constants.ts
  4. 7
      src/i18n/locales/de.ts
  5. 7
      src/i18n/locales/en.ts
  6. 80
      src/lib/asciidoctor-server-client.ts
  7. 70
      src/lib/publication-asciidoc-assembler.test.ts
  8. 205
      src/lib/publication-asciidoc-assembler.ts
  9. 142
      src/lib/publication-export.ts
  10. 13
      vite.config.ts

2
.env.development

@ -4,3 +4,5 @@ VITE_PROXY_SERVER=/sites
VITE_READ_ALOUD_TTS_URL=/api/piper-tts VITE_READ_ALOUD_TTS_URL=/api/piper-tts
VITE_LANGUAGE_TOOL_URL=/api/languagetool VITE_LANGUAGE_TOOL_URL=/api/languagetool
VITE_TRANSLATE_URL=/api/translate 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

104
src/components/NoteOptions/useMenuActions.tsx

@ -42,7 +42,10 @@ import {
isEventInPinList isEventInPinList
} from '@/lib/replaceable-list-latest' } from '@/lib/replaceable-list-latest'
import indexedDb from '@/services/indexed-db.service' 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 { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
@ -58,6 +61,7 @@ import {
Bell, Bell,
BellOff, BellOff,
Bookmark, Bookmark,
Download,
Pin, Pin,
Settings, Settings,
Share2, Share2,
@ -837,55 +841,25 @@ export function useMenuActions({
const exportAsAsciidoc = async () => { const exportAsAsciidoc = async () => {
if (!isArticleType) return if (!isArticleType) return
try { 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) { if (event.kind === ExtendedKind.PUBLICATION) {
const contentParts: string[] = [] closeDrawer()
await toast.promise(exportPublicationDownload(event, 'adoc', relayUrls), {
// Extract all 'a' tag references loading: t('Exporting publication…'),
const aTags = event.tags.filter(tag => tag[0] === 'a' && tag[1]) success: () => t('Article exported as AsciiDoc'),
error: (err: unknown) =>
// Fetch all referenced events t('Failed to export article') +
const fetchPromises = aTags.map(async (tag) => { ': ' +
try { (err instanceof Error ? err.message : String(err))
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
}
}) })
return
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')
}
} }
const title = articleMetadata?.title || 'Article'
const content = event.content
const filename = `${title}.adoc`
const blob = new Blob([content], { type: 'text/plain' }) const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
@ -895,7 +869,7 @@ export function useMenuActions({
a.click() a.click()
document.body.removeChild(a) document.body.removeChild(a)
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
logger.info('[NoteOptions] Exported article as AsciiDoc') logger.info('[NoteOptions] Exported article as AsciiDoc')
toast.success(t('Article exported as AsciiDoc')) toast.success(t('Article exported as AsciiDoc'))
} catch (error) { } 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 // View on external sites functions
const handleViewOnAlexandria = () => { const handleViewOnAlexandria = () => {
if (!naddr) return 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) // Delete only when signed in as the author with a signing key (not read-only npub)
if (canSignEvents && pubkey && event.pubkey === pubkey) { if (canSignEvents && pubkey && event.pubkey === pubkey) {
actions.push({ actions.push({

7
src/constants.ts

@ -29,6 +29,13 @@ export const LANGUAGE_TOOL_URL =
export const TRANSLATE_URL = export const TRANSLATE_URL =
(import.meta.env.VITE_TRANSLATE_URL as string | undefined)?.trim() || '' (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. */ /** HiveTalk (WebRTC video call) base URL; override with VITE_HIVETALK_BASE_URL for self-hosted instances. */
export const HIVETALK_BASE_URL = export const HIVETALK_BASE_URL =
(import.meta.env.VITE_HIVETALK_BASE_URL as string | undefined) ?? 'https://honey.hivetalk.org' (import.meta.env.VITE_HIVETALK_BASE_URL as string | undefined) ?? 'https://honey.hivetalk.org'

7
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.":
"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', '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 exported as Markdown': 'Article exported as Markdown',
'Article title (optional)': 'Article title (optional)', 'Article title (optional)': 'Article title (optional)',
articleDTagDefaultHint: articleDTagDefaultHint:

7
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.":
"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', '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 exported as Markdown': 'Article exported as Markdown',
'Article title (optional)': 'Article title (optional)', 'Article title (optional)': 'Article title (optional)',
articleDTagDefaultHint: articleDTagDefaultHint:

80
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<string, string> = {
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 }
}

70
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<string, Event>([
[root.id, root],
[`30040:${PK}:my-book`, root],
[coord, intro],
[intro.id, intro]
])
const byAddress = new Map<string, Event>([
[`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.')
})
})

205
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<string, Event>
): Event | undefined {
return fetched.get(publicationRefKey(ref))
}
function appendIndexBody(
index: Event,
eventsByAddress: Map<string, Event>,
fetched: Map<string, Event>,
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<string, Event>,
eventsByAddress: Map<string, Event>
): 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<string, Event>,
events: Iterable<Event>
): void {
for (const event of events) {
target.set(event.id, event)
const addr = eventTagAddress(event)
if (addr) target.set(addr, event)
}
}

142
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<string[]> {
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<Map<string, Event>> {
const fetched = new Map<string, Event>()
indexPublicationEvents(fetched, [rootIndex])
const queue: Event[] = [rootIndex]
const visitedPublicationIds = new Set<string>()
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<ReturnType<typeof assemblePublicationAsciidoc>> {
const fetched = await fetchPublicationTreeForExport(rootIndex, relayUrls)
const eventsByAddress = new Map<string, Event>()
const seenIds = new Set<string>()
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 }
}

13
vite.config.ts

@ -50,7 +50,7 @@ function fullReloadOnProvidersAndPages(): Plugin {
} }
/** Loopback targets in `server.proxy` — optional in dev; see PROXY_SETUP.md. */ /** 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 { function blobFromLogArgs(args: unknown[]): string {
return args return args
@ -69,6 +69,7 @@ const DEV_INDEX_RELAY_PROXY_PATH_MARKERS = [
'/api/languagetool', '/api/languagetool',
'/v2/', '/v2/',
'/api/piper-tts', '/api/piper-tts',
'/api/asciidoctor',
'/api/translate', '/api/translate',
'/sites', '/sites',
'/dev-index-relay', '/dev-index-relay',
@ -263,6 +264,16 @@ export default defineConfig(({ mode }) => {
hint: 'Start LibreTranslate (or compatible API) on :5000 — see PROXY_SETUP.md' 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': { '/sites': {
target: 'http://127.0.0.1:8090', target: 'http://127.0.0.1:8090',
changeOrigin: true, changeOrigin: true,

Loading…
Cancel
Save