diff --git a/scripts/actor-sync.mjs b/scripts/actor-sync.mjs index 0ca376a..8f1fa47 100644 --- a/scripts/actor-sync.mjs +++ b/scripts/actor-sync.mjs @@ -195,6 +195,10 @@ export class ActorSync { } // Don't auto-create actors during initial sync — only update existing links. } + + // Surface unresolved character links (broken / missing) so the GM can + // fix them in the dashboard's Issues tab rather than silently desyncing. + await this._notifySyncIssues(); } catch (err) { console.warn('Chronicle: Actor initial sync failed', err); } @@ -754,6 +758,182 @@ export class ActorSync { return a.name.localeCompare(b.name); }); } + + // --------------------------------------------------------------------------- + // Sync issue resolver — recover broken / missing character links and re-push + // stranded data (Foundry → Chronicle). See sync-dashboard "Issues" tab. + // --------------------------------------------------------------------------- + + /** + * The candidate pool for matching: every Chronicle character entity (the + * system character type plus the PC sub-type, the same set onInitialSync walks). + * @returns {Promise>} + * @private + */ + async _listCharacterEntities() { + if (!this._characterTypeId) return []; + const typeIds = [this._characterTypeId]; + if (this._pcSubtypeId) typeIds.push(this._pcSubtypeId); + const out = []; + for (const typeId of typeIds) { + try { + const result = await this._api.get(`/entities?type_id=${typeId}&per_page=100`); + out.push(...(result?.data || [])); + } catch (err) { + console.warn('Chronicle: failed to list character entities', err); + } + } + return out; + } + + /** + * Whether a Chronicle entity still exists. Only a definite 404 counts as + * "gone"; ambiguous/transient errors return true so a flaky network never + * mislabels a healthy link as broken. + * @param {string} entityId + * @returns {Promise} + * @private + */ + async _entityExists(entityId) { + try { + await this._api.get(`/entities/${entityId}`); + return true; + } catch (err) { + const status = err?.status ?? err?.statusCode ?? err?.response?.status; + if (status === 404 || /\b404\b|not found/i.test(err?.message || '')) return false; + return true; + } + } + + /** + * Best name match for an actor among candidate entities: exact + * (case-insensitive), then prefix, then substring. Null when nothing is a + * confident match (the resolver then defaults to "Create new"). + * @param {string} actorName + * @param {Array<{id:string,name:string}>} entities + * @returns {{id:string,name:string}|null} + * @private + */ + _suggestMatch(actorName, entities) { + const n = (actorName || '').trim().toLowerCase(); + if (!n) return null; + const norm = (e) => (e.name || '').trim().toLowerCase(); + let m = entities.find((e) => norm(e) === n); + if (m) return m; + m = entities.find((e) => { const en = norm(e); return en && (en.startsWith(n) || n.startsWith(en)); }); + if (m) return m; + m = entities.find((e) => { const en = norm(e); return en && (en.includes(n) || n.includes(en)); }); + return m || null; + } + + /** + * Scan character actors for issues the resolver can fix: + * - 'unlinked': no entityId flag (never linked) + * - 'broken': entityId flag points at an entity that 404s — "failed to + * find existing character" (linked once, now gone) + * Linked + valid actors aren't issues (re-pushing their data is the explicit + * repushActor action). Each issue carries a name-matched suggestion, and the + * result includes the full candidate list so the UI can prefill + offer a pick. + * @returns {Promise<{issues:Array, candidates:Array<{id:string,name:string}>}>} + */ + async getSyncIssues() { + if (!this._adapter || !this._characterTypeId) return { issues: [], candidates: [] }; + const entities = await this._listCharacterEntities(); + const ids = new Set(entities.map((e) => e.id)); + const issues = []; + for (const actor of game.actors.contents) { + if (actor.type !== this._actorType) continue; + const entityId = actor.getFlag(FLAG_SCOPE, 'entityId') || null; + let kind = null; + if (!entityId) { + kind = 'unlinked'; + } else if (!ids.has(entityId) && !(await this._entityExists(entityId))) { + kind = 'broken'; + } + if (!kind) continue; + const suggestion = this._suggestMatch(actor.name, entities); + issues.push({ actorId: actor.id, name: actor.name, img: actor.img, kind, suggestionId: suggestion?.id || null }); + } + return { + issues, + candidates: entities.map((e) => ({ id: e.id, name: e.name })) + .sort((a, b) => (a.name || '').localeCompare(b.name || '')), + }; + } + + /** + * If any character actors can't be matched to Chronicle (broken / missing + * links), nudge the GM toward the dashboard's Issues tab. Quiet when all clear. + * @private + */ + async _notifySyncIssues() { + try { + const { issues } = await this.getSyncIssues(); + if (!issues.length) return; + const n = issues.length; + ui.notifications?.warn( + `Chronicle Sync: ${n} character${n === 1 ? '' : 's'} need attention — open the Chronicle dashboard (Issues) to match or create.` + ); + console.warn(`Chronicle: ${n} character sync issue(s) detected`, issues); + } catch (err) { + console.warn('Chronicle: issue check failed', err); + } + } + + /** + * Push a LINKED actor's current fields to its Chronicle entity. Fixes the + * "stranded data on a valid link" case (actor edited while the module was + * offline, so updateActor never fired). + * @param {string} actorId + * @returns {Promise} + */ + async repushActor(actorId) { + const actor = game.actors.get(actorId); + const entityId = actor?.getFlag(FLAG_SCOPE, 'entityId'); + if (!actor || !entityId || !this._adapter) return false; + await this._api.put(`/entities/${entityId}/fields`, { fields_data: this._adapter.toChronicleFields(actor) }); + this._syncing = true; + try { await actor.setFlag(FLAG_SCOPE, 'lastSync', new Date().toISOString()); } + finally { this._syncing = false; } + console.debug(`Chronicle: Re-pushed actor "${actor.name}" → entity ${entityId}`); + return true; + } + + /** + * Link an orphaned actor to an EXISTING Chronicle entity, then push the + * actor's current data up (Foundry is source of truth for characters). + * @param {string} actorId + * @param {string} entityId + * @returns {Promise} + */ + async matchActorToEntity(actorId, entityId) { + const actor = game.actors.get(actorId); + if (!actor || !entityId || !this._adapter) return false; + this._syncing = true; + try { await actor.setFlag(FLAG_SCOPE, 'entityId', entityId); } + finally { this._syncing = false; } + return this.repushActor(actorId); + } + + /** + * Create a NEW Chronicle entity of the SYSTEM'S character type for an orphaned + * actor and link it. Clears any stale/broken link first so _handleCreateActor + * creates fresh instead of skipping an "already linked" actor. + * @param {string} actorId + * @returns {Promise} + */ + async createEntityForActor(actorId) { + const actor = game.actors.get(actorId); + if (!actor) return false; + if (actor.getFlag(FLAG_SCOPE, 'entityId')) { + this._syncing = true; + try { await actor.unsetFlag(FLAG_SCOPE, 'entityId'); } + finally { this._syncing = false; } + } + await this._handleCreateActor(actor, {}, game.user.id); + return !!actor.getFlag(FLAG_SCOPE, 'entityId'); + } + /** * Return true when any actor of the character type has a non-GM Foundry * owner. Used by the dashboard to surface the PC-claiming hint when the diff --git a/scripts/sync-dashboard.mjs b/scripts/sync-dashboard.mjs index d9fb08a..9daa114 100644 --- a/scripts/sync-dashboard.mjs +++ b/scripts/sync-dashboard.mjs @@ -77,6 +77,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { 'bulk-delete': SyncDashboard.#onBulkDeleteAction, 'create-entity-type': SyncDashboard.#onCreateEntityTypeAction, 'refresh-members': SyncDashboard.#onRefreshMembersAction, + 'resolve-match': SyncDashboard.#onResolveMatchAction, + 'resolve-create': SyncDashboard.#onResolveCreateAction, + 'resolve-repush': SyncDashboard.#onResolveRepushAction, }, }; @@ -216,6 +219,15 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { // Build characters tab data. const characterData = this._buildCharacterData(); + // Build sync-issues (resolver) tab data. + let issuesData = { issues: [], hasIssues: false, count: 0, headline: '' }; + try { + issuesData = await this._buildIssuesData(); + } catch (err) { + console.error('Chronicle Dashboard: Failed to load sync issues', err); + loadErrors.push({ tab: 'issues', message: err.message || 'Failed to load issues' }); + } + // Build members (permission mapping) tab data. const membersData = this._buildMembersData(); @@ -232,6 +244,9 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { loadErrors, hasLoadErrors: loadErrors.length > 0, + // Issues (resolver) tab. + issues: issuesData, + // Config tab. config: configData, @@ -686,6 +701,39 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { return { characters, hasUnlinkedCharacters, pcClaimingHint }; } + /** @returns {object|null} the live ActorSync module, if active. @private */ + _actorSync() { + return this._syncManager?._modules?.find((m) => m.constructor?.name === 'ActorSync') || null; + } + + /** + * Build the Issues (resolver) tab: character actors that can't be matched to + * Chronicle (unlinked, or linked to a now-missing entity). Each row carries a + * name-matched suggestion preselected + the full candidate list. Async — it + * reaches Chronicle to verify links and list candidates. + * @returns {Promise} + * @private + */ + async _buildIssuesData() { + const actorSync = this._actorSync(); + if (!actorSync?.getSyncIssues) return { issues: [], hasIssues: false, count: 0, headline: '' }; + const { issues, candidates } = await actorSync.getSyncIssues(); + const decorated = issues.map((iss) => ({ + actorId: iss.actorId, + name: iss.name, + img: iss.img, + label: iss.kind === 'broken' ? 'Failed to find existing character' : 'Not linked to Chronicle', + options: candidates.map((c) => ({ id: c.id, name: c.name, selected: c.id === iss.suggestionId })), + })); + const count = decorated.length; + return { + issues: decorated, + hasIssues: count > 0, + count, + headline: `${count} character${count === 1 ? '' : 's'} couldn't be matched to Chronicle — match each to an existing character, or create a new one.`, + }; + } + // --------------------------------------------------------------------------- // Members (permission mapping) data // --------------------------------------------------------------------------- @@ -1327,6 +1375,21 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { this._onPushAllActors(); } + /** Resolver: link an orphaned actor to the row's selected existing entity. */ + static #onResolveMatchAction(event, target) { + return this._onResolveMatch(target); + } + + /** Resolver: create a new Chronicle character (the system's type) for an orphan. */ + static #onResolveCreateAction(event, target) { + return this._onResolveCreate(target); + } + + /** Resolver / Characters: re-push a linked actor's current data to Chronicle. */ + static #onResolveRepushAction(event, target) { + return this._onResolveRepush(target); + } + /** Test connection to Chronicle using current config field values. */ static #onTestConnectionAction() { this._onTestConnection(); @@ -1713,6 +1776,56 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { * Shows a confirmation dialog before proceeding. * @private */ + /** Link an orphaned actor (button's data-actor-id) to the row's selected entity. */ + async _onResolveMatch(target) { + const actorId = target?.dataset?.actorId; + const row = target?.closest?.('.issue-row'); + const entityId = row?.querySelector?.('.issue-match-select')?.value; + if (!actorId || !entityId) { ui.notifications.warn('Chronicle: Pick a character to match to first.'); return; } + const actorSync = this._actorSync(); + if (!actorSync) { ui.notifications.warn('Chronicle: Character sync not active.'); return; } + try { + const ok = await actorSync.matchActorToEntity(actorId, entityId); + ui.notifications[ok ? 'info' : 'warn'](ok ? 'Chronicle: Matched and synced.' : 'Chronicle: Match failed.'); + } catch (err) { + console.error('Chronicle Dashboard: match failed', err); + ui.notifications.error('Chronicle: Match failed — see console.'); + } + this.render({ force: true }); + } + + /** Create a new Chronicle character (the system's type) for an orphaned actor. */ + async _onResolveCreate(target) { + const actorId = target?.dataset?.actorId; + if (!actorId) return; + const actorSync = this._actorSync(); + if (!actorSync) { ui.notifications.warn('Chronicle: Character sync not active.'); return; } + try { + const ok = await actorSync.createEntityForActor(actorId); + ui.notifications[ok ? 'info' : 'warn'](ok ? 'Chronicle: Created and linked.' : 'Chronicle: Create failed.'); + } catch (err) { + console.error('Chronicle Dashboard: create failed', err); + ui.notifications.error('Chronicle: Create failed — see console.'); + } + this.render({ force: true }); + } + + /** Re-push a linked actor's current data to Chronicle (the stranded-data fix). */ + async _onResolveRepush(target) { + const actorId = target?.dataset?.actorId; + if (!actorId) return; + const actorSync = this._actorSync(); + if (!actorSync) { ui.notifications.warn('Chronicle: Character sync not active.'); return; } + try { + const ok = await actorSync.repushActor(actorId); + ui.notifications[ok ? 'info' : 'warn'](ok ? 'Chronicle: Re-synced to Chronicle.' : 'Chronicle: Re-sync failed.'); + } catch (err) { + console.error('Chronicle Dashboard: re-sync failed', err); + ui.notifications.error('Chronicle: Re-sync failed — see console.'); + } + this.render({ force: true }); + } + async _onPushAllActors() { const actorSync = this._syncManager?._modules?.find( (m) => m.constructor?.name === 'ActorSync' diff --git a/styles/chronicle-sync.css b/styles/chronicle-sync.css index fd47ab3..c742fa6 100644 --- a/styles/chronicle-sync.css +++ b/styles/chronicle-sync.css @@ -1490,6 +1490,19 @@ flex-shrink: 0; } +/* --- Issues (character link resolver) tab --- */ +.issue-row .issue-actions { + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + max-width: 62%; +} +.issue-match-select { + max-width: 170px; + font-size: 0.8rem; + padding: 2px 4px; +} + /* --- Members (permission mapping) tab --- */ .member-list { display: flex; diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs index 19850f5..f7289c9 100644 --- a/templates/sync-dashboard.hbs +++ b/templates/sync-dashboard.hbs @@ -35,6 +35,10 @@ Characters + + Issues + {{#if issues.hasIssues}}{{issues.count}}{{/if}} + Members {{#if members.hasUnmatched}}{{members.unmatchedCount}}{{/if}} @@ -595,6 +599,11 @@ {{timeAgo lastSync}} + {{else}} + + + + {{/each}} + + {{else}} +
+ +

No character sync issues.

+

Every character actor is linked to a Chronicle entity. If a linked character's data looks stale, use the Re-sync button on the Characters tab.

+
+ {{/if}} + + {{!-- ================================================================= --}} {{!-- MEMBERS TAB --}} {{!-- ================================================================= --}}