diff --git a/src/index.html b/src/index.html index a73b96a..4419ebd 100644 --- a/src/index.html +++ b/src/index.html @@ -3568,7 +3568,9 @@
+
+

Publish visibility: @@ -10114,6 +10116,137 @@ return await decryptProjectEnvelope(parsed); } + // Walk the manifest and queue any sidecar (page / master / cover + // / chapter) whose content isn't already in state. Idempotent — + // skipping entries whose layers / markdown are already loaded. + function _collectBookSidecarJobs(book, { force = false } = {}) { + const jobs = []; + if (!book) return jobs; + if (book.cover?.ref) { + const hasCoverLayers = (state.layers || []).some(l => /^book-cover-/.test(String(l.target || ''))); + if (force || !hasCoverLayers) jobs.push({ kind: 'cover', ref: book.cover.ref }); + } + for (const m of (book.masters || [])) { + if (!m?.ref || !m.id) continue; + const target = `book-master:${m.id}`; + const hasLayers = (state.layers || []).some(l => l.target === target); + if (force || !hasLayers) jobs.push({ kind: 'master', ref: m.ref, masterId: m.id }); + } + for (const p of (book.pages || [])) { + if (!p) continue; + if (p.type === 'design' && p.ref) { + const target = `book-page:${p.id}`; + const hasLayers = (state.layers || []).some(l => l.target === target); + if (force || !hasLayers) jobs.push({ kind: 'page', ref: p.ref, entryId: p.id }); + } + if (p.type === 'reflow' && p.textRef && (force || !String(p.chapterMarkdown || '').trim())) { + jobs.push({ kind: 'chapter', ref: p.textRef, entryId: p.id }); + } + } + return jobs; + } + // Turn a sidecar ref ({kind, pubkey, dTag} or {eventId}) into the + // `decoded` shape _resolveEmbedEvent expects. + function _refToDecoded(ref) { + if (!ref) return null; + if (ref.eventId) { + return { type: 'nevent', eventId: ref.eventId, relays: ref.relays || [], author: ref.pubkey || '', kind: ref.kind || null }; + } + if (ref.dTag && ref.pubkey) { + return { type: 'naddr', kind: ref.kind || 30078, pubkey: ref.pubkey, dTag: ref.dTag, relays: ref.relays || [] }; + } + return null; + } + // Apply a resolved page event to state.layers under the entry's + // own target prefix. Replaces any prior layers for that entry so + // a refresh wipes stale content cleanly. + function _applyResolvedBookPage(entryId, payload) { + const target = `book-page:${entryId}`; + const incoming = Array.isArray(payload?.page?.layers) ? payload.page.layers : []; + const remapped = incoming.map(l => ({ ...l, target })); + state.layers = (state.layers || []).filter(l => l.target !== target); + state.layers.push(...remapped); + const entry = state.book?.pages?.find(p => p.id === entryId); + if (entry && payload?.page?.background) entry.background = payload.page.background; + } + function _applyResolvedBookMaster(masterId, payload) { + const target = `book-master:${masterId}`; + const incoming = Array.isArray(payload?.page?.layers) ? payload.page.layers : []; + const remapped = incoming.map(l => ({ ...l, target })); + state.layers = (state.layers || []).filter(l => l.target !== target); + state.layers.push(...remapped); + } + function _applyResolvedBookCover(payload) { + const incoming = Array.isArray(payload?.page?.layers) ? payload.page.layers : []; + // Cover layers already carry book-cover-front/back/spine targets. + const coverTargets = new Set(incoming.map(l => l.target).filter(t => /^book-cover-/.test(String(t || '')))); + state.layers = (state.layers || []).filter(l => !coverTargets.has(l.target)); + state.layers.push(...incoming.filter(l => /^book-cover-/.test(String(l.target || '')))); + } + function _applyResolvedBookChapter(entryId, markdown) { + const entry = state.book?.pages?.find(p => p.id === entryId); + if (!entry || entry.type !== 'reflow') return; + entry.chapterMarkdown = String(markdown || ''); + } + // Progressive sidecar resolver. Fetches every missing sidecar in + // parallel; each one renders as it arrives so the book "fills in". + // Returns a summary { resolved, failed } for UI feedback. + let _bookResolveInFlight = null; + async function resolveBookSidecars(book = state.book, opts = {}) { + if (!book) return { resolved: 0, failed: 0 }; + const jobs = _collectBookSidecarJobs(book, opts); + if (!jobs.length) return { resolved: 0, failed: 0 }; + // Coalesce concurrent calls so spamming "Refresh" doesn't fan out. + if (_bookResolveInFlight) return _bookResolveInFlight; + const summary = { resolved: 0, failed: 0 }; + _bookResolveInFlight = (async () => { + await Promise.all(jobs.map(async (job) => { + const decoded = _refToDecoded(job.ref); + if (!decoded) { summary.failed++; return; } + try { + const ev = await _resolveEmbedEvent(decoded); + if (!ev) { summary.failed++; return; } + // Try decrypting first (private sidecar); fall back to + // plain JSON parse or raw markdown for public chapters. + let payload = null; + try { + const decrypted = await _decryptBookSidecarEvent(ev); + if (decrypted) payload = decrypted; + } catch (decErr) { + // Encrypted but not addressed to us → can't decrypt. + if ((ev.tags || []).some(t => t[0] === 'encrypted')) { + summary.failed++; return; + } + } + if (!payload) { + if (job.kind === 'chapter' && ev.kind === 30023) { + // Public NIP-23: content is raw markdown. + _applyResolvedBookChapter(job.entryId, ev.content || ''); + summary.resolved++; + return; + } + try { payload = JSON.parse(ev.content || ''); } catch { payload = null; } + } + if (!payload) { summary.failed++; return; } + if (job.kind === 'page') _applyResolvedBookPage(job.entryId, payload); + else if (job.kind === 'master') _applyResolvedBookMaster(job.masterId, payload); + else if (job.kind === 'cover') _applyResolvedBookCover(payload); + else if (job.kind === 'chapter') { + const md = payload?.chapter?.markdown ?? payload?.content ?? ''; + _applyResolvedBookChapter(job.entryId, md); + } + summary.resolved++; + } catch (err) { + console.warn('book sidecar resolve failed', job, err); + summary.failed++; + } + })); + })().finally(() => { _bookResolveInFlight = null; }); + await _bookResolveInFlight; + render(); + return summary; + } + // Build the casewrap-book manifest payload. Lean — references // sibling page / master / cover / chapter events via {kind, pubkey, // dTag} coords instead of inlining their layers. Phase D1 hands @@ -25454,6 +25587,44 @@ return; } + if (mode === 'book' || parsed.book) { + const book = parsed.book || {}; + const incomingSpec = { ...defaultBookSpec(), ...(book.spec || {}) }; + // Merge default textStyles under the loaded ones so old + // manifests missing keys still render. + incomingSpec.textStyles = { ...defaultBookTextStyles(), ...(book.spec?.textStyles || {}) }; + Object.assign(state, { + templateMode: 'book', + meta: { ...state.meta, ...(parsed.meta || {}) }, + book: { + spec: incomingSpec, + cover: book.cover || null, + masters: Array.isArray(book.masters) ? book.masters.map(m => ({ ...m })) : [], + pages: Array.isArray(book.pages) ? book.pages.map(p => ({ ...p })) : [], + editingEntryId: null, + editingMasterId: null, + editingChapterId: null, + }, + // Drop layers from other modes; keep book-* targets if the + // payload happens to inline them (rare — manifests are + // sidecar-only, but legacy / inline-published projects + // might). + layers: (state.layers || []).filter(l => String(l.target || '').startsWith('book-')), + layerGroups: { ...ensureLayerGroups(), ...(parsed.layerGroups || {}) }, + selectedLayerId: '', + selectedLayerIds: [], + }); + syncInputs(); + render(); + // Progressive fetch: every page / master / cover / chapter + // ref pulls its sidecar in parallel. Encrypted sidecars are + // decrypted via the existing private-publishing key-wrap. + setTimeout(() => { + resolveBookSidecars(state.book).catch(err => console.warn('book resolver failed', err)); + }, 0); + return; + } + if (mode === 'pixelart' || (parsed.pixelArt && parsed.templateMode === 'pixelart')) { Object.assign(state, { templateMode: 'pixelart', @@ -33680,6 +33851,14 @@ if (linkedSpecs.length) { try { linkedDesigns.prefetchBatch(linkedSpecs); } catch {} } + // Book autosave restore: fetch any sidecar whose layers / + // markdown weren't in the saved state. Non-blocking, so the + // editor opens immediately; refs fill in as relays answer. + if (state.templateMode === 'book' && state.book) { + setTimeout(() => { + resolveBookSidecars(state.book).catch(err => console.warn('book resolver (autosave) failed', err)); + }, 0); + } } catch (error) { console.warn('Autosave restore failed', error); } @@ -33781,6 +33960,29 @@ // Book Designer — Document settings modal. on('bookDocumentSettingsBtn', 'click', openBookDocumentSettings); on('bookVisibilityChip', 'click', toggleBookVisibility); + on('bookResolveBtn', 'click', async () => { + const status = document.getElementById('bookResolveStatus'); + const btn = document.getElementById('bookResolveBtn'); + if (!state.book) return; + if (status) { status.textContent = 'Resolving sidecars…'; status.style.color = 'var(--muted)'; } + if (btn) btn.disabled = true; + try { + const summary = await resolveBookSidecars(state.book, { force: true }); + if (status) { + if (summary.resolved || summary.failed) { + status.textContent = `Resolved ${summary.resolved}${summary.failed ? ` · ${summary.failed} failed` : ''}.`; + status.style.color = summary.failed ? '#b91c1c' : '#15803d'; + } else { + status.textContent = 'Nothing to resolve.'; + status.style.color = 'var(--muted)'; + } + } + } catch (err) { + if (status) { status.textContent = 'Refresh failed: ' + (err?.message || 'unknown'); status.style.color = '#b91c1c'; } + } finally { + if (btn) btn.disabled = false; + } + }); on('bookDocumentSettingsCloseBtn', 'click', closeBookDocumentSettings); on('bookSettingsCancelBtn', 'click', closeBookDocumentSettings); on('bookSettingsApplyBtn', 'click', applyBookDocumentSettings);