From 842e672fc05e85b21d86452b1941dcbb3ddc4e89 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:05:25 -0400 Subject: [PATCH] feat(book): load cover from Nostr or JSON (+ fix bookCoverPayload name collision) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new import paths for book covers: - Load cover from Nostr: paste an naddr / nevent pointing at a casewrap-page cover-spread event. Pulls layers + spec hints (trim, bleed, hasBack, hasSpine) + the publish ref so the existing Re-publish cover flow keeps replacing in place. - Load cover from JSON: file picker that accepts the format bookCoverFileExportPayload() emits. Applies layers + spec hints; clears book.cover.ref because the file isn't yet published. Both ask before overwriting an existing cover and update the live preview immediately. The 'Replace cover from Nostr…' button was already in the HTML but hard-disabled with a 'Phase D1 stub' note — now wired and re-labelled '📥 Load cover from Nostr…'. Also fixes a name collision from the prior JSON-export commit: bookCoverPayload() was defined twice (Nostr publisher at line 11938, file exporter that I added at line 26056). JS function hoisting let the later one win, silently breaking publishBookCoverToNostr's 'page.layers' lookup. Renamed the file exporter to bookCoverFileExportPayload() so both coexist. Co-Authored-By: Claude Opus 4.7 --- src/index.html | 151 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 143 insertions(+), 8 deletions(-) diff --git a/src/index.html b/src/index.html index 697ce15..b176d7f 100644 --- a/src/index.html +++ b/src/index.html @@ -4782,7 +4782,9 @@
- + + +

The cover spread reflows automatically when page count or paper thickness changes (spine width re-derives) and when trim or bleed changes in Document settings.

@@ -9336,6 +9338,126 @@ } } + // Load a cover spread from Nostr by naddr / nevent pointer. The + // event must be a casewrap-page (kind 30078) with role='cover- + // spread' (mirrors what publishBookCoverToNostr emits). On success + // the cover's layers, the cover-spread spec (trim, bleed, spine + // hints) and the publish ref all land on the active book. + async function importBookCoverFromNostr() { + const book = ensureBookState(); + const raw = (typeof prompt === 'function') + ? prompt('Paste an naddr / nevent / note / hex pointing at a casewrap-page cover-spread event:') + : ''; + if (!raw) return; + const decoded = decodeNostrId(raw.trim()); + if (!decoded) { toastError && toastError('Could not decode that id.'); return; } + const decodedForResolver = decoded.type === 'naddr' + ? decoded + : { type: 'nevent', eventId: decoded.eventId, relays: decoded.relays || [], author: decoded.author || '', kind: decoded.kind || null }; + try { + const ev = await _resolveEmbedEvent(decodedForResolver); + if (!ev) { toastError && toastError('No event found on your relays.'); return; } + if (ev.kind !== 30078) { toastError && toastError(`Event is kind ${ev.kind}, not a casewrap-page.`); return; } + const roleTag = (ev.tags || []).find(t => t[0] === 'role')?.[1] || ''; + let payload = null; + try { + const decrypted = await _decryptBookSidecarEvent(ev); + if (decrypted) payload = decrypted; + } catch {} + if (!payload) { + try { payload = JSON.parse(ev.content || ''); } catch {} + } + const role = roleTag || payload?.page?.role || ''; + if (role !== 'cover-spread') { + const ok = confirm(`This event's role is "${role || '(none)'}", not "cover-spread". Load it as the cover anyway?`); + if (!ok) return; + } + if (!payload?.page) { toastError && toastError('Event payload is missing "page" data.'); return; } + const dTag = (ev.tags || []).find(t => t[0] === 'd' && t[1])?.[1] || (decoded.dTag || ''); + if (!dTag || !ev.pubkey) { toastError && toastError('Event is missing d-tag / pubkey.'); return; } + // Warn if the current book has cover layers already. + const hasExistingCover = (state.layers || []).some(l => /^book-cover-|^book-spine$/.test(String(l.target || ''))); + if (hasExistingCover) { + const ok = confirm('This book already has a cover. Replace it with the imported one?'); + if (!ok) return; + } + pushHistory('book: import cover'); + // Pull spec hints from the payload — these were captured at + // publish time so the cover lays out correctly even if our + // local book.spec drifted (different trim, missing back, etc.). + if (payload.page.trimW || payload.page.trimH || payload.page.bleed != null) { + book.spec = book.spec || {}; + if (payload.page.trimW) book.spec.trimW = Number(payload.page.trimW) || book.spec.trimW; + if (payload.page.trimH) book.spec.trimH = Number(payload.page.trimH) || book.spec.trimH; + if (payload.page.bleed != null) book.spec.bleed = Number(payload.page.bleed) || book.spec.bleed; + } + if (payload.page.coverSpread) { + book.spec = book.spec || {}; + if (payload.page.coverSpread.hasBack != null) book.spec.coverHasBack = !!payload.page.coverSpread.hasBack; + if (payload.page.coverSpread.hasSpine != null) book.spec.coverHasSpine = !!payload.page.coverSpread.hasSpine; + } + // Replace cover layers + publish ref. + _applyResolvedBookCover(payload); + book.cover = book.cover || {}; + book.cover.ref = { + kind: 30078, pubkey: ev.pubkey, dTag, + relays: (decoded.relays || []).slice(0, 4), + encrypted: (ev.tags || []).some(t => t[0] === 'encrypted') || undefined, + }; + _bookCacheWrite(book.cover.ref, ev); + syncInputs(); + render(); + if (typeof toastSuccess === 'function') toastSuccess('Cover imported.'); + } catch (err) { + console.warn('book: importBookCoverFromNostr failed', err); + toastError && toastError('Import failed: ' + (err?.message || 'unknown error')); + } + } + + // Load a cover spread from a local JSON file (matching the format + // bookCoverFileExportPayload emits). Replaces the active book's + // cover layers + cover-spread spec hints; clears book.cover.ref + // because a file-loaded cover hasn't been re-published yet. + async function importBookCoverFromJsonFile(file) { + if (!file) return; + const book = ensureBookState(); + let parsed; + try { + const text = await file.text(); + parsed = JSON.parse(text); + } catch (err) { + toastError && toastError('Not a valid JSON file.'); + return; + } + if (parsed?.templateMode !== 'book-cover' || !parsed?.bookCover) { + toastError && toastError('Not a book-cover JSON (expected templateMode "book-cover").'); + return; + } + const hasExistingCover = (state.layers || []).some(l => /^book-cover-|^book-spine$/.test(String(l.target || ''))); + if (hasExistingCover) { + const ok = confirm('This book already has a cover. Replace it with the imported one?'); + if (!ok) return; + } + pushHistory('book: import cover (JSON)'); + const bc = parsed.bookCover; + book.spec = book.spec || {}; + if (bc.trimW) book.spec.trimW = Number(bc.trimW) || book.spec.trimW; + if (bc.trimH) book.spec.trimH = Number(bc.trimH) || book.spec.trimH; + if (bc.bleed != null) book.spec.bleed = Number(bc.bleed) || book.spec.bleed; + if (bc.safe != null) book.spec.safe = Number(bc.safe) || book.spec.safe; + if (bc.coverHasBack != null) book.spec.coverHasBack = !!bc.coverHasBack; + if (bc.coverHasSpine != null) book.spec.coverHasSpine = !!bc.coverHasSpine; + // Build a fake page payload so _applyResolvedBookCover can do + // the layer swap with its standard filter logic. + const coverLayers = (parsed.layers || []).filter(l => /^book-cover-|^book-spine$/.test(String(l.target || ''))); + _applyResolvedBookCover({ page: { layers: coverLayers } }); + book.cover = book.cover || {}; + book.cover.ref = null; // file-loaded, not yet published + syncInputs(); + render(); + if (typeof toastSuccess === 'function') toastSuccess('Cover loaded from JSON.'); + } + // Starter book templates (Phase F1). Each preset seeds the spec // + a default master with a text-frame + a starter chapter so the // user lands on a working book instead of an empty pages overview. @@ -26048,12 +26170,15 @@ }; } - // Cover-only payload — just the three cover panels + the spec - // fields the cover needs to render correctly (trim, bleed, safe, - // spine width snapshotted at export so the file is portable). Use - // it to move a cover spread between books or stash it as a - // standalone artifact. - function bookCoverPayload() { + // Cover-only JSON payload (file export — distinct from the + // Nostr-publish payload bookCoverPayload() at the top of the file, + // which has a different shape used for casewrap-page events). + // Captures the three cover panels + the spec fields the cover + // needs to render correctly (trim, bleed, safe, spine width + // snapshotted at export so the file is portable). Use it to move + // a cover spread between books or stash it as a standalone + // artifact. + function bookCoverFileExportPayload() { const spec = state.book?.spec || {}; return { version: SCHEMA_VERSION, @@ -37296,6 +37421,16 @@ on('bookViewTogglePages', 'click', () => setBookSubView('pages')); on('bookViewToggleCover', 'click', () => setBookSubView('cover')); on('bookCoverEditBtn', 'click', openBookCover); + on('bookCoverReplaceBtn', 'click', importBookCoverFromNostr); + on('bookCoverImportJsonBtn', 'click', () => { + const input = document.getElementById('bookCoverImportJsonFile'); + if (input) input.click(); + }); + on('bookCoverImportJsonFile', 'change', async (e) => { + const file = e.target?.files?.[0]; + e.target.value = ''; + if (file) await importBookCoverFromJsonFile(file); + }); on('bookExportPdfBtn', 'click', openBookExportModal); on('bookExportPdfCloseBtn', 'click', closeBookExportModal); on('bookExportPdfCancelBtn', 'click', closeBookExportModal); @@ -37334,7 +37469,7 @@ const sizePart = filenamePart(spec.bookSize, '') || `${Number(spec.trimW) || 0}x${Number(spec.trimH) || 0}`; const filename = [titlePart, 'book_cover', sizePart].filter(Boolean).join('_') + '.json'; - triggerDownload(bookCoverPayload(), filename); + triggerDownload(bookCoverFileExportPayload(), filename); toastSuccess?.('Cover spread saved.'); }); on('bookPageEditProofPdf', 'click', async () => {