Skip to content

Commit b616563

Browse files
committed
Add multiplayer sync features: player notes, notes sync, map markers, conflict resolution, members
Implements the new Chronicle API capabilities for the Foundry VTT sync module: - Player notes: Separate player-visible journal page synced from entity.player_notes_html - Optimistic concurrency: expected_updated_at on entity PUTs with 409 conflict handling - Conflict resolution: Implements chronicle/foundry/newest strategies (previously unused setting) - Notes sync: New note-sync.mjs module for Chronicle Notes <-> Foundry JournalEntry sync - Map markers/pins: CRUD sync for Chronicle markers with pin_category icon mapping - Campaign members: Fetch and auto-match Chronicle members to Foundry users by name - Entity type enrichment: Folders colored/named from entity type metadata - Case normalization: camelCase/snake_case conversion for Notes API responses - Dashboard: Notes tab, notes sync direction config, updated localization https://claude.ai/code/session_018qBcuR4Wh8Vwa5rpGkktrb
1 parent 86d58a0 commit b616563

11 files changed

Lines changed: 1507 additions & 14 deletions

File tree

lang/en.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
"SyncCharacters": {
3434
"Name": "Sync Characters",
3535
"Hint": "Sync Foundry actors with Chronicle character entities (requires matching game system)"
36+
},
37+
"SyncNotes": {
38+
"Name": "Sync Notes",
39+
"Hint": "Sync Chronicle campaign notes with Foundry journal entries"
3640
}
3741
},
3842
"Status": {
@@ -46,6 +50,11 @@
4650
"JournalSynced": "Journal synced from Chronicle: {name}",
4751
"MapSynced": "Map synced from Chronicle: {name}",
4852
"SyncConflict": "Sync conflict detected for: {name}",
53+
"ConflictKeptChronicle": "Conflict on \"{name}\" — kept Chronicle version.",
54+
"ConflictKeptFoundry": "Conflict on \"{name}\" — kept Foundry version.",
55+
"ConflictNewerChronicle": "Conflict on \"{name}\" — Chronicle version was newer.",
56+
"ConflictNewerFoundry": "Conflict on \"{name}\" — Foundry version was newer.",
57+
"MembersMatched": "Auto-matched {count} Chronicle members to Foundry users.",
4958
"ConnectionLost": "Lost connection to Chronicle. Retrying...",
5059
"ConnectionRestored": "Connection to Chronicle restored"
5160
},
@@ -58,6 +67,7 @@
5867
"Entities": "Entities",
5968
"Shops": "Shops",
6069
"Maps": "Maps",
70+
"Notes": "Notes",
6171
"Characters": "Characters",
6272
"Calendar": "Calendar",
6373
"Status": "Status"
@@ -113,6 +123,17 @@
113123
"NoModuleHint": "Install Calendaria or Simple Calendar to enable calendar sync.",
114124
"UnableToRead": "Unable to read"
115125
},
126+
"Notes": {
127+
"Synced": "Synced",
128+
"ChronicleOnly": "Chronicle Only",
129+
"Pull": "Pull",
130+
"Shared": "Shared",
131+
"Private": "Private",
132+
"NoNotes": "No Chronicle notes found.",
133+
"NoNotesHint": "Notes created in Chronicle will appear here when note sync is enabled.",
134+
"Disabled": "Note sync is disabled.",
135+
"DisabledHint": "Enable \"Sync Notes\" in Module Settings."
136+
},
116137
"Characters": {
117138
"Synced": "Synced",
118139
"NotLinked": "Not linked",

scripts/actor-sync.mjs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import { getSetting } from './settings.mjs';
18+
import { ConflictError } from './api-client.mjs';
1819
import { createGenericAdapter } from './adapters/generic-adapter.mjs';
1920

2021
// Flag namespace for Chronicle data stored on Foundry documents.
@@ -276,8 +277,11 @@ export class ActorSync {
276277
);
277278
}
278279

279-
// Update sync timestamp.
280+
// Update sync timestamp and Chronicle updated_at for conflict detection.
280281
await actor.setFlag(FLAG_SCOPE, 'lastSync', new Date().toISOString());
282+
if (entity.updated_at) {
283+
await actor.setFlag(FLAG_SCOPE, 'chronicleUpdatedAt', entity.updated_at);
284+
}
281285

282286
console.debug(`Chronicle: Updated actor "${actor.name}" from entity`);
283287
} catch (err) {
@@ -413,14 +417,45 @@ export class ActorSync {
413417

414418
try {
415419
const fields = this._adapter.toChronicleFields(actor);
416-
const updatePayload = { fields_data: fields };
417-
if (change.name) updatePayload.name = change.name;
418420

419421
await this._api.put(`/entities/${entityId}/fields`, { fields_data: fields });
420422

421-
// Update name separately if changed.
423+
// Update name separately if changed, with conflict detection.
422424
if (change.name) {
423-
await this._api.put(`/entities/${entityId}`, { name: change.name });
425+
const nameBody = { name: change.name };
426+
const chronicleUpdatedAt = actor.getFlag(FLAG_SCOPE, 'chronicleUpdatedAt');
427+
if (chronicleUpdatedAt) {
428+
nameBody.expected_updated_at = chronicleUpdatedAt;
429+
}
430+
431+
try {
432+
const result = await this._api.put(`/entities/${entityId}`, nameBody);
433+
if (result?.updated_at) {
434+
this._syncing = true;
435+
try {
436+
await actor.setFlag(FLAG_SCOPE, 'chronicleUpdatedAt', result.updated_at);
437+
} finally {
438+
this._syncing = false;
439+
}
440+
}
441+
} catch (err) {
442+
if (err instanceof ConflictError) {
443+
const strategy = getSetting('conflictResolution');
444+
if (strategy === 'chronicle') {
445+
// Re-pull from Chronicle.
446+
const entity = await this._api.get(`/entities/${entityId}`);
447+
if (entity) await this._updateActorFromEntity(actor, entity);
448+
ui.notifications.warn(`Chronicle: Conflict on "${actor.name}" — kept Chronicle version.`);
449+
} else {
450+
// Force push.
451+
delete nameBody.expected_updated_at;
452+
await this._api.put(`/entities/${entityId}`, nameBody);
453+
ui.notifications.warn(`Chronicle: Conflict on "${actor.name}" — kept Foundry version.`);
454+
}
455+
return;
456+
}
457+
throw err;
458+
}
424459
}
425460

426461
try {

scripts/api-client.mjs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,67 @@
99

1010
import { getSetting } from './settings.mjs';
1111

12+
/**
13+
* Error thrown when a PUT/POST request conflicts with a concurrent edit.
14+
* The Chronicle API returns 409 when `expected_updated_at` is stale.
15+
*/
16+
export class ConflictError extends Error {
17+
/**
18+
* @param {object} data - Parsed response body from the 409 response.
19+
*/
20+
constructor(data) {
21+
super(data?.message || 'Entity was modified by another user');
22+
this.name = 'ConflictError';
23+
this.status = 409;
24+
this.data = data;
25+
}
26+
}
27+
28+
// --- Case conversion helpers for Notes API ---
29+
// Notes responses use camelCase keys; requests use snake_case.
30+
31+
/**
32+
* Convert an object's keys from camelCase to snake_case (shallow).
33+
* @param {object} obj
34+
* @returns {object}
35+
*/
36+
function camelToSnake(obj) {
37+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
38+
const result = {};
39+
for (const [key, value] of Object.entries(obj)) {
40+
const snakeKey = key.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
41+
result[snakeKey] = value;
42+
}
43+
return result;
44+
}
45+
46+
/**
47+
* Convert an object's keys from snake_case to camelCase (shallow).
48+
* @param {object} obj
49+
* @returns {object}
50+
*/
51+
function snakeToCamel(obj) {
52+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj;
53+
const result = {};
54+
for (const [key, value] of Object.entries(obj)) {
55+
const camelKey = key.replace(/_([a-z])/g, (_, ch) => ch.toUpperCase());
56+
result[camelKey] = value;
57+
}
58+
return result;
59+
}
60+
61+
/**
62+
* Normalize a Notes API response: convert camelCase keys to snake_case
63+
* so internal code uses a consistent key format.
64+
* Handles both single objects and arrays.
65+
* @param {object|Array} data
66+
* @returns {object|Array}
67+
*/
68+
function normalizeNoteResponse(data) {
69+
if (Array.isArray(data)) return data.map(camelToSnake);
70+
return camelToSnake(data);
71+
}
72+
1273
/**
1374
* ChronicleAPI handles all communication with the Chronicle backend.
1475
* Combines REST fetch calls and a persistent WebSocket connection.
@@ -116,6 +177,14 @@ export class ChronicleAPI {
116177
this.health.restErrorCount++;
117178
this.health.lastRestError = Date.now();
118179
this._logError('error', method, path, response.status, errorBody);
180+
181+
// Throw ConflictError for 409 so callers can handle optimistic concurrency.
182+
if (response.status === 409) {
183+
let data;
184+
try { data = JSON.parse(errorBody); } catch { data = { message: errorBody }; }
185+
throw new ConflictError(data);
186+
}
187+
119188
throw new Error(`Chronicle API error ${response.status}: ${errorBody}`);
120189
}
121190

@@ -194,6 +263,55 @@ export class ChronicleAPI {
194263
return this.fetch(path, { method: 'DELETE' });
195264
}
196265

266+
// --- Notes API (camelCase ↔ snake_case normalization) ---
267+
268+
/**
269+
* GET notes from the Chronicle API, normalizing camelCase response keys to snake_case.
270+
* @param {string} path - API path (e.g., '/notes').
271+
* @returns {Promise<any>} Normalized response.
272+
*/
273+
async getNotes(path) {
274+
const data = await this.get(path);
275+
if (!data) return data;
276+
// Handle { data: [...] } wrapper or plain array/object.
277+
if (data.data) {
278+
return { ...data, data: normalizeNoteResponse(data.data) };
279+
}
280+
return normalizeNoteResponse(data);
281+
}
282+
283+
/**
284+
* POST a note, converting snake_case request body to snake_case (already native).
285+
* Normalizes the camelCase response.
286+
* @param {string} path
287+
* @param {object} body - Request body in snake_case.
288+
* @returns {Promise<any>}
289+
*/
290+
async postNote(path, body) {
291+
const data = await this.post(path, body);
292+
return data ? normalizeNoteResponse(data) : data;
293+
}
294+
295+
/**
296+
* PUT a note, normalizing the camelCase response.
297+
* @param {string} path
298+
* @param {object} body - Request body in snake_case.
299+
* @returns {Promise<any>}
300+
*/
301+
async putNote(path, body) {
302+
const data = await this.put(path, body);
303+
return data ? normalizeNoteResponse(data) : data;
304+
}
305+
306+
/**
307+
* DELETE a note.
308+
* @param {string} path
309+
* @returns {Promise<any>}
310+
*/
311+
async deleteNote(path) {
312+
return this.delete(path);
313+
}
314+
197315
/**
198316
* Upload a file to the Chronicle media API.
199317
* @param {File|Blob} file

0 commit comments

Comments
 (0)