diff --git a/scripts/adapters/generic-adapter.mjs b/scripts/adapters/generic-adapter.mjs index 6fa7672..9a0ad37 100644 --- a/scripts/adapters/generic-adapter.mjs +++ b/scripts/adapters/generic-adapter.mjs @@ -25,6 +25,12 @@ * - type: "json"/"string" → serialized JSON string; else a raw array * Collection fields are READ-ONLY today (pull only); write-back is a future tier, * so they are never included in the Foundry update path. + * + * Every extracted value (scalar or projected) is run through + * `normalizeFoundryValue` so live Foundry structures that `JSON.stringify` + * cannot serialize — Sets (keywords, skills, characteristics, actor.statuses) + * and Collections of pseudo-documents (an ability's `system.power.effects` tier + * ladder) — become plain arrays/objects instead of `{}`. */ /** @@ -145,6 +151,68 @@ export function getNestedValue(obj, path) { return current; } +/** + * Normalize a value read off a live Foundry document into a JSON-safe plain + * value. Foundry models many fields as data structures that `JSON.stringify` + * cannot serialize meaningfully: + * - a **Set** (keywords, skills, power-roll characteristics, actor.statuses) + * serializes to `{}` — must become an array; + * - a **Collection / ModelCollection** (a Map subclass — e.g. a CollectionField + * of pseudo-documents like an ability's `system.power.effects` tier ladder) + * also serializes to `{}` — must become an array of its members; + * - a **DataModel / pseudo-document** member exposes its data via `toObject()`, + * not own-enumerable properties (the schema fields are getters). + * + * This walks such structures recursively and returns arrays / plain objects / + * primitives only. It is defensive (never throws) and depth-guarded against + * cycles. Primitives and already-plain values pass straight through, so it is + * safe to apply to every extracted field. + * + * @param {*} value + * @param {number} [depth] + * @returns {*} a JSON-safe value (primitive | array | plain object | null) + */ +export function normalizeFoundryValue(value, depth) { + depth = depth || 0; + if (value == null) return value; + if (typeof value !== 'object') return value; // primitives pass through + if (depth > 8) return null; // cycle / runaway guard + + // Native Set OR Foundry Set → array of normalized members. + if (value instanceof Set) { + return Array.from(value, (v) => normalizeFoundryValue(v, depth + 1)); + } + // Native array → map members. + if (Array.isArray(value)) { + return value.map((v) => normalizeFoundryValue(v, depth + 1)); + } + // Foundry Collection (a Map subclass) exposes its members as `.contents`. + // This covers CollectionField values (pseudo-document tier ladders) and the + // actor's items/effects collections when a path points straight at one. + if (Array.isArray(value.contents)) { + return value.contents.map((v) => normalizeFoundryValue(v, depth + 1)); + } + // Plain Map (no `.contents`) → array of its values. + if (value instanceof Map) { + return Array.from(value.values(), (v) => normalizeFoundryValue(v, depth + 1)); + } + // DataModel / pseudo-document → its source object (schema fields are getters, + // not own-enumerable props, so a key-copy would miss them). `toObject(false)` + // yields the stored source (Sets already serialized to arrays); re-normalize + // it to catch any nested live structures. + if (typeof value.toObject === 'function') { + try { + return normalizeFoundryValue(value.toObject(false), depth + 1); + } catch (e) { /* fall through to a shallow copy */ } + } + // Plain-ish object → normalize own enumerable props (catches nested Sets). + const out = {}; + for (const k of Object.keys(value)) { + out[k] = normalizeFoundryValue(value[k], depth + 1); + } + return out; +} + /** * Extract a collection-mapped field (e.g. abilities/inventory from actor.items[]). * Reads field.foundry_collection off the actor, optionally filters by @@ -187,7 +255,7 @@ export function extractCollectionField(actor, field) { if (!first) return ''; if (proj) { const firstPath = Object.values(proj)[0]; - return getNestedValue(first, firstPath) ?? ''; + return normalizeFoundryValue(getNestedValue(first, firstPath)) ?? ''; } return first.name ?? ''; } @@ -196,7 +264,7 @@ export function extractCollectionField(actor, field) { if (!proj) return { id: it.id ?? null, name: it.name ?? null, type: it.type ?? null }; const out = {}; for (const [outKey, path] of Object.entries(proj)) { - out[outKey] = getNestedValue(it, path) ?? null; + out[outKey] = normalizeFoundryValue(getNestedValue(it, path)) ?? null; } return out; }); @@ -223,7 +291,14 @@ export function buildChronicleFields(actor, mappedFields) { if (field.foundry_collection) { result[field.key] = extractCollectionField(actor, field); } else { - const value = getNestedValue(actor, field.foundry_path); + let value = normalizeFoundryValue(getNestedValue(actor, field.foundry_path)); + // A Set/array/object scalar (e.g. system.skills.value, actor.statuses) + // declared on a string/json field is serialized to a JSON string so it + // arrives as parseable JSON, mirroring how collection fields are stored. + if (value != null && typeof value === 'object' + && (field.type === 'string' || field.type === 'json')) { + value = JSON.stringify(value); + } result[field.key] = value ?? null; } } diff --git a/scripts/sync-dashboard.mjs b/scripts/sync-dashboard.mjs index 66986f9..98c0523 100644 --- a/scripts/sync-dashboard.mjs +++ b/scripts/sync-dashboard.mjs @@ -66,6 +66,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { 'open-map-journal': SyncDashboard.#onOpenMapJournalAction, 'resync-all-maps': SyncDashboard.#onResyncAllMapsAction, 'resync-all-journals': SyncDashboard.#onResyncAllJournalsAction, + 'resync-everything': SyncDashboard.#onResyncEverythingAction, 'open-maps-folder': SyncDashboard.#onOpenMapsFolderAction, 'dismiss-map-errors': SyncDashboard.#onDismissMapErrorsAction, 'pull-date': SyncDashboard.#onPullDateAction, @@ -1469,6 +1470,11 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { await this._onResyncAllJournals(); } + /** Re-sync EVERYTHING: journals + every linked character + maps. */ + static async #onResyncEverythingAction() { + await this._onResyncEverything(); + } + /** * Reveal the "Chronicle Maps" folder in Foundry's journal sidebar. * Activates the journal tab and expands the folder. @@ -2172,6 +2178,71 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { this.render({ force: true }); } + /** + * Re-sync everything that's already connected to Chronicle in one action: + * all journals, every LINKED character (re-pushes current field data — Foundry + * is source of truth for characters), and all maps. This is the Overview's + * "Sync Everything Now"; the previous button only did journals, which left + * actor field data (and inventory/notes) stale after a manifest/path change. + * + * Unlinked actors are intentionally NOT auto-created here — that's a heavier, + * entity-creating operation kept on the Characters tab's "Push All Actors". + * Each module's resyncAll / repushActor is the same proven path the per-tab + * buttons use; failures are isolated per item so one bad actor can't abort the + * sweep. + */ + async _onResyncEverything() { + let confirmed; + try { + confirmed = await confirmDialog({ + title: 'Sync Everything Now', + content: '
Re-sync all journals, linked characters, and maps with Chronicle?
' + + 'Foundry is the source of truth for characters — their current data is pushed up.
', + }); + } catch { + return; // User dismissed the dialog. + } + if (!confirmed) return; + + ui.notifications.info('Chronicle: Re-syncing everything…'); + const mods = this._syncManager?._modules || []; + const find = (name) => mods.find((m) => m.constructor?.name === name); + const done = []; + + // 1. Journals (verbose=false to avoid a flood of per-entry toasts mid-sweep). + const journalSync = find('JournalSync'); + if (journalSync?.resyncAll) { + try { await journalSync.resyncAll({ verbose: false }); done.push('journals'); } + catch (err) { console.error('Chronicle: journal resync failed', err); } + } + + // 2. Characters — re-push every LINKED actor so stale fields refresh. + const actorSync = find('ActorSync'); + if (actorSync?.repushActor) { + const chars = actorSync.getSyncedActors?.() ?? []; + let pushed = 0; + for (const c of chars) { + if (!c.synced) continue; // unlinked → use "Push All Actors" (creates entities) + try { if (await actorSync.repushActor(c.id)) pushed++; } + catch (err) { console.error(`Chronicle: re-push failed for "${c.name}"`, err); } + } + if (pushed) done.push(`${pushed} character(s)`); + } + + // 3. Maps. + const mapSync = find('MapSync'); + if (mapSync?.resyncAll) { + try { await mapSync.resyncAll({ verbose: false }); done.push('maps'); } + catch (err) { console.error('Chronicle: map resync failed', err); } + } + + this._cache.maps = null; + this._cache.entities = null; + this._logActivity('push', `Re-synced everything (${done.join(', ') || 'nothing'})`); + this.render({ force: true }); + ui.notifications.info(`Chronicle: Re-sync complete — ${done.join(', ') || 'nothing to sync'}.`); + } + // --------------------------------------------------------------------------- // Config actions // --------------------------------------------------------------------------- diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs index 808dea8..fe253df 100644 --- a/templates/sync-dashboard.hbs +++ b/templates/sync-dashboard.hbs @@ -138,8 +138,8 @@ {{!-- Quick actions --}}