Skip to content

Commit f76a293

Browse files
authored
Merge pull request #75 from keyxmakerx/claude/chronicle-sheet-sync-j2m9s4
adapter: normalize Foundry Sets & pseudo-doc collections to JSON (Phase C)
2 parents 3585894 + b8f77a8 commit f76a293

4 files changed

Lines changed: 234 additions & 6 deletions

File tree

scripts/adapters/generic-adapter.mjs

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
* - type: "json"/"string" → serialized JSON string; else a raw array
2626
* Collection fields are READ-ONLY today (pull only); write-back is a future tier,
2727
* so they are never included in the Foundry update path.
28+
*
29+
* Every extracted value (scalar or projected) is run through
30+
* `normalizeFoundryValue` so live Foundry structures that `JSON.stringify`
31+
* cannot serialize — Sets (keywords, skills, characteristics, actor.statuses)
32+
* and Collections of pseudo-documents (an ability's `system.power.effects` tier
33+
* ladder) — become plain arrays/objects instead of `{}`.
2834
*/
2935

3036
/**
@@ -145,6 +151,68 @@ export function getNestedValue(obj, path) {
145151
return current;
146152
}
147153

154+
/**
155+
* Normalize a value read off a live Foundry document into a JSON-safe plain
156+
* value. Foundry models many fields as data structures that `JSON.stringify`
157+
* cannot serialize meaningfully:
158+
* - a **Set** (keywords, skills, power-roll characteristics, actor.statuses)
159+
* serializes to `{}` — must become an array;
160+
* - a **Collection / ModelCollection** (a Map subclass — e.g. a CollectionField
161+
* of pseudo-documents like an ability's `system.power.effects` tier ladder)
162+
* also serializes to `{}` — must become an array of its members;
163+
* - a **DataModel / pseudo-document** member exposes its data via `toObject()`,
164+
* not own-enumerable properties (the schema fields are getters).
165+
*
166+
* This walks such structures recursively and returns arrays / plain objects /
167+
* primitives only. It is defensive (never throws) and depth-guarded against
168+
* cycles. Primitives and already-plain values pass straight through, so it is
169+
* safe to apply to every extracted field.
170+
*
171+
* @param {*} value
172+
* @param {number} [depth]
173+
* @returns {*} a JSON-safe value (primitive | array | plain object | null)
174+
*/
175+
export function normalizeFoundryValue(value, depth) {
176+
depth = depth || 0;
177+
if (value == null) return value;
178+
if (typeof value !== 'object') return value; // primitives pass through
179+
if (depth > 8) return null; // cycle / runaway guard
180+
181+
// Native Set OR Foundry Set → array of normalized members.
182+
if (value instanceof Set) {
183+
return Array.from(value, (v) => normalizeFoundryValue(v, depth + 1));
184+
}
185+
// Native array → map members.
186+
if (Array.isArray(value)) {
187+
return value.map((v) => normalizeFoundryValue(v, depth + 1));
188+
}
189+
// Foundry Collection (a Map subclass) exposes its members as `.contents`.
190+
// This covers CollectionField values (pseudo-document tier ladders) and the
191+
// actor's items/effects collections when a path points straight at one.
192+
if (Array.isArray(value.contents)) {
193+
return value.contents.map((v) => normalizeFoundryValue(v, depth + 1));
194+
}
195+
// Plain Map (no `.contents`) → array of its values.
196+
if (value instanceof Map) {
197+
return Array.from(value.values(), (v) => normalizeFoundryValue(v, depth + 1));
198+
}
199+
// DataModel / pseudo-document → its source object (schema fields are getters,
200+
// not own-enumerable props, so a key-copy would miss them). `toObject(false)`
201+
// yields the stored source (Sets already serialized to arrays); re-normalize
202+
// it to catch any nested live structures.
203+
if (typeof value.toObject === 'function') {
204+
try {
205+
return normalizeFoundryValue(value.toObject(false), depth + 1);
206+
} catch (e) { /* fall through to a shallow copy */ }
207+
}
208+
// Plain-ish object → normalize own enumerable props (catches nested Sets).
209+
const out = {};
210+
for (const k of Object.keys(value)) {
211+
out[k] = normalizeFoundryValue(value[k], depth + 1);
212+
}
213+
return out;
214+
}
215+
148216
/**
149217
* Extract a collection-mapped field (e.g. abilities/inventory from actor.items[]).
150218
* Reads field.foundry_collection off the actor, optionally filters by
@@ -187,7 +255,7 @@ export function extractCollectionField(actor, field) {
187255
if (!first) return '';
188256
if (proj) {
189257
const firstPath = Object.values(proj)[0];
190-
return getNestedValue(first, firstPath) ?? '';
258+
return normalizeFoundryValue(getNestedValue(first, firstPath)) ?? '';
191259
}
192260
return first.name ?? '';
193261
}
@@ -196,7 +264,7 @@ export function extractCollectionField(actor, field) {
196264
if (!proj) return { id: it.id ?? null, name: it.name ?? null, type: it.type ?? null };
197265
const out = {};
198266
for (const [outKey, path] of Object.entries(proj)) {
199-
out[outKey] = getNestedValue(it, path) ?? null;
267+
out[outKey] = normalizeFoundryValue(getNestedValue(it, path)) ?? null;
200268
}
201269
return out;
202270
});
@@ -223,7 +291,14 @@ export function buildChronicleFields(actor, mappedFields) {
223291
if (field.foundry_collection) {
224292
result[field.key] = extractCollectionField(actor, field);
225293
} else {
226-
const value = getNestedValue(actor, field.foundry_path);
294+
let value = normalizeFoundryValue(getNestedValue(actor, field.foundry_path));
295+
// A Set/array/object scalar (e.g. system.skills.value, actor.statuses)
296+
// declared on a string/json field is serialized to a JSON string so it
297+
// arrives as parseable JSON, mirroring how collection fields are stored.
298+
if (value != null && typeof value === 'object'
299+
&& (field.type === 'string' || field.type === 'json')) {
300+
value = JSON.stringify(value);
301+
}
227302
result[field.key] = value ?? null;
228303
}
229304
}

scripts/sync-dashboard.mjs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
6666
'open-map-journal': SyncDashboard.#onOpenMapJournalAction,
6767
'resync-all-maps': SyncDashboard.#onResyncAllMapsAction,
6868
'resync-all-journals': SyncDashboard.#onResyncAllJournalsAction,
69+
'resync-everything': SyncDashboard.#onResyncEverythingAction,
6970
'open-maps-folder': SyncDashboard.#onOpenMapsFolderAction,
7071
'dismiss-map-errors': SyncDashboard.#onDismissMapErrorsAction,
7172
'pull-date': SyncDashboard.#onPullDateAction,
@@ -1469,6 +1470,11 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
14691470
await this._onResyncAllJournals();
14701471
}
14711472

1473+
/** Re-sync EVERYTHING: journals + every linked character + maps. */
1474+
static async #onResyncEverythingAction() {
1475+
await this._onResyncEverything();
1476+
}
1477+
14721478
/**
14731479
* Reveal the "Chronicle Maps" folder in Foundry's journal sidebar.
14741480
* Activates the journal tab and expands the folder.
@@ -2172,6 +2178,71 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
21722178
this.render({ force: true });
21732179
}
21742180

2181+
/**
2182+
* Re-sync everything that's already connected to Chronicle in one action:
2183+
* all journals, every LINKED character (re-pushes current field data — Foundry
2184+
* is source of truth for characters), and all maps. This is the Overview's
2185+
* "Sync Everything Now"; the previous button only did journals, which left
2186+
* actor field data (and inventory/notes) stale after a manifest/path change.
2187+
*
2188+
* Unlinked actors are intentionally NOT auto-created here — that's a heavier,
2189+
* entity-creating operation kept on the Characters tab's "Push All Actors".
2190+
* Each module's resyncAll / repushActor is the same proven path the per-tab
2191+
* buttons use; failures are isolated per item so one bad actor can't abort the
2192+
* sweep.
2193+
*/
2194+
async _onResyncEverything() {
2195+
let confirmed;
2196+
try {
2197+
confirmed = await confirmDialog({
2198+
title: 'Sync Everything Now',
2199+
content: '<p>Re-sync <strong>all</strong> journals, linked characters, and maps with Chronicle?</p>'
2200+
+ '<p class="hint">Foundry is the source of truth for characters — their current data is pushed up.</p>',
2201+
});
2202+
} catch {
2203+
return; // User dismissed the dialog.
2204+
}
2205+
if (!confirmed) return;
2206+
2207+
ui.notifications.info('Chronicle: Re-syncing everything…');
2208+
const mods = this._syncManager?._modules || [];
2209+
const find = (name) => mods.find((m) => m.constructor?.name === name);
2210+
const done = [];
2211+
2212+
// 1. Journals (verbose=false to avoid a flood of per-entry toasts mid-sweep).
2213+
const journalSync = find('JournalSync');
2214+
if (journalSync?.resyncAll) {
2215+
try { await journalSync.resyncAll({ verbose: false }); done.push('journals'); }
2216+
catch (err) { console.error('Chronicle: journal resync failed', err); }
2217+
}
2218+
2219+
// 2. Characters — re-push every LINKED actor so stale fields refresh.
2220+
const actorSync = find('ActorSync');
2221+
if (actorSync?.repushActor) {
2222+
const chars = actorSync.getSyncedActors?.() ?? [];
2223+
let pushed = 0;
2224+
for (const c of chars) {
2225+
if (!c.synced) continue; // unlinked → use "Push All Actors" (creates entities)
2226+
try { if (await actorSync.repushActor(c.id)) pushed++; }
2227+
catch (err) { console.error(`Chronicle: re-push failed for "${c.name}"`, err); }
2228+
}
2229+
if (pushed) done.push(`${pushed} character(s)`);
2230+
}
2231+
2232+
// 3. Maps.
2233+
const mapSync = find('MapSync');
2234+
if (mapSync?.resyncAll) {
2235+
try { await mapSync.resyncAll({ verbose: false }); done.push('maps'); }
2236+
catch (err) { console.error('Chronicle: map resync failed', err); }
2237+
}
2238+
2239+
this._cache.maps = null;
2240+
this._cache.entities = null;
2241+
this._logActivity('push', `Re-synced everything (${done.join(', ') || 'nothing'})`);
2242+
this.render({ force: true });
2243+
ui.notifications.info(`Chronicle: Re-sync complete — ${done.join(', ') || 'nothing to sync'}.`);
2244+
}
2245+
21752246
// ---------------------------------------------------------------------------
21762247
// Config actions
21772248
// ---------------------------------------------------------------------------

templates/sync-dashboard.hbs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,8 @@
138138
{{!-- Quick actions --}}
139139
<h3 class="section-heading">Quick Actions</h3>
140140
<div class="overview-actions">
141-
<button type="button" class="dashboard-btn btn-sm" data-action="resync-all-journals">
142-
<i class="fa-solid fa-rotate"></i> Sync Journals Now
141+
<button type="button" class="dashboard-btn btn-sm" data-action="resync-everything">
142+
<i class="fa-solid fa-rotate"></i> Sync Everything Now
143143
</button>
144144
<button type="button" class="dashboard-btn btn-sm" data-jump-tab="status">
145145
<i class="fa-solid fa-stethoscope"></i> Diagnostics

tools/test-generic-adapter.mjs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,25 @@
1212
import test from 'node:test';
1313
import assert from 'node:assert/strict';
1414

15-
const { getNestedValue, extractCollectionField, buildChronicleFields } =
15+
const { getNestedValue, extractCollectionField, buildChronicleFields, normalizeFoundryValue } =
1616
await import('../scripts/adapters/generic-adapter.mjs');
1717

18+
// ── Foundry-ish data structures for the normalizer tests ──────────────────
19+
// A Foundry Collection is a Map subclass exposing members via `.contents`.
20+
class FakeCollection extends Map {
21+
constructor(members) {
22+
super(members.map((m, i) => [m._id ?? String(i), m]));
23+
}
24+
get contents() { return Array.from(this.values()); }
25+
}
26+
// A pseudo-document / DataModel exposes data via toObject(), not own props —
27+
// the schema fields here are GETTERS so a naive key-copy would miss them.
28+
class FakeModel {
29+
constructor(source) { this._source = source; }
30+
get tier1() { return this._source.tier1; }
31+
toObject() { return JSON.parse(JSON.stringify(this._source)); }
32+
}
33+
1834
// A Foundry-ish actor with system data + an items collection (Map-like w/ .contents).
1935
function makeActor() {
2036
return {
@@ -134,3 +150,69 @@ test('buildChronicleFields sets null for a missing scalar path', () => {
134150
const out = buildChronicleFields(makeActor(), [{ key: 'speed', foundry_path: 'system.movement.speed', type: 'number' }]);
135151
assert.equal(out.speed, null);
136152
});
153+
154+
// ── normalizeFoundryValue ─────────────────────────────────────────────────
155+
156+
test('normalizeFoundryValue passes primitives and plain values through', () => {
157+
assert.equal(normalizeFoundryValue(7), 7);
158+
assert.equal(normalizeFoundryValue('hi'), 'hi');
159+
assert.equal(normalizeFoundryValue(null), null);
160+
assert.equal(normalizeFoundryValue(undefined), undefined);
161+
assert.deepEqual(normalizeFoundryValue([1, 2, 3]), [1, 2, 3]);
162+
assert.deepEqual(normalizeFoundryValue({ a: 1, b: 'x' }), { a: 1, b: 'x' });
163+
});
164+
165+
test('normalizeFoundryValue turns a Set into an array (keywords/skills/statuses)', () => {
166+
assert.deepEqual(normalizeFoundryValue(new Set(['magic', 'ranged'])), ['magic', 'ranged']);
167+
assert.deepEqual(normalizeFoundryValue(new Set()), []);
168+
});
169+
170+
test('normalizeFoundryValue normalizes nested Sets inside a plain object', () => {
171+
const v = normalizeFoundryValue({ type: 'damage', types: new Set(['fire']), n: 2 });
172+
assert.deepEqual(v, { type: 'damage', types: ['fire'], n: 2 });
173+
});
174+
175+
test('normalizeFoundryValue unrolls a Foundry Collection via .contents + toObject', () => {
176+
const effects = new FakeCollection([
177+
new FakeModel({ _id: 'a', type: 'damage', tier1: { value: '2 + @chr', types: ['fire'] } }),
178+
new FakeModel({ _id: 'b', type: 'damage', tier1: { value: '3', types: [] } }),
179+
]);
180+
const out = normalizeFoundryValue(effects);
181+
assert.ok(Array.isArray(out));
182+
assert.equal(out.length, 2);
183+
assert.deepEqual(out[0], { _id: 'a', type: 'damage', tier1: { value: '2 + @chr', types: ['fire'] } });
184+
assert.equal(out[1].tier1.value, '3');
185+
});
186+
187+
test('extractCollectionField projects a Set sub-field to a JSON array (ability keywords)', () => {
188+
const actor = {
189+
items: { contents: [
190+
{ id: 'i1', name: 'Ray of Wrath', type: 'ability',
191+
system: { type: 'main', category: 'signature', keywords: new Set(['magic', 'ranged']) } },
192+
] },
193+
};
194+
const json = extractCollectionField(actor, {
195+
key: 'abilities_json', type: 'string',
196+
foundry_collection: 'items', foundry_item_type: 'ability',
197+
foundry_item_fields: { name: 'name', type: 'system.type', keywords: 'system.keywords' },
198+
});
199+
const abilities = JSON.parse(json);
200+
assert.deepEqual(abilities[0], { name: 'Ray of Wrath', type: 'main', keywords: ['magic', 'ranged'] });
201+
});
202+
203+
test('buildChronicleFields serializes a Set scalar on a string field to JSON (skills)', () => {
204+
const actor = { system: { skills: { value: new Set(['alchemy', 'timescape']) } } };
205+
const out = buildChronicleFields(actor, [
206+
{ key: 'skills_json', foundry_path: 'system.skills.value', type: 'string' },
207+
]);
208+
assert.equal(typeof out.skills_json, 'string');
209+
assert.deepEqual(JSON.parse(out.skills_json), ['alchemy', 'timescape']);
210+
});
211+
212+
test('buildChronicleFields serializes actor.statuses (Set) for conditions', () => {
213+
const actor = { system: {}, statuses: new Set(['slowed', 'weakened']) };
214+
const out = buildChronicleFields(actor, [
215+
{ key: 'conditions_json', foundry_path: 'statuses', type: 'string' },
216+
]);
217+
assert.deepEqual(JSON.parse(out.conditions_json), ['slowed', 'weakened']);
218+
});

0 commit comments

Comments
 (0)