mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-02 11:40:39 +00:00
book(D1.5): Linked Designs resolver for book sidecars
When a casewrap-book manifest loads, the resolver progressively
fetches every referenced page / master / cover / chapter event and
grafts its content into state so a fresh client can reconstruct the
book from refs alone.
- _collectBookSidecarJobs(book, { force }) walks the manifest and
returns one job per ref whose layers / markdown aren't already in
state. Idempotent — autosave restore with cached layers triggers
nothing.
- _refToDecoded turns {kind, pubkey, dTag} or {eventId} into the
shape _resolveEmbedEvent uses (already proven on the embed route).
- resolveBookSidecars(book, opts) fetches every job in parallel,
decrypts envelopes via _decryptBookSidecarEvent (works for private
publishes from D3), and dispatches to per-kind apply helpers:
_applyResolvedBookPage — rewrites target to book-page:<id>
_applyResolvedBookMaster — rewrites to book-master:<id>
_applyResolvedBookCover — keeps book-cover-front/back/spine
_applyResolvedBookChapter — drops markdown into entry.chapterMarkdown
Concurrent calls coalesce on a single in-flight promise so spamming
"Refresh" doesn't fan out.
- Public NIP-23 chapter events (kind-30023) fall through to the raw
ev.content markdown path; encrypted chapters route through the
decrypt helper.
Wiring:
- loadProjectFromText: new explicit `book` branch sets state.book,
preserves any existing book-* layers, drops non-book layers, then
kicks off the resolver async. Default textStyles + spec are merged
under the loaded ones so older manifests with missing keys still
render correctly.
- restoreAutosave: when the restored project is templateMode='book',
fires the resolver to backfill any sidecars whose layers aren't in
the cached state.
- New ↻ Refresh from Nostr button + status line in the book sidebar
invokes resolveBookSidecars(state.book, { force: true }) so the
user can manually pull fresh upstream changes.
This closes the round-trip: publish a manifest + sidecars → open
the manifest on another device → resolver reconstructs the book.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
988f25c754
commit
dc0a5ad20e
1 file changed
+202
+202
@@ -3568,7 +3568,9 @@
|
||||
<div class="buttonRow" style="margin-top:8px">
|
||||
<button type="button" id="bookDocumentSettingsBtn">Document settings…</button>
|
||||
<button type="button" id="bookManageMastersBtn">Manage masters…</button>
|
||||
<button type="button" id="bookResolveBtn" title="Re-fetch every referenced page / master / cover / chapter event from your relays">↻ Refresh from Nostr</button>
|
||||
</div>
|
||||
<p class="hint" id="bookResolveStatus" style="margin-top:6px;font-size:.74rem"></p>
|
||||
<div id="bookVisibilityRow" style="margin-top:10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span style="font-size:.78rem;color:var(--muted)">Publish visibility:</span>
|
||||
<button type="button" id="bookVisibilityChip" class="bookVisibilityChip" title="Toggle public / private (private = end-to-end encrypted to your own key)">Public</button>
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user