diff --git a/.ai.md b/.ai.md index bc547d7..656abc0 100644 --- a/.ai.md +++ b/.ai.md @@ -121,6 +121,8 @@ Calendaria back-catalog note mappings are per-GM-user, not per-world (#95). | `scripts/actor-sync.mjs` | Actor ↔ character entity sync, adapter loading. `is_private` ↔ `prototypeToken.hidden` NPC visibility syncs bidirectionally. Deleting from Chronicle unlinks the Actor (data preserved); deleting the Actor in Foundry asks before deleting the Chronicle entity (`_remote-deletes.mjs`). Player Character Claiming addon-aware: PC sub-type routing, `owner_user_id` gated on addon state, one-time hint when addon is off but player-owned actors exist | | `scripts/item-sync.mjs` | Actor inventory ↔ Chronicle "Has Item" relations (quantity, equipped). Chronicle → Foundry reconciles a whole character per relation event or feed entry | | `scripts/_inventory-plan.mjs` | Pure: the create/adopt/update/unlink plan that brings an actor's linked items in line with its relations | +| `scripts/_inline-pictures.mjs` | Pictures inside Chronicle page text (`
`) ↔ Foundry: on pull a shared picture's src points at its local copy and a GM-only one (`ce-img--gm`) goes inside a native `
`, never copied, which the pull then stores as a placeholder like any GM-only text (`_gm-secrets.mjs`); on push every src goes back to `/media/` and every Chronicle picture inside a secret block goes back GM-only, whatever shape the editor left it in. A copy made while a picture was shared stays in the world's files if it later turns GM-only (no longer shown; Foundry has no file delete for modules). Pure. `tools/test-inline-pictures.mjs`, bench "pictures inside page text" | +| `scripts/picture-store.mjs` | Local copies of those pictures in `worlds//chronicle-media/.` (Chronicle's signed links expire in minutes, so a journal can't point at Chronicle). GM-only: `GET /media/:id` for the signed link, a cookieless fetch on the `apiUrl` host only, PNG/JPEG/GIF/WebP/AVIF only, 25 MB cap, served type must match, `FilePicker.upload`. Copied once per id. `watchGMPictures` shows GM-only pictures on the GM's screen from a fresh signed link, as `gm-secret-view.mjs` fills their placeholders, outside editors. `tools/test-inline-pictures.mjs` | | `scripts/player-notebook.mjs` | Notebook window and bottom-right "Jot notes" tab for every user: frames of Chronicle's own Journal and Jot pages (`/embed/campaigns/:id/notes/journal\|jots`), fed the player's notes grant over `postMessage`. First use opens Chronicle's Allow window. Tracks the newest open Chronicle-linked sheet so jots follow the page in view | | `scripts/_notes-grant.mjs` | Pure checks for the notebook: Allow/frame URLs, the grant message (Chronicle origin, `cnt_` token, campaign, and the GM's member matching for this Foundry login), per-Foundry-user client storage, frame message source/origin, `PageTracker`. `tools/test-notes-grant.mjs` | | `scripts/_scene-controls.mjs` | Builds the Chronicle scene-control group: Dashboard + Sync Calendar for GMs only, Notebook for everyone when connected. `tools/test-scene-controls.mjs` | @@ -295,6 +297,7 @@ break-out, or a leaked bearer token. | Class | What we trust | What we validate | Where | Pin | |---|---|---|---|---| | HTML for `JournalEntry.text.content` | Chronicle's bluemonday UGCPolicy at write | Pre-sanitize via `TextEditor.cleanHTML` at ingress | `scripts/_html-sanitizer.mjs` | `tools/test-html-sanitizer.mjs` | +| Pictures inside page text | Chronicle's own media path | Only `/media/` (UUID-checked, so never a path) is copied; the signed link must be on the `apiUrl` host; raster MIME only, declared and served types must match; GM-only pictures are never copied | `scripts/picture-store.mjs`, `scripts/_inline-pictures.mjs` | `tools/test-inline-pictures.mjs` | | Image URLs from map sub-resources | Operator-configured `apiUrl` host | `_isAllowedImageHost` rejects full-URL `image_url`/token `image` whose hostname doesn't match `apiUrl`, in `map-sync.mjs` + `map-viewer.mjs`; rejection → blank `` + warning | `scripts/_url-validation.mjs` | `tools/test-url-validation.mjs` | | User-visible DOM interpolation (e.g. dashboard test results) | Nothing | `replaceChildren` + `createTextNode`, never `innerHTML` of Chronicle data | `scripts/sync-dashboard.mjs::_renderTestResults` | `tools/test-sync-dashboard-xss.mjs` | | Sync history rows (names, messages, people) | Nothing | Built as node descriptions; `toDom` sets only text and attributes | `scripts/_history-view.mjs::toDom` | `tools/test-history-view.mjs` | diff --git a/API-CONTRACT.md b/API-CONTRACT.md index 62038a3..a74c07f 100644 --- a/API-CONTRACT.md +++ b/API-CONTRACT.md @@ -999,7 +999,10 @@ Uploads a media file (image, etc.). ``` #### GET /media/:mediaId -Returns media metadata. +Returns media metadata: `mime_type`, `file_size`, and `url`, a signed +`/media/?expires=…&sig=…` link valid for about 15 minutes. Journal sync +uses it to copy pictures inside page text into the world's files +(`scripts/picture-store.mjs`); a saved signed link would expire. #### DELETE /media/:mediaId Deletes a media file. diff --git a/CLAUDE.md b/CLAUDE.md index 6c54e3a..ba2562a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ data flow, file index and feature details. Entry point: `scripts/module.mjs` - `module.json` (Foundry manifest, v12–v14), `chronicle-package.json` (serving descriptor, schema v1) — cross-validated by `tools/check-package-descriptor.mjs`. -- `scripts/*.mjs`: sync (`journal-sync`, `map-sync`+`map-viewer`+`map-sheet-items`, +- `scripts/*.mjs`: sync (`journal-sync`+`picture-store`, `map-sync`+`map-viewer`+`map-sheet-items`, `calendar-sync`+`sync-calendar`+`sync-calendar-*`, `actor-sync`, `item-sync`, `stash-sync`+`stash-client`), UI (`sync-dashboard`, `npc-presence`, `sync-diagnostic-bundle`, `update-info`, `gm-secret-view`, `character-claim-indicator`, diff --git a/bench/fake-foundry.mjs b/bench/fake-foundry.mjs index a8b2730..9be5f1e 100644 --- a/bench/fake-foundry.mjs +++ b/bench/fake-foundry.mjs @@ -397,6 +397,7 @@ export function installFoundry({ settings = {}, systemId = 'dnd5e' } = {}) { items: new Collection(), scenes: new Collection(), system: { id: systemId, version: '5.0.0' }, + world: { id: 'bench-world' }, version: '14.300', modules: { get: () => null }, time: { worldTime: 0, components: {} }, @@ -424,6 +425,28 @@ export function installFoundry({ settings = {}, systemId = 'dnd5e' } = {}) { world.game = game; world.settingsValues = values; + // The world's user-data files, in memory: path → { size, type }. + const files = new Map(); + world.files = files; + const FilePicker = { + async browse(source, dir) { + const prefix = `${dir}/`; + const listed = [...files.keys()].filter((p) => p.startsWith(prefix)); + if (listed.length === 0 && ![...files.keys()].some((p) => p === dir)) throw new Error(`ENOENT: ${dir}`); + return { files: listed, dirs: [] }; + }, + async createDirectory(source, dir) { + if (files.has(dir)) throw new Error(`EEXIST: ${dir}`); + files.set(dir, { dir: true }); + }, + async upload(source, dir, file) { + const path = `${dir}/${file.name}`; + files.set(path, { size: file.size, type: file.type }); + log.writes.push({ op: 'upload', type: 'File', id: path }); + return { status: 'success', path }; + }, + }; + world.collectionFor = (type) => ({ JournalEntry: journal, Actor: actors, Folder: folders }[type]); function makeDocClass(type, Impl) { @@ -478,6 +501,7 @@ export function installFoundry({ settings = {}, systemId = 'dnd5e' } = {}) { escapeHTML: (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])), }, applications: { + apps: { FilePicker: { implementation: FilePicker } }, api: { ApplicationV2: class { render() { return this; } close() {} }, HandlebarsApplicationMixin: (b) => b, diff --git a/bench/journals.bench.mjs b/bench/journals.bench.mjs index b8f558f..db1dd3b 100644 --- a/bench/journals.bench.mjs +++ b/bench/journals.bench.mjs @@ -8,6 +8,7 @@ */ import test from 'node:test'; import assert from 'node:assert/strict'; +import { CHRONICLE_URL } from './chronicle.mjs'; import { openWorld, closeWorld, settle, recordRequests, waitFor } from './world.mjs'; import { FLAG, linked, byEntity, writes, pageText, chroniclePage, scenario } from './scenario.mjs'; @@ -268,6 +269,64 @@ test('the same page edited on both sides at once ends the same on both sides', ( assert.equal(world.game.journal.filter((x) => x.name.startsWith('Crossroads')).length, 1); })); +// Two tiny, different PNGs: Chronicle gives identical bytes one media id. +const PNGS = [ + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGM4IScHRAwQCgAfJgQRoo8irwAAAABJRU5ErkJggg==', +].map((b) => Buffer.from(b, 'base64')); + +async function uploadPicture(seed, name, bytes) { + const form = new FormData(); + form.append('file', new Blob([bytes], { type: 'image/png' }), name); + const res = await fetch(`${CHRONICLE_URL}/api/v1/campaigns/${seed.campaignId}/media`, { + method: 'POST', headers: { authorization: `Bearer ${seed.moduleKey}` }, body: form, + }); + assert.equal(res.status, 201, `upload ${name}: ${await res.clone().text()}`); + return (await res.json()).id; +} + +test('pictures inside page text show in Foundry, GM-only ones stay secret, and an edit sends both back unchanged', (t) => scenario('pictures', async ({ seed, world }) => { + const shared = await uploadPicture(seed, 'mira.png', PNGS[0]); + const secret = await uploadPicture(seed, 'traitor.png', PNGS[1]); + assert.notEqual(shared, secret); + const html = `

Mira runs the docks.

` + + `
Mira
Mira Kell
` + + `
x
The traitor
` + + `

She owes the guild.

`; + const e = await chroniclePage(seed, 'Mira Kell', html); + const stored = (await seed.chronicle.get(`/entities/${e.id}`)).entry_html || ''; + if (!stored.includes('ce-img--gm')) { + t.skip('this Chronicle does not keep pictures inside page text yet (Chronicle#997)'); + return; + } + + await openWorld(world); + await JournalSync_resync(world); + const j = byEntity(world, e.id); + const text = pageText(j); + const local = `worlds/bench-world/chronicle-media/${shared}.png`; + assert.ok(text.includes(`src="${local}"`), `shared picture points at its copy: ${text}`); + assert.ok(world.files.has(local), 'the copy is in the world files'); + // The GM-only picture is a placeholder in the saved page, like GM-only text. + assert.ok(!text.includes(secret) && !text.includes('ce-img--gm'), `the GM-only picture is not in the saved page: ${text}`); + assert.match(text, /
]* id="secret-chrk[0-9a-f]{32}"/); + assert.ok(![...world.files.keys()].some((p) => p.includes(secret)), 'a GM-only picture is never copied'); + + // A second pull reuses the copy. + await JournalSync_resync(world); + assert.equal([...world.files.keys()].filter((p) => p.includes(shared)).length, 1); + + // An edit in Foundry sends the plain Chronicle paths back, GM-only intact. + const page = j.pages.contents.find((p) => p.type === 'text'); + await page.update({ 'text.content': page.text.content.replace('She owes the guild.', 'She owes the guild 40 gold.') }); + await settle(); + const after = (await seed.chronicle.get(`/entities/${e.id}`)).entry_html || ''; + assert.match(after, /40 gold/); + assert.ok(after.includes(`src="/media/${shared}"`), `shared path restored: ${after}`); + assert.match(after, new RegExp(`
m.constructor.name === 'JournalSync'); diff --git a/scripts/_inline-pictures.mjs b/scripts/_inline-pictures.mjs new file mode 100644 index 0000000..e1936c6 --- /dev/null +++ b/scripts/_inline-pictures.mjs @@ -0,0 +1,251 @@ +/** + * Pictures inside Chronicle page text, translated between Chronicle's stored + * shape and what a Foundry journal page can show. + * + * Chronicle stores each picture as + *
+ *
…
+ * with a src relative to the Chronicle server. Chronicle only serves those + * files through signed links that expire after minutes, so a link saved into + * a journal would break; sync keeps its own copy of each picture in the + * world's files instead (scripts/picture-store.mjs) and points the src there. + * + * GM-only pictures (ce-img--gm) are never copied. They go inside Foundry's + * own secret block, so players don't see them; the GM's client shows them + * from a fresh signed link at render time. + * + * On push every src goes back to the plain `/media/` path, and every + * Chronicle picture inside a secret block goes back marked GM only, in + * whatever shape Foundry's editor left it, so an edit in Foundry can never + * turn a GM-only picture into one players see. + * + * Pure string transforms over the editor's fixed output shape, so they run + * in Node tests. `tools/test-inline-pictures.mjs`. + */ + +/** Chronicle media ids are UUIDs; anything else is never used in a path. */ +export const MEDIA_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Folder, inside the world's own folder, that holds the copies. */ +export const PICTURE_DIR_NAME = 'chronicle-media'; + +/** Class on the secret block sync wraps around a GM-only picture. */ +export const GM_PICTURE_SECTION_CLASS = 'chronicle-gm-picture'; + +const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; + +// The editor writes a figure as one img plus an optional plain-text caption, +// never a nested figure, so the lazy match ends at its own closing tag. +const FIGURE_RE = /]*>[\s\S]*?<\/figure>/gi; +const IMG_SRC_RE = /(]*?(?]*>[\s\S]*?<\/section>/gi; + +const EXT_BY_MIME = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/avif': 'avif', +}; + +function classList(tag) { + const m = /\bclass=(["'])([^"']*)\1/i.exec(tag); + return m ? m[2].split(/\s+/).filter(Boolean) : []; +} + +function openingTag(html) { + const end = html.indexOf('>'); + return end < 0 ? html : html.slice(0, end + 1); +} + +function isPictureFigure(figure) { + return classList(openingTag(figure)).includes('ce-img'); +} + +function isGMFigure(figure) { + return classList(openingTag(figure)).includes('ce-img--gm'); +} + +/** + * The Chronicle media id a src points at, or null. Accepts the plain + * `/media/` path, with or without a signed query. + * @param {string} src + * @returns {string|null} + */ +export function mediaIdFromChronicleSrc(src) { + const m = new RegExp(`^/media/(${UUID})(?:[?#].*)?$`, 'i').exec(String(src || '')); + return m ? m[1].toLowerCase() : null; +} + +/** + * The file extension a copy gets for a MIME type, or null for a type sync + * does not copy (anything that isn't a plain raster picture). + * @param {string} mime + * @returns {string|null} + */ +export function extensionForMime(mime) { + return EXT_BY_MIME[String(mime || '').toLowerCase().split(';')[0].trim()] || null; +} + +/** + * Media ids of the pictures in Chronicle HTML that sync should copy: every + * `/media/` image outside a GM-only figure and outside a secret block. + * @param {string} html + * @returns {string[]} distinct ids, in order of appearance + */ +export function sharedPictureIds(html) { + const ids = []; + const visible = String(html || '') + .replace(SECTION_RE, (sec) => (isSecretSection(sec) ? '' : sec)) + .replace(FIGURE_RE, (fig) => (isGMFigure(fig) ? '' : fig)); + for (const m of visible.matchAll(IMG_SRC_RE)) { + const id = mediaIdFromChronicleSrc(m[3]); + if (id && !ids.includes(id)) ids.push(id); + } + return ids; +} + +// Stable across pulls (no churn on every sync), unique within a page. +function secretIdFor(figure, n) { + const m = new RegExp(`/media/(${UUID})`, 'i').exec(figure); + const seed = m ? m[1].replace(/-/g, '').slice(0, 16) : '0'; + return `secret-chr${seed}${n}`; +} + +function isSecretSection(section) { + return classList(openingTag(section)).includes('secret'); +} + +/** + * Chronicle HTML → Foundry HTML. Shared pictures point at their local copy + * when `localPathFor(id)` has one (otherwise they keep the Chronicle path + * and are retried on the next pull); GM-only figures go inside a secret + * block untouched. + * @param {string} html + * @param {(id: string) => string|undefined} localPathFor + * @returns {string} + */ +export function toFoundryPictures(html, localPathFor) { + const swapSrcs = (fragment) => fragment.replace(IMG_SRC_RE, (all, pre, q, src) => { + const id = mediaIdFromChronicleSrc(src); + const local = id ? localPathFor(id) : ''; + return local ? `${pre}${q}${local}${q}` : all; + }); + + let n = 0; + const outsideSecrets = (fragment) => { + let out = ''; + let last = 0; + for (const m of fragment.matchAll(FIGURE_RE)) { + out += swapSrcs(fragment.slice(last, m.index)); + const fig = m[0]; + if (isPictureFigure(fig) && isGMFigure(fig)) { + out += `
${fig}
`; + } else { + out += swapSrcs(fig); + } + last = m.index + fig.length; + } + return out + swapSrcs(fragment.slice(last)); + }; + + // A secret block already in the page is left exactly as it is: whatever + // is inside stays secret, and a second wrapper would nest. + let out = ''; + let last = 0; + const input = String(html || ''); + for (const m of input.matchAll(SECTION_RE)) { + if (!isSecretSection(m[0])) continue; + out += outsideSecrets(input.slice(last, m.index)) + m[0]; + last = m.index + m[0].length; + } + return out + outsideSecrets(input.slice(last)); +} + +/** + * A src Foundry holds → the plain Chronicle `/media/` path, or null + * when it isn't a Chronicle picture. Recognizes a local copy, the plain + * path, and a full link on the Chronicle host. + * @param {string} src + * @param {string} apiUrl + * @returns {string|null} + */ +export function chronicleSrcFor(src, apiUrl) { + const s = String(src || ''); + const local = new RegExp(`(?:^|/)${PICTURE_DIR_NAME}/(${UUID})\\.[a-z0-9]+(?:[?#].*)?$`, 'i').exec(s); + if (local) return `/media/${local[1].toLowerCase()}`; + const plain = mediaIdFromChronicleSrc(s); + if (plain) return `/media/${plain}`; + if (/^https?:/i.test(s) && apiUrl) { + try { + const u = new URL(s); + const base = new URL(apiUrl); + if (u.protocol === base.protocol && u.host === base.host) { + const id = mediaIdFromChronicleSrc(u.pathname); + if (id) return `/media/${id}`; + } + } catch { /* not a URL: not ours */ } + } + return null; +} + +function markGM(figure) { + return figure.replace(/^]*)>/i, (open, attrs) => { + const classes = classList(open); + if (classes.includes('ce-img') && classes.includes('ce-img--gm')) return open; + const add = ['ce-img', 'ce-img--gm'].filter((c) => !classes.includes(c)).join(' '); + if (/\bclass=/i.test(attrs)) { + return open.replace(/\bclass=(["'])([^"']*)\1/i, (_, q, v) => `class=${q}${v ? `${v} ` : ''}${add}${q}`); + } + return `
`; + }); +} + +// Source only, without a backreference, so it can sit inside other patterns. +const CHRONICLE_IMG = `]*?(?]*>`; +const hasChroniclePicture = (fragment) => new RegExp(CHRONICLE_IMG, 'i').test(fragment); + +/** + * Inside a secret block every Chronicle picture leaves as GM-only, whatever + * shape Foundry's editor left it in: a figure of any class is marked, and a + * bare picture (or one alone in a paragraph) gets a GM-only figure of its own. + */ +function markSecretPictures(section) { + let out = ''; + let last = 0; + const bare = (fragment) => fragment + .replace(new RegExp(`]*>\\s*(${CHRONICLE_IMG})\\s*

`, 'gi'), '$1') + .replace(new RegExp(CHRONICLE_IMG, 'gi'), (img) => `
${img}
`); + for (const m of section.matchAll(FIGURE_RE)) { + out += bare(section.slice(last, m.index)); + out += hasChroniclePicture(m[0]) ? markGM(m[0]) : m[0]; + last = m.index + m[0].length; + } + return out + bare(section.slice(last)); +} + +/** + * Foundry HTML → Chronicle HTML for a push. Every Chronicle picture src goes + * back to `/media/`; every Chronicle picture inside any secret block + * is marked GM only, and the block sync itself added (holding nothing but the + * figure) is removed. + * @param {string} html + * @param {string} apiUrl + * @returns {string} + */ +export function toChroniclePictures(html, apiUrl) { + let out = String(html || '').replace(IMG_SRC_RE, (all, pre, q, src) => { + const plain = chronicleSrcFor(src, apiUrl); + return plain ? `${pre}${q}${plain}${q}` : all; + }); + + out = out.replace(SECTION_RE, (section) => { + if (!isSecretSection(section)) return section; + const marked = markSecretPictures(section); + const inner = marked.slice(openingTag(marked).length, -'
'.length).trim(); + const figs = inner.match(FIGURE_RE) || []; + if (figs.length === 1 && figs[0] === inner && isPictureFigure(inner)) return inner; + return marked; + }); + return out; +} diff --git a/scripts/journal-sync.mjs b/scripts/journal-sync.mjs index 04bc364..3c596e9 100644 --- a/scripts/journal-sync.mjs +++ b/scripts/journal-sync.mjs @@ -30,6 +30,8 @@ import { setAside } from './_set-aside.mjs'; import { isOldNotesJournal } from './_notes-folder.mjs'; import { queueRemoteDelete } from './_remote-deletes.mjs'; import { collapseChanges } from './_change-feed.mjs'; +import { sharedPictureIds, toFoundryPictures, toChroniclePictures } from './_inline-pictures.mjs'; +import { PictureStore, watchGMPictures } from './picture-store.mjs'; /** * Validate and resolve a Chronicle entity's `image_path` to a safe src @@ -180,6 +182,9 @@ export class JournalSync { if (!getSetting('syncJournals')) return; + this._pictures = new PictureStore({ api }); + if (game.user?.isGM) this._stopGMPictures = watchGMPictures(this._pictures); + // Register Foundry hooks for JournalEntry changes. Hooks.on('createJournalEntry', this._onCreateJournal); Hooks.on('updateJournalEntry', this._onUpdateJournal); @@ -568,6 +573,8 @@ export class JournalSync { Hooks.off('closeJournalSheet', this._onCloseJournalSheet); Hooks.off('closeJournalEntrySheet', this._onCloseJournalSheet); globalThis.window?.removeEventListener?.('beforeunload', this._onBeforeUnload); + this._stopGMPictures?.(); + this._stopGMPictures = null; this._stopGMSecrets?.(); this._stopGMSecrets = null; // Module stop is itself a form of "unload" — never drop the last edit. @@ -1657,7 +1664,9 @@ export class JournalSync { console.warn(`Chronicle: did not send the text of "${journal.name}": its GM-only parts changed in Chronicle`); return undefined; } - return toChronicleSecrets(r.html); + // Pictures after the restore, so a GM-only one in restored content or + // in a block the GM typed leaves marked GM-only. + return toChronicleSecrets(toChroniclePictures(r.html, getSetting('apiUrl'))); } /** @@ -1671,8 +1680,10 @@ export class JournalSync { */ async _pullHtml(entityId, html) { if (!html) return ''; + // Pictures before hiding: shared ones point at their local copy, and + // GM-only ones go in a secret block, so they are hidden like GM text. const { html: hidden, pieces } = await hideSecrets( - toFoundrySecrets(html), secretPlaceholderText(), secretKeyer(getSetting('apiKey'), entityId), + await this._withPictures(toFoundrySecrets(html)), secretPlaceholderText(), secretKeyer(getSetting('apiKey'), entityId), ); for (const [id, content] of pieces) this._secretPieces.set(id, content); return _sanitizeIncomingHTML(hidden); @@ -1693,8 +1704,9 @@ export class JournalSync { const entity = await this._api.get(`/entities/${entityId}`); for (const html of [entity?.entry_html, entity?.player_notes_html]) { if (!html) continue; + // Same blocks as the pull, so the placeholder ids match; no copies. const { pieces } = await hideSecrets( - toFoundrySecrets(html), secretPlaceholderText(), secretKeyer(getSetting('apiKey'), entityId), + toFoundryPictures(toFoundrySecrets(html), () => undefined), secretPlaceholderText(), secretKeyer(getSetting('apiKey'), entityId), ); for (const [id, content] of pieces) this._secretPieces.set(id, content); } @@ -1872,6 +1884,22 @@ export class JournalSync { } } + /** + * Chronicle HTML with its pictures made showable in Foundry: shared ones + * copied into the world's files, GM-only ones inside a secret block + * (scripts/_inline-pictures.mjs). A picture that can't be copied keeps + * its Chronicle path and is retried on the next pull. + * @param {string} html + * @returns {Promise} + * @private + */ + async _withPictures(html) { + if (!html) return html; + const ids = sharedPictureIds(html); + const local = ids.length && this._pictures ? await this._pictures.ensure(ids) : new Map(); + return toFoundryPictures(html, (id) => local.get(id)); + } + /** * Sync entity HTML content to journal pages. Splits by headings and * updates existing pages or creates/removes pages as needed. diff --git a/scripts/picture-store.mjs b/scripts/picture-store.mjs new file mode 100644 index 0000000..481bab0 --- /dev/null +++ b/scripts/picture-store.mjs @@ -0,0 +1,232 @@ +/** + * Local copies of the pictures inside Chronicle page text, kept in the + * world's own folder (`worlds//chronicle-media/.`). + * + * Chronicle serves media only through signed links that expire after + * minutes, so a journal cannot point at Chronicle directly: players would + * see a broken picture soon after every pull. A copy in the world's files is + * served by Foundry itself, to everyone who can see the journal. Media ids + * are random UUIDs and a file never changes under its id, so a copy is made + * once and reused. + * + * GM-only pictures are never copied (scripts/_inline-pictures.mjs). The GM's + * own client shows them from a fresh signed link, swapped in on screen by + * `watchGMPictures`; the stored HTML keeps the plain Chronicle path. + * + * GM only: uploading to the world's files needs the GM's file permission, + * and the signed links need the GM's API key. + */ + +import { getSetting } from './settings.mjs'; +import { _isAllowedImageHost, _describeRejection } from './_url-validation.mjs'; +import { + MEDIA_ID_RE, PICTURE_DIR_NAME, extensionForMime, chronicleSrcFor, +} from './_inline-pictures.mjs'; + +/** A copy larger than this is skipped; Chronicle's own upload limit is lower. */ +export const MAX_PICTURE_BYTES = 25 * 1024 * 1024; + +/** Signed links last 15 minutes on Chronicle; reuse one for 10. */ +const SIGNED_LINK_TTL_MS = 10 * 60 * 1000; + +/** + * Foundry's FilePicker across v12 (global) and v13+ (namespaced). + * @returns {any|null} + */ +function filePicker() { + const ns = globalThis.foundry?.applications?.apps?.FilePicker; + return ns?.implementation ?? ns ?? globalThis.FilePicker?.implementation ?? globalThis.FilePicker ?? null; +} + +export class PictureStore { + /** + * @param {object} deps + * @param {{get: (path: string) => Promise}} deps.api Chronicle API client. + * @param {() => string} [deps.worldId] + * @param {typeof fetch} [deps.fetchFn] + * @param {() => any} [deps.picker] FilePicker class. + */ + constructor({ api, worldId, fetchFn, picker } = {}) { + this._api = api; + this._worldId = worldId ?? (() => globalThis.game?.world?.id ?? ''); + this._fetch = fetchFn ?? ((...a) => globalThis.fetch(...a)); + this._picker = picker ?? filePicker; + /** @type {Map|null} media id → local path, null until listed */ + this._known = null; + /** @type {Map>} */ + this._inFlight = new Map(); + /** @type {Map} */ + this._signed = new Map(); + this._dirReady = null; + } + + /** The world-relative folder the copies live in. */ + get dir() { + return `worlds/${this._worldId()}/${PICTURE_DIR_NAME}`; + } + + /** + * Make sure each id has a local copy. Never throws: a picture that can't + * be copied keeps its Chronicle path and is retried on the next pull. + * @param {string[]} ids + * @returns {Promise>} id → local path, for the ids copied + */ + async ensure(ids) { + const out = new Map(); + const wanted = (ids || []).filter((id) => MEDIA_ID_RE.test(id)); + if (wanted.length === 0) return out; + await this._listExisting(); + for (const id of wanted) { + const path = await this._ensureOne(id); + if (path) out.set(id, path); + } + return out; + } + + /** @private */ + async _listExisting() { + if (this._known) return; + const known = new Map(); + try { + const res = await this._picker()?.browse?.('data', this.dir); + for (const file of res?.files || []) { + const m = /([0-9a-f-]{36})\.[a-z0-9]+$/i.exec(decodeURIComponent(String(file))); + if (m && MEDIA_ID_RE.test(m[1])) known.set(m[1].toLowerCase(), file); + } + this._dirReady = Promise.resolve(true); + } catch { + // No folder yet: made on the first copy. + } + this._known = known; + } + + /** @private */ + async _ensureDir() { + if (!this._dirReady) { + this._dirReady = (async () => { + try { + await this._picker()?.createDirectory?.('data', this.dir, {}); + } catch (err) { + // Already there is fine; anything else surfaces on the upload. + if (!/EEXIST|exists/i.test(String(err?.message || err))) { + console.warn('Chronicle: could not create the picture folder', err); + } + } + return true; + })(); + } + return this._dirReady; + } + + /** @private */ + _ensureOne(id) { + const known = this._known?.get(id); + if (known) return Promise.resolve(known); + if (!this._inFlight.has(id)) { + const p = this._copy(id) + .catch((err) => { + console.warn(`Chronicle: could not copy picture ${id}`, err); + return ''; + }) + .finally(() => this._inFlight.delete(id)); + this._inFlight.set(id, p); + } + return this._inFlight.get(id); + } + + /** @private */ + async _copy(id) { + const meta = await this._api.get(`/media/${id}`); + const ext = extensionForMime(meta?.mime_type); + if (!ext) return ''; + if (Number(meta?.file_size) > MAX_PICTURE_BYTES) return ''; + + const url = this._absolute(meta?.url || ''); + if (!url) return ''; + // No redirects: the bytes must come from the Chronicle host itself. + const res = await this._fetch(url, { credentials: 'omit', redirect: 'error' }); + if (!res?.ok) return ''; + const blob = await res.blob(); + if (!blob || blob.size > MAX_PICTURE_BYTES) return ''; + if (extensionForMime(blob.type) !== ext) return ''; + + await this._ensureDir(); + const file = new File([blob], `${id}.${ext}`, { type: blob.type }); + const up = await this._picker()?.upload?.('data', this.dir, file, {}, { notify: false }); + const path = up?.path || ''; + if (path) this._known?.set(id, path); + return path; + } + + /** + * A media link from Chronicle made absolute, only on the Chronicle host. + * @private + */ + _absolute(link) { + const apiUrl = getSetting('apiUrl'); + if (!link) return ''; + if (/^https?:/i.test(link)) { + if (_isAllowedImageHost(link, apiUrl)) return link; + console.warn(_describeRejection('inline_picture', link, apiUrl)); + return ''; + } + const base = String(apiUrl || '').replace(/\/+$/, ''); + return base ? `${base}${link.startsWith('/') ? '' : '/'}${link}` : ''; + } + + /** + * A fresh signed link for one picture, for the GM's own screen only. + * @param {string} id + * @returns {Promise} + */ + async signedLink(id) { + if (!MEDIA_ID_RE.test(id)) return ''; + const hit = this._signed.get(id); + if (hit && Date.now() - hit.at < SIGNED_LINK_TTL_MS) return hit.url; + try { + const meta = await this._api.get(`/media/${id}`); + const url = this._absolute(meta?.url || ''); + if (url) this._signed.set(id, { url, at: Date.now() }); + return url; + } catch { + return ''; + } + } +} + +/** + * Show GM-only pictures on the GM's screen. They are stored with the plain + * Chronicle path, which Foundry can't load; this swaps a fresh signed link + * into each such as it appears. Editors are left alone, so the + * swapped link is never typed into a saved page. + * @param {PictureStore} store + * @returns {() => void} stop watching + */ +export function watchGMPictures(store) { + const root = globalThis.document?.body; + const MO = globalThis.MutationObserver; + if (!root || !MO) return () => {}; + const apiUrl = () => getSetting('apiUrl'); + + const fix = (img) => { + if (img.closest?.('[contenteditable="true"], .ProseMirror')) return; + if (!img.closest?.('section.secret')) return; + const plain = chronicleSrcFor(img.getAttribute('src'), apiUrl()); + if (!plain) return; + const id = plain.slice('/media/'.length); + store.signedLink(id).then((url) => { + if (url && img.getAttribute('src') !== url) img.setAttribute('src', url); + }); + }; + const scan = (node) => { + if (node?.nodeType !== 1) return; + if (node.tagName === 'IMG') fix(node); + else node.querySelectorAll?.('section.secret img').forEach(fix); + }; + + const obs = new MO((records) => { + for (const r of records) r.addedNodes.forEach(scan); + }); + obs.observe(root, { childList: true, subtree: true }); + return () => obs.disconnect(); +} diff --git a/styles/chronicle-sync.css b/styles/chronicle-sync.css index 5e11ba2..375fb97 100644 --- a/styles/chronicle-sync.css +++ b/styles/chronicle-sync.css @@ -2905,6 +2905,35 @@ } /* end @layer chronicle-sync */ +/* ---- Pictures inside Chronicle page text (scripts/_inline-pictures.mjs) ---- + The same size, side and caption Chronicle gives them. */ +.journal-page-content:has(.ce-img) { display: flow-root; } +.ce-img { position: relative; margin: 1rem auto; max-width: 100%; } +.ce-img img { display: block; width: 100%; height: auto; margin: 0; border: 0; border-radius: 0.375rem; } +.ce-img figcaption { margin-top: 0.35rem; font-size: 0.85em; text-align: center; opacity: 0.8; } +.ce-img--left { float: left; margin: 0.25rem 1.25rem 0.75rem 0; } +.ce-img--right { float: right; margin: 0.25rem 0 0.75rem 1.25rem; } +.ce-img--center { clear: both; } +.ce-img--w10 { width: 10%; } +.ce-img--w15 { width: 15%; } +.ce-img--w20 { width: 20%; } +.ce-img--w25 { width: 25%; } +.ce-img--w30 { width: 30%; } +.ce-img--w35 { width: 35%; } +.ce-img--w40 { width: 40%; } +.ce-img--w45 { width: 45%; } +.ce-img--w50 { width: 50%; } +.ce-img--w55 { width: 55%; } +.ce-img--w60 { width: 60%; } +.ce-img--w65 { width: 65%; } +.ce-img--w70 { width: 70%; } +.ce-img--w75 { width: 75%; } +.ce-img--w80 { width: 80%; } +.ce-img--w85 { width: 85%; } +.ce-img--w90 { width: 90%; } +.ce-img--w95 { width: 95%; } +.ce-img--w100 { width: 100%; } +.ce-img--w100 { float: none; margin-inline: 0; } /* Player notebook and jot notes: frames of Chronicle's own notes pages. */ .chronicle-notebook .window-content { padding: 0; diff --git a/tools/test-gm-secrets.mjs b/tools/test-gm-secrets.mjs index 456b328..ea85554 100644 --- a/tools/test-gm-secrets.mjs +++ b/tools/test-gm-secrets.mjs @@ -151,11 +151,14 @@ test('journal-sync wires the helpers on every pull and push path', () => { const pulls = src.match(/_sanitizeIncomingHTML\(entity\.(?:entry_html|player_notes_html)\b[^)]*\)/g) || []; assert.deepEqual(pulls, [], 'every entity HTML pull goes through _pullHtml'); assert.equal((src.match(/await this\._pullHtml\(entity\.id, entity\.(?:entry_html|player_notes_html)\)/g) || []).length, 4); - assert.match(src, /hideSecrets\(\s*toFoundrySecrets\(html\)/, '_pullHtml hides what toFoundrySecrets made'); + assert.match(src, /hideSecrets\(\s*(?:await this\._withPictures\()?toFoundrySecrets\(html\)/, '_pullHtml hides what toFoundrySecrets made'); assert.equal((src.match(/await this\._entryForPush\(journal, (?:entity\.id|entityId)/g) || []).length, 3, 'every entry push restores placeholders'); assert.match(src, /await this\._playerNotesForPush\(journal, entityId\)/); - assert.match(src, /return toChronicleSecrets\(r\.html\);/); assert.doesNotMatch(src, /_collectTextPages|_collectPlayerNotes/, 'no push path skips the restore'); + // Pictures go in before hiding (GM-only ones become secret blocks) and + // come back after the restore, so restored GM pictures leave GM-only. + assert.match(src, /hideSecrets\(\s*await this\._withPictures\(toFoundrySecrets\(html\)\)/); + assert.match(src, /return toChronicleSecrets\(toChroniclePictures\(r\.html, getSetting\('apiUrl'\)\)\);/); assert.match(src, /secretBlockRanges\(html\)/, 'page breaks skip headings inside secret blocks'); assert.doesNotMatch(src, /fields:\s*entity\.fields_data/, 'field values (GM-only ones included) are never stored in journal flags'); }); diff --git a/tools/test-inline-pictures.mjs b/tools/test-inline-pictures.mjs new file mode 100644 index 0000000..509d960 --- /dev/null +++ b/tools/test-inline-pictures.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node +/** + * Pictures inside Chronicle page text (scripts/_inline-pictures.mjs, + * scripts/picture-store.mjs): shared pictures point at a local copy in + * Foundry, GM-only ones go inside a secret block and are never copied, and a + * push always sends the plain `/media/` path back with GM-only intact. + * + * Run: node --test tools/test-inline-pictures.mjs + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import './_journal-test-env.mjs'; +import { + sharedPictureIds, toFoundryPictures, toChroniclePictures, chronicleSrcFor, + mediaIdFromChronicleSrc, extensionForMime, +} from '../scripts/_inline-pictures.mjs'; + +const A = '0b6a3f0e-8c1d-4f6a-9a51-2f3c4d5e6f70'; +const B = '1c7b4f1f-9d2e-4a7b-8b62-3a4d5e6f7081'; +const API = 'https://chronicle.example'; +const LOCAL = (id) => `worlds/w1/chronicle-media/${id}.png`; + +const shared = (id, cls = 'ce-img ce-img--w40 ce-img--right') => + `
Mira
Mira Kell
`; +const gm = (id) => + `
x
The traitor
`; + +test('only pictures outside GM-only figures are copied', () => { + const html = `

a

${shared(A)}${gm(B)}

b

`; + assert.deepEqual(sharedPictureIds(html), [A]); + assert.deepEqual(sharedPictureIds(`${shared(A)}${shared(A)}`), [A], 'each id once'); + assert.deepEqual(sharedPictureIds(``), [A], 'a bare picture counts'); + assert.deepEqual(sharedPictureIds(``), [], 'only UUIDs'); + assert.deepEqual(sharedPictureIds(``), [], 'only the plain path'); +}); + +test('pull: a shared picture points at its local copy', () => { + const out = toFoundryPictures(`

a

${shared(A)}`, (id) => (id === A ? LOCAL(A) : '')); + assert.ok(out.includes(`src="${LOCAL(A)}"`), out); + assert.ok(out.includes('class="ce-img ce-img--w40 ce-img--right"')); + assert.ok(out.includes('
Mira Kell
')); + assert.ok(!out.includes('section'), 'no secret block for a shared picture'); +}); + +test('pull: a picture with no copy yet keeps its Chronicle path', () => { + const out = toFoundryPictures(shared(A), () => undefined); + assert.equal(out, shared(A)); +}); + +test('pull: a GM-only picture goes in a secret block and is never swapped', () => { + const out = toFoundryPictures(`

a

${gm(B)}

b

`, () => 'worlds/w1/chronicle-media/should-not-appear.png'); + assert.match(out, /^

a<\/p>

b

')); + assert.equal(toFoundryPictures(gm(B), () => ''), toFoundryPictures(gm(B), () => ''), 'same block id every pull'); +}); + +test('push: a local copy goes back as the plain Chronicle path', () => { + const pulled = toFoundryPictures(shared(A), () => LOCAL(A)); + assert.equal(toChroniclePictures(pulled, API), shared(A)); +}); + +test('push: round trip of a GM-only picture is lossless', () => { + const html = `

a

${gm(B)}

b

${shared(A)}`; + const pulled = toFoundryPictures(html, () => LOCAL(A)); + assert.equal(toChroniclePictures(pulled, API), html); +}); + +test('push: a picture inside any secret block goes back GM only', () => { + // Foundry's editor may drop the class, or the GM may put a shared + // picture into a secret block: either way players must not get it. + const lost = `
`; + assert.equal(toChroniclePictures(lost, API), `
`); + + const withText = `

note

`; + const out = toChroniclePictures(withText, API); + assert.ok(out.includes('ce-img ce-img--gm'), out); + assert.ok(out.startsWith(' { + const inP = `

`; + assert.equal(toChroniclePictures(inP, API), `
`); + const bareWithText = `

t

`; + assert.equal(toChroniclePictures(bareWithText, API), `

t

`); + const noClass = `
x
`; + assert.equal(toChroniclePictures(noClass, API), `
x
`); + const foundryOnly = '

'; + assert.equal(toChroniclePictures(foundryOnly, API), foundryOnly, "Foundry's own pictures are not touched"); +}); + +test('pull: a secret block already in the page is left alone, never nested', () => { + const kept = `

t

${gm(B)}${shared(A)}
`; + assert.equal(toFoundryPictures(kept, () => LOCAL(A)), kept); + assert.deepEqual(sharedPictureIds(kept), [], 'nothing inside a secret block is copied'); +}); + +test('pull: the same GM-only picture twice gets two block ids', () => { + const ids = [...toFoundryPictures(gm(B) + gm(B), () => '').matchAll(/id="([^"]+)"/g)].map((m) => m[1]); + assert.equal(new Set(ids).size, 2); +}); + +test('push: a full or signed Chronicle link goes back as the plain path', () => { + const signed = ``; + assert.equal(toChroniclePictures(signed, API), ``); + assert.equal(toChroniclePictures(``, API), ``); + const other = ``; + assert.equal(toChroniclePictures(other, API), other, 'another host is left as is'); + const foundryOwn = ''; + assert.equal(toChroniclePictures(foundryOwn, API), foundryOwn, "Foundry's own pictures are left alone"); +}); + +test('src helpers', () => { + assert.equal(mediaIdFromChronicleSrc(`/media/${A.toUpperCase()}`), A); + assert.equal(mediaIdFromChronicleSrc(`/media/${A}/thumb/300`), null); + assert.equal(chronicleSrcFor(`/worlds/w1/chronicle-media/${A}.jpg`, API), `/media/${A}`); + assert.equal(chronicleSrcFor('', API), null); + assert.equal(extensionForMime('image/jpeg'), 'jpg'); + assert.equal(extensionForMime('image/svg+xml'), null, 'never copy SVG'); + assert.equal(extensionForMime('text/html'), null); +}); + +// --- PictureStore: the copies in the world's files ------------------------- + +const { PictureStore } = await import('../scripts/picture-store.mjs'); + +function fakeWorld({ existing = [], meta = {}, bodies = {} } = {}) { + globalThis.game.settings.get = (_m, k) => (k === 'apiUrl' ? API : ''); + const calls = { get: [], fetch: [], upload: [], mkdir: 0 }; + const picker = { + browse: async () => { + if (existing === null) throw new Error('ENOENT'); + return { files: existing }; + }, + createDirectory: async () => { calls.mkdir++; }, + upload: async (_src, dir, file) => { + calls.upload.push(file.name); + return { path: `${dir}/${file.name}` }; + }, + }; + const api = { + get: async (path) => { + calls.get.push(path); + const id = path.split('/').pop(); + return meta[id] ?? null; + }, + }; + const fetchFn = async (url, opts) => { + calls.fetch.push({ url, opts }); + const id = /media\/([0-9a-f-]{36})/.exec(url)[1]; + const b = bodies[id]; + return b ? { ok: true, blob: async () => b } : { ok: false }; + }; + const store = new PictureStore({ api, worldId: () => 'w1', fetchFn, picker: () => picker }); + return { store, calls }; +} + +const png = (n = 10) => new Blob([new Uint8Array(n)], { type: 'image/png' }); + +test('store: copies a picture once and reuses the copy', async () => { + const { store, calls } = fakeWorld({ + existing: null, + meta: { [A]: { mime_type: 'image/png', file_size: 10, url: `/media/${A}?expires=9&sig=s` } }, + bodies: { [A]: png() }, + }); + const first = await store.ensure([A]); + assert.equal(first.get(A), `worlds/w1/chronicle-media/${A}.png`); + assert.equal(calls.fetch[0].url, `${API}/media/${A}?expires=9&sig=s`); + assert.equal(calls.fetch[0].opts.credentials, 'omit'); + assert.equal(calls.mkdir, 1); + await store.ensure([A]); + assert.equal(calls.upload.length, 1, 'second pull reuses the copy'); +}); + +test('store: an existing copy in the folder is found without fetching', async () => { + const { store, calls } = fakeWorld({ existing: [`worlds/w1/chronicle-media/${A}.webp`] }); + const got = await store.ensure([A]); + assert.equal(got.get(A), `worlds/w1/chronicle-media/${A}.webp`); + assert.equal(calls.get.length, 0); +}); + +test('store: refuses what is not a plain picture, too big, or off-host', async () => { + const big = 26 * 1024 * 1024; + const { store, calls } = fakeWorld({ + existing: [], + meta: { + [A]: { mime_type: 'image/svg+xml', file_size: 10, url: `/media/${A}` }, + [B]: { mime_type: 'image/png', file_size: big, url: `/media/${B}` }, + }, + }); + assert.equal((await store.ensure([A, B, 'not-a-uuid'])).size, 0); + assert.equal(calls.fetch.length, 0); + + const off = fakeWorld({ + existing: [], + meta: { [A]: { mime_type: 'image/png', file_size: 10, url: `https://evil.example/media/${A}` } }, + bodies: { [A]: png() }, + }); + assert.equal((await off.store.ensure([A])).size, 0); + assert.equal(off.calls.fetch.length, 0, 'never fetches another host'); + + const lying = fakeWorld({ + existing: [], + meta: { [A]: { mime_type: 'image/png', file_size: 10, url: `/media/${A}` } }, + bodies: { [A]: new Blob([''], { type: 'text/html' }) }, + }); + assert.equal((await lying.store.ensure([A])).size, 0, 'the served type must match'); + assert.equal(lying.calls.upload.length, 0); +}); + +test('store: a failed copy never throws and is retried next time', async () => { + const w = fakeWorld({ existing: [], meta: { [A]: { mime_type: 'image/png', file_size: 10, url: `/media/${A}` } } }); + assert.equal((await w.store.ensure([A])).size, 0); + w.calls.fetch.length = 0; + await w.store.ensure([A]); + assert.equal(w.calls.fetch.length, 1, 'retried'); +}); + +test('store: signed links for the GM screen are reused for a while', async () => { + const w = fakeWorld({ meta: { [B]: { mime_type: 'image/png', url: `/media/${B}?expires=9&sig=s` } } }); + assert.equal(await w.store.signedLink(B), `${API}/media/${B}?expires=9&sig=s`); + await w.store.signedLink(B); + assert.equal(w.calls.get.length, 1); + assert.equal(await w.store.signedLink('../x'), ''); +}); + +test('with the GM-only text pass: pull then push gives Chronicle back exactly what it sent', async () => { + const { toFoundrySecrets, toChronicleSecrets } = await import('../scripts/_gm-secrets.mjs'); + const html = `

Mira is the spy runs docks.

${shared(A)}${gm(B)}

end

`; + const pulled = toFoundryPictures(toFoundrySecrets(html), (id) => LOCAL(id)); + assert.ok(pulled.includes(`src="${LOCAL(A)}"`)); + assert.ok(!pulled.includes(LOCAL(B)), 'the GM-only picture is not swapped for a copy'); + assert.equal(sharedPictureIds(toFoundrySecrets(html)).join(), A); + assert.equal(toChronicleSecrets(toChroniclePictures(pulled, API)), html); +}); + +test('with placeholders: a GM-only picture leaves the stored page and comes back GM-only', async () => { + const { toFoundrySecrets, toChronicleSecrets, hideSecrets, restoreSecrets } = await import('../scripts/_gm-secrets.mjs'); + const keyOf = async (c) => String(c.length); + const html = `

Mira is the spy.

${shared(A)}${gm(B)}`; + const { html: stored, pieces } = await hideSecrets(toFoundryPictures(toFoundrySecrets(html), (id) => LOCAL(id)), 'label', keyOf); + assert.ok(stored.includes(`src="${LOCAL(A)}"`)); + assert.ok(!stored.includes(B), 'the GM-only picture is not in the stored page'); + // A reload rebuilds the same placeholders without copying anything. + const again = await hideSecrets(toFoundryPictures(toFoundrySecrets(html), () => undefined), 'label', keyOf); + assert.deepEqual([...again.pieces.keys()], [...pieces.keys()]); + const r = restoreSecrets(stored, pieces, 'label'); + assert.deepEqual(r.missing, []); + assert.equal(toChronicleSecrets(toChroniclePictures(r.html, API)), html); +});