Skip to content

Commit 7940c48

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/gallant-johnson-kd574n
2 parents 1be6b2a + 18300f6 commit 7940c48

19 files changed

Lines changed: 1173 additions & 63 deletions

API-CONTRACT.md

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -487,37 +487,61 @@ Lists all sync mappings for the campaign.
487487
```
488488

489489
#### POST /sync/mappings
490-
Creates a new sync mapping.
490+
Creates a new sync mapping. Body shape is `CreateSyncMappingInput`.
491491

492492
**Request:**
493493
```json
494494
{
495+
"chronicle_type": "entity",
495496
"chronicle_id": "entity-uuid",
496-
"foundry_id": "foundry-doc-id",
497-
"type": "entity"
497+
"external_system": "foundry",
498+
"external_id": "foundry-doc-id",
499+
"sync_direction": "both",
500+
"sync_metadata": {}
498501
}
499502
```
500503

504+
- `chronicle_type` — one of `entity, map, calendar_event, marker, drawing, token`
505+
- `external_system``foundry`
506+
- `sync_direction``both | push | pull` (defaults to `both`)
507+
- `sync_metadata` — optional object
508+
509+
Returns **409 Conflict** (`message: "sync mapping already exists for this object"`)
510+
if a mapping already exists for the object.
511+
501512
#### DELETE /sync/mappings/:mappingId
502513
Removes a sync mapping.
503514

504515
#### GET /sync/lookup
505-
Looks up a mapping by Foundry ID.
516+
Looks up a mapping by **Chronicle identity** OR **external (Foundry) identity**.
506517

507-
**Used by:** All sync modules to find existing mappings
518+
**Used by:** all sync modules (`sync-manager.mjs``findMapping` /
519+
`findMappingByExternal`) to find existing mappings before create.
508520

509-
**Query:** `?foundry_id=abc123&type=entity`
521+
**Query:** `?chronicle_type=entity&chronicle_id=<uuid>`
522+
&nbsp;— or —&nbsp; `?external_system=foundry&external_id=<foundry-doc-id>`
510523

511-
**Response:**
524+
**Response:** the full `SyncMapping`
512525
```json
513526
{
527+
"id": "mapping-uuid",
528+
"campaign_id": "campaign-uuid",
529+
"chronicle_type": "entity",
514530
"chronicle_id": "entity-uuid",
515-
"foundry_id": "abc123",
516-
"type": "entity"
531+
"external_system": "foundry",
532+
"external_id": "foundry-doc-id",
533+
"sync_version": 1,
534+
"last_synced_at": "2026-01-01T00:00:00Z",
535+
"sync_direction": "both",
536+
"sync_metadata": {},
537+
"created_at": "2026-01-01T00:00:00Z",
538+
"updated_at": "2026-01-01T00:00:00Z"
517539
}
518540
```
519541

520-
Returns 404 if no mapping exists.
542+
Returns **404** (`{"error":"Not Found","message":"sync mapping not found"}`) when no
543+
mapping exists. This is the normal "not yet mapped" signal callers use to decide to
544+
create one — **not** an error (the client scrubs it from the diagnostics error log).
521545

522546
#### GET /sync/pull
523547
Pulls all changes since a timestamp.

AUDIT-2026-06-21-foundry-errors.md

Lines changed: 349 additions & 0 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,11 @@ Integration — Install & Updates".
6666

6767
## TODO
6868

69-
- (none currently)
69+
- (none currently) — the Foundry V1→V2 `DialogV2` migration and the
70+
`render(true)``render({ force: true })` cleanup are complete, centralized in
71+
`scripts/_dialogs.mjs` and pinned by `tools/test-dialogs.mjs`.
72+
- Recommended once on a live **v14** client (can't be unit-tested headlessly):
73+
smoke-test the migrated dialogs (resync / pull / push confirms, the "create
74+
entity type" prompt, map pin & marker delete confirms, the calendar cleanup
75+
confirm) and the dashboard Calendar tab (Foundry local date now renders, In/Out
76+
of Sync badge, Push-date button).

lang/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,7 @@
605605
"Success": "Chronicle Sync: calendar imported.",
606606
"ChronicleTooOld": "Chronicle doesn't expose the calendar-create endpoint yet — update Chronicle, then click Recheck.",
607607
"Unreachable": "Could not reach Chronicle to check for a calendar. Check your sync settings.",
608+
"NotAuthorized": "Chronicle rejected the sync token while checking for a calendar. Re-check your API key in Module Settings, or reinstall the module from a fresh campaign URL.",
608609
"Recheck": "Recheck Chronicle",
609610
"CalendariaUnavailable": "Calendaria's API is not available — cannot read the source calendar.",
610611
"CalendarNotFound": "Calendaria has no calendar with that id.",

scripts/_calendar-probe-state.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Chronicle Sync — calendar probe state classifier.
3+
*
4+
* Pure helper mapping a failed `GET /calendar` probe (thrown by api-client) to
5+
* the Sync Calendar editor's import-banner state. Extracted from
6+
* sync-calendar.mjs so the classification is unit-testable in isolation and so
7+
* a future edit can't silently regress the 401/403 → "auth" banner.
8+
*
9+
* Classification anchors on an explicit numeric `err.status` when present (e.g.
10+
* the api-client's ConflictError) and otherwise on the authoritative
11+
* "Chronicle API error <status>:" prefix the api-client formats — NOT on a bare
12+
* digit run, so a response body that merely contains "404" (an entity named
13+
* "Room 404", a UUID, …) cannot misclassify the banner. Body-keyword fallbacks
14+
* cover transports that don't surface a numeric status.
15+
*/
16+
17+
/**
18+
* @param {{status?: number, message?: string}|null|undefined} err
19+
* @returns {'absent'|'auth'|'unreachable'}
20+
* - `'absent'` — 404 / `calendar_not_configured`: Chronicle has no
21+
* calendar for this campaign (import one).
22+
* - `'auth'` — 401 / 403 / `invalid_token`: token/auth problem
23+
* (re-check the API key, or reinstall from a fresh campaign URL).
24+
* - `'unreachable'` — anything else (network error, 5xx, unknown).
25+
*/
26+
export function calendarStateFromError(err) {
27+
const msg = String(err?.message || '');
28+
// Prefer an explicit numeric status; else read the authoritative status from
29+
// the api-client's "Chronicle API error <status>:" prefix. Never key on a
30+
// bare digit run in the body.
31+
const status = (typeof err?.status === 'number' && err.status)
32+
|| Number(msg.match(/Chronicle API error (\d{3})\b/)?.[1])
33+
|| 0;
34+
if (status === 404 || /calendar_not_configured/i.test(msg)) return 'absent';
35+
if (status === 401 || status === 403 || /invalid_token|unauthor/i.test(msg)) return 'auth';
36+
return 'unreachable';
37+
}

scripts/_dialogs.mjs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Chronicle Sync — dialog helpers (Foundry V1 → V2 shim).
3+
*
4+
* Centralizes the module's confirm/prompt dialogs on
5+
* `foundry.applications.api.DialogV2` (Foundry v13+), falling back to the V1
6+
* `Dialog` global when DialogV2 is absent (v12 floor) OR if a DialogV2 call
7+
* throws. This:
8+
* - removes the "V1 Application framework is deprecated" warnings on v13/v14,
9+
* - keeps the module working when V1 `Dialog` is removed in v16,
10+
* - preserves the declared v12 compatibility floor, and
11+
* - gives one place to evolve dialog behavior.
12+
*
13+
* Both helpers treat dialog dismissal as "cancel" (confirm → false, prompt →
14+
* null) and never reject, so callers can use `if (!result) return;` uniformly.
15+
*
16+
* NOTE: the actual rendered dialog can only be exercised inside a live Foundry
17+
* client; the unit tests (tools/test-dialogs.mjs) pin the branching, the
18+
* option shape passed to DialogV2, the close→cancel coercion, and the V1
19+
* fallback. A v14 smoke-test of the real dialogs is still recommended.
20+
*/
21+
22+
/** @returns {any|null} DialogV2 class if available (v13+), else null. */
23+
function _DialogV2() {
24+
return globalThis.foundry?.applications?.api?.DialogV2 ?? null;
25+
}
26+
27+
/**
28+
* Yes/No confirmation.
29+
* @param {{title?: string, content?: string, defaultYes?: boolean}} [opts]
30+
* @returns {Promise<boolean>} true only on explicit confirm; false on No/dismiss.
31+
*/
32+
export async function confirmDialog({ title = '', content = '', defaultYes = true } = {}) {
33+
const DialogV2 = _DialogV2();
34+
if (DialogV2?.confirm) {
35+
try {
36+
// Per Foundry docs: DialogV2.confirm resolves true (yes) / false (no), or
37+
// null when dismissed with rejectClose:false. Do NOT pass yes/no overrides
38+
// — that replaces their built-in true/false callbacks and the result would
39+
// become the button's action string instead of a boolean. `defaultYes`
40+
// (which button is focused) is a V1-only nicety, omitted here.
41+
const res = await DialogV2.confirm({
42+
window: { title },
43+
content,
44+
rejectClose: false, // dismissal resolves null instead of throwing
45+
});
46+
return res === true;
47+
} catch (err) {
48+
console.warn('Chronicle Sync [dialogs]: DialogV2.confirm failed; falling back to V1 Dialog', err);
49+
}
50+
}
51+
// v12 / safety fallback: V1 Dialog.confirm.
52+
const Dialog = globalThis.Dialog;
53+
if (Dialog?.confirm) {
54+
try {
55+
return (await Dialog.confirm({ title, content, defaultYes, rejectClose: false })) === true;
56+
} catch { return false; }
57+
}
58+
return false;
59+
}
60+
61+
/**
62+
* Prompt with custom form content. Resolves to the callback's return value, or
63+
* null/undefined if dismissed. The callback receives the dialog's root
64+
* HTMLElement (so existing `root.querySelector('form')` logic keeps working).
65+
* @param {{title?: string, content?: string, label?: string, callback: (root: any) => any}} opts
66+
* @returns {Promise<any>}
67+
*/
68+
export async function promptDialog({ title = '', content = '', label, callback } = {}) {
69+
const okLabel = label || globalThis.game?.i18n?.localize?.('Confirm') || 'OK';
70+
const DialogV2 = _DialogV2();
71+
if (DialogV2?.prompt) {
72+
try {
73+
return await DialogV2.prompt({
74+
window: { title },
75+
content,
76+
rejectClose: false,
77+
ok: {
78+
label: okLabel,
79+
// DialogV2 button callback signature is (event, button, dialog). Pass
80+
// the dialog's ROOT element so the caller's root.querySelector('form')
81+
// keeps working — note button.form is the form element itself, on
82+
// which querySelector('form') would return null.
83+
callback: (_event, _button, dialog) => callback?.(dialog?.element ?? dialog),
84+
},
85+
});
86+
} catch (err) {
87+
console.warn('Chronicle Sync [dialogs]: DialogV2.prompt failed; falling back to V1 Dialog', err);
88+
}
89+
}
90+
// v12 / safety fallback: V1 Dialog.prompt (callback receives jQuery; pass the
91+
// underlying element so the caller's querySelector logic is unchanged).
92+
const Dialog = globalThis.Dialog;
93+
if (Dialog?.prompt) {
94+
try {
95+
return await Dialog.prompt({
96+
title,
97+
content,
98+
label: okLabel,
99+
callback: (html) => callback?.(html?.[0] ?? html),
100+
rejectClose: false,
101+
});
102+
} catch { return null; }
103+
}
104+
return null;
105+
}

scripts/_html-sanitizer.mjs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,32 @@
3333
import { MODULE_ID } from './constants.mjs';
3434

3535
/**
36-
* Look up Foundry's HTML sanitizer across v12 / v13+ namespaces. The
37-
* function moved from a global `TextEditor` (v12) into
38-
* `foundry.applications.ux.TextEditor` (v13+). Either may be aliased
39-
* back to the global in some versions, so we probe both.
36+
* Look up Foundry's HTML sanitizer across v12 / v13 / v14+ namespaces.
37+
* The class moved from a global `TextEditor` (v12) into
38+
* `foundry.applications.ux.TextEditor` (v13), then in v14 the concrete
39+
* class that carries the static `cleanHTML` moved again onto that
40+
* namespace's `.implementation` property — the v14 deprecation warning
41+
* ("now namespaced under foundry.applications.ux.TextEditor.implementation")
42+
* names this location explicitly. We probe most-specific-modern → namespace
43+
* → deprecated-global, both so we resolve `cleanHTML` on current Foundry and
44+
* so we avoid touching the deprecated global (which logs a compatibility
45+
* warning on every access) unless nothing else resolves.
4046
*
4147
* @returns {((html: string) => string) | null}
4248
*/
4349
function _resolveCleanHTML() {
50+
const ns = globalThis.foundry?.applications?.ux?.TextEditor;
51+
// v14+: the static lives on the implementation class.
4452
try {
45-
const v13 = globalThis.foundry?.applications?.ux?.TextEditor?.cleanHTML;
46-
if (typeof v13 === 'function') return v13.bind(globalThis.foundry.applications.ux.TextEditor);
53+
const impl = ns?.implementation?.cleanHTML;
54+
if (typeof impl === 'function') return impl.bind(ns.implementation);
4755
} catch { /* ignore — fall through */ }
56+
// v13: the static lived directly on the namespace object.
57+
try {
58+
const direct = ns?.cleanHTML;
59+
if (typeof direct === 'function') return direct.bind(ns);
60+
} catch { /* ignore — fall through */ }
61+
// v12: global TextEditor (deprecated in v13+, slated for removal in v15).
4862
try {
4963
const v12 = globalThis.TextEditor?.cleanHTML;
5064
if (typeof v12 === 'function') return v12.bind(globalThis.TextEditor);

scripts/api-client.mjs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -618,21 +618,25 @@ export class ChronicleAPI {
618618

619619
/**
620620
* Drop the most-recent error-log entry if it matches the given criteria.
621-
* Used by SyncManager.ensureMapping to clean up after absorbing a 400
622-
* "sync mapping already exists" conflict — that 400 is not a real error
623-
* once handled, and should not pollute the FM-MAP-DIAG dashboard log.
621+
* Used by SyncManager.findMapping (benign lookup 404) and ensureMapping
622+
* (absorbed mapping-already-exists conflict — 409, or 400 on older
623+
* deployments) to keep expected/handled non-OK responses out of the
624+
* FM-MAP-DIAG dashboard error log.
624625
*
625626
* Also rolls back the `restErrorCount` health metric so the dashboard's
626627
* error count stays accurate.
627628
*
628-
* @param {{path?: string|RegExp, status?: number, messageIncludes?: string}} match
629+
* @param {{path?: string|RegExp, status?: number|number[], messageIncludes?: string}} match
629630
* @returns {boolean} True if an entry was removed.
630631
*/
631632
dropLastErrorLogEntry(match = {}) {
632633
const top = this._errorLog[0];
633634
if (!top) return false;
634635

635-
if (match.status != null && top.status !== match.status) return false;
636+
if (match.status != null) {
637+
const statuses = Array.isArray(match.status) ? match.status : [match.status];
638+
if (!statuses.includes(top.status)) return false;
639+
}
636640
if (match.path != null) {
637641
const ok = typeof match.path === 'string'
638642
? top.path === match.path

scripts/map-viewer.mjs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { FLAG_SCOPE, MODULE_ID } from './constants.mjs';
3939
import { PIN_ICONS } from './map-sync.mjs';
4040
import { getUserMappings } from './settings.mjs';
4141
import { _isAllowedImageHost, _describeRejection } from './_url-validation.mjs';
42+
import { confirmDialog } from './_dialogs.mjs';
4243

4344
/* ============================================================
4445
Constants
@@ -872,7 +873,7 @@ export class MapViewerSheet extends HandlebarsApplicationMixin(_JournalEntryPage
872873

873874
try {
874875
const doc = fromUuidSync(pin.linkedJournalId);
875-
if (doc?.sheet) doc.sheet.render(true);
876+
if (doc?.sheet) doc.sheet.render({ force: true });
876877
} catch (err) {
877878
console.warn('Chronicle MapViewer: Could not open linked journal', err);
878879
}
@@ -888,7 +889,7 @@ export class MapViewerSheet extends HandlebarsApplicationMixin(_JournalEntryPage
888889
pinTypes: VIEWER_PIN_TYPES,
889890
onSave: (updated) => this._updatePin(updated),
890891
onDelete: (id) => this._deletePin(id),
891-
}).render(true);
892+
}).render({ force: true });
892893
}
893894

894895
/* ----------------------------------------------------------
@@ -928,7 +929,7 @@ export class MapViewerSheet extends HandlebarsApplicationMixin(_JournalEntryPage
928929
await mapSync.createMarker(mapId, data);
929930
this.render(false);
930931
},
931-
}).render(true);
932+
}).render({ force: true });
932933

933934
this._setActiveTool(null);
934935
}
@@ -954,7 +955,7 @@ export class MapViewerSheet extends HandlebarsApplicationMixin(_JournalEntryPage
954955
await mapSync?.deleteMarker(mapId, markerId);
955956
this.render(false);
956957
},
957-
}).render(true);
958+
}).render({ force: true });
958959
}
959960

960961
_onChronicleMarkerDoubleClick(markerId) {
@@ -964,7 +965,7 @@ export class MapViewerSheet extends HandlebarsApplicationMixin(_JournalEntryPage
964965
if (!marker?.entity_id) return;
965966

966967
const journal = game.journal.find((j) => j.getFlag(FLAG_SCOPE, 'entityId') === marker.entity_id);
967-
if (journal?.sheet) journal.sheet.render(true);
968+
if (journal?.sheet) journal.sheet.render({ force: true });
968969
}
969970

970971
/* ----------------------------------------------------------
@@ -1088,7 +1089,7 @@ export class PinConfigDialog extends HandlebarsApplicationMixin(ApplicationV2) {
10881089
}
10891090

10901091
static async #onDelete(_event, _target) {
1091-
const confirmed = await Dialog.confirm({
1092+
const confirmed = await confirmDialog({
10921093
title: game.i18n.localize('CHRONICLE.MapViewer.PinDelete'),
10931094
content: `<p>${game.i18n.localize('CHRONICLE.MapViewer.PinDeleteConfirm')}</p>`,
10941095
});
@@ -1185,7 +1186,7 @@ export class ChronicleMarkerConfigDialog extends HandlebarsApplicationMixin(Appl
11851186
}
11861187

11871188
static async #onDelete(_event, _target) {
1188-
const confirmed = await Dialog.confirm({
1189+
const confirmed = await confirmDialog({
11891190
title: game.i18n.localize('CHRONICLE.MapViewer.MarkerDelete'),
11901191
content: `<p>${game.i18n.localize('CHRONICLE.MapViewer.MarkerDeleteConfirm')}</p>`,
11911192
});

0 commit comments

Comments
 (0)