Skip to content

Commit 6df2e84

Browse files
committed
feat: Resync All Journals button — update existing journals incl. permissions
Add a "Resync All Journals" action that re-pulls every Chronicle entity and applies it to Foundry: updating existing journals (name, content, AND permissions via _buildOwnership) as well as creating missing ones. Unlike "Pull All" which only creates journals for chronicle-only entities, resyncAll() fixes journals with stale permissions — the gap users hit after changing Chronicle visibility settings. Mirrors the MapSync.resyncAll / resync-all-maps pattern exactly: - JournalSync.resyncAll({verbose}) — paginated entity fetch, sequential update-or-create loop, honors _isExcluded / _isHandledByActorSync / _syncing guard, returns {updated,created,skipped,errors}. - Dashboard action 'resync-all-journals' with confirm dialog. - "Resync All Journals" button in the Entities tab toolbar. - Lang keys CHRONICLE.Dashboard.Entities.ResyncAllJournals/.Hint. - 12 new node:test tests in tools/test-journal-resync-all.mjs. Full suite: 497 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y14Y6VXb7iB2xcNrYNDJa5
1 parent f7cd718 commit 6df2e84

5 files changed

Lines changed: 562 additions & 1 deletion

File tree

lang/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@
111111
"PublicClickPrivate": "Public — click to make private",
112112
"EmptyState": "No entities found. Create entities in Chronicle or journals in Foundry to get started.",
113113
"CreateType": "Create Type",
114-
"CreateTypeTitle": "Create Entity Type"
114+
"CreateTypeTitle": "Create Entity Type",
115+
"ResyncAllJournals": "Resync All Journals",
116+
"ResyncAllJournalsHint": "Re-fetch every entity from Chronicle and update existing journals, including their permissions"
115117
},
116118
"Bulk": {
117119
"Selected": "selected",

scripts/journal-sync.mjs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,138 @@ export class JournalSync {
153153
await this.cleanupActorJournalDuplicates();
154154
}
155155

156+
/**
157+
* Re-fetch every Chronicle entity and apply it to Foundry. Unlike `_onPullAll`
158+
* (which only creates journals for chronicle-only entities), this method also
159+
* UPDATES existing journals — refreshing content, name, and ownership/permissions.
160+
* This is the correct fix for journals that synced before a permission change.
161+
*
162+
* Mirrors MapSync.resyncAll / _runMapSync in structure:
163+
* - Paginated fetch of all entities (same source _prepareContext/_buildEntityGroups uses).
164+
* - For each entity: if a journal already exists → call `_onEntityUpdated` (which
165+
* re-runs `_buildOwnership` so permissions refresh); if none exists → create it.
166+
* - Respects `_syncing` guard (set by callers), `_isExcluded`, `_isHandledByActorSync`,
167+
* `isCalendarNoteJournal`, and `_isHandledByNoteSync`.
168+
* - Sequential/awaited (not parallelized) so a large campaign doesn't hammer the API.
169+
* - Returns a summary {updated, created, skipped, errors} for test assertions; also
170+
* fires a verbose `ui.notifications.info` when the option is set.
171+
*
172+
* @param {{verbose?: boolean}} [opts]
173+
* @returns {Promise<{updated: number, created: number, skipped: number, errors: number}>}
174+
*/
175+
async resyncAll({ verbose = true } = {}) {
176+
if (!game.user.isGM || !this._api) {
177+
return { updated: 0, created: 0, skipped: 0, errors: 0 };
178+
}
179+
if (!getSetting('syncJournals')) {
180+
return { updated: 0, created: 0, skipped: 0, errors: 0 };
181+
}
182+
183+
if (verbose) ui.notifications.info('Chronicle: fetching entities for resync…');
184+
185+
// Paginated fetch — mirrors the dashboard's _buildEntityGroups page loop.
186+
let allEntities = [];
187+
try {
188+
let page = 1;
189+
let hasMore = true;
190+
while (hasMore && page <= 5) {
191+
const result = await this._api.get(`/entities?per_page=100&page=${page}`);
192+
const entities = Array.isArray(result) ? result
193+
: (Array.isArray(result?.entities) ? result.entities
194+
: (Array.isArray(result?.data) ? result.data : []));
195+
if (entities.length > 0) {
196+
allEntities.push(...entities);
197+
hasMore = entities.length === 100;
198+
page++;
199+
} else {
200+
hasMore = false;
201+
}
202+
}
203+
} catch (err) {
204+
const status = err?.status || null;
205+
console.warn(`Chronicle JournalSync.resyncAll: GET /entities failed (${status || 'network'})`, err);
206+
ui.notifications.error(
207+
`Chronicle: could not fetch entities from Chronicle${status ? ` (HTTP ${status})` : ''}. Verify apiUrl, apiKey, and campaignId in Module Settings.`
208+
);
209+
return { updated: 0, created: 0, skipped: 0, errors: 1 };
210+
}
211+
212+
console.debug(`Chronicle: resyncAll fetched ${allEntities.length} entity(ies).`);
213+
214+
// Build a fast lookup of already-linked journals by chronicle entity id.
215+
const journalByEntityId = new Map();
216+
for (const j of game.journal.contents) {
217+
const eid = j.getFlag(FLAG_SCOPE, 'entityId');
218+
if (eid) journalByEntityId.set(eid, j);
219+
}
220+
221+
let updated = 0;
222+
let created = 0;
223+
let skipped = 0;
224+
let errors = 0;
225+
226+
for (const entity of allEntities) {
227+
if (!entity?.id) { skipped++; continue; }
228+
229+
// Skip: excluded by dashboard settings.
230+
if (this._isExcluded(entity)) { skipped++; continue; }
231+
232+
// Skip: character entities are handled by ActorSync.
233+
if (this._isHandledByActorSync(entity)) { skipped++; continue; }
234+
235+
const journal = journalByEntityId.get(entity.id);
236+
237+
try {
238+
if (journal) {
239+
// Journal exists → fetch full entity data and update (refreshes
240+
// content + re-runs _buildOwnership so permissions are current).
241+
let fullEntity = entity;
242+
try {
243+
fullEntity = (await this._api.get(`/entities/${entity.id}`)) || entity;
244+
} catch (fetchErr) {
245+
console.warn(`Chronicle: resyncAll — failed to fetch full entity ${entity.id}, updating with summary`, fetchErr);
246+
}
247+
// _onEntityUpdated skips character entities again via _isHandledByActorSync,
248+
// but the check above already guards against that; calling it handles the
249+
// update path (name, content pages, ownership) cleanly.
250+
await this._onEntityUpdated(fullEntity);
251+
updated++;
252+
} else {
253+
// No journal yet → fetch full entity and create.
254+
let fullEntity = entity;
255+
try {
256+
fullEntity = (await this._api.get(`/entities/${entity.id}`)) || entity;
257+
} catch (fetchErr) {
258+
console.warn(`Chronicle: resyncAll — failed to fetch full entity ${entity.id}, creating with summary`, fetchErr);
259+
}
260+
// Double-check actor routing on the full payload.
261+
if (this._isHandledByActorSync(fullEntity)) { skipped++; continue; }
262+
await this._createJournalFromEntity(fullEntity);
263+
created++;
264+
}
265+
} catch (err) {
266+
errors++;
267+
console.warn(`Chronicle: resyncAll failed for entity "${entity.name || entity.id}":`, err);
268+
}
269+
}
270+
271+
console.debug(`Chronicle: resyncAll complete — updated=${updated} created=${created} skipped=${skipped} errors=${errors}`);
272+
273+
if (verbose) {
274+
if (errors > 0) {
275+
ui.notifications.warn(
276+
`Chronicle: resynced ${updated + created} of ${allEntities.length - skipped} entity(ies); ${errors} failed. See the sync dashboard for details.`
277+
);
278+
} else {
279+
ui.notifications.info(
280+
`Chronicle: resynced ${updated + created} entity(ies) (${updated} updated, ${created} created).`
281+
);
282+
}
283+
}
284+
285+
return { updated, created, skipped, errors };
286+
}
287+
156288
/**
157289
* Clean up hooks on destroy.
158290
*/

scripts/sync-dashboard.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
5454
'toggle-visibility': SyncDashboard.#onToggleVisibilityAction,
5555
'open-map-journal': SyncDashboard.#onOpenMapJournalAction,
5656
'resync-all-maps': SyncDashboard.#onResyncAllMapsAction,
57+
'resync-all-journals': SyncDashboard.#onResyncAllJournalsAction,
5758
'open-maps-folder': SyncDashboard.#onOpenMapsFolderAction,
5859
'dismiss-map-errors': SyncDashboard.#onDismissMapErrorsAction,
5960
'pull-date': SyncDashboard.#onPullDateAction,
@@ -1203,6 +1204,11 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
12031204
this.render({ force: true });
12041205
}
12051206

1207+
/** Trigger a verbose full journal resync (updates existing + creates missing). */
1208+
static async #onResyncAllJournalsAction() {
1209+
await this._onResyncAllJournals();
1210+
}
1211+
12061212
/**
12071213
* Reveal the "Chronicle Maps" folder in Foundry's journal sidebar.
12081214
* Activates the journal tab and expands the folder.
@@ -1513,6 +1519,36 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
15131519
}
15141520
}
15151521

1522+
/**
1523+
* Re-fetch every Chronicle entity and apply it to Foundry, updating existing
1524+
* journals (name, content, AND permissions) as well as creating missing ones.
1525+
* This is the correct fix for journals with stale permissions.
1526+
* @private
1527+
*/
1528+
async _onResyncAllJournals() {
1529+
let confirmed;
1530+
try {
1531+
confirmed = await Dialog.confirm({
1532+
title: game.i18n.localize('CHRONICLE.Dashboard.Entities.ResyncAllJournals'),
1533+
content: `<p>${game.i18n.localize('CHRONICLE.Dashboard.Entities.ResyncAllJournalsHint')}</p>`,
1534+
});
1535+
} catch {
1536+
return; // User closed dialog.
1537+
}
1538+
if (!confirmed) return;
1539+
1540+
const journalSync = this._syncManager?._modules?.find(
1541+
(m) => m.constructor?.name === 'JournalSync'
1542+
);
1543+
if (!journalSync) {
1544+
ui.notifications.warn('Chronicle: JournalSync is not running.');
1545+
return;
1546+
}
1547+
await journalSync.resyncAll({ verbose: true });
1548+
this._cache.entities = null;
1549+
this.render({ force: true });
1550+
}
1551+
15161552
/**
15171553
* Pull all Chronicle-only entities.
15181554
* @private

templates/sync-dashboard.hbs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@
227227
<i class="fa-solid fa-upload"></i> Push All
228228
</button>
229229
{{/if}}
230+
<button type="button" data-action="resync-all-journals" class="dashboard-btn btn-sm"
231+
title="{{localize 'CHRONICLE.Dashboard.Entities.ResyncAllJournalsHint'}}">
232+
<i class="fa-solid fa-rotate"></i> {{localize "CHRONICLE.Dashboard.Entities.ResyncAllJournals"}}
233+
</button>
230234
<button type="button" data-action="create-entity-type" class="dashboard-btn btn-sm"
231235
title="{{localize "CHRONICLE.Dashboard.Entities.CreateType"}}">
232236
<i class="fa-solid fa-plus"></i> {{localize "CHRONICLE.Dashboard.Entities.CreateType"}}

0 commit comments

Comments
 (0)