Skip to content

Commit 6b77be0

Browse files
authored
Merge pull request #70 from keyxmakerx/claude/gallant-johnson-kd574n
feat: sync issue resolver — recover broken/missing character links + re-sync stranded data
2 parents 18300f6 + 7940c48 commit 6b77be0

4 files changed

Lines changed: 367 additions & 0 deletions

File tree

scripts/actor-sync.mjs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ export class ActorSync {
195195
}
196196
// Don't auto-create actors during initial sync — only update existing links.
197197
}
198+
199+
// Surface unresolved character links (broken / missing) so the GM can
200+
// fix them in the dashboard's Issues tab rather than silently desyncing.
201+
await this._notifySyncIssues();
198202
} catch (err) {
199203
console.warn('Chronicle: Actor initial sync failed', err);
200204
}
@@ -754,6 +758,182 @@ export class ActorSync {
754758
return a.name.localeCompare(b.name);
755759
});
756760
}
761+
762+
// ---------------------------------------------------------------------------
763+
// Sync issue resolver — recover broken / missing character links and re-push
764+
// stranded data (Foundry → Chronicle). See sync-dashboard "Issues" tab.
765+
// ---------------------------------------------------------------------------
766+
767+
/**
768+
* The candidate pool for matching: every Chronicle character entity (the
769+
* system character type plus the PC sub-type, the same set onInitialSync walks).
770+
* @returns {Promise<Array<{id:string,name:string,entity_type_id?:number}>>}
771+
* @private
772+
*/
773+
async _listCharacterEntities() {
774+
if (!this._characterTypeId) return [];
775+
const typeIds = [this._characterTypeId];
776+
if (this._pcSubtypeId) typeIds.push(this._pcSubtypeId);
777+
const out = [];
778+
for (const typeId of typeIds) {
779+
try {
780+
const result = await this._api.get(`/entities?type_id=${typeId}&per_page=100`);
781+
out.push(...(result?.data || []));
782+
} catch (err) {
783+
console.warn('Chronicle: failed to list character entities', err);
784+
}
785+
}
786+
return out;
787+
}
788+
789+
/**
790+
* Whether a Chronicle entity still exists. Only a definite 404 counts as
791+
* "gone"; ambiguous/transient errors return true so a flaky network never
792+
* mislabels a healthy link as broken.
793+
* @param {string} entityId
794+
* @returns {Promise<boolean>}
795+
* @private
796+
*/
797+
async _entityExists(entityId) {
798+
try {
799+
await this._api.get(`/entities/${entityId}`);
800+
return true;
801+
} catch (err) {
802+
const status = err?.status ?? err?.statusCode ?? err?.response?.status;
803+
if (status === 404 || /\b404\b|not found/i.test(err?.message || '')) return false;
804+
return true;
805+
}
806+
}
807+
808+
/**
809+
* Best name match for an actor among candidate entities: exact
810+
* (case-insensitive), then prefix, then substring. Null when nothing is a
811+
* confident match (the resolver then defaults to "Create new").
812+
* @param {string} actorName
813+
* @param {Array<{id:string,name:string}>} entities
814+
* @returns {{id:string,name:string}|null}
815+
* @private
816+
*/
817+
_suggestMatch(actorName, entities) {
818+
const n = (actorName || '').trim().toLowerCase();
819+
if (!n) return null;
820+
const norm = (e) => (e.name || '').trim().toLowerCase();
821+
let m = entities.find((e) => norm(e) === n);
822+
if (m) return m;
823+
m = entities.find((e) => { const en = norm(e); return en && (en.startsWith(n) || n.startsWith(en)); });
824+
if (m) return m;
825+
m = entities.find((e) => { const en = norm(e); return en && (en.includes(n) || n.includes(en)); });
826+
return m || null;
827+
}
828+
829+
/**
830+
* Scan character actors for issues the resolver can fix:
831+
* - 'unlinked': no entityId flag (never linked)
832+
* - 'broken': entityId flag points at an entity that 404s — "failed to
833+
* find existing character" (linked once, now gone)
834+
* Linked + valid actors aren't issues (re-pushing their data is the explicit
835+
* repushActor action). Each issue carries a name-matched suggestion, and the
836+
* result includes the full candidate list so the UI can prefill + offer a pick.
837+
* @returns {Promise<{issues:Array, candidates:Array<{id:string,name:string}>}>}
838+
*/
839+
async getSyncIssues() {
840+
if (!this._adapter || !this._characterTypeId) return { issues: [], candidates: [] };
841+
const entities = await this._listCharacterEntities();
842+
const ids = new Set(entities.map((e) => e.id));
843+
const issues = [];
844+
for (const actor of game.actors.contents) {
845+
if (actor.type !== this._actorType) continue;
846+
const entityId = actor.getFlag(FLAG_SCOPE, 'entityId') || null;
847+
let kind = null;
848+
if (!entityId) {
849+
kind = 'unlinked';
850+
} else if (!ids.has(entityId) && !(await this._entityExists(entityId))) {
851+
kind = 'broken';
852+
}
853+
if (!kind) continue;
854+
const suggestion = this._suggestMatch(actor.name, entities);
855+
issues.push({ actorId: actor.id, name: actor.name, img: actor.img, kind, suggestionId: suggestion?.id || null });
856+
}
857+
return {
858+
issues,
859+
candidates: entities.map((e) => ({ id: e.id, name: e.name }))
860+
.sort((a, b) => (a.name || '').localeCompare(b.name || '')),
861+
};
862+
}
863+
864+
/**
865+
* If any character actors can't be matched to Chronicle (broken / missing
866+
* links), nudge the GM toward the dashboard's Issues tab. Quiet when all clear.
867+
* @private
868+
*/
869+
async _notifySyncIssues() {
870+
try {
871+
const { issues } = await this.getSyncIssues();
872+
if (!issues.length) return;
873+
const n = issues.length;
874+
ui.notifications?.warn(
875+
`Chronicle Sync: ${n} character${n === 1 ? '' : 's'} need attention — open the Chronicle dashboard (Issues) to match or create.`
876+
);
877+
console.warn(`Chronicle: ${n} character sync issue(s) detected`, issues);
878+
} catch (err) {
879+
console.warn('Chronicle: issue check failed', err);
880+
}
881+
}
882+
883+
/**
884+
* Push a LINKED actor's current fields to its Chronicle entity. Fixes the
885+
* "stranded data on a valid link" case (actor edited while the module was
886+
* offline, so updateActor never fired).
887+
* @param {string} actorId
888+
* @returns {Promise<boolean>}
889+
*/
890+
async repushActor(actorId) {
891+
const actor = game.actors.get(actorId);
892+
const entityId = actor?.getFlag(FLAG_SCOPE, 'entityId');
893+
if (!actor || !entityId || !this._adapter) return false;
894+
await this._api.put(`/entities/${entityId}/fields`, { fields_data: this._adapter.toChronicleFields(actor) });
895+
this._syncing = true;
896+
try { await actor.setFlag(FLAG_SCOPE, 'lastSync', new Date().toISOString()); }
897+
finally { this._syncing = false; }
898+
console.debug(`Chronicle: Re-pushed actor "${actor.name}" → entity ${entityId}`);
899+
return true;
900+
}
901+
902+
/**
903+
* Link an orphaned actor to an EXISTING Chronicle entity, then push the
904+
* actor's current data up (Foundry is source of truth for characters).
905+
* @param {string} actorId
906+
* @param {string} entityId
907+
* @returns {Promise<boolean>}
908+
*/
909+
async matchActorToEntity(actorId, entityId) {
910+
const actor = game.actors.get(actorId);
911+
if (!actor || !entityId || !this._adapter) return false;
912+
this._syncing = true;
913+
try { await actor.setFlag(FLAG_SCOPE, 'entityId', entityId); }
914+
finally { this._syncing = false; }
915+
return this.repushActor(actorId);
916+
}
917+
918+
/**
919+
* Create a NEW Chronicle entity of the SYSTEM'S character type for an orphaned
920+
* actor and link it. Clears any stale/broken link first so _handleCreateActor
921+
* creates fresh instead of skipping an "already linked" actor.
922+
* @param {string} actorId
923+
* @returns {Promise<boolean>}
924+
*/
925+
async createEntityForActor(actorId) {
926+
const actor = game.actors.get(actorId);
927+
if (!actor) return false;
928+
if (actor.getFlag(FLAG_SCOPE, 'entityId')) {
929+
this._syncing = true;
930+
try { await actor.unsetFlag(FLAG_SCOPE, 'entityId'); }
931+
finally { this._syncing = false; }
932+
}
933+
await this._handleCreateActor(actor, {}, game.user.id);
934+
return !!actor.getFlag(FLAG_SCOPE, 'entityId');
935+
}
936+
757937
/**
758938
* Return true when any actor of the character type has a non-GM Foundry
759939
* owner. Used by the dashboard to surface the PC-claiming hint when the

scripts/sync-dashboard.mjs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
7777
'bulk-delete': SyncDashboard.#onBulkDeleteAction,
7878
'create-entity-type': SyncDashboard.#onCreateEntityTypeAction,
7979
'refresh-members': SyncDashboard.#onRefreshMembersAction,
80+
'resolve-match': SyncDashboard.#onResolveMatchAction,
81+
'resolve-create': SyncDashboard.#onResolveCreateAction,
82+
'resolve-repush': SyncDashboard.#onResolveRepushAction,
8083
},
8184
};
8285

@@ -216,6 +219,15 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
216219
// Build characters tab data.
217220
const characterData = this._buildCharacterData();
218221

222+
// Build sync-issues (resolver) tab data.
223+
let issuesData = { issues: [], hasIssues: false, count: 0, headline: '' };
224+
try {
225+
issuesData = await this._buildIssuesData();
226+
} catch (err) {
227+
console.error('Chronicle Dashboard: Failed to load sync issues', err);
228+
loadErrors.push({ tab: 'issues', message: err.message || 'Failed to load issues' });
229+
}
230+
219231
// Build members (permission mapping) tab data.
220232
const membersData = this._buildMembersData();
221233

@@ -232,6 +244,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
232244
loadErrors,
233245
hasLoadErrors: loadErrors.length > 0,
234246

247+
// Issues (resolver) tab.
248+
issues: issuesData,
249+
235250
// Config tab.
236251
config: configData,
237252

@@ -686,6 +701,39 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
686701
return { characters, hasUnlinkedCharacters, pcClaimingHint };
687702
}
688703

704+
/** @returns {object|null} the live ActorSync module, if active. @private */
705+
_actorSync() {
706+
return this._syncManager?._modules?.find((m) => m.constructor?.name === 'ActorSync') || null;
707+
}
708+
709+
/**
710+
* Build the Issues (resolver) tab: character actors that can't be matched to
711+
* Chronicle (unlinked, or linked to a now-missing entity). Each row carries a
712+
* name-matched suggestion preselected + the full candidate list. Async — it
713+
* reaches Chronicle to verify links and list candidates.
714+
* @returns {Promise<object>}
715+
* @private
716+
*/
717+
async _buildIssuesData() {
718+
const actorSync = this._actorSync();
719+
if (!actorSync?.getSyncIssues) return { issues: [], hasIssues: false, count: 0, headline: '' };
720+
const { issues, candidates } = await actorSync.getSyncIssues();
721+
const decorated = issues.map((iss) => ({
722+
actorId: iss.actorId,
723+
name: iss.name,
724+
img: iss.img,
725+
label: iss.kind === 'broken' ? 'Failed to find existing character' : 'Not linked to Chronicle',
726+
options: candidates.map((c) => ({ id: c.id, name: c.name, selected: c.id === iss.suggestionId })),
727+
}));
728+
const count = decorated.length;
729+
return {
730+
issues: decorated,
731+
hasIssues: count > 0,
732+
count,
733+
headline: `${count} character${count === 1 ? '' : 's'} couldn't be matched to Chronicle — match each to an existing character, or create a new one.`,
734+
};
735+
}
736+
689737
// ---------------------------------------------------------------------------
690738
// Members (permission mapping) data
691739
// ---------------------------------------------------------------------------
@@ -1327,6 +1375,21 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
13271375
this._onPushAllActors();
13281376
}
13291377

1378+
/** Resolver: link an orphaned actor to the row's selected existing entity. */
1379+
static #onResolveMatchAction(event, target) {
1380+
return this._onResolveMatch(target);
1381+
}
1382+
1383+
/** Resolver: create a new Chronicle character (the system's type) for an orphan. */
1384+
static #onResolveCreateAction(event, target) {
1385+
return this._onResolveCreate(target);
1386+
}
1387+
1388+
/** Resolver / Characters: re-push a linked actor's current data to Chronicle. */
1389+
static #onResolveRepushAction(event, target) {
1390+
return this._onResolveRepush(target);
1391+
}
1392+
13301393
/** Test connection to Chronicle using current config field values. */
13311394
static #onTestConnectionAction() {
13321395
this._onTestConnection();
@@ -1713,6 +1776,56 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) {
17131776
* Shows a confirmation dialog before proceeding.
17141777
* @private
17151778
*/
1779+
/** Link an orphaned actor (button's data-actor-id) to the row's selected entity. */
1780+
async _onResolveMatch(target) {
1781+
const actorId = target?.dataset?.actorId;
1782+
const row = target?.closest?.('.issue-row');
1783+
const entityId = row?.querySelector?.('.issue-match-select')?.value;
1784+
if (!actorId || !entityId) { ui.notifications.warn('Chronicle: Pick a character to match to first.'); return; }
1785+
const actorSync = this._actorSync();
1786+
if (!actorSync) { ui.notifications.warn('Chronicle: Character sync not active.'); return; }
1787+
try {
1788+
const ok = await actorSync.matchActorToEntity(actorId, entityId);
1789+
ui.notifications[ok ? 'info' : 'warn'](ok ? 'Chronicle: Matched and synced.' : 'Chronicle: Match failed.');
1790+
} catch (err) {
1791+
console.error('Chronicle Dashboard: match failed', err);
1792+
ui.notifications.error('Chronicle: Match failed — see console.');
1793+
}
1794+
this.render({ force: true });
1795+
}
1796+
1797+
/** Create a new Chronicle character (the system's type) for an orphaned actor. */
1798+
async _onResolveCreate(target) {
1799+
const actorId = target?.dataset?.actorId;
1800+
if (!actorId) return;
1801+
const actorSync = this._actorSync();
1802+
if (!actorSync) { ui.notifications.warn('Chronicle: Character sync not active.'); return; }
1803+
try {
1804+
const ok = await actorSync.createEntityForActor(actorId);
1805+
ui.notifications[ok ? 'info' : 'warn'](ok ? 'Chronicle: Created and linked.' : 'Chronicle: Create failed.');
1806+
} catch (err) {
1807+
console.error('Chronicle Dashboard: create failed', err);
1808+
ui.notifications.error('Chronicle: Create failed — see console.');
1809+
}
1810+
this.render({ force: true });
1811+
}
1812+
1813+
/** Re-push a linked actor's current data to Chronicle (the stranded-data fix). */
1814+
async _onResolveRepush(target) {
1815+
const actorId = target?.dataset?.actorId;
1816+
if (!actorId) return;
1817+
const actorSync = this._actorSync();
1818+
if (!actorSync) { ui.notifications.warn('Chronicle: Character sync not active.'); return; }
1819+
try {
1820+
const ok = await actorSync.repushActor(actorId);
1821+
ui.notifications[ok ? 'info' : 'warn'](ok ? 'Chronicle: Re-synced to Chronicle.' : 'Chronicle: Re-sync failed.');
1822+
} catch (err) {
1823+
console.error('Chronicle Dashboard: re-sync failed', err);
1824+
ui.notifications.error('Chronicle: Re-sync failed — see console.');
1825+
}
1826+
this.render({ force: true });
1827+
}
1828+
17161829
async _onPushAllActors() {
17171830
const actorSync = this._syncManager?._modules?.find(
17181831
(m) => m.constructor?.name === 'ActorSync'

styles/chronicle-sync.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,6 +1490,19 @@
14901490
flex-shrink: 0;
14911491
}
14921492

1493+
/* --- Issues (character link resolver) tab --- */
1494+
.issue-row .issue-actions {
1495+
flex-wrap: wrap;
1496+
justify-content: flex-end;
1497+
gap: 4px;
1498+
max-width: 62%;
1499+
}
1500+
.issue-match-select {
1501+
max-width: 170px;
1502+
font-size: 0.8rem;
1503+
padding: 2px 4px;
1504+
}
1505+
14931506
/* --- Members (permission mapping) tab --- */
14941507
.member-list {
14951508
display: flex;

0 commit comments

Comments
 (0)