From 77cc8d954cbd3a8f5f91f0b8c3d8e85769e41ef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 04:19:45 +0000 Subject: [PATCH 1/2] fix(calendar-sync): correct hook date off-by-one, back-catalog, structure guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three calendar-sync fixes (FM-CAL-SYNC-HOTFIX) + a CI ride-along. No Chronicle-side change. 1. Off-by-one (LIVE): the Calendaria realtime note* hooks deliver RAW 0-indexed month/dayOfMonth with an absolute year, but the module read them verbatim — mis-dating every pushed event −1 month/−1 day (a 1st-of-month or January note sent an invalid month 0 / day 0). Verified against Calendaria release-1.1.3 source (api.mjs toPublic, note-manager hook emission). New pure helper chronicleDateFromCalendariaStartDate normalizes by date SHAPE (dayOfMonth → +1; public `day` → verbatim; year never touched), and _calendariaNoteToChronicleEvent now resolves via CALENDARIA.api.getNote() (authoritative toPublic) first with the raw payload as fallback — zero double-correction. The false "1-indexed months" comment is gone. 2. Back-catalog (SILENT): initial sync did a bare GET /calendar/events, which Chronicle serves month-filtered (current month only), so every event outside the current month was never ID-mapped. It now paginates ?year=&month= across the current year ±1 from the cached calendar structure (bounded, deduped, logged). The create loop holds the _syncing guard so pulled events don't echo back through the noteCreated hook as duplicate POSTs. 3. Structure mismatch (LIVE, B-R2): when the active Calendaria calendar's structure differs from Chronicle's (month count / per-month day counts / weekday count; moons/seasons/eras excluded), calendar sync is paused for the session in BOTH directions across all pull funnels and push handlers, with a persistent dashboard banner + "Sync Paused" badge + an Overview alert, one ui.notifications.warn, and the state added to the diagnostics export. Fails open (only a confirmed, both-sides-readable mismatch pauses). Journals/characters/maps are untouched. CI (B-R8): the full suite (node --test 'tools/test-*.mjs') now runs on push + PR alongside the descriptor check. Tests: tools/test-calendar-sync-hotfix.mjs (17) — date normalization + wiring, fetch-coordinate bounds, the no-echo _syncing guard, and structure comparison (incl. the operator's Gregorian 12mo/7wd vs Therin 15mo/6wd case). 592 green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014bAhi59Sajsmd5bGCu7Smp --- .github/workflows/check-descriptor.yml | 8 +- scripts/_overview-model.mjs | 12 +- scripts/calendar-sync.mjs | 322 ++++++++++++++++++++++--- scripts/sync-calendar-diagnostics.mjs | 2 + scripts/sync-dashboard.mjs | 25 ++ templates/sync-dashboard.hbs | 14 +- tools/test-calendar-sync-hotfix.mjs | 207 ++++++++++++++++ 7 files changed, 552 insertions(+), 38 deletions(-) create mode 100644 tools/test-calendar-sync-hotfix.mjs diff --git a/.github/workflows/check-descriptor.yml b/.github/workflows/check-descriptor.yml index 2be726a..74b4902 100644 --- a/.github/workflows/check-descriptor.yml +++ b/.github/workflows/check-descriptor.yml @@ -29,5 +29,9 @@ jobs: - name: Validate chronicle-package.json run: node tools/check-package-descriptor.mjs - - name: Test update-info classifier helpers - run: node --test tools/test-update-info.mjs + # Full off-DOM test suite (B-R8). The glob is quoted so the shell passes + # it to Node literally — Node's own glob matches each tools/test-*.mjs file + # (the bare directory form `node --test tools/` errors on Node 20). This + # supersedes the old single-file test-update-info step. + - name: Run test suite + run: node --test 'tools/test-*.mjs' diff --git a/scripts/_overview-model.mjs b/scripts/_overview-model.mjs index b697cbc..a63d01a 100644 --- a/scripts/_overview-model.mjs +++ b/scripts/_overview-model.mjs @@ -44,6 +44,7 @@ export function buildOverviewModel(p = {}) { unmatchedMembers = 0, calendarAvailable = false, calendarInSync = false, + calendarSyncPaused = false, errorCount = 0, matchedSystem = null, lastSyncTime = 'Never', @@ -94,7 +95,16 @@ export function buildOverviewModel(p = {}) { tab: 'members', }); } - if (calendarAvailable && !calendarInSync) { + if (calendarAvailable && calendarSyncPaused) { + // Structure mismatch (B-R2): a hard pause, not a mere date drift — flag it + // as an error so it sorts above ordinary warnings. + attention.push({ + severity: 'error', + icon: 'fa-calendar-xmark', + text: 'Calendar sync paused — Foundry and Chronicle calendars have different structures.', + tab: 'calendar', + }); + } else if (calendarAvailable && !calendarInSync) { attention.push({ severity: 'warn', icon: 'fa-calendar-xmark', diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index 36a03a7..2d5f1e9 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -153,6 +153,128 @@ export function isCalendarNoteJournal(journal) { return false; } +/** + * Normalize a Calendaria note startDate to Chronicle's 1-indexed month/day. + * + * Calendaria stores dates 0-indexed internally: a raw note startDate carries a + * 0-indexed `month` and `dayOfMonth`, with an ABSOLUTE `year` (already includes + * yearZero). Its `toPublic` conversion (returned by CALENDARIA.api.getNote) + * instead yields a 1-indexed `month` + `day` (dayOfMonth deleted), same + * absolute year. Verified against Calendaria release-1.1.3 source + * (scripts/api.mjs `toPublic`: `month = (month ?? 0) + 1`, + * `day = (dayOfMonth ?? 0) + 1`, year untouched; note* hooks fire the RAW stub). + * + * The realtime `calendaria.note*` hooks deliver the RAW (0-indexed) stub, so + * reading it verbatim mis-dated every pushed event by −1 month / −1 day (a + * first-of-month or January note became month 0 / day 0 = an INVALID Chronicle + * date). FM-CAL-SYNC-HOTFIX item 1. + * + * The correction is keyed on the SHAPE, never on the code path: a `day` field + * means already-public (no correction); a `dayOfMonth` field means raw + * (0-indexed → +1). So both the getNote (public) and raw-hook sources are + * correct with zero double-correction risk. The YEAR is absolute in both and is + * never adjusted (an earlier yearZero hypothesis was wrong — audit §2). + * + * @param {object} startDate - Calendaria startDate ({year, month, day?|dayOfMonth?}). + * @returns {{year:number, month:number, day:number}|null} 1-indexed, or null. + */ +export function chronicleDateFromCalendariaStartDate(startDate) { + if (!startDate || startDate.year === undefined) return null; + const year = startDate.year; // absolute — never adjust (no yearZero math) + // A public (toPublic) date carries a 1-indexed `day` with `dayOfMonth` + // deleted; a raw hook/stored date carries a 0-indexed `month` + `dayOfMonth`. + if (startDate.day !== undefined) { + return { year, month: startDate.month, day: startDate.day }; + } + return { + year, + month: (startDate.month ?? 0) + 1, + day: (startDate.dayOfMonth ?? 0) + 1, + }; +} + +/** + * Build the (year, month) fetch coordinates for the initial event back-catalog + * sync. Chronicle's GET /calendar/events is MONTH-FILTERED (defaults to the + * calendar's current year+month), so a bare fetch silently misses every event + * outside the current month (FM-CAL-SYNC-HOTFIX item 2). We enumerate each + * month of the calendar across the current year ±`yearSpan` — bounded, never + * unbounded history. + * + * @param {object} calendar - Chronicle calendar ({current_year, months:[...]}). + * @param {number} [yearSpan=1] - Years to fetch on each side of current_year. + * @returns {Array<{year:number, month:number}>} + */ +export function calendarEventFetchCoordinates(calendar, yearSpan = 1) { + if (!calendar) return []; + const monthCount = Array.isArray(calendar.months) ? calendar.months.length : 0; + if (monthCount < 1) return []; + const current = Number(calendar.current_year); + const baseYear = Number.isFinite(current) ? current : 0; + const span = Number.isFinite(yearSpan) && yearSpan >= 0 ? Math.floor(yearSpan) : 1; + const coords = []; + for (let y = baseYear - span; y <= baseYear + span; y++) { + for (let m = 1; m <= monthCount; m++) { + coords.push({ year: y, month: m }); + } + } + return coords; +} + +/** + * Compare Chronicle's calendar structure to the active Foundry calendar's, for + * the structure-mismatch guard (FM-CAL-SYNC-HOTFIX item 3, ruling B-R2). + * Compares month count, per-month day counts, and weekday count ONLY — moons, + * seasons, and eras are cosmetic to date coordinates and excluded (B-R2). + * + * @param {object} chronicle - Chronicle calendar ({months:[{days}], weekdays:[]}). + * @param {object} foundry - normalized active Foundry structure + * ({name, monthDays:number[], weekdayCount:number}). + * @returns {{match:boolean, detail:string}} + */ +export function compareCalendarStructures(chronicle, foundry) { + const cMonths = Array.isArray(chronicle?.months) ? chronicle.months : []; + const cWeekdays = Array.isArray(chronicle?.weekdays) ? chronicle.weekdays : []; + const chronicleMonthDays = cMonths.map((m) => Number(m?.days) || 0); + const chronicleWeekdays = cWeekdays.length; + + const fMonthDays = Array.isArray(foundry?.monthDays) ? foundry.monthDays.map((d) => Number(d) || 0) : []; + const fWeekdays = Number(foundry?.weekdayCount) || 0; + + const reasons = []; + if (chronicleMonthDays.length !== fMonthDays.length) { + reasons.push(`month count (Chronicle ${chronicleMonthDays.length} vs Foundry ${fMonthDays.length})`); + } else { + for (let i = 0; i < chronicleMonthDays.length; i++) { + if (chronicleMonthDays[i] !== fMonthDays[i]) { + reasons.push(`month ${i + 1} day count (Chronicle ${chronicleMonthDays[i]} vs Foundry ${fMonthDays[i]})`); + break; // one representative day-count difference is enough to pause + } + } + } + if (chronicleWeekdays !== fWeekdays) { + reasons.push(`weekday count (Chronicle ${chronicleWeekdays} vs Foundry ${fWeekdays})`); + } + + if (!reasons.length) return { match: true, detail: '' }; + return { match: false, detail: reasons.join('; ') }; +} + +/** + * Coerce a Calendaria collection (array, or an id-keyed `{values:{...}}` object) + * into a plain array. Mirrors sync-calendar.mjs's reader — Calendaria stores + * months/weekdays as id-keyed maps. + * @param {...*} sources + * @returns {Array} + */ +function readArrayLike(...sources) { + for (const src of sources) { + if (Array.isArray(src)) return src; + if (src && typeof src === 'object') return Object.values(src); + } + return []; +} + /** * CalendarSync handles calendar ↔ Foundry calendar module synchronization. */ @@ -171,6 +293,17 @@ export class CalendarSync { /** @type {boolean} Whether modern Calendaria API (CALENDARIA.api) is available. */ this._hasModernCalendariaApi = false; + /** + * Session-scoped structure-mismatch guard (FM-CAL-SYNC-HOTFIX item 3, + * ruling B-R2). When the active Foundry calendar's structure differs from + * Chronicle's, calendar sync is paused for the session (both directions) to + * avoid guaranteed mis-dating; journals/characters/maps are untouched. + * @type {boolean} + */ + this._calendarSyncDisabled = false; + /** @type {string|null} Human-readable mismatch detail for the dashboard + diagnostics. */ + this._calendarMismatchDetail = null; + // Bound hook handlers for cleanup. this._boundHandlers = {}; } @@ -209,6 +342,7 @@ export class CalendarSync { */ async onMessage(msg) { if (!getSetting('syncCalendar') || !this._calendarModule) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): no pull switch (msg.type) { case 'calendar.date.advanced': @@ -234,6 +368,7 @@ export class CalendarSync { async onSyncMapping(mapping) { if (mapping.chronicle_type !== 'calendar_event') return; if (!getSetting('syncCalendar') || !this._calendarModule) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2) // Store the mapping so we can correlate local ↔ Chronicle events. if (mapping.external_id && mapping.chronicle_id) { @@ -247,6 +382,7 @@ export class CalendarSync { */ async onInitialSync() { if (!getSetting('syncCalendar') || !this._calendarModule) return; + if (this._calendarSyncDisabled) return; try { this._chronicleCalendar = await this._api.get('/calendar'); @@ -255,6 +391,26 @@ export class CalendarSync { return; } + // Structure-mismatch guard (B-R2): if the active Calendaria calendar's + // structure differs from Chronicle's, date coordinates are meaningless + // across the wire — pause calendar sync for the session (both directions) + // and warn loudly, rather than push a Gregorian date into a custom + // calendar. Fails OPEN: only a CONFIRMED mismatch pauses; an unreadable + // structure does not. Other sync (journals/characters/maps) is untouched. + // Require BOTH sides readable before comparing — a degraded /calendar + // response (no months) must fail OPEN (don't pause), not report a false + // 0-vs-N mismatch. + if (this._calendarModule === 'calendaria' && this._chronicleCalendar.months?.length > 0) { + const foundryStruct = this._readActiveCalendariaStructure(); + if (foundryStruct) { + const cmp = compareCalendarStructures(this._chronicleCalendar, foundryStruct); + if (!cmp.match) { + this._pauseCalendarSyncForMismatch(this._chronicleCalendar, foundryStruct, cmp.detail); + return; + } + } + } + // Sync the current date from Chronicle to the Foundry calendar module. await this._setLocalDate({ year: this._chronicleCalendar.current_year, @@ -275,6 +431,58 @@ export class CalendarSync { } } + /** + * Read the active Calendaria calendar's structure (per-month day counts + + * weekday count) for the mismatch guard. Calendaria stores months/weekdays as + * id-keyed `{values:{...}}` maps and calls weekdays "days" (`cal.days`). + * Returns null when the structure can't be read — the guard then fails OPEN + * (sync is only paused on a CONFIRMED mismatch, never on inability to compare). + * @returns {{name:string, monthDays:number[], weekdayCount:number}|null} + * @private + */ + _readActiveCalendariaStructure() { + try { + const api = globalThis.CALENDARIA?.api; + const cal = api?.getActiveCalendar?.(); + if (!cal) return null; + const months = readArrayLike(cal.monthsArray, cal.months?.values, cal.months); + const weekdays = readArrayLike(cal.weekdaysArray, cal.days?.values, cal.days); + if (!months.length) return null; + return { + name: cal.name || cal.metadata?.name || 'active Foundry calendar', + monthDays: months.map((m) => Number(m?.days ?? 0)), + weekdayCount: weekdays.length, + }; + } catch (err) { + console.debug('Chronicle: could not read active Calendaria structure', err?.message); + return null; + } + } + + /** + * Pause calendar sync for the session on a structure mismatch (B-R2): set the + * guard flag + detail (which suppresses both push and pull), log, and emit ONE + * persistent ui.notifications.warn. The dashboard surfaces the same state. + * @param {object} chronicleCal - Chronicle calendar ({name, months, weekdays}). + * @param {{name:string, monthDays:number[], weekdayCount:number}} foundryStruct + * @param {string} detail - the specific mismatch (from compareCalendarStructures). + * @private + */ + _pauseCalendarSyncForMismatch(chronicleCal, foundryStruct, detail) { + this._calendarSyncDisabled = true; + const chronicleName = chronicleCal?.name || 'Chronicle calendar'; + const chronicleShape = `${(chronicleCal?.months || []).length}mo/${(chronicleCal?.weekdays || []).length}wd`; + const foundryName = foundryStruct?.name || 'active Foundry calendar'; + const foundryShape = `${(foundryStruct?.monthDays || []).length}mo/${foundryStruct?.weekdayCount ?? 0}wd`; + this._calendarMismatchDetail = + `Chronicle: ${chronicleName} ${chronicleShape} · Foundry: ${foundryName} ${foundryShape} — ${detail}`; + const msg = `Chronicle Sync: calendar structures differ (${this._calendarMismatchDetail}). ` + + 'Calendar sync is paused for this session — import or author the matching calendar in Chronicle. ' + + '(Journals, characters, and maps still sync.)'; + console.warn(msg); + try { globalThis.ui?.notifications?.warn(msg, { permanent: true }); } catch { /* headless */ } + } + /** * Clean up hooks on destroy. */ @@ -443,6 +651,7 @@ export class CalendarSync { async _onCalendariaDateTimeChange(data) { if (this._syncing) return; if (!game.user.isGM) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs if (this._isActiveCalendarExcluded()) return; try { @@ -466,6 +675,7 @@ export class CalendarSync { async _onCalendariaNoteCreated(noteData) { if (this._syncing) return; if (!game.user.isGM) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs if (this._isActiveCalendarExcluded()) return; const eventPayload = this._calendariaNoteToChronicleEvent(noteData); @@ -489,6 +699,7 @@ export class CalendarSync { async _onCalendariaNoteUpdated(noteData) { if (this._syncing) return; if (!game.user.isGM) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs if (this._isActiveCalendarExcluded()) return; const chronicleId = this._getChronicleEventId(noteData.id); @@ -516,6 +727,7 @@ export class CalendarSync { async _onCalendariaNoteDeleted(noteData) { if (this._syncing) return; if (!game.user.isGM) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs if (this._isActiveCalendarExcluded()) return; const noteId = noteData?.id || noteData?.pageId; @@ -541,34 +753,34 @@ export class CalendarSync { _calendariaNoteToChronicleEvent(noteData) { if (!noteData) return null; - // Calendaria notes store date in flagData or startDate. - const flagData = noteData.flagData || noteData; - const startDate = flagData.startDate || flagData; - - // Validate we have date info. - if (startDate.year === undefined && startDate.month === undefined) { - // Try getting the date from the note's page document via API. - if (this._hasModernCalendariaApi && noteData.id) { - try { - const note = CALENDARIA.api.getNote(noteData.id); - if (note?.flagData?.startDate) { - return this._calendariaNoteToChronicleEvent({ - ...noteData, - flagData: note.flagData, - name: note.name || noteData.name, - }); - } - } catch { /* fall through */ } - } - return null; + // Prefer the authoritative note from the modern Calendaria API: getNote() + // returns toPublic (1-indexed) dates — the single source of truth, per + // CALENDARIA-INTEGRATION.md's "use the API, don't read raw stored dates". + // The realtime note* hook payload instead carries RAW 0-indexed dates; both + // shapes are normalized by chronicleDateFromCalendariaStartDate below, which + // keys the +1 on the date SHAPE (dayOfMonth vs day) so there is no + // double-correction (FM-CAL-SYNC-HOTFIX item 1). + let flagData = noteData.flagData || noteData; + let name = noteData.name || noteData.title; + if (this._hasModernCalendariaApi && noteData.id && typeof globalThis.CALENDARIA?.api?.getNote === 'function') { + try { + const note = CALENDARIA.api.getNote(noteData.id); + if (note?.flagData?.startDate) { + flagData = note.flagData; + name = note.name || name; + } + } catch { /* fall through to the raw hook payload */ } } - // Calendaria uses 1-indexed months (same as Chronicle). + const startDate = flagData.startDate || flagData; + const date = chronicleDateFromCalendariaStartDate(startDate); + if (!date) return null; + return { - name: noteData.name || noteData.title || 'Untitled Note', - year: startDate.year, - month: startDate.month, - day: startDate.day ?? startDate.dayOfMonth ?? 1, + name: name || 'Untitled Note', + year: date.year, + month: date.month, + day: date.day, description: noteData.content || noteData.description || flagData.content || '', // Wire visibility is kebab-case per the wire contract // (cordinator/decisions/2026-05-17-calendar-sync-wire-contract.md). @@ -591,6 +803,7 @@ export class CalendarSync { async _onLocalDateChange(dateData) { if (this._syncing) return; if (!game.user.isGM) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs try { await this._api.put('/calendar/date', { @@ -614,6 +827,7 @@ export class CalendarSync { async _onSimpleCalendarDateChange(data) { if (this._syncing) return; if (!game.user.isGM) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs // SimpleCalendar uses different data shape depending on version. // The hook provides { date: { year, month, day, ... }, diff: N, ... } @@ -642,6 +856,7 @@ export class CalendarSync { async _onLocalEventCreate(eventData) { if (this._syncing) return; if (!game.user.isGM) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs try { const result = await this._api.post('/calendar/events', { @@ -670,6 +885,7 @@ export class CalendarSync { async _onLocalEventUpdate(eventData) { if (this._syncing) return; if (!game.user.isGM) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs const chronicleId = this._getChronicleEventId(eventData.id); if (!chronicleId) { @@ -699,6 +915,7 @@ export class CalendarSync { async _onLocalEventDelete(eventData) { if (this._syncing) return; if (!game.user.isGM) return; + if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): pause push both dirs const chronicleId = this._getChronicleEventId(eventData.id); if (!chronicleId) return; @@ -1065,19 +1282,56 @@ export class CalendarSync { async _syncChronicleEventsToCalendariaNotes() { if (!this._hasModernCalendariaApi && !game.Calendaria?.createEvent) return; + // GET /calendar/events is MONTH-FILTERED (defaults to the calendar's current + // year+month), so the old bare fetch silently mapped only current-month + // events and missed the entire back-catalog (FM-CAL-SYNC-HOTFIX item 2). + // Enumerate each month across the current year ±1 from the cached Chronicle + // structure — bounded, never unbounded history. + const coords = calendarEventFetchCoordinates(this._chronicleCalendar, 1); + // No cached structure to enumerate (e.g. /calendar returned no months) → + // fall back to the single default (current-month) fetch rather than nothing. + const fetches = coords.length ? coords : [null]; + + const seen = new Set(); + let created = 0; + // Suppress the echo: _createLocalEvent → CALENDARIA.api.createNote fires the + // synchronous calendaria.noteCreated hook, which would otherwise re-POST the + // just-pulled event back to Chronicle as a DUPLICATE. Hold the _syncing guard + // across the create loop, exactly as the WS pull path _onChronicleEventCreated + // does around the same call. + this._syncing = true; try { - const events = await this._api.get('/calendar/events'); - if (!Array.isArray(events)) return; - - for (const event of events) { - const localId = this._getLocalEventId(event.id); - if (localId) continue; // Already synced. - - await this._createLocalEvent(event); + for (const coord of fetches) { + const path = coord + ? `/calendar/events?year=${coord.year}&month=${coord.month}` + : '/calendar/events'; + let events; + try { + events = await this._api.get(path); + } catch (err) { + console.debug('Chronicle: back-catalog fetch failed for', path, err.message); + continue; + } + if (!Array.isArray(events)) continue; + for (const event of events) { + // Dedupe across months: a recurring event can surface in several + // month windows, but maps to ONE Chronicle event id. + if (!event || event.id == null || seen.has(event.id)) continue; + seen.add(event.id); + if (this._getLocalEventId(event.id)) continue; // already synced + await this._createLocalEvent(event); + created++; + } } + const bound = coords.length + ? `years ${this._chronicleCalendar.current_year - 1}–${this._chronicleCalendar.current_year + 1}, ${fetches.length} month-window(s)` + : 'current month only (no calendar structure cached)'; + console.debug(`Chronicle: back-catalog event sync complete — scanned ${bound}; created ${created} local note(s).`); } catch (err) { // Calendar events endpoint may not exist yet; not critical. - console.debug('Chronicle: Could not fetch calendar events for initial sync', err.message); + console.debug('Chronicle: Could not sync calendar events back-catalog', err.message); + } finally { + this._syncing = false; } } diff --git a/scripts/sync-calendar-diagnostics.mjs b/scripts/sync-calendar-diagnostics.mjs index 04eac4b..3b3b9f4 100644 --- a/scripts/sync-calendar-diagnostics.mjs +++ b/scripts/sync-calendar-diagnostics.mjs @@ -104,6 +104,8 @@ export function buildCalendarDiagnostics(input = {}) { L.push(kv('Calendar sync enabled (global)', boolLabel(s.calendarSyncEnabled))); L.push(kv('Master sync enabled', boolLabel(s.syncEnabled))); L.push(kv('This calendar excluded from sync', boolLabel(s.thisCalendarExcluded))); + L.push(kv('Calendar sync paused (structure mismatch)', boolLabel(s.calendarSyncPaused))); + if (s.mismatchDetail) L.push(kv('Mismatch detail', s.mismatchDetail)); L.push(''); // --- Calendar identity + structure --- diff --git a/scripts/sync-dashboard.mjs b/scripts/sync-dashboard.mjs index 98c0523..589cbf7 100644 --- a/scripts/sync-dashboard.mjs +++ b/scripts/sync-dashboard.mjs @@ -278,6 +278,7 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { unmatchedMembers: membersData.unmatchedCount, calendarAvailable: calendarData.available, calendarInSync: calendarData.inSync, + calendarSyncPaused: calendarData.structureMismatch, errorCount: statusData.errorLog?.length ?? 0, matchedSystem: statusData.matchedSystem, lastSyncTime: statusData.lastSyncTime, @@ -666,6 +667,13 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { && chronicle.current_month === localDate?.month && chronicle.current_day === localDate?.day; + // Structure-mismatch guard state (FM-CAL-SYNC-HOTFIX item 3, B-R2): surfaced + // from the live CalendarSync instance so the dashboard can show a persistent + // warning + a "paused" badge when calendar sync has been paused this session. + const calSync = this._getCalendarSyncModule(); + const structureMismatch = !!calSync?._calendarSyncDisabled; + const mismatchDetail = calSync?._calendarMismatchDetail || null; + return { available: true, enabled: calendarEnabled, @@ -680,9 +688,22 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { }, localDate, inSync, + structureMismatch, + mismatchDetail, }; } + /** + * Resolve the live CalendarSync module instance from the SyncManager (for the + * structure-mismatch guard state). Mirrors the class-name lookup used + * elsewhere in the dashboard. Returns null when unavailable. + * @returns {object|null} + * @private + */ + _getCalendarSyncModule() { + return this._syncManager?._modules?.find((m) => m?.constructor?.name === 'CalendarSync') ?? null; + } + /** * Detect which Foundry calendar module is active. * @returns {string|null} @@ -2777,11 +2798,15 @@ export class SyncDashboard extends HandlebarsApplicationMixin(ApplicationV2) { } } catch { /* defensive */ } const calendarModule = this._detectCalendarModule(); + const calSync = this._getCalendarSyncModule(); const syncStatus = { calendarModule: calendarModule ?? 'none', calendarSyncEnabled: getSetting('syncCalendar'), syncEnabled: getSetting('syncEnabled'), thisCalendarExcluded: null, + // Structure-mismatch guard (B-R2) — surfaced in the bug-report block. + calendarSyncPaused: !!calSync?._calendarSyncDisabled, + mismatchDetail: calSync?._calendarMismatchDetail || null, }; const errorLog = this.api?.getErrorLog() ?? []; const recentErrors = errorLog.slice(0, 10).map((e) => ({ diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs index fe253df..9329704 100644 --- a/templates/sync-dashboard.hbs +++ b/templates/sync-dashboard.hbs @@ -887,6 +887,16 @@

{{localize "CHRONICLE.Dashboard.Calendar.OpenSyncCalendarHint"}}

{{#if calendar.available}} + {{#if calendar.structureMismatch}} +
+ +
+ Calendar sync paused — structure mismatch. +
{{calendar.mismatchDetail}}
+
Import or author the matching calendar in Chronicle. Journals, characters, and maps still sync.
+
+
+ {{/if}}
@@ -922,7 +932,9 @@
- {{#if calendar.inSync}} + {{#if calendar.structureMismatch}} + Sync Paused + {{else if calendar.inSync}} In Sync {{else}} Out of Sync diff --git a/tools/test-calendar-sync-hotfix.mjs b/tools/test-calendar-sync-hotfix.mjs new file mode 100644 index 0000000..fd264f4 --- /dev/null +++ b/tools/test-calendar-sync-hotfix.mjs @@ -0,0 +1,207 @@ +// test-calendar-sync-hotfix.mjs — FM-CAL-SYNC-HOTFIX: off-DOM tests for the +// three calendar-sync fixes (off-by-one, back-catalog pagination, structure +// mismatch guard) plus the _calendariaNoteToChronicleEvent wiring. +// +// Run: node --test tools/test-calendar-sync-hotfix.mjs +// +// The date-normalization behavior is verified against Calendaria release-1.1.3 +// source: raw note* hooks carry 0-indexed month/dayOfMonth + absolute year; +// CALENDARIA.api.getNote returns toPublic (1-indexed) dates. + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Stub the Foundry globals calendar-sync.mjs touches at module-load time. +globalThis.foundry = globalThis.foundry || { + applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (base) => base } }, +}; +globalThis.game = globalThis.game || { + settings: { get: () => null, register: () => {}, registerMenu: () => {} }, + i18n: { localize: (k) => k, format: (k) => k }, + user: { isGM: true }, + modules: { get: () => null }, +}; +globalThis.Hooks = globalThis.Hooks || { on: () => {}, off: () => {} }; + +const { + chronicleDateFromCalendariaStartDate, + calendarEventFetchCoordinates, + compareCalendarStructures, + CalendarSync, +} = await import('../scripts/calendar-sync.mjs'); + +// ── Item 1: date normalization (the off-by-one core) ───────────────────────── + +test('raw hook startDate (0-indexed month + dayOfMonth) is corrected +1', () => { + // March 15: Calendaria raw stores month 2 / dayOfMonth 14. + assert.deepEqual( + chronicleDateFromCalendariaStartDate({ year: 1492, month: 2, dayOfMonth: 14 }), + { year: 1492, month: 3, day: 15 }, + ); +}); + +test('the first-of-month / January zero case maps to 1/1, not an invalid 0/0', () => { + assert.deepEqual( + chronicleDateFromCalendariaStartDate({ year: 1492, month: 0, dayOfMonth: 0 }), + { year: 1492, month: 1, day: 1 }, + ); +}); + +test('a public (toPublic / getNote) startDate with `day` is NOT double-corrected', () => { + // Already 1-indexed (dayOfMonth deleted, day set). Pass through unchanged. + assert.deepEqual( + chronicleDateFromCalendariaStartDate({ year: 1492, month: 3, day: 15 }), + { year: 1492, month: 3, day: 15 }, + ); +}); + +test('the year is absolute and never adjusted (no yearZero math)', () => { + const out = chronicleDateFromCalendariaStartDate({ year: 20250, month: 5, dayOfMonth: 9 }); + assert.equal(out.year, 20250); + assert.deepEqual(out, { year: 20250, month: 6, day: 10 }); +}); + +test('missing/invalid startDate returns null', () => { + assert.equal(chronicleDateFromCalendariaStartDate(null), null); + assert.equal(chronicleDateFromCalendariaStartDate({}), null); // no year +}); + +// ── Item 1: _calendariaNoteToChronicleEvent wiring (raw vs getNote) ─────────── + +function makeCalendarSync(overrides) { + return Object.assign(Object.create(CalendarSync.prototype), { _hasModernCalendariaApi: false }, overrides); +} + +test('_calendariaNoteToChronicleEvent: raw hook payload (no modern API) is +1 corrected', () => { + const cs = makeCalendarSync({ _hasModernCalendariaApi: false }); + const out = cs._calendariaNoteToChronicleEvent({ + name: 'Harvest Moon', + flagData: { startDate: { year: 1492, month: 2, dayOfMonth: 14 } }, // raw March 15 + }); + assert.equal(out.year, 1492); + assert.equal(out.month, 3); + assert.equal(out.day, 15); + assert.equal(out.name, 'Harvest Moon'); +}); + +test('_calendariaNoteToChronicleEvent: getNote (public) date wins and is NOT double-corrected', () => { + // getNote returns toPublic (1-indexed) data — it must take precedence over the + // raw hook payload and receive no +1. + globalThis.CALENDARIA = { + api: { getNote: () => ({ name: 'Council', flagData: { startDate: { year: 1492, month: 3, day: 15 } } }) }, + }; + const cs = makeCalendarSync({ _hasModernCalendariaApi: true }); + const out = cs._calendariaNoteToChronicleEvent({ + id: 'note-1', + name: 'Council', + flagData: { startDate: { year: 1492, month: 1, dayOfMonth: 0 } }, // stale raw payload — should be ignored + }); + assert.equal(out.month, 3); // from getNote (public), not raw month 1 → +1 = 2 + assert.equal(out.day, 15); + assert.equal(out.year, 1492); + delete globalThis.CALENDARIA; +}); + +test('_calendariaNoteToChronicleEvent: getNote failure falls back to the raw payload with +1', () => { + globalThis.CALENDARIA = { api: { getNote: () => { throw new Error('note gone'); } } }; + const cs = makeCalendarSync({ _hasModernCalendariaApi: true }); + const out = cs._calendariaNoteToChronicleEvent({ + id: 'note-2', + name: 'Skirmish', + flagData: { startDate: { year: 1492, month: 2, dayOfMonth: 14 } }, + }); + assert.equal(out.month, 3); + assert.equal(out.day, 15); + delete globalThis.CALENDARIA; +}); + +// ── Item 2: back-catalog fetch coordinates ─────────────────────────────────── + +test('fetch coordinates enumerate every month across current year ±1', () => { + const cal = { current_year: 1492, months: new Array(12).fill({ days: 30 }) }; + const coords = calendarEventFetchCoordinates(cal, 1); + assert.equal(coords.length, 36); // 3 years × 12 months + assert.deepEqual(coords[0], { year: 1491, month: 1 }); + assert.deepEqual(coords[coords.length - 1], { year: 1493, month: 12 }); +}); + +test('a 15-month calendar (Calendar of Therin) yields 45 coordinates over ±1 year', () => { + const cal = { current_year: 500, months: new Array(15).fill({ days: 24 }) }; + assert.equal(calendarEventFetchCoordinates(cal, 1).length, 45); +}); + +test('yearSpan 0 fetches only the current year', () => { + const cal = { current_year: 1492, months: new Array(12).fill({ days: 30 }) }; + const coords = calendarEventFetchCoordinates(cal, 0); + assert.equal(coords.length, 12); + assert.ok(coords.every((c) => c.year === 1492)); +}); + +test('no calendar / no months yields no coordinates (caller falls back to a bare fetch)', () => { + assert.deepEqual(calendarEventFetchCoordinates(null), []); + assert.deepEqual(calendarEventFetchCoordinates({ current_year: 1492, months: [] }), []); + assert.deepEqual(calendarEventFetchCoordinates({ current_year: 1492 }), []); +}); + +// ── Item 2: the back-catalog must NOT echo (holds _syncing during create) ──── + +test('back-catalog sync holds _syncing while creating local notes (no echo re-push)', async () => { + const syncingAtCreate = []; + const cs = makeCalendarSync({ + _hasModernCalendariaApi: true, + _syncing: false, + _chronicleCalendar: { current_year: 1492, months: new Array(12).fill({ days: 30 }) }, + _api: { get: async () => [{ id: 'e1' }] }, // each month-window returns the same event + _getLocalEventId: () => null, // nothing mapped yet + _createLocalEvent(event) { + // _createLocalEvent → CALENDARIA.api.createNote fires the noteCreated hook + // synchronously; _syncing MUST be true here to suppress the echo re-push. + syncingAtCreate.push(this._syncing); + return Promise.resolve(); + }, + }); + await cs._syncChronicleEventsToCalendariaNotes(); + assert.ok(syncingAtCreate.length > 0, 'at least one event was created'); + assert.ok(syncingAtCreate.every((v) => v === true), '_syncing must be held true during every create'); + assert.equal(cs._syncing, false, '_syncing is reset after the loop'); +}); + +// ── Item 3: structure comparison (B-R2) ────────────────────────────────────── + +const gregorian = { + months: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].map((d) => ({ days: d })), + weekdays: new Array(7).fill({}), +}; + +test('identical structures match', () => { + const foundry = { name: 'Gregorian', monthDays: gregorian.months.map((m) => m.days), weekdayCount: 7 }; + const r = compareCalendarStructures(gregorian, foundry); + assert.equal(r.match, true); + assert.equal(r.detail, ''); +}); + +test('the operator’s real case: Gregorian 12mo/7wd vs Therin 15mo/6wd → mismatch', () => { + const therin = { name: 'Calendar of Therin', monthDays: new Array(15).fill(24), weekdayCount: 6 }; + const r = compareCalendarStructures(gregorian, therin); + assert.equal(r.match, false); + assert.match(r.detail, /month count \(Chronicle 12 vs Foundry 15\)/); + assert.match(r.detail, /weekday count \(Chronicle 7 vs Foundry 6\)/); +}); + +test('same month count but a different per-month day count is a mismatch', () => { + const foundry = { + name: 'Almost', + monthDays: [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], // Feb 29 not 28 + weekdayCount: 7, + }; + const r = compareCalendarStructures(gregorian, foundry); + assert.equal(r.match, false); + assert.match(r.detail, /month 2 day count \(Chronicle 28 vs Foundry 29\)/); +}); + +test('weekday count alone differing is a mismatch', () => { + const foundry = { name: 'SixDay', monthDays: gregorian.months.map((m) => m.days), weekdayCount: 6 }; + const r = compareCalendarStructures(gregorian, foundry); + assert.equal(r.match, false); + assert.match(r.detail, /weekday count/); +}); From 8e1e19f84a164814ee4a27af1d3b92d7f5526699 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 04:22:20 +0000 Subject: [PATCH 2/2] ci: shell-expand the test glob (Node 20 lacks node --test glob support) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014bAhi59Sajsmd5bGCu7Smp --- .github/workflows/check-descriptor.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/check-descriptor.yml b/.github/workflows/check-descriptor.yml index 74b4902..b61995f 100644 --- a/.github/workflows/check-descriptor.yml +++ b/.github/workflows/check-descriptor.yml @@ -29,9 +29,12 @@ jobs: - name: Validate chronicle-package.json run: node tools/check-package-descriptor.mjs - # Full off-DOM test suite (B-R8). The glob is quoted so the shell passes - # it to Node literally — Node's own glob matches each tools/test-*.mjs file - # (the bare directory form `node --test tools/` errors on Node 20). This - # supersedes the old single-file test-update-info step. + # Full off-DOM test suite (B-R8). The glob is UNQUOTED so the bash shell + # expands tools/test-*.mjs to explicit file args — Node's own glob for + # --test only landed in Node 21+, and this workflow pins Node 20, so a + # quoted glob reaches Node literally and "cannot be found". The bare + # directory form (`node --test tools/`) also errors on Node 20. Shell + # expansion works on every Node version. This supersedes the old + # single-file test-update-info step. - name: Run test suite - run: node --test 'tools/test-*.mjs' + run: node --test tools/test-*.mjs