10 changed files with 589 additions and 48 deletions
@ -0,0 +1,80 @@
@@ -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 } |
||||
} |
||||
@ -0,0 +1,70 @@
@@ -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.') |
||||
}) |
||||
}) |
||||
@ -0,0 +1,205 @@
@@ -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) |
||||
} |
||||
} |
||||
@ -0,0 +1,142 @@
@@ -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 } |
||||
} |
||||
Loading…
Reference in new issue