From 58656f03dd93ae9bc038f5300bc5d4d660838677 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 06:25:30 +0000 Subject: [PATCH 1/7] Remove the Calendaria and Simple Calendar integrations The module no longer talks to third-party calendar modules; Chronicle's own calendar inside Foundry replaces them. CalendarSync keeps Chronicle's calendar state, the calendar.* WebSocket routing and the guarded date push for that calendar to use. The Sync Calendar editor, its scene button and menu, the setup wizard's calendar step and the dashboard's Foundry-date comparison go with the integrations. Notes those modules left in a world are still never pushed to Chronicle as entities. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn --- .ai.md | 128 +- API-CONTRACT.md | 64 +- CALENDARIA-INTEGRATION.md | 230 --- CLAUDE.md | 22 +- CONSTANTS.md | 10 - README.md | 4 +- TESTING.md | 26 +- lang/en.json | 235 +-- module.json | 13 - scripts/_calendar-probe-state.mjs | 4 +- scripts/_calendar-subresources.mjs | 2 +- scripts/_scene-controls.mjs | 12 +- scripts/calendar-sync.mjs | 1772 ++--------------- scripts/import-wizard.mjs | 29 +- scripts/journal-sync.mjs | 8 +- scripts/module.mjs | 8 +- scripts/settings.mjs | 39 - scripts/sync-calendar-condition-builder.mjs | 287 --- scripts/sync-calendar-diagnostics.mjs | 205 -- .../sync-calendar-import-from-calendaria.mjs | 241 --- scripts/sync-calendar-moon-strip.mjs | 180 -- scripts/sync-calendar-note-form.mjs | 350 ---- scripts/sync-calendar-validation.mjs | 296 --- scripts/sync-calendar.mjs | 1494 -------------- scripts/sync-dashboard.mjs | 352 +--- styles/chronicle-sync.css | 851 -------- styles/sync-calendar-pr3.css | 358 ---- templates/import-wizard.hbs | 25 +- templates/sync-calendar.hbs | 621 ------ templates/sync-dashboard.hbs | 110 +- tools/test-applied-date-confirm.mjs | 221 +- tools/test-calendar-backcatalog-fix.mjs | 262 +-- tools/test-calendar-blackout.mjs | 2 +- tools/test-calendar-mismatch-remedy.mjs | 129 -- tools/test-calendar-note-journal.mjs | 79 +- tools/test-calendar-resilience.mjs | 123 +- tools/test-calendar-subresource-routing.mjs | 197 +- tools/test-calendar-sync-hotfix.mjs | 219 -- tools/test-calendar-sync-state.mjs | 6 +- tools/test-calendar-sync-visibility.mjs | 99 +- tools/test-dashboard-calendar-data.mjs | 88 + tools/test-dashboard-local-calendar-date.mjs | 55 - tools/test-import-wizard-steps.mjs | 2 +- tools/test-lang-expand.mjs | 33 - tools/test-partial-put-contract.mjs | 42 +- tools/test-realtime-date-signal.mjs | 111 +- tools/test-scene-controls.mjs | 10 +- .../test-sync-calendar-condition-builder.mjs | 326 --- tools/test-sync-calendar-diagnostics.mjs | 105 - tools/test-sync-calendar-discoverability.mjs | 118 -- ...t-sync-calendar-import-from-calendaria.mjs | 284 --- tools/test-sync-calendar-moon-strip.mjs | 442 ---- tools/test-sync-calendar-note-form.mjs | 462 ----- tools/test-sync-calendar-probe-state.mjs | 27 +- tools/test-sync-calendar-validation.mjs | 457 ----- ...st-sync-dashboard-calendar-diagnostics.mjs | 116 -- tools/test-sync-wire-fix.mjs | 97 - tools/test-template-roots.mjs | 21 - 58 files changed, 600 insertions(+), 11509 deletions(-) delete mode 100644 CALENDARIA-INTEGRATION.md delete mode 100644 scripts/sync-calendar-condition-builder.mjs delete mode 100644 scripts/sync-calendar-diagnostics.mjs delete mode 100644 scripts/sync-calendar-import-from-calendaria.mjs delete mode 100644 scripts/sync-calendar-moon-strip.mjs delete mode 100644 scripts/sync-calendar-note-form.mjs delete mode 100644 scripts/sync-calendar-validation.mjs delete mode 100644 scripts/sync-calendar.mjs delete mode 100644 styles/sync-calendar-pr3.css delete mode 100644 templates/sync-calendar.hbs delete mode 100644 tools/test-calendar-mismatch-remedy.mjs delete mode 100644 tools/test-calendar-sync-hotfix.mjs create mode 100644 tools/test-dashboard-calendar-data.mjs delete mode 100644 tools/test-dashboard-local-calendar-date.mjs delete mode 100644 tools/test-sync-calendar-condition-builder.mjs delete mode 100644 tools/test-sync-calendar-diagnostics.mjs delete mode 100644 tools/test-sync-calendar-discoverability.mjs delete mode 100644 tools/test-sync-calendar-import-from-calendaria.mjs delete mode 100644 tools/test-sync-calendar-moon-strip.mjs delete mode 100644 tools/test-sync-calendar-note-form.mjs delete mode 100644 tools/test-sync-calendar-validation.mjs delete mode 100644 tools/test-sync-dashboard-calendar-diagnostics.mjs diff --git a/.ai.md b/.ai.md index 3f9862e..4bf1c13 100644 --- a/.ai.md +++ b/.ai.md @@ -13,7 +13,7 @@ module.mjs (entry point) ├─ ChronicleAPI (api-client.mjs) ← REST + WebSocket client ├─ JournalSync (journal-sync.mjs) ← Entity ↔ JournalEntry ├─ MapSync (map-sync.mjs) ← Map + markers/drawings/tokens/fog/layers ↔ JournalEntry (image page) with overlay - ├─ CalendarSync (calendar-sync.mjs) ← Calendar events/date ↔ Calendaria/SimpleCalendar + ├─ CalendarSync (calendar-sync.mjs) ← Chronicle calendar date/events/world state (no Foundry calendar integrated) ├─ ShopWidget (shop-widget.mjs) ← Chronicle shops as shop rooms in Foundry ├─ ItemSync (item-sync.mjs) ← Actor inventory ↔ Chronicle relations ├─ SyncDashboard (sync-dashboard.mjs) ← 10-tab ApplicationV2 status/management UI @@ -31,40 +31,36 @@ module.mjs (entry point) `_syncing`-guard convention, real-time-calendar date-push guard, calendar sub-resource display-only rule: see CLAUDE.md → Code Conventions. -`scripts/_realtime-date-guard.mjs` is shared by `calendar-sync.mjs`'s three -hook-triggered pushes and `sync-dashboard.mjs`'s manual push button, with a -one-time-per-session GM notice as a shared module-level singleton. +`scripts/_realtime-date-guard.mjs` guards `CalendarSync.pushDate`, with a +one-time-per-session GM notice as a module-level singleton. ### Applied-date confirmation Chronicle's sync chip distinguishes what Foundry *saw* (the `GET -/calendar/date` beacon) from what it *applied*. `calendar-sync.mjs`'s -`_setLocalDate` (choke point for both the poll and WS -`calendar.date.advanced` apply paths) resolves `Promise` reporting -whether a real local setter ran without throwing. Both sites then call +/calendar/date` beacon) from what it *applied*. `_applied-date-confirm.mjs`'s `confirmAppliedDate(api, {year, month, day})` → -`POST /calendar/date/confirm`, only on `true`. A 404/405 (older Chronicle) is -tolerated and logged once per session. `tools/test-applied-date-confirm.mjs`. +`POST /calendar/date/confirm` is for the caller that applied a date to a +Foundry calendar, only after a real apply. A 404/405 (older Chronicle) is +tolerated and logged once per session. No Foundry calendar is integrated, so +nothing calls it yet (TODO(#95): the built-in calendar). `tools/test-applied-date-confirm.mjs`. ### Sync wire rules - `sync-manager.mjs`'s `_onSyncStatus` reads `msg.status ?? msg.payload?.status` (emit shapes vary by message). -- SimpleCalendar shares Calendaria's structure-mismatch guard; fails open on - an unreadable structure. -- Dashboard sync badge: pure four-state classifier in - `scripts/_calendar-sync-state.mjs` (`in-sync` / `date-drift` / - `incompatible-structures` / `paused`). +- Sync-state classifier `scripts/_calendar-sync-state.mjs` (`in-sync` / + `date-drift` / `incompatible-structures` / `paused`) is unused until a + Foundry calendar exists to compare against (TODO(#95)). - Visibility toggles: `POST /entities/:id/reveal`, not a bare `PUT` (400s without a name). - Item relations: flat `DELETE|PUT /relations/:relationId`, snake_case bodies. -- Chronicle → Foundry calendar events: `isChronicleEventPublic` decides note visibility. GM-only events, and `everyone` events with `visibility_rules`, become hidden Calendaria notes; legacy Calendaria and Simple Calendar can't hide notes, so those events are not copied there. Fails closed. `tools/test-calendar-sync-visibility.mjs`. +- Chronicle calendar events: `isChronicleEventPublic` decides whether an event may be shown to every player (GM-only events, and `everyone` events with `visibility_rules`, may not). Fails closed. Nothing consumes it until the built-in calendar (TODO(#95)). `tools/test-calendar-sync-visibility.mjs`. Pinned by `tools/test-sync-wire-fix.mjs`, `tools/test-calendar-sync-state.mjs`. ### Journals, catch-up and concurrency -- **Journal made in Foundry → Chronicle page.** `POST /entities` with a real `entity_type_id` (world setting `journalCreateTypeId`, chosen on the dashboard Config tab; unset means the campaign's first non-character type), then the text as `entry` in `PUT /entities/:id`, because Chronicle's create request has no text field. Journals owned by MapSync (`mapId` page flag or the Maps folder) or CalendarSync, and the old Chronicle Notes folder, are skipped. `_journal-create.mjs`, `_journal-ownership.mjs`; `tools/test-journal-create.mjs`. +- **Journal made in Foundry → Chronicle page.** `POST /entities` with a real `entity_type_id` (world setting `journalCreateTypeId`, chosen on the dashboard Config tab; unset means the campaign's first non-character type), then the text as `entry` in `PUT /entities/:id`, because Chronicle's create request has no text field. Journals owned by MapSync (`mapId` page flag or the Maps folder) or already linked to a calendar event (`calendarEventId` flag), and the old Chronicle Notes folder, are skipped. `_journal-create.mjs`, `_journal-ownership.mjs`; `tools/test-journal-create.mjs`. - **Per-document guarding, no global flag.** Incoming Chronicle changes apply per entity id in arrival order (`_keyed-queue.mjs`); creates re-check for an existing journal inside the queue slot. Every write sync makes passes `SYNC_OPTIONS` (`{chronicleSync: true}`), and the create/update/delete hooks ignore only writes carrying it, so a GM edit during an apply still pushes. A journal whose create POST is in flight is remembered so the `entity.created` broadcast makes no second copy. `tools/test-journal-concurrency.mjs`. - **Unsent local edit wins.** An incoming change for a journal with a debounced push pending is not applied; the stored `chronicleUpdatedAt` moves to the incoming version, the push goes out (Chronicle updates are partial) and the GM gets a warning. - **Catch-up on connect.** `/sync/pull` is walked through `has_more` in 1000-row pages, each resuming just before the previous page's last `updated_at` (the server's `since` is strict and has no cursor) with repeats dropped by id (`_sync-pull-walk.mjs`); `lastSyncTime` is the first page's `server_time`, written only after the whole walk. The first sync latches `_initialSyncDone` only on success. `JournalSync.onInitialSync` runs the entity-list resync (changed pages, plus pages created in Chronicle since the last sync; an older page with no journal was deleted or never imported here and is not recreated; a first connect creates nothing) then sets aside journals whose page returns 404, only after a complete walk. Actor lists page via `walkEntityPages`. `tools/test-initial-sync-catchup.mjs`, `tools/test-journal-reconcile.mjs`. @@ -72,16 +68,15 @@ Pinned by `tools/test-sync-wire-fix.mjs`, `tools/test-calendar-sync-state.mjs`. - **Versions only move forward.** A journal's `chronicleUpdatedAt` is never set older than it is: an incoming copy older than it is ignored (an echo built before a later save), equal versions still apply (one-second precision). A permissions push is skipped when identical to the last one sent for that journal (`pushedPermissions`), and after a real one the module reads the page back for the version it stamped, because Chronicle's permissions save returns none and broadcasts the old one. `tools/test-journal-versions.mjs`. - **Journal pushes** come from journal edits and from page create/edit/delete hooks (page text never fires `updateJournalEntry`), through the same per-journal debounce. A pulled sync mapping never creates a journal: the module only writes a mapping after making the journal, so a mapping without one means the GM deleted it here; the dashboard Resync brings it back on purpose. `tools/test-journal-page-edits.mjs`, `tools/test-journal-reconcile.mjs`, `bench/journals.bench.mjs`. - **Actor pushes** are debounced per actor, take the mapped fields the edit touched (`_actor-field-diff.mjs`, adapter `toChronicleFieldsChanged`) and lay them over the entity's current `fields_data` before the PUT (`_putFieldsMerged`), because older Chronicle replaces the whole set on that route; a failed read pushes nothing. The name goes first with the stored version, then re-read `updated_at` after the fields PUT. `tools/test-actor-push.mjs`. -Calendaria back-catalog note mappings are per-GM-user, not per-world (#95). ## Files | File | Purpose | |------|---------| -| `module.json` | Module manifest. Foundry v12-v14. Optional deps: Calendaria, Monk's Enhanced Journal | +| `module.json` | Module manifest. Foundry v12-v14. Optional dep: Monk's Enhanced Journal | | `scripts/module.mjs` | Entry point: settings on `init`, SyncManager on `ready` (GM only) | | `scripts/_keyed-queue.mjs`, `_journal-ownership.mjs`, `_journal-create.mjs`, `_sync-pull-walk.mjs`, `_actor-field-diff.mjs` | Pure helpers for the section above, each with a `tools/test-*.mjs` | -| `scripts/settings.mjs` | Settings shown in Foundry's Module Settings: `apiUrl`, `campaignId`, `syncEnabled`, `syncJournals`/`syncMaps`/`syncCalendar`/`syncCharacters` (world), `apiKey` and `connectLine` (client), `skipIncomingSanitization`, `calendarAnnounce*`. Hidden (`config: false`): `lastSyncTime`, `changeFeedCursor`, `journalCreateTypeId`, `detectedSystem`, `userMappings`, `stashesAvailable`, `syncExclusions`, `syncDirections`, `syncPermissions`, `defaultOwnership`, `dmOnlyHidden`, `conflictResolution`, `autoSync`, `excludedTags`, `excludedNamePattern`, `calendarSyncExclusions`, `wizardCompleted`, and the client-scoped `notesGrants`, `dashboardActiveTab`, `dashboardCollapsedTypes`. Menus: Update Source, Sync Calendar | +| `scripts/settings.mjs` | Settings shown in Foundry's Module Settings: `apiUrl`, `campaignId`, `syncEnabled`, `syncJournals`/`syncMaps`/`syncCalendar`/`syncCharacters` (world), `apiKey` and `connectLine` (client), `skipIncomingSanitization`, `calendarAnnounce*`. Hidden (`config: false`): `lastSyncTime`, `changeFeedCursor`, `journalCreateTypeId`, `detectedSystem`, `userMappings`, `stashesAvailable`, `syncExclusions`, `syncDirections`, `syncPermissions`, `defaultOwnership`, `dmOnlyHidden`, `conflictResolution`, `autoSync`, `excludedTags`, `excludedNamePattern`, `wizardCompleted`, and the client-scoped `notesGrants`, `dashboardActiveTab`, `dashboardCollapsedTypes`. Menus: Update Source | | `scripts/constants.mjs` | FLAG_SCOPE, MODULE_ID | | `scripts/api-client.mjs` | REST (Bearer) + WebSocket (auto-reconnect, message queue, event emitter) | | `scripts/sync-manager.mjs` | Orchestrator: owns API client, routes WS, sync mapping lookups | @@ -97,23 +92,16 @@ Calendaria back-catalog note mappings are per-GM-user, not per-world (#95). | `scripts/update-info.mjs` | "Update Source" diagnostic dialog — see "Chronicle Integration" below | | `scripts/npc-presence.mjs` | GM token-HUD tools for Chronicle NPCs: Spotlight (Foundry socket `module.chronicle-sync`, type `npc-presence`; each client pans and draws a ring only if it can see the token, and players get the name only when the page or nameplate is already shown), Talking (token flag `talking: {at}`, one NPC at a time, off after two quiet minutes, glow eases to still while the viewer is idle or the tab hidden), Open page. A token finds its page by actor flag `npcEntityId` (set by dropping a Chronicle journal on the token) or a unique name match; hero actors stay with `actor-sync`. Revealing a hidden NPC token asks before calling `POST /entities/:id/reveal`. Chronicle's "Show in Foundry" (WS `npc.spotlight`) runs the same spotlight on the active GM's client via `npcSpotlightRelay`, picking a shown token linked to that page on the GM's current scene. Pure rules in `_npc-presence.mjs`; `tools/test-npc-presence.mjs` | | `scripts/negotiation-mirror.mjs` | SyncManager module for WS `system_state.updated` (Draw Steel `negotiation` tracker). Active GM only, `game.system.id === 'draw-steel'` only: fetches `GET /entities/:id/system-state/drawsteel/negotiation` (404 is quiet) and writes interest, patience, motivations, pitfalls and impression onto the page's NPC actors' `system.negotiation`, skipping writes when nothing differs. Actors: flag `npcEntityId`, else a unique NPC name match; heroes never. Also runs when a page is linked by dropping a journal on a token (`onNpcLinked` in `npc-presence.mjs`). One way only: Foundry sheet edits don't sync back yet. Failures are logged, never shown to players. Pure rules in `_negotiation-mirror.mjs`; `tools/test-negotiation-mirror.mjs` | -| `scripts/sync-calendar.mjs` | "Sync Calendar" editor (ApplicationV2, GM-only): year/month/day view + moon strip + writable note form (drag-select, recurrence presets); empty-state Calendaria import. Weather/structure editing not implemented | -| `scripts/sync-calendar-validation.mjs` | Pure validation rule engine → `null` or `{severity, code, message, ...}`. `tools/test-sync-calendar-validation.mjs` | -| `scripts/sync-calendar-note-form.mjs` | Pure form ↔ Calendaria API translation; round-trips `conditionTree`. `tools/test-sync-calendar-note-form.mjs` | -| `scripts/sync-calendar-moon-strip.mjs` | Pure moon-strip view-model + convergence detection. `tools/test-sync-calendar-moon-strip.mjs` | -| `scripts/sync-calendar-condition-builder.mjs` | Pure conditionTree preset composer/validator (root-must-be-group rule). `tools/test-sync-calendar-condition-builder.mjs` | -| `scripts/sync-calendar-diagnostics.mjs` | `buildCalendarDiagnostics(input)` → Markdown report, backs both "Copy diagnostics" buttons. `tools/test-sync-calendar-diagnostics.mjs` | -| `scripts/sync-calendar-import-from-calendaria.mjs` | Calendaria → Chronicle calendar-create transform, `IMPORT_WIRE_VERSION: 1`. `tools/test-sync-calendar-import-from-calendaria.mjs` | | `chronicle-package.json` | Repo-root serving descriptor (schema v1) — see "Chronicle Integration" | | `tools/check-package-descriptor.mjs` | CI: validates descriptor against `module.json`, via `.github/workflows/check-descriptor.yml` | | `tools/check-error-catalog.mjs` | CI: API-CONTRACT.md's error table and `update-info.mjs`'s categories match Chronicle's `error-catalog.json`, via `.github/workflows/check-error-catalog.yml`. `tools/test-error-catalog.mjs` | | `scripts/_icon-name.mjs` | `toIconName`: bare Font Awesome name for categories the import wizard creates. `tools/test-icon-name.mjs` | | `tools/test-lang-expand.mjs` | CI guard for `lang/en.json` — see F-LANG-1 | | `tools/test-template-roots.mjs` | CI guard: PARTS templates render exactly one root — see F-PR3-4 | -| `scripts/calendar-sync.mjs` | Adapter pattern for Calendaria/SimpleCalendar; 0-↔1-indexed date conversion | +| `scripts/calendar-sync.mjs` | Pulls Chronicle's calendar (date, events, weather/season/era/moons) and routes `calendar.*` WS types; `pushDate` is the guarded `PUT /calendar/date`. Writes nothing into Foundry; TODO(#95) built-in calendar. `tools/test-calendar-backcatalog-fix.mjs`, `tools/test-calendar-resilience.mjs`, `tools/test-calendar-subresource-routing.mjs` | | `scripts/_realtime-date-guard.mjs` | `shouldSkipDatePush`, `isRealTimeRejection` (422 backstop), `notifyRealTimePushPaused`. `tools/test-realtime-date-signal.mjs` | | `scripts/_applied-date-confirm.mjs` | See "Applied-date confirmation" above. `tools/test-applied-date-confirm.mjs` | -| `scripts/_calendar-sync-state.mjs` | `classifyCalendarSyncState`; priority module-paused > structure mismatch > date drift > in-sync, fails open. `tools/test-calendar-sync-state.mjs` | +| `scripts/_calendar-sync-state.mjs` | `classifyCalendarSyncState`; priority paused > structure mismatch > date drift > in-sync, fails open; unused until a Foundry calendar exists. `tools/test-calendar-sync-state.mjs` | | `scripts/shop-widget.mjs` | Opens Chronicle shops (journal context menu, dashboard) as shop rooms; refreshes open rooms on WebSocket events | | `scripts/shop-room-window.mjs` | Shop room window (ApplicationV2): runs Chronicle's own `shop_room` widget from `vendor/chronicle/` against a stand-in for Chronicle's page globals; GM reads `GET /armory/shops/:id/room`, "Show to players" sends the room over the module socket; players' clients open it (`registerShopRoomSocket`). Buying is the widget's own basket: the GM's client calls `/buyers` and `/buy` directly; a player's basket goes over the socket to the active GM's client (`setShopBuyRelay`, set by `ShopWidget`), which buys as the Chronicle member the player is matched to (`actingUserId`), only for a shop it is showing, and whispers each sale to the GMs. The sender is Foundry's own socket sender id, never a field in the message; answers are encrypted to the asking request's one-off key (`_stash-crypto.mjs`), since they show a character's coins | | `scripts/dm-screen.mjs` | GM-only DM Screen window from the Chronicle toolbar group: Chronicle's `GET /dm-screen` drawn as three parchment leaves (world, party folded per hero, rules and reveals), with the site's d20 opening (click or Escape skips) and reveal/downtime actions. Pure parsing in `_dm-screen-view.mjs` (`tools/test-dm-screen-view.mjs`) | @@ -126,7 +114,7 @@ Calendaria back-catalog note mappings are per-GM-user, not per-world (#95). | `scripts/picture-store.mjs` | Local copies of those pictures in `worlds//chronicle-media/.` (Chronicle's signed links expire in minutes, so a journal can't point at Chronicle). GM-only: `GET /media/:id` for the signed link, a cookieless fetch on the `apiUrl` host only, PNG/JPEG/GIF/WebP/AVIF only, 25 MB cap, served type must match, `FilePicker.upload`. Copied once per id. `watchGMPictures` shows GM-only pictures on the GM's screen from a fresh signed link, as `gm-secret-view.mjs` fills their placeholders, outside editors. `tools/test-inline-pictures.mjs` | | `scripts/player-notebook.mjs` | Notebook window and bottom-right "Jot notes" tab for every user: frames of Chronicle's own Journal and Jot pages (`/embed/campaigns/:id/notes/journal\|jots`), fed the player's notes grant over `postMessage`. First use opens Chronicle's Allow window. Tracks the newest open Chronicle-linked sheet so jots follow the page in view | | `scripts/_notes-grant.mjs` | Pure checks for the notebook: Allow/frame URLs, the grant message (Chronicle origin, `cnt_` token, campaign, and the GM's member matching for this Foundry login), per-Foundry-user client storage, frame message source/origin, `PageTracker`. `tools/test-notes-grant.mjs` | -| `scripts/_scene-controls.mjs` | Builds the Chronicle scene-control group: Dashboard + Sync Calendar for GMs only, Notebook for everyone when connected. `tools/test-scene-controls.mjs` | +| `scripts/_scene-controls.mjs` | Builds the Chronicle scene-control group: Dashboard + DM Screen for GMs only, Notebook for everyone when connected. `tools/test-scene-controls.mjs` | | `scripts/_gm-secrets.mjs` | GM-only text in Chronicle page text (``) and GM-only pictures become Foundry secret blocks on pull, each holding only a placeholder whose id is an HMAC (under the GM's API key) of its content; the content stays in the GM client's memory. On push placeholders get their content back (or the text is not sent), and everything in a secret block goes back GM-only; split paragraphs are joined again. Headings inside a secret block never become page breaks. `tools/test-gm-secrets.mjs` | | `scripts/gm-secret-view.mjs` | On the GM's client, fills each placeholder secret block on screen with its content from Chronicle; skips editors and never changes the saved page. | | `scripts/_set-aside.mjs` | Sync never deletes a Foundry journal for a Chronicle-side removal: an entity or note deleted in Chronicle, and the reconnect clean-up of journals that duplicate synced actors, unlink the journal and move it to one "Chronicle: removed" folder instead. `tools/test-set-aside.mjs` | @@ -140,13 +128,13 @@ Calendaria back-catalog note mappings are per-GM-user, not per-world (#95). | `scripts/stash-chat.mjs` | Wires the Approve / Turn down buttons of the request card on `renderChatMessageHTML` (v13+) or `renderChatMessage` (v12); GM only | | `scripts/_stash-model.mjs`, `_stash-relay.mjs`, `_stash-cards.mjs`, `_stash-probe.mjs` | Pure helpers: response unwrapping and move validation; sender-to-member mapping, relay plan, pending requests with timeout; card model, HTML and dedupe; availability verdict and button rule. `tools/test-stash-model.mjs`, `test-stash-relay.mjs`, `test-stash-cards.mjs`, `test-stash-probe.mjs`, `test-stash-wiring.mjs` | | `scripts/adapters/generic-adapter.mjs` | API-driven adapter: `foundry_path` annotations from `/systems/:id/character-fields` → field mappings, for any system | -| `scripts/sync-dashboard.mjs` | 10-tab ApplicationV2 dashboard (Overview, Config, Entities, Shops, Maps, Characters, Calendar, Issues, Members, Status). Members tab: per-member mapping dropdown + matched/UNMATCHED badge. Status tab: Copy Calendar Diagnostics + DM-only-hidden counter | +| `scripts/sync-dashboard.mjs` | 10-tab ApplicationV2 dashboard (Overview, Config, Entities, Shops, Maps, Characters, Calendar, Issues, Members, Status). Members tab: per-member mapping dropdown + matched/UNMATCHED badge. Status tab: DM-only-hidden counter. Calendar tab: Chronicle's date and world state, display only | | `scripts/_overview-model.mjs` | `buildOverviewModel`: the Overview tab's banner, stat tiles and prioritized "needs attention" rows, each routed to the tab that fixes it. `tools/test-overview-model.mjs` | | `scripts/sync-diagnostic-bundle.mjs` | `buildDiagnosticBundle`: Markdown troubleshooting report (versions, connection health, per-resource sync state, field mapping, capability summary, recent activity and errors) behind the Status tab's "Copy Diagnostic Bundle"; callers pass already-redacted data. `tools/test-sync-diagnostic-bundle.mjs` | | `scripts/capability-inspector.mjs` | Status tab's Sync Capability panel: snapshots a chosen actor, diffs it against the system's declared character fields, and renders copyable Markdown/JSON. `tools/test-capability-inspector.mjs` | | `scripts/logger.mjs` | Leveled logger with a 500-entry ring buffer the dashboard exports. `tools/test-logger.mjs` | | `scripts/_dialogs.mjs` | `confirmDialog`/`promptDialog` on DialogV2 with a v12 `Dialog` fallback; dismissal is cancel. `tools/test-dialogs.mjs` | -| `scripts/import-wizard.mjs` | "Setup Wizard" (Config tab): seven steps (Connect, Scan, Types, Tags, Characters, Calendar, Review) that bulk-import a Foundry world into Chronicle; Tags, Characters and Calendar steps are skipped when unavailable. `tools/test-import-wizard-steps.mjs` | +| `scripts/import-wizard.mjs` | "Setup Wizard" (Config tab): six steps (Connect, Scan, Types, Tags, Characters, Review) that bulk-import a Foundry world into Chronicle; Tags and Characters steps are skipped when unavailable. `tools/test-import-wizard-steps.mjs` | | `scripts/character-claim-indicator.mjs` | Read-only "Chronicle: claimed by …/unclaimed" pill in the header of a linked actor's sheet, from the `chronicleOwnerUserId` actor flag; opens the entity page. `tools/test-pc-claiming.mjs` | | `scripts/_member-mapping.mjs` | `buildMemberRows` view-model for the Members tab. `tools/test-permission-sync.mjs` | | `scripts/sync-history-tab.mjs` | The dashboard's History tab (after Status): Chronicle's sync history (`GET /sync/history`) with search, direction and failures filters and "Show older"; keeps its rows across dashboard re-renders | @@ -155,11 +143,9 @@ Calendaria back-catalog note mappings are per-GM-user, not per-world (#95). | `templates/sync-dashboard.hbs` | Handlebars templates | | `styles/chronicle-sync.css` | Status indicator, shop room window, History tab and Debug tab styles | | `styles/stashes.css` | Stashes window, request card and the report box | -| `styles/sync-calendar-pr3.css` | Sidecar: moon-strip grid, recurrence section, import banner; loaded after `chronicle-sync.css` for cascade priority | | `lang/en.json` | UI string localization | | `TESTING.md` | E2E checklist (journals, maps, calendar, shops, characters) | | `bench/` | Two-sided sync bench: real Chronicle, real sync code, fake Foundry world; scenarios in `*.bench.mjs` (see `bench/README.md`) | -| `CALENDARIA-INTEGRATION.md` | Calendaria data-model reference; read before touching any `createJournalEntry` handler — see F-SYNC-1 | ## API Endpoints Used @@ -180,7 +166,7 @@ Full request/response schemas, WebSocket protocol, auth: **API-CONTRACT.md**. | Map Markers | `GET/POST/PUT/DELETE /maps/:id/markers/*` | `map-sync.mjs` | | Stashes | `GET /stashes/downtime`, `GET /stashes/requests`, `POST /stashes/requests/:moveId/approve\|decline`; a player's `GET /stashes/view`, `GET /stashes/history`, `POST /stashes/moves` are relayed through the GM's client with `actingUserId` | `stash-sync.mjs`, `_stash-relay.mjs` | | Calendar | `GET /calendar`, `GET/PUT /calendar/date` (`tracks_real_time` gates push), `POST /calendar/date/confirm` (optional), `POST /calendar/advance`, `POST /calendar/advance-time` | `calendar-sync.mjs`, `_realtime-date-guard.mjs`, `_applied-date-confirm.mjs` | -| Calendar Events | `GET/POST/PUT/DELETE /calendar/events/*` | `calendar-sync.mjs` | +| Calendar Events | `GET /calendar/events` (month-filtered) | `calendar-sync.mjs` | | Calendar Sub-Resources | `GET/PUT /calendar/{seasons,moons,eras,event-categories,cycles,festivals,weather}` | Not yet used | | Calendar Structure | `GET /calendar/structure`, `PUT /calendar/{settings,months,weekdays}`, `GET /calendar/export`, `POST /calendar/import` | Not yet used | | Sync Mappings | `GET/POST/DELETE /sync/mappings`, `GET /sync/lookup`, `GET /sync/pull`, `GET /sync/changes`, `POST /sync` | `sync-manager.mjs`, all sync modules | @@ -201,26 +187,20 @@ ItemSync; `stash.*`, `downtime.changed` → StashSync; `npc.spotlight` → `npc- pauses". Date and event routes are live; the routes still answering `503 calendar_rebuilding` arm a self-clearing push pause only when the body says so. Chronicle-side calendar changes arrive as `calendar.*` WS messages, routed in -`calendar-sync.mjs`; reconnect or a manual pull also re-checks the -structure-mismatch pause. +`calendar-sync.mjs`. Date-push refusals (400/422/403) pause date push once per session -(`_date-push-rejection.mjs`); note echo suppression is per note -(`_apply-guard.mjs`), while `_syncing`/`_syncDepth` now guards only the date -write in `_setLocalDate`. +(`_date-push-rejection.mjs`); `_apply-guard.mjs` is the per-note echo guard for +the built-in calendar's writes (TODO(#95)). **Not yet handled:** `sync.error`, `sync.conflict`. Any other `calendar.*` type logs one `console.debug` per type per session (`default:` branch). **Calendar sub-resources** (`scripts/_calendar-subresources.mjs`): display-only, -no Foundry document writes. No structure auto-apply on -`calendar.structure.updated` (would silently re-date every Calendaria note) — -badges the mismatch instead, routed ahead of the sync-disabled guard since -fixing the calendar in Chronicle is what emits this broadcast (the only way -to clear a mismatch pause). No auto-created note on +no Foundry document writes. `calendar.structure.updated` only refetches +`GET /calendar`. No auto-created note on `calendar.worldstate.changed` (no stable dedupe id to key a note on; the panel -and a GM chat line are the whole response). No speculative -weather setter (Calendaria exposes reads only) — probes known setter names, -degrades to a GM chat line. Announcements are GM whispers only +and a GM chat line are the whole response). Weather reaches the dashboard +panel and a GM chat line. Announcements are GM whispers only (`CalendarSync._announceToGM`) since Chronicle's `dm_only` gating is server-side and re-broadcasting would leak it to players. `tools/test-calendar-subresources.mjs`, `tools/test-calendar-subresource-routing.mjs`. @@ -351,7 +331,6 @@ API-CONTRACT.md defines canonical names; code uses some accepted aliases. | Entity visibility (GET) | `visibility: "public"` | `is_private: true/false` + `visibility` | `journal-sync.mjs`, `actor-sync.mjs` | | Relations (READ/WS) | — | `sourceEntityId`, `targetEntityId`, `relationType` (camelCase) | `item-sync.mjs`, `shop-room-window.mjs` | | Relations (WRITE) | `target_entity_id`, `relation_type`, `reverse_relation_type` (snake_case) | snake_case | `item-sync.mjs` | -| Calendar events (POST) | `color`, `icon`, `all_day`, `recurrence_*`, `start_hour`, `start_minute` | Not sent (simplified fields) | `calendar-sync.mjs` | Relations are asymmetric on purpose: reads/WS are camelCase (`internal/widgets/relations/model.go`), writes are snake_case @@ -362,8 +341,9 @@ nested under `/entities/:id`. - Map drawings/tokens/fog/layers: read-only SVG overlays in `MapViewerSheet`; only markers are bidirectional. -- SimpleCalendar events: journal notes only, no CRUD hooks (structure guard - still applies). +- Calendar: Chronicle's date, events and world state are pulled and shown, but + no Foundry calendar module is integrated, so nothing is written into Foundry + (TODO(#95), the built-in calendar). - Shop rooms: arranging stays on the Chronicle shop page. A player who joins after the GM showed a shop sees it the next time the GM shows it. Players buy only while the GM is in the game and showing that shop, and only once @@ -465,13 +445,9 @@ now (Admin → Packages), and its install hook writes the tag into `module.json` - **F-PR2-1 — ApplicationV2 `actions` only delegates `click`.** Non-click events must be wired directly in `_onRender` via `addEventListener`; adding them to the `actions` map is silently dropped, no error. -- **F-PR2-2 — hook re-render wipes in-progress form state.** A mid-edit - Calendaria hook can trigger a re-render; write every keystroke to a - class-side `_formData` object (`#wireFormInputs` in `_onRender`) instead of - reading DOM values, and throttle re-renders (250ms, `#scheduleRerender`). - **F-PR2-3 — test bootstrap for `foundry.applications.api` files.** Importing `calendar-sync.mjs` (or anything pulling `settings.mjs` → - `update-info.mjs`/`sync-calendar.mjs`) from a unit test throws + `update-info.mjs`) from a unit test throws `ReferenceError: foundry is not defined`; stub `globalThis.foundry/game/Hooks` before the dynamic `import()`. Canonical stub: `tools/test-calendar-sync-visibility.mjs`. @@ -482,22 +458,12 @@ now (Admin → Packages), and its install hook writes the tag into `module.json` - **F-PR3-1 — Handlebars helpers need explicit registration.** `eq`, `neq`, `lt`, `gt`, `json`, `timeAgo`, etc. are registered in `module.mjs`'s `init` hook; register new ones alongside them. -- **F-PR3-2 — Calendaria `conditionTree` root must be a group.** A root other - than `{type:'group', mode, children}` is silently ignored (note never - fires, no error). Validator: `sync-calendar-condition-builder.mjs`; save - path blocks on `Errors.RootMustBeGroup`/`EmptyGroup`/`NotAnObject`. - **F-PR3-4 — ApplicationV2 PARTS templates need exactly one root element.** `_parsePartHTML` rejects a template rendering more or fewer than one root, only at `app.render()` time — and the failing branch depends on which `{{#if}}` path fires. Wrap the whole template body in one root element so every branch emits exactly one. CI guard: `tools/test-template-roots.mjs` (recurses every branch, asserts max-root-count == 1). -- **F-CAL-RAIL-SCROLL — the single-root wrapper needs a height-constrained - flex column.** Consequence of F-PR3-4: without `display:flex; - flex-direction:column; height:100%; min-height:0` on `.sync-calendar-root` - (`styles/chronicle-sync.css`), a `flex:1` scroll region inside it overflows - instead of scrolling. Any new single-root wrapper with an internal scroll - region needs the same rule. - **F-LANG-1 — `lang/en.json` can't mix a dotted-flat key with a sibling literal-key string.** `expandObject` throws writing a property onto a string when both `parent.X = "..."` and `parent.X.Y = "..."` exist as @@ -505,26 +471,18 @@ now (Admin → Packages), and its install hook writes the tag into `module.json` then renders raw, with no error at lint time. Fix: nest the option set under a sibling key (e.g. `VisibilityOption: {Visible, Hidden, Secret}`), keep the field label flat. CI guard: `tools/test-lang-expand.mjs`. -- **F-PR3-3 — Sync Calendar editor reaches Chronicle via the public API.** No - direct SyncManager reference; calls - `game.modules.get('chronicle-sync').api.getAPI()`, avoiding a coupling to - SyncManager lifecycle. - **F-SYNC-1 — JournalSync must skip JournalEntries owned by another sync - domain.** `JournalSync` and `CalendarSync` share the global - `createJournalEntry` hook (`JournalSync` first). Calendaria and - SimpleCalendar store notes as JournalEntries, and so does the old Chronicle - Notes folder. `JournalSync` used to push - any journal lacking an `entityId` flag to `POST /entities` with - `entity_type_id: 0`, which Chronicle resolves to the campaign's first - entity type (almost always "Character") — e.g. Calendaria's auto-seeded - festival notes showed up as Character entities. Fix: skip when - `isCalendarNoteJournal(journal)` (from `calendar-sync.mjs`) or - `isOldNotesJournal(journal)` (`_notes-folder.mjs`), mirroring `_isHandledByActorSync`. Cleanup - order matters: unlink the journal locally before deleting the bad entity - server-side, never the reverse (deleting first sets the Foundry note aside, - unlinked, via `entity.deleted`; unlinking without deleting leaves an - orphan `ActorSync` re-pulls as an Actor) — see the macro in - `CALENDARIA-INTEGRATION.md`. Tested at `tools/test-calendar-note-journal.mjs`. + domain.** `JournalSync` pushed any journal lacking an `entityId` flag to + `POST /entities` with `entity_type_id: 0`, which Chronicle resolves to the + campaign's first entity type (almost always "Character"). Fix: skip when + `isCalendarNoteJournal(journal)` (a journal already linked to a calendar + event by our `calendarEventId` flag, from `calendar-sync.mjs`) or + `isOldNotesJournal(journal)` (`_notes-folder.mjs`), mirroring + `_isHandledByActorSync`. Cleanup order matters: unlink the journal locally + before deleting the bad entity server-side, never the reverse (deleting + first sets the Foundry note aside, unlinked, via `entity.deleted`; unlinking + without deleting leaves an orphan `ActorSync` re-pulls as an Actor). Tested + at `tools/test-calendar-note-journal.mjs`. ## Architectural Decision Log diff --git a/API-CONTRACT.md b/API-CONTRACT.md index 7ebff56..8f105aa 100644 --- a/API-CONTRACT.md +++ b/API-CONTRACT.md @@ -393,7 +393,7 @@ Lists all tags in the campaign. #### POST /tags Create a new tag. -**Used by:** `import-wizard.mjs` → Step 7 (Review) tag creation during import +**Used by:** `import-wizard.mjs` → Step 6 (Review) tag creation during import **Request:** ```json @@ -822,7 +822,7 @@ moons, seasons, eras, event_categories, cycles, festivals. #### GET /calendar/date Returns current date/time with computed state: current season, moon phases, era, weather. -**Used by:** `calendar-sync.mjs` → poll current state; `_realtime-date-guard.mjs` → +**Used by:** `_realtime-date-guard.mjs` → fetch-before-push real-time check (see below) **Response:** @@ -870,19 +870,16 @@ a retryable sync error. #### POST /calendar Creates the campaign's calendar. Answers `201 {"created": …, "warnings": […]}`; -`409` when the campaign already has a calendar. +`409` when the campaign already has a calendar. The module does not call it. #### POST /calendar/date/confirm **Optional** (newer Chronicle deployments only). Confirms Foundry *applied* a -date pulled from Chronicle to the active local calendar module (Calendaria or -SimpleCalendar), not merely fetched it — drives the sync chip's "SAW" vs. +date pulled from Chronicle to a local calendar, not merely fetched it — drives the sync chip's "SAW" vs. "APPLIED" distinction (SAW = the `GET /calendar/date` beacon). -**Used by:** `calendar-sync.mjs` → `_confirmAppliedDate` via -`scripts/_applied-date-confirm.mjs`, from the poll path (`onInitialSync`) and -the WebSocket path (`_onChronicaleDateAdvanced`, `calendar.date.advanced`) — -only after the local setter (`_setLocalDate`) runs without throwing. Never -sent on a bare fetch or an apply failure. +**Used by:** `scripts/_applied-date-confirm.mjs`, for a caller that applied +a date to a Foundry calendar; never sent on a bare fetch or an apply failure. +No Foundry calendar is integrated, so nothing calls it yet (TODO(#95)). **Request:** ```json @@ -925,8 +922,11 @@ Each resource has a `GET` (returns all definitions) and a `PUT` (bulk-replaces a #### GET /calendar/events Lists events for a month. Query: `?year=1492&month=3` or `?entity_id=uuid`. +**Used by:** `calendar-sync.mjs` → `fetchEvents`, one request per month across the +current year ±1. + #### POST /calendar/events -Creates a calendar event. +Creates a calendar event. The module does not call it. **Request:** ```json @@ -945,7 +945,7 @@ Creates a calendar event. } ``` -**Fields (Calendaria parity):** `color` (hex), `icon` (FontAwesome/custom id), +**Fields:** `color` (hex), `icon` (FontAwesome/custom id), `all_day`, `recurrence_interval` (periods between recurrences), `recurrence_end_year/month/day`, `recurrence_max_occurrences`. @@ -954,11 +954,9 @@ Updates a calendar event. Same fields as POST; **PARTIAL update** — absent preserves, an explicit `null` clears, a present value replaces (see "The partial-update contract" under `PUT /entities/:entityId`). -`calendar-sync.mjs` pushes note edits here from three paths -(`_onCalendariaNoteUpdated`, `_onLocalEventUpdate`, -`_onSimpleCalendarNoteUpdate`), each with a five-key body. Keep bodies -narrow: a Foundry note edit means the name, the date and the body, nothing -else. +The module does not call it. A future push of a Foundry note edit must keep +the body narrow: the name, the date and the body, nothing else +(`tools/test-partial-put-contract.mjs`). #### DELETE /calendar/events/:eventId Deletes a calendar event. @@ -975,7 +973,7 @@ Returns a single event by ID. | `PUT /calendar/settings` | Updates calendar name, time system, leap year, current date/time | | `PUT /calendar/months` | Replaces all month definitions | | `PUT /calendar/weekdays` | Replaces all weekday definitions | -| `GET /calendar/structure` | Returns calendar structure in Calendaria-compatible format | +| `GET /calendar/structure` | Returns the calendar structure | | `GET /calendar/weather` | Returns current weather state, or `{}` if none set | | `PUT /calendar/weather` | Sets current weather state (GM override) | | `GET /calendar/export` | Exports the full calendar as Chronicle JSON; `?events=true` includes events | @@ -1530,22 +1528,22 @@ aside on the GM's world load (`scripts/_notes-folder.mjs`). ### What the module does with each `calendar.*` type Handled in `scripts/calendar-sync.mjs` `onMessage` + `scripts/_calendar-subresources.mjs`. -**Display-level and non-destructive**: no branch writes a Chronicle value -into the Foundry calendar's stored structure, and none creates a note. Chat -announcements are **GM whispers only** — never public, so a DM-gated payload -is never laundered into a player-visible one. +**Display-level and non-destructive**: no Foundry calendar module is +integrated, and no branch writes into Foundry. Chat announcements are **GM +whispers only** — never public, so a DM-gated payload is never laundered into +a player-visible one. -| Type | Module behavior | Calendaria | Simple Calendar | -|---|---|---|---| -| `calendar.date.advanced` | Applies the date, confirms it back | `CALENDARIA.api.setDateTime` | `SimpleCalendar.api` date set | -| `calendar.event.created/updated/deleted` | Mirrors to a calendar note | Full (notes API) | Full (journal-flag notes) | -| `calendar.weather.changed` | Updates the dashboard world-state panel; applies to the calendar module if it exposes a weather **setter**, else whispers a GM chat line. `null` payload → one `GET /calendar/weather` refetch. | Probes `setWeather` / `setCurrentWeather` / `setWeatherForDate`, falls back to chat when absent (probe result in diagnostics bundle) | No weather surface → chat fallback | -| `calendar.season.changed` | Panel + GM chat line (`calendarAnnounceSeasonEra`, default **on**) | Display only | Display only | -| `calendar.era.changed` | Panel + GM chat line (`calendarAnnounceSeasonEra`, default **on**) | Display only | Display only | -| `calendar.moon.phase_changed` | Panel + GM chat line (`calendarAnnounceMoon`, default **off** — moons change phase every few in-world days) | Display only | Display only | -| `calendar.worldstate.changed` | Panel + GM chat line (`calendarAnnounceWorldstate`, default **on**). Wired and tested, unreachable during blackout. | Display only | Display only | -| `calendar.structure.updated`, `calendar.cycle.changed`, `calendar.festival.changed` | Refetches `GET /calendar`, re-runs the structure comparison, sets the badge: pause if now incompatible, clear a prior pause if compatible, else raise advisory `structure-changed`. **Never auto-applies the structure** — rewriting months/weekdays would silently re-date every note. Runs even while sync is paused (the only recovery path). | Both | Both | -| any other `calendar.*` | `default:` logs one `console.debug` line **per type per session** — no silent drops | — | — | +| Type | Module behavior | +|---|---| +| `calendar.date.advanced` | Updates the cached date the dashboard shows. TODO(#95): apply it to the built-in calendar and confirm it back | +| `calendar.event.created/updated/deleted` | Routed; no handler writes anywhere yet. TODO(#95) | +| `calendar.weather.changed` | Updates the dashboard world-state panel and whispers a GM chat line (`calendarAnnounceWeather`). `null` payload → one `GET /calendar/weather` refetch | +| `calendar.season.changed` | Panel + GM chat line (`calendarAnnounceSeasonEra`, default **on**) | +| `calendar.era.changed` | Panel + GM chat line (`calendarAnnounceSeasonEra`, default **on**) | +| `calendar.moon.phase_changed` | Panel + GM chat line (`calendarAnnounceMoon`, default **off** — moons change phase every few in-world days) | +| `calendar.worldstate.changed` | Panel + GM chat line (`calendarAnnounceWorldstate`, default **on**). Wired and tested, unreachable during blackout | +| `calendar.structure.updated`, `calendar.cycle.changed`, `calendar.festival.changed` | Refetches `GET /calendar` into the cache | +| any other `calendar.*` | `default:` logs one `console.debug` line **per type per session** — no silent drops | Every cross-repo claim here carries a `Re-verify by:` line. diff --git a/CALENDARIA-INTEGRATION.md b/CALENDARIA-INTEGRATION.md deleted file mode 100644 index 1d87ec5..0000000 --- a/CALENDARIA-INTEGRATION.md +++ /dev/null @@ -1,230 +0,0 @@ -# Calendaria Integration Reference - -A condensed, integration-focused reference for **Calendaria** (the Foundry VTT -calendar module this module syncs with). Written so contributors — human or AI — -don't have to guess at Calendaria's data model again. The exact behaviours that -caused calendar holidays to show up as Chronicle **Characters** are called out -under [Integration rules](#integration-rules-read-before-touching-journal-sync). - -> **Source of truth.** All facts below were read directly from the upstream -> repo **[Sayshal/Calendaria](https://github.com/Sayshal/Calendaria)** at commit -> `2945dd2` (2026‑06‑14, `module.json` compatibility: Foundry v14, verified -> 14.364). When in doubt, re-read the cited files rather than this summary, and -> bump the commit ref when you do. Full user docs: the upstream README and the -> [Calendaria wiki](https://wiki.3deathsaves.com/calendaria/). - ---- - -## TL;DR for sync code - -- **Calendaria notes are Foundry `JournalEntry` documents.** Creating a note - fires `createJournalEntry`. Any sync module that listens on that hook will see - them. -- Each note JournalEntry is tagged **`flags.calendaria.isCalendarNote === true`** - and lives in a **"Calendar Notes"** folder (per‑calendar subfolders). -- **Festivals/holidays are auto‑seeded as notes.** When a calendar defines - `festivals`, Calendaria creates one note JournalEntry per festival - (e.g. "Day of Rebirth", "Eve of the Dead"). These are ordinary calendar notes - with an extra `linkedFestival` descriptor. -- ⇒ **Journal‑listening sync code MUST skip these documents.** This module does - so via `isCalendarNoteJournal()` in `scripts/calendar-sync.mjs`. They are the - domain of `CalendarSync` (mirrored to Chronicle *calendar events*), never of - `JournalSync` (entities). - ---- - -## Note storage model - -| Aspect | Detail | Source | -|--------|--------|--------| -| Document type | `JournalEntry` + a `JournalEntryPage` of type `calendaria.calendarnote` | `notes/note-manager.mjs:379‑380`, `notes/note-data.mjs:63` | -| Folder | Root **"Calendar Notes"** folder, with a subfolder per calendar | `notes/note-manager.mjs` (`getCalendarFolder`, `:752`) | -| Module flag scope | `calendaria` (`MODULE.ID`) | `constants.mjs:3` | -| Note marker flag | `flags.calendaria.isCalendarNote === true` | `notes/note-manager.mjs:379` | -| Also stored on note | `flags.calendaria.calendarId` | `notes/note-manager.mjs:379` | - -### Flags Calendaria writes - -| Flag (under `flags.calendaria`) | On | Meaning | -|------|----|---------| -| `isCalendarNote` | JournalEntry | A calendar note (incl. festival notes) | -| `isCalendarJournal` | JournalEntry | A calendar **structure** journal; deletion is hard‑blocked | -| `isCalendarNotesFolder` | Folder | The root "Calendar Notes" folder; deletion blocked | -| `isCalendarFolder` | Folder | A per‑calendar subfolder; deletion blocked | -| `isCalendariumExport` | — | Marks Calendarium export data | -| `isDescriptionPage` | JournalEntryPage | The note's description page | - -### Note page system data (`calendaria.calendarnote`) - -From `notes/note-data.mjs`: - -``` -startDate: { year, month, dayOfMonth, hour, minute } // required -endDate: { ... } | null -allDay: boolean // default false -repeat: 'never' | ... // legacy; superseded by conditionTree -repeatInterval, repeatEndDate -displayStyle: 'icon' | 'pip' | 'banner' // DISPLAY_STYLES, default 'icon' -visibility: 'visible' | 'hidden' | 'secret' // NOTE_VISIBILITY -linkedFestival: { calendarId, festivalKey, countsForWeekday, leapYearOnly, leapDuration } | null -conditionTree: { type:'group', mode, children } // recurrence engine (root must be a group) -``` - -`visibility` defaults to `hidden` when a GM creates the note, `visible` for a -player. Chronicle wire visibility maps via `chronicleVisibilityFromCalendariaNote()` -in `calendar-sync.mjs` (`hidden`/`secret` → `gm-only`, else `everyone`). - ---- - -## Festival / holiday seeding (the bug mechanism) - -`scripts/festivals/festival-manager.mjs`: - -- `seedFestivalNotes(calendarId, calendar)` — runs **once per calendar** (guarded - by the `SEEDED_CALENDARS` world setting). For every entry in - `calendar.festivals`, it calls `createFestivalNote(...)`. -- `createFestivalNote(...)` → `NoteManager.createNote({ name, content, noteData, ... })` - — i.e. festival holidays become **ordinary calendar notes** (JournalEntries - with `isCalendarNote`), plus a `linkedFestival` descriptor on the page. -- Seeding is one‑shot: a deleted festival note is **not** re‑seeded unless the - seed record is cleared (`clearSeedRecord`). - -So a calendar like *"Calendar of Therin"* with festivals -("Day of Rebirth", "Eve of the Dead", "Haelyn's Day", "Night of Fire", -"Veneration of the Sleeping", …) produces that many note JournalEntries the -first time the calendar activates. Those are exactly the documents that were -mis‑filed as Chronicle Characters before the guard existed. - ---- - -## Hooks - -### Custom hooks Calendaria fires (`constants.mjs` `HOOKS`) - -The ones this module consumes (in `calendar-sync.mjs`): - -| Hook | When | -|------|------| -| `calendaria.dateTimeChange` | World date/time changed (full date+time payload) | -| `calendaria.noteCreated` | A note was created | -| `calendaria.noteUpdated` | A note was updated | -| `calendaria.noteDeleted` | A note was deleted | - -Other custom hooks exist (`calendaria.calendarAdded/Removed/Switched/Updated`, -`calendaria.dayChange`, `calendaria.clockUpdate`, `calendaria.conditionEvaluated`, -cinematic hooks, …) — see `HOOKS` in upstream `constants.mjs`. - -### Foundry document hooks Calendaria itself registers - -Relevant overlap: Calendaria registers `createJournalEntry`, -`createJournalEntryPage`, `updateJournalEntry`, `updateJournalEntryPage`, -`preUpdateJournalEntryPage`, `deleteJournalEntry`, `deleteJournalEntryPage`, -`preDeleteJournalEntry`, `preDeleteFolder`. **This module's `JournalSync` and -(SimpleCalendar) `CalendarSync` both share `createJournalEntry` -with Calendaria** — hence the need for strict domain ownership checks. - ---- - -## `CALENDARIA.api` surface (used / useful) - -From `scripts/api.mjs`. Notes API is used by `calendar-sync.mjs`: - -- Notes: `createNote(options)`, `updateNote(pageId, updates)`, `deleteNote(pageId)`, `getNote(id)`, `getNotesForDate(y, m, d)` -- Date/time: `getCurrentDateTime()`, `setDateTime(components)`, `advanceTime(delta)`, `jumpToDate({year,month,day})`, `formatDate(components, format)` -- Calendars: `getActiveCalendar()`, `getCalendar(id)`, `getAllCalendars()`, `switchCalendar(id)`, `setActiveCalendar(id)`, `addCalendar(id, def)` -- Seasons: `getCurrentSeason()` -- Moons: `getMoonPhase(i)`, `getAllMoonPhases()`, `isMoonFull(...)`, `getNextFullMoon(...)`, `getNextConvergence(...)`, `getEclipse(...)` -- Weather: `getCurrentWeather(zoneId)`, `setWeather(presetId, options)` (GM only), `getWeatherForecast(options)` -- Conversion: `convertDate(...)`, `getEquivalentDates(...)`, `getCurrentDateOn(id)` - -Hooks beyond the four this module consumes: `calendaria.dayChange` (day boundary -crossed), `calendaria.seasonChange`, `calendaria.moonPhaseChange`, -`calendaria.weatherChange`, `calendaria.calendarAdded/Removed/Switched/Updated`, -`calendaria.clockUpdate`, `calendaria.conditionEvaluated`, plus cinematic hooks — -see upstream `constants.mjs` `HOOKS`. - -> Calendaria uses a `yearZero` offset internally; the API exposes "public" dates -> (`toPublic`) — keep using the API, don't read raw stored dates. - ---- - -## Delete protection (matters for cleanup) - -`notes/note-manager.mjs` `onPreDeleteJournalEntry`: - -- `isCalendarJournal` documents → deletion **always blocked** (unless dev mode or - an internal bypass). -- Festival notes (`page.system.linkedFestival`) → deletion blocked **only for - non‑GM users**. **A GM can delete a festival note.** - -⚠️ **Consequence for Chronicle sync.** Chronicle sync runs as the GM. If a -mis‑synced festival entity is deleted on the Chronicle side while Foundry is -connected, the `entity.deleted` broadcast makes `JournalSync._onEntityDeleted` -delete the linked JournalEntry — and Calendaria does **not** block that for the -GM, so the festival note is permanently lost (no re‑seed). Always **unlink the -journal locally first** (drop its `chronicle-sync.entityId` flag) before deleting -the entity server‑side. See the cleanup macro below. - ---- - -## Integration rules (read before touching journal sync) - -1. **Never push a calendar note to `/entities`.** Calendar notes are - JournalEntries; `JournalSync` must skip any journal where - `isCalendarNoteJournal(journal)` is true (calendar‑module flags or our - `calendarEventId` link). They sync as Chronicle **calendar events** via - `CalendarSync`, not as entities. (Guarded; see `F-SYNC-1` in `.ai.md`.) -2. **`entity_type_id: 0` is a footgun.** The Chronicle API resolves it to the - campaign's *first* entity type (`syncapi/api_handler.go` `CreateEntity`), - almost always "Character". Anything pushed without an explicit type lands - there. The wizard maps types deliberately; the realtime hook should not push - ambiguous documents at all. -3. **Multiple modules share `createJournalEntry`.** Scope every journal hook - handler to its own domain (flags/folder), the way `isOldNotesJournal` - and the calendar guard do — don't assume "any new journal is mine". -4. **Cleanup must unlink before deleting** (see the delete‑protection note). - ---- - -## One‑off cleanup macro (existing mis‑synced festival notes) - -Run **as the GM**, in Foundry's script console (F12) or a Script macro, **once**. -It unlinks each calendar‑note journal that carries a bogus Chronicle entity link -(so deleting the entity can't cascade back and destroy your festival note), then -deletes that bogus entity in Chronicle. Real characters (Foundry Actors) are -never touched. Review the logged list afterwards. - -```js -// Chronicle Sync — clean up calendar notes wrongly synced as entities. -const FLAG = 'chronicle-sync'; -const api = game.modules.get(FLAG)?.api?.getAPI?.(); -if (!api) { ui.notifications.error('Chronicle Sync API unavailable — is sync configured & connected?'); } -else { - const isCalNote = (j) => { - const f = j.flags ?? {}; - const cal = f.calendaria; - if (cal && (cal.isCalendarNote === true || cal.isCalendarJournal === true)) return true; - if (f['foundryvtt-simple-calendar'] || f['simple-calendar']) return true; - return !!j.getFlag(FLAG, 'calendarEventId'); - }; - const targets = game.journal.contents.filter((j) => j.getFlag(FLAG, 'entityId') && isCalNote(j)); - const removed = []; - for (const j of targets) { - const entityId = j.getFlag(FLAG, 'entityId'); - // 1) Unlink locally FIRST so the entity.deleted broadcast can't cascade - // back and delete this calendar note. - await j.unsetFlag(FLAG, 'entityId'); - await j.unsetFlag(FLAG, 'lastSync'); - await j.unsetFlag(FLAG, 'chronicleUpdatedAt'); - // 2) Delete the bogus entity in Chronicle. - try { await api.delete(`/entities/${entityId}`); removed.push(`${j.name} (${entityId})`); } - catch (e) { console.warn('Chronicle cleanup: failed to delete entity', entityId, e); } - } - console.log('Chronicle cleanup — unlinked & removed bogus entities:', removed); - ui.notifications.info(`Chronicle: cleaned up ${removed.length} mis-synced calendar note(s). See console for the list.`); -} -``` - -If you'd rather delete the entities yourself in the Chronicle UI, run only the -unlink portion first (drop the `api.delete(...)` line) — that breaks the cascade -so the manual delete is safe — then delete them in Chronicle. diff --git a/CLAUDE.md b/CLAUDE.md index e513e44..140baaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ data flow, file index and feature details. Entry point: `scripts/module.mjs` (serving descriptor, schema v1) — cross-validated by `tools/check-package-descriptor.mjs`. - `scripts/*.mjs`: sync (`journal-sync`+`picture-store`, `map-sync`+`map-viewer`+`map-sheet-items`, - `calendar-sync`+`sync-calendar`+`sync-calendar-*`, `actor-sync`, + `calendar-sync`, `actor-sync`, `item-sync`, `stash-sync`+`stash-client`), UI (`sync-dashboard`, `npc-presence`, `negotiation-mirror`, `sync-diagnostic-bundle`, `update-info`, `gm-secret-view`, `character-claim-indicator`, `capability-inspector`, `import-wizard`, `shop-widget`+`shop-room-window`, `player-notebook`, `stash-window`+`stash-chat`), core (`module`, @@ -45,12 +45,13 @@ Install/update flow: also `.ai.md` → "Chronicle Integration — Install & Upda - **ES modules** (`.mjs`), `export default class` pattern. - **Comments say why, briefly**, pointing at a test/issue for more — no incident stories, task IDs, dates or `file:line` (those go in the PR). Deferred work is `TODO(#issue)`. -- `_syncing` guard against infinite loops: boolean, except `calendar-sync.mjs`'s reentrant `_syncDepth` counter (overlapping back-catalog loop and WebSocket handlers). +- `_syncing` guard against infinite loops: a boolean. - Adapters implement `toChronicleFields()`/`fromChronicleFields()`; REST uses Bearer auth via `api-client.mjs`. - **API key is CLIENT-scoped, never world-scoped** (a world setting syncs to every client). `migrateApiKeyToClientScope()` migrates legacy values. `tools/test-api-key-scope.mjs`. - **List responses are bare array or `{"data":[…],"total":N}`** — unwrap defensively everywhere. Envelope: `/entities`, `/entity-types`, `/systems`, `/addons`, `/tags`, `/relations/types`, `/calendar/events`. Bare: `/maps`, `/maps/:id/*`, `/members`, `/entities/:id/relations`, `/notes`. `tools/test-envelope-audit.mjs`. +- **No Foundry calendar module is integrated.** `calendar-sync.mjs` pulls Chronicle's date, events and world state and writes nothing into Foundry; the built-in calendar plugs in there (TODO(#95)). - **Real-time calendars are read-only for dates**: `tracks_real_time` from `GET /calendar/date` pauses date-push only, via `scripts/_realtime-date-guard.mjs`. `tools/test-realtime-date-signal.mjs`. -- **Calendar sub-resources are display-only** (dashboard + optional GM-whisper, never public); `structure.updated` badges mismatches, never auto-applies. `scripts/_calendar-subresources.mjs`, `tools/test-calendar-subresources.mjs`, `tools/test-calendar-subresource-routing.mjs`. +- **Calendar sub-resources are display-only** (dashboard + optional GM-whisper, never public); `structure.updated` only refetches the calendar. `scripts/_calendar-subresources.mjs`, `tools/test-calendar-subresources.mjs`, `tools/test-calendar-subresource-routing.mjs`. - **Chronicle update endpoints are PARTIAL**: absent preserves, `null` clears, present replaces (API-CONTRACT.md → "partial-update contract"). Send only changed fields; never echo untouched ones back. `tools/test-partial-put-contract.mjs`. One deliberate exception: the marker dialog in `scripts/map-viewer.mjs` spreads the stored marker, harmless on current Chronicle and needed by older servers that replace the whole record. Actor field pushes do the same for `fields_data` (`ActorSync._putFieldsMerged`). - **GM-only content never goes into a saved Foundry document.** Every client receives every journal page, so a pulled page holds a placeholder secret block (id keyed by an HMAC under the GM's API key) where Chronicle has GM-only text or pictures; the GM's client fills it on screen (`scripts/gm-secret-view.mjs`) and puts the content back before a push, which leaves the text out rather than send it without a placeholder's content. Anything in a secret block goes back to Chronicle GM-only. `scripts/_gm-secrets.mjs`, `tools/test-gm-secrets.mjs`. - **Sync never deletes without asking.** A Chronicle-side removal sets the Foundry journal aside (unlinked, in a "Chronicle: removed" folder, `scripts/_set-aside.mjs`); an item whose "Has Item" relation is gone is only unlinked (`scripts/_inventory-plan.mjs`), unless a stash move the GM just applied took it off that character (`scripts/_stash-reconcile.mjs`); a Foundry-side delete of a linked actor or journal asks before deleting the Chronicle copy (`scripts/_remote-deletes.mjs`). `tools/test-set-aside.mjs`, `tools/test-remote-deletes.mjs`. @@ -70,15 +71,12 @@ failed request retried next tick. The GM gets one notice when it arms; pushes pause for 30 s, then one is let through, and any good calendar answer clears it silently. `tools/test-calendar-blackout.mjs`. -A structure-mismatch pause is re-checked on every pull (`onInitialSync`: -reconnect and the dashboard's manual pull) and lifts when the calendars match; -no world reload. A 400 (or non-real-time 422) on a date push means Chronicle's -calendar cannot hold the date; a 403 means the key is not the campaign owner's. -Either pauses date push, with one GM notice per session, until the next pull (reconnect or manual) or a lifted mismatch pause -(`scripts/_date-push-rejection.mjs`). Note hooks (Calendaria and Simple -Calendar) share the mismatch guard and per-calendar exclusions. Echo -suppression for notes is per note id or name+date (`scripts/_apply-guard.mjs`), -so GM edits during a pull still push. `tools/test-calendar-resilience.mjs`. +A 400 (or non-real-time 422) on a date push means Chronicle's calendar cannot +hold the date; a 403 means the key is not the campaign owner's. Either pauses +date push (`CalendarSync.pushDate`), with one GM notice per session, until the +next pull (reconnect) (`scripts/_date-push-rejection.mjs`). The per-note echo +guard for the built-in calendar's writes is `scripts/_apply-guard.mjs`. +`tools/test-calendar-resilience.mjs`. ## Working with this project diff --git a/CONSTANTS.md b/CONSTANTS.md index 02b3061..f850c2b 100644 --- a/CONSTANTS.md +++ b/CONSTANTS.md @@ -61,16 +61,6 @@ image page, so no pixel conversion happens. | `"1"` | Player | | `"2"` | Scribe | -## Calendar Indexing - -| Calendar Module | Month indexing | Day indexing | -|----------------|---------------|-------------| -| Chronicle API | 1-indexed | 1-indexed | -| Calendaria | 1-indexed | 1-indexed | -| SimpleCalendar | **0-indexed** | **0-indexed** | - -The module adds/subtracts 1 when converting between SimpleCalendar and Chronicle. - ## System Matching System matching is API-driven: the `/systems` endpoint returns each system's diff --git a/README.md b/README.md index 270063e..1582e70 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Bidirectional real-time sync between [Chronicle](https://github.com/keyxmakerx/C - **Journal Sync** — Chronicle entities ↔ Foundry journal entries (with multi-page splitting); GM-only text and pictures in Chronicle pages sit in Foundry secret blocks, hidden from players who don't own the page; catches up on reconnect from Chronicle's change feed; nothing is deleted without asking - **Map Sync** — Chronicle maps render as Foundry journal pages (not Scenes) in Chronicle's own frame and pin shapes, with markers, drawings, tokens, layers and fog drawn as overlays; players get the smudged picture of a shadowed map; markers are editable, with Chronicle's icon picker ("Open in Chronicle web editor" for the full map editor); the GM can give a map to a character, who opens it from their sheet -- **Calendar Sync** — Calendaria and Simple Calendar integration +- **Calendar** — The dashboard's Calendar tab shows Chronicle's date, weather, season, era and moon phases; no Foundry calendar module is integrated - **Character Sync** — Actor ↔ character entity with system-aware field mapping (D&D 5e, Pathfinder 2e, or any system with annotated fields) - **Shop Rooms** — Chronicle shops open in Foundry as shop rooms; the GM can show a room to players, and players buy in it (charged to the character they pick) while the GM is in the game - **Stashes** — A Stashes window on linked character sheets to move items and money between characters and stashes; the GM approves players' requests from a chat card (needs Chronicle's Armory addon) @@ -115,8 +115,6 @@ The module runs sync for the GM only. Players receive updates passively through ## Optional Modules - [Monk's Enhanced Journal](https://foundryvtt.com/packages/monks-enhanced-journal) — Enhanced journal page support -- [Calendaria](https://foundryvtt.com/packages/calendaria) — Calendar sync -- [Simple Calendar](https://foundryvtt.com/packages/foundryvtt-simple-calendar) — Calendar sync (alternative) ## For Chronicle integrators diff --git a/TESTING.md b/TESTING.md index 934388e..6589b58 100644 --- a/TESTING.md +++ b/TESTING.md @@ -168,23 +168,15 @@ module calls none of them). An older Chronicle answers those with `503 calendar_rebuilding`, which pauses date pushes for 30 seconds and shows one GM notice. See API-CONTRACT.md. -### Chronicle -> Foundry -- [ ] Advance date in Chronicle -> Calendaria/SimpleCalendar date updates -- [ ] Create calendar event -> Event appears in calendar module -- [ ] Update event -> Calendar module event updates -- [ ] Delete event -> Calendar module event removed - -### Foundry -> Chronicle -- [ ] Change date in Calendaria/SimpleCalendar -> Chronicle date updates -- [ ] A real-time Chronicle calendar is never overwritten by a Foundry date change -- [ ] Create event in calendar module -> Chronicle event created -- [ ] Update event -> Chronicle event updates -- [ ] Delete event -> Chronicle event removed - -### Adapter Compatibility -- [ ] Test with Calendaria module active -- [ ] Test with SimpleCalendar module active (note 0-indexed months/days) -- [ ] Test with neither module -> Calendar sync gracefully disabled +No Foundry calendar module is integrated, so nothing is written into Foundry. + +### Dashboard +- [ ] Calendar tab shows Chronicle's calendar name, date (day, month, year) and time +- [ ] Advance the date in Chronicle -> the tab shows the new date after the next refresh +- [ ] Change weather, season, era or a moon phase in Chronicle -> the world-state panel updates +- [ ] With the announce settings on, a GM-only whisper appears for each change; players never see it +- [ ] Calendar sync off in Module Settings -> the tab says it is disabled +- [ ] A campaign with no calendar -> the tab says none is configured ## Shop Widget diff --git a/lang/en.json b/lang/en.json index 30e31b0..e698465 100644 --- a/lang/en.json +++ b/lang/en.json @@ -54,11 +54,11 @@ }, "SyncCalendar": { "Name": "Sync Calendar", - "Hint": "Sync Chronicle calendar with Calendaria or Simple Calendar module" + "Hint": "Show Chronicle's calendar date, weather, season and era in the dashboard" }, "CalendarAnnounceWeather": { "Name": "Announce Chronicle weather in chat", - "Hint": "Whisper a GM-only chat line when weather changes in Chronicle. Used when the active Foundry calendar module has no weather setter to apply the reading to (Simple Calendar, and Calendaria builds with read-only weather). Never posted to players." + "Hint": "Whisper a GM-only chat line when weather changes in Chronicle. Never posted to players." }, "CalendarAnnounceWorldstate": { "Name": "Announce Chronicle world-state changes in chat", @@ -84,11 +84,6 @@ "Name": "Update Source", "Hint": "Show the install-time manifest URL Foundry uses for updates and run a manual check against Chronicle.", "Label": "Open" - }, - "SyncCalendarMenu": { - "Name": "Sync Calendar", - "Hint": "Open the Sync Calendar — a 3-pane view of your Calendaria calendar with an always-on validation panel. Event authoring + drag-select multi-day landed in PR 2; recurrence + moon strip + weather + structure editing land in PR 3-5.", - "Label": "Open" } }, "Status": { @@ -194,22 +189,11 @@ "NoShopTypeHint": "Add a \"Shop\" entity type to your campaign in Chronicle." }, "Calendar": { - "CalendarModule": "Calendar Module", "Chronicle": "Chronicle", - "Foundry": "Foundry", - "InSync": "In Sync", - "OutOfSync": "Out of Sync", - "PullDate": "Pull Date from Chronicle", - "PushDate": "Push Date to Chronicle", "NoCalendar": "No calendar configured for this campaign in Chronicle.", "Disabled": "Calendar sync is disabled. Enable it in Module Settings.", - "NoModule": "No calendar module detected.", - "NoModuleHint": "Install Calendaria or Simple Calendar to enable calendar sync.", - "UnableToRead": "Unable to read", - "OpenSyncCalendar": "Open Sync Calendar", - "OpenSyncCalendarHint": "Open the full Sync Calendar editor — import calendars, edit events, view moon phases.", "WorldStateTitle": "Chronicle world state", - "WorldStateHint": "Latest weather, season, era and moon phases received from Chronicle this session. Display only — the Foundry calendar is never modified from these.", + "WorldStateHint": "Latest weather, season, era and moon phases received from Chronicle this session. Display only.", "WorldStateEmpty": "No world-state broadcasts received yet this session." }, "Characters": { @@ -256,11 +240,7 @@ "NoActivity": "No recent activity.", "ClearLog": "Clear Log", "Settings": "Settings", - "Reconnect": "Reconnect", - "CopyCalendarDiagnostics": "Copy Calendar Diagnostics", - "CopyCalendarDiagnosticsHint": "Copy a calendar diagnostics report (versions, structure, Calendaria API probe, recent sync errors) to the clipboard — paste into a bug report or to Claude.", - "CalDiagCopied": "Calendar diagnostics copied to clipboard.", - "CalDiagCopyFailed": "Failed to copy calendar diagnostics — check the console (F12)." + "Reconnect": "Reconnect" } }, "MapItems": { @@ -352,7 +332,6 @@ "Types": "Map Types", "Tags": "Tags", "Characters": "Characters", - "Calendar": "Calendar", "Review": "Review" }, "ConnectDesc": "Verifying connection to Chronicle and detecting your campaign configuration.", @@ -392,10 +371,6 @@ "PC": "PC", "Include": "Include", "NoActors": "No unsynced actors found.", - "CalendarDesc": "Choose how to sync your calendar between Foundry and Chronicle.", - "CalendarPull": "Pull Chronicle calendar into Foundry", - "CalendarPush": "Push Foundry calendar to Chronicle", - "CalendarSkip": "Skip calendar sync for now", "ReviewSummary": "The following operations will be performed:", "Errors": "errors", "NoPlan": "No import plan generated — go back and configure at least one step.", @@ -463,7 +438,6 @@ } }, "SceneControl": { - "SyncCalendar": "Open Sync Calendar", "Notebook": "Open my Chronicle notebook", "DMScreen": "Open the DM Screen" }, @@ -563,207 +537,6 @@ "campaign": "That Chronicle window was for a different campaign. Try again from the notebook button." } }, - "SyncCalendar": { - "Title": "Sync Calendar", - "CalendariaVersion": "Calendaria version", - "SelectedDate": "Selected", - "YearLabel": "Year", - "JumpToday": "Jump to today", - "YearOverview": "Year overview", - "YearOverviewHint": "Click a month to focus the inspector.", - "YearOverviewFootnote": "Click a month to drill into the day grid (drag to select a range). Moon-phase strip lands in PR 3; weather + structure editing in PR 4-5.", - "MonthViewHint": "Click a day to focus the inspector. Drag across days to create a multi-day event.", - "MonthViewFootnote": "Drag from one day to another to open the multi-day event form pre-filled with that range.", - "BackToYear": "Back to year", - "DaysSuffix": "days", - "NotesSuffix": "notes", - "ViewMonth": "View month", - "Diagnostics": { - "Button": "Copy diagnostics", - "Hint": "Copy a full diagnostics report (versions, structure, validation findings, selected-day weather, recent sync errors, settings) to the clipboard — paste it into a bug report or to Claude.", - "Copied": "Calendar diagnostics copied to clipboard.", - "CopyFailed": "Failed to copy diagnostics — see the console (F12)." - }, - "SyncToggle": { - "LabelOn": "Syncing this calendar", - "LabelOff": "Not syncing this calendar", - "Hint": "Toggle whether this Calendaria calendar's date and notes sync to Chronicle. Turning it off keeps the calendar local to Foundry.", - "Notify": "Chronicle sync {state} for this calendar.", - "On": "enabled", - "Off": "disabled" - }, - "Cleanup": { - "Button": "Clean up mis-synced notes", - "Hint": "Find calendar/festival notes that were wrongly synced to Chronicle as entities (\"Characters\") and safely remove them.", - "Title": "Clean up mis-synced calendar notes", - "Confirm": "Found {count} calendar note(s) that were wrongly synced to Chronicle as entities. Unlink them locally and delete those entities in Chronicle? Your Calendaria notes are kept; real character Actors are untouched.", - "Done": "Cleaned up {count} mis-synced calendar note(s).", - "None": "No mis-synced calendar notes found — nothing to clean up.", - "NoApi": "Chronicle Sync API unavailable — is sync configured and connected?" - }, - "Findings": { - "Pill": "validation finding(s)", - "None": "No validation findings — calendar looks clean." - }, - "Rail": { - "Year": "Year", - "Structure": "Structure", - "Validation": "Validation", - "SelectedDay": "Selected day" - }, - "Structure": { - "Months": "Months", - "Weekdays": "Weekdays", - "Seasons": "Seasons", - "Moons": "Moons", - "Eras": "Eras", - "Festivals": "Festivals", - "Cycles": "Cycles", - "WeatherZones": "Weather zones", - "ReadOnlyHint": "Structure groups are read-only in PR 1. Edit via Calendaria's Calendar Editor for now." - }, - "Day": { - "Events": "Events", - "NoEvents": "No events on this day.", - "Moons": "Moons", - "NoMoons": "Calendar has no moons defined.", - "Weather": "Weather", - "NoWeather": "No weather configured." - }, - "AddEvent": { - "Label": "Add event" - }, - "NoteForm": { - "HeaderAdd": "Add event", - "HeaderEdit": "Edit event", - "Name": "Name", - "NamePlaceholder": "What's this event?", - "Content": "Content", - "ContentPlaceholder": "Optional notes / HTML", - "StartDate": "Start date", - "EndDate": "End date", - "EndDateHint": "Leave blank for a single-day event.", - "Year": "Year", - "Month": "Month", - "Day": "Day", - "AllDay": "All-day event", - "Time": "Time", - "Hour": "Hour", - "Minute": "Minute", - "EndHour": "End hour", - "EndMinute": "End minute", - "Visibility": "Visibility", - "DisplayStyle": "Display style", - "Icon": "Icon", - "Color": "Color", - "Categories": "Categories", - "CategoriesPlaceholder": "festival, session, …", - "CategoriesHint": "Comma-separated. Existing preset IDs from Calendaria. PR 4 will add an inline picker.", - "Save": "Save changes", - "Create": "Create event", - "Cancel": "Cancel", - "Delete": "Delete event", - "DeleteConfirmTitle": "Delete event?", - "DeleteConfirmBody": "This deletes the event from Calendaria. The Chronicle copy will also be removed via the existing sync.", - "EditTooltip": "Edit", - "Created": "Chronicle Sync: event created.", - "Updated": "Chronicle Sync: event updated.", - "Deleted": "Chronicle Sync: event deleted.", - "NotFound": "Chronicle Sync: that event could not be loaded for editing.", - "SaveFailed": "Chronicle Sync: failed to save event. See console for details.", - "DeleteFailed": "Chronicle Sync: failed to delete event. See console for details.", - "VisibilityOption": { - "Visible": "Visible (everyone)", - "Hidden": "Hidden (GM only)", - "Secret": "Secret (GM only, hidden by default)" - }, - "DisplayStyleOption": { - "Icon": "Icon", - "Pip": "Pip", - "Banner": "Banner" - }, - "Errors": { - "NotAnObject": "Form data is not an object.", - "NameRequired": "Name is required.", - "YearInvalid": "Year must be a number.", - "MonthInvalid": "Month must be a positive integer.", - "DayInvalid": "Day must be a positive integer.", - "EndDateInvalid": "End date is incomplete or invalid.", - "EndBeforeStart": "End date is before start date.", - "HourInvalid": "Hour must be between 0 and 23.", - "MinuteInvalid": "Minute must be between 0 and 59.", - "VisibilityInvalid": "Visibility must be 'visible', 'hidden', or 'secret'.", - "DisplayStyleInvalid": "Display style must be 'icon', 'pip', or 'banner'.", - "ApiUnavailable": "Calendaria's API is not available — cannot save.", - "OptionsBuild": "Internal error building Calendaria options from the form.", - "SaveFailed": "Save failed — see browser console for details." - } - }, - "Footer": { - "PR2": "Event CRUD · PR 2", - "PR3": "Event CRUD · Moon strip · Recurrence · PR 3" - }, - "MoonStrip": { - "Heading": "Moon phases", - "RandomizedTooltip": "Randomized phase mode — positions vary each tick.", - "ConvergenceLabel": "Multi-moon full convergence on day(s):", - "ClickHint": "Click a strip position to jump the day inspector there." - }, - "Recurrence": { - "Legend": "Recurrence", - "Hint": "Apply a preset for events that repeat. Leave blank for a one-off date.", - "NoneSelected": "No recurrence (event fires once on its start date).", - "CurrentSummaryLabel": "Current pattern", - "Clear": "Clear recurrence", - "AdvancedHint": "Need more? Open Calendaria's note editor for the full condition tree.", - "PresetBadParams": "Recurrence preset needs valid parameters.", - "Preset": { - "EveryNthOfMonth": "Every Nth of every month", - "EveryFullMoon": "Every full", - "EveryNewMoon": "Every new", - "EverySeason": "Every day in", - "EveryWeekday": "Every weekday Nth", - "EveryNthWeekdayOfSeason": "Every Nth weekday in", - "EveryMonth": "Every day of month", - "EveryCycleStage": "Every cycle stage" - }, - "Errors": { - "NotAnObject": "Recurrence is not an object.", - "RootMustBeGroup": "Recurrence root must be a group (Calendaria silently ignores non-group roots).", - "InvalidGroupMode": "Recurrence group mode must be 'and' or 'or'.", - "EmptyGroup": "Recurrence group must contain at least one condition.", - "TooDeep": "Recurrence nesting deeper than 2 levels (use Calendaria's advanced editor for this).", - "InvalidChild": "Recurrence has an invalid child entry.", - "InvalidChildType": "Recurrence has a child of unsupported type.", - "FieldNotInV1Scope": "Recurrence uses a field outside the v1 builder scope (still saved verbatim).", - "OperatorNotInV1Scope": "Recurrence uses an operator outside the v1 builder scope (still saved verbatim).", - "MissingValue": "Recurrence condition is missing its value." - } - }, - "Import": { - "HeaderAbsent": "No calendar in Chronicle yet — import from Calendaria", - "HintAbsent": "Pick a Calendaria calendar to push into Chronicle. After import, Chronicle becomes the source of truth and the existing two-way sync handles events and the current date.", - "Button": "Import into Chronicle", - "InProgress": "Importing…", - "Probing": "Checking Chronicle for an existing calendar…", - "Success": "Chronicle Sync: calendar imported.", - "ChronicleTooOld": "Chronicle doesn't expose the calendar-create endpoint yet — update Chronicle, then click Recheck.", - "Unreachable": "Could not reach Chronicle to check for a calendar. Check your sync settings.", - "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.", - "Recheck": "Recheck Chronicle", - "CalendariaUnavailable": "Calendaria's API is not available — cannot read the source calendar.", - "CalendarNotFound": "Calendaria has no calendar with that id.", - "TransformFailed": "Could not transform the Calendaria calendar into a Chronicle payload.", - "ChronicleUnreachable": "Could not reach Chronicle — sync manager not initialized.", - "PostFailed": "Chronicle rejected the calendar payload.", - "NoCalendariaCalendars": "Calendaria has no calendars loaded. Open Calendaria's Calendar Editor to create one first." - }, - "Degraded": { - "NoCalendaria": "Calendaria is not installed or not enabled. Sync Calendar requires Calendaria to be active.", - "IncompatibleApi": "Calendaria is installed but its API is not exposed. This editor needs CALENDARIA.api to function; check your Calendaria version.", - "NoActiveCalendar": "Calendaria is loaded but no active calendar is selected. Open Calendaria's Calendar Editor to set one." - } - }, "Removed": { "FolderName": "Chronicle: removed", "Entity": "\"{name}\" was deleted in Chronicle. Its journal is kept, unlinked, in the \"Chronicle: removed\" folder.", diff --git a/module.json b/module.json index 8d71cf0..d64e193 100644 --- a/module.json +++ b/module.json @@ -27,7 +27,6 @@ "styles/map-viewer.css", "styles/map-frames.css", "styles/stashes.css", - "styles/sync-calendar-pr3.css", "styles/dm-screen.css" ], "languages": [ @@ -45,18 +44,6 @@ "type": "module", "compatibility": {}, "reason": "Optional: Enhanced journal page support for Chronicle entity sync" - }, - { - "id": "calendaria", - "type": "module", - "compatibility": {}, - "reason": "Optional: Calendar sync between Chronicle and Calendaria" - }, - { - "id": "foundryvtt-simple-calendar", - "type": "module", - "compatibility": {}, - "reason": "Optional: Calendar sync between Chronicle and Simple Calendar" } ] } diff --git a/scripts/_calendar-probe-state.mjs b/scripts/_calendar-probe-state.mjs index e37f49f..da0e6e1 100644 --- a/scripts/_calendar-probe-state.mjs +++ b/scripts/_calendar-probe-state.mjs @@ -1,6 +1,6 @@ /** * Pure helper mapping a failed `GET /calendar` probe (thrown by api-client) - * to the Sync Calendar editor's import-banner state. + * to the dashboard's calendar-banner state. * * Classification anchors on an explicit numeric `err.status` when present, * otherwise on the authoritative "Chronicle API error :" prefix the @@ -13,7 +13,7 @@ * @param {{status?: number, message?: string}|null|undefined} err * @returns {'absent'|'auth'|'rebuilding'|'unreachable'} * - `'absent'` — 404 / `calendar_not_configured`: Chronicle has no - * calendar for this campaign (import one). + * calendar for this campaign. * - `'auth'` — 401 / 403 / `invalid_token`: token/auth problem * (re-check the API key, or reinstall from a fresh campaign URL). * - `'rebuilding'` — a 503 whose body says `calendar_rebuilding`: that diff --git a/scripts/_calendar-subresources.mjs b/scripts/_calendar-subresources.mjs index 5fdb945..51e1ff2 100644 --- a/scripts/_calendar-subresources.mjs +++ b/scripts/_calendar-subresources.mjs @@ -5,7 +5,7 @@ * payload normalization, the one-liners the chat/dashboard surfaces render, * and the reducer that maintains the "last known sub-resource state" * snapshot the dashboard's Calendar tab reads. Everything touching Foundry - * globals (ChatMessage, CALENDARIA.api, ui.notifications) stays in + * globals (ChatMessage, ui.notifications) stays in * `calendar-sync.mjs` so this file is unit-testable off-DOM. * * `calendar.season/structure/cycle/festival.changed` and diff --git a/scripts/_scene-controls.mjs b/scripts/_scene-controls.mjs index 475442f..2d9ff3b 100644 --- a/scripts/_scene-controls.mjs +++ b/scripts/_scene-controls.mjs @@ -1,7 +1,7 @@ /** * Pure builder for the Chronicle group in Foundry's scene controls. * - * GMs get the Sync Dashboard, Sync Calendar and DM Screen; the Notebook is for every + * GMs get the Sync Dashboard and DM Screen; the Notebook is for every * user once the world is connected to Chronicle. The GM tools must never * reach a player: tools/test-scene-controls.mjs pins that. */ @@ -14,8 +14,8 @@ * @param {object} opts * @param {boolean} opts.isGM * @param {boolean} opts.notebook - the world is connected to Chronicle - * @param {{dashboard: Function, syncCalendar: Function, dmScreen: Function, notebook: Function}} opts.run - * @param {{syncCalendar: string, dmScreen: string, notebook: string}} opts.titles - localized + * @param {{dashboard: Function, dmScreen: Function, notebook: Function}} opts.run + * @param {{dmScreen: string, notebook: string}} opts.titles - localized */ export function addChronicleControls(controls, { isGM, notebook, run, titles }) { const tools = []; @@ -26,12 +26,6 @@ export function addChronicleControls(controls, { isGM, notebook, run, titles }) icon: 'fa-solid fa-rotate', button: true, run: run.dashboard, - }, { - name: 'sync-calendar', - title: titles.syncCalendar, - icon: 'fa-solid fa-calendar-days', - button: true, - run: run.syncCalendar, }, { // The DM Screen's opening rolls its die out of this button. name: 'dm-screen', diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index 91b15f3..06c997d 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -1,24 +1,20 @@ /** - * Chronicle Sync - Calendar/Calendaria/SimpleCalendar Sync + * Chronicle Sync - Calendar * - * Bidirectional sync between Chronicle's calendar system and Foundry's - * calendar modules (Calendaria or SimpleCalendar, via an adapter pattern; - * a no-op when neither is active). Chronicle → Foundry arrives via - * WebSocket and updates the active module's date/events/notes; Foundry → - * Chronicle is detected via Hooks and pushed to the Chronicle API (PUT - * /calendar/date, POST/PUT/DELETE /calendar/events). Calendaria notes sync - * as Chronicle calendar events, using Calendaria's modern hook names with - * fallbacks for older versions. On first connect, fetches Chronicle's - * calendar structure and optionally pushes it to the active module. + * Pulls Chronicle's calendar (current date, events, weather, season, era, + * moon phases) over REST and the WebSocket and exposes it to the dashboard. + * No Foundry calendar module is integrated, so nothing here writes a date or + * a note into Foundry. The guarded date push (`pushDate`) and the + * `calendar.*` message routing are what the built-in calendar plugs into. + * TODO(#95): apply Chronicle's date and events to the built-in calendar and + * call `pushDate` from its date-change hook. */ -import { getSetting, getCalendarSyncExclusions } from './settings.mjs'; +import { getSetting } from './settings.mjs'; import { FLAG_SCOPE } from './constants.mjs'; import { shouldSkipDatePush, isRealTimeRejection, notifyRealTimePushPaused } from './_realtime-date-guard.mjs'; import { calendarBlackoutActive, handleIfCalendarRebuilding, noteCalendarAnswerOk } from './_calendar-blackout-guard.mjs'; import { datePushPaused, handleDatePushRefusal, resumeDatePush } from './_date-push-rejection.mjs'; -import { ApplyGuard, echoKey } from './_apply-guard.mjs'; -import { confirmAppliedDate } from './_applied-date-confirm.mjs'; import { ROUTED_CALENDAR_TYPES, STRUCTURE_SIGNAL_TYPES, @@ -41,29 +37,6 @@ export const WIRE_VISIBILITY = Object.freeze({ GM_ONLY: 'gm-only', }); -/** - * Pure helper: derive the wire-visibility value to emit when sending a - * Calendaria note to Chronicle. Exported for unit testing — the hot path - * call sites delegate to this so tests can pin the exact wire string. - * - * Accepts either: - * - A note stub with `gmOnly: boolean` (Calendaria's documented field) - * - A note stub with `visibility: 'visible'|'hidden'|'secret'` — - * anything other than 'visible' is treated as GM-only since Calendaria - * hides those notes from non-GM users by default. - * - * @param {object|null} noteData - * @returns {'everyone'|'gm-only'} - */ -export function chronicleVisibilityFromCalendariaNote(noteData) { - if (!noteData || typeof noteData !== 'object') return WIRE_VISIBILITY.EVERYONE; - if (noteData.gmOnly === true) return WIRE_VISIBILITY.GM_ONLY; - if (noteData.gmOnly === false) return WIRE_VISIBILITY.EVERYONE; - const v = noteData.visibility ?? noteData.flagData?.visibility; - if (v === 'hidden' || v === 'secret') return WIRE_VISIBILITY.GM_ONLY; - return WIRE_VISIBILITY.EVERYONE; -} - /** * Pure helper: should an incoming Chronicle event be treated as GM-only? * Accepts both the canonical wire form `'gm-only'` (kebab) and the legacy @@ -123,27 +96,6 @@ function hasVisibilityRules(raw) { return named(rules.allowed_users) || named(rules.denied_users); } -/** - * Calendaria's Foundry module id. It tags every note JournalEntry it creates - * with flags under this scope. (Verified against Sayshal/Calendaria - * `scripts/constants.mjs` → `MODULE.ID = 'calendaria'`.) - */ -export const CALENDARIA_FLAG_SCOPE = 'calendaria'; - -/** - * Candidate Calendaria weather-setter method names, probed in order by - * `_applyWeatherToCalendaria`. Calendaria's published API exposes weather - * reads but no documented setter (weather is generated from zone/preset - * tables). Probing a short list and degrading to chat beats hard-coding one - * speculative name. Mirrored into the diagnostics probe list so a build - * that does expose one shows up in a bug report. - */ -export const CALENDARIA_WEATHER_SETTERS = Object.freeze([ - 'setWeather', - 'setCurrentWeather', - 'setWeatherForDate', -]); - /** * Escape a plain-text line for safe interpolation into a ChatMessage body. * Chronicle-authored strings (weather descriptions, season names) arrive @@ -161,96 +113,6 @@ function escapeChatHtml(s) { .replace(/'/g, '''); } -/** - * SimpleCalendar persists each note as a JournalEntry under one of these module - * flag scopes; the namespace's presence on the document is the note signal. - * Frozen so a later edit can't silently drop one. - */ -export const SIMPLE_CALENDAR_FLAG_SCOPES = Object.freeze([ - 'foundryvtt-simple-calendar', - 'simple-calendar', -]); - -/** - * Pure predicate: is this Foundry JournalEntry a calendar-module note? - * - * Detects Calendaria's `flags.calendaria.isCalendarNote`/`isCalendarJournal` - * or a SimpleCalendar flag scope (SIMPLE_CALENDAR_FLAG_SCOPES), plus our - * own `calendarEventId` link flag once CalendarSync has mirrored a note. - * JournalSync calls this to skip these documents: an unguarded POST to - * `/entities` with `entity_type_id: 0` resolves to the campaign's first - * entity type, wrongly surfacing holidays there instead of as calendar - * events. - * - * Reads the nested `flags` object directly when `getFlag` is unavailable, - * so it also works against plain object stubs in tests. - * - * @param {object|null} journal - A Foundry JournalEntry (or test stub). - * @returns {boolean} - */ -export function isCalendarNoteJournal(journal) { - if (!journal || typeof journal !== 'object') return false; - - const flags = journal.flags || {}; - - // Calendaria: note / structure journals carry these explicit boolean flags. - // Match the specific flags (not merely the presence of a `calendaria` scope) - // so an unrelated journal that happens to hold a Calendaria enricher flag is - // not wrongly skipped from entity sync. - const cal = flags[CALENDARIA_FLAG_SCOPE]; - if (cal && typeof cal === 'object' && (cal.isCalendarNote === true || cal.isCalendarJournal === true)) { - return true; - } - - // SimpleCalendar: presence of its note flag scope is the signal. - for (const scope of SIMPLE_CALENDAR_FLAG_SCOPES) { - if (flags[scope] && typeof flags[scope] === 'object') return true; - } - - // A note already mirrored to a Chronicle calendar event carries this flag - // under our own scope. - if (typeof journal.getFlag === 'function') { - if (journal.getFlag(FLAG_SCOPE, 'calendarEventId')) return true; - } else if (flags[FLAG_SCOPE]?.calendarEventId) { - return true; - } - - return false; -} - -/** - * Normalize a Calendaria note startDate to Chronicle's 1-indexed month/day. - * - * Calendaria stores dates 0-indexed internally (`month`/`dayOfMonth`), but - * its `toPublic` conversion yields 1-indexed `month`/`day` (dayOfMonth - * deleted); realtime `calendaria.note*` hooks deliver the raw form. So the - * correction is keyed on SHAPE, never code path: a `day` field means - * already-public (no correction), `dayOfMonth` means raw (0-indexed → +1). - * Year is never adjusted. - * - * CAUTION: a raw Foundry `game.time.components` spread carries both - * `dayOfMonth` (0-indexed) AND a day-of-YEAR `day`, which would wrongly - * trip the passthrough branch — extract `{year, month, dayOfMonth}` first - * (see `_onCalendariaDateTimeChange`). - * - * @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 @@ -278,121 +140,60 @@ export function calendarEventFetchCoordinates(calendar, yearSpan = 1) { } /** - * Compare Chronicle's calendar structure to the active Foundry calendar's, - * for the structure-mismatch guard. Compares month count, per-month day - * counts, and weekday count only — moons, seasons, eras are cosmetic to - * date coordinates and excluded. - * - * Weekday comparison is skipped when either side reports 0 weekdays (a real - * calendar always has ≥1, so 0 means the list was unreadable — pausing sync - * on an unreadable list while months match would be a false positive). Leap - * variants are excluded too: the Foundry structure reader only surfaces - * base per-month `days`, so leap representation isn't pinned symmetrically. - * - * @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}} + * Flag scopes third-party calendar modules put on their note journals. The + * module no longer talks to those calendars, but a world that once used one + * still holds its notes, and they must never be pushed as entities. */ -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 - } - } - } - // Only compare weekday counts when BOTH sides expose a real count (> 0). A 0 - // on either side signals an unreadable list, not a genuine mismatch — see the - // doc comment's ride-along note. - if (chronicleWeekdays > 0 && fWeekdays > 0 && chronicleWeekdays !== fWeekdays) { - reasons.push(`weekday count (Chronicle ${chronicleWeekdays} vs Foundry ${fWeekdays})`); - } - - if (!reasons.length) return { match: true, detail: '' }; - return { match: false, detail: reasons.join('; ') }; -} +const FOREIGN_CALENDAR_NOTE_FLAGS = Object.freeze({ + calendaria: (f) => f?.isCalendarNote === true || f?.isCalendarJournal === true, + 'foundryvtt-simple-calendar': (f) => !!f, + 'simple-calendar': (f) => !!f, +}); /** - * 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} + * Pure predicate: is this Foundry JournalEntry a calendar note JournalSync + * must skip? True for one Chronicle already mirrored to a calendar event + * (our own `calendarEventId` link flag) and for notes a third-party calendar + * module left in the world. An unguarded POST to `/entities` with + * `entity_type_id: 0` resolves to the campaign's first entity type, wrongly + * surfacing the note there. + * + * Reads the nested `flags` object directly so it also works against plain + * object stubs in tests. + * + * @param {object|null} journal - A Foundry JournalEntry (or test stub). + * @returns {boolean} */ -function readArrayLike(...sources) { - for (const src of sources) { - if (Array.isArray(src)) return src; - if (src && typeof src === 'object') return Object.values(src); +export function isCalendarNoteJournal(journal) { + if (!journal || typeof journal !== 'object') return false; + const flags = journal.flags || {}; + if (flags[FLAG_SCOPE]?.calendarEventId) return true; + for (const [scope, isNote] of Object.entries(FOREIGN_CALENDAR_NOTE_FLAGS)) { + if (isNote(flags[scope])) return true; } - return []; + return false; } /** - * CalendarSync handles calendar ↔ Foundry calendar module synchronization. + * CalendarSync keeps Chronicle's calendar state for the dashboard and routes + * its `calendar.*` WebSocket types. */ export class CalendarSync { constructor() { /** @type {import('./api-client.mjs').ChronicleAPI|null} */ this._api = null; - /** - * Reentrant echo guard for the DATE write only (`_setLocalDate`), backed - * by a depth counter. Notes use the per-note `_echoGuard` instead, so a - * GM's unrelated edits during a pull still push. Read through `_syncing`. - * @type {number} - */ - this._syncDepth = 0; - - /** @type {'calendaria'|'simple-calendar'|null} */ - this._calendarModule = null; - /** @type {object|null} Cached Chronicle calendar structure. */ + /** @type {object|null} Cached Chronicle calendar (GET /calendar). */ this._chronicleCalendar = null; - /** @type {boolean} Whether modern Calendaria API (CALENDARIA.api) is available. */ - this._hasModernCalendariaApi = false; - - /** - * Session-scoped structure-mismatch guard (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; - /** * Last-known Chronicle sub-resource snapshot (weather / season / era / * moon phases), folded from the WebSocket stream by - * `reduceSubresourceState`. The dashboard's Calendar tab renders it; - * nothing here writes into Foundry. + * `reduceSubresourceState`. The dashboard's Calendar tab renders it. * @type {ReturnType} */ this._subresourceState = emptySubresourceState(); - /** - * Set when a `calendar.structure.updated` (or cycle/festival) broadcast - * arrived this session AND the re-compare found the structures still - * compatible. Feeds the dashboard's advisory `structure-changed` badge. We - * never auto-apply the new structure — see `_onChronicleStructureUpdated`. - * @type {string|null} - */ - this._structureChangedDetail = null; - /** * Types already logged by the `default:` branch of `onMessage`, so an * unhandled `calendar.*` type produces one debug line per session, not @@ -400,130 +201,48 @@ export class CalendarSync { * @type {Set} */ this._loggedUnhandledTypes = new Set(); - - // Bound hook handlers for cleanup. - this._boundHandlers = {}; - } - - /** - * Reentrant echo guard for the date write: `true` while `_setLocalDate` is - * applying Chronicle's date, so the date hook it triggers isn't pushed back. - * A counter, not a boolean, so overlapping applies don't unmask each other. - * @returns {boolean} - * @private - */ - get _syncing() { - return this._syncDepth > 0; - } - - /** - * Per-note echo guard: names the notes being applied from Chronicle so only - * their own hook echoes are ignored (see `_apply-guard.mjs`). Created lazily. - * @returns {ApplyGuard} - * @private - */ - get _echoGuard() { - if (!this.__echoGuard) this.__echoGuard = new ApplyGuard(); - return this.__echoGuard; - } - - /** - * Is this Calendaria note hook the echo of a note Chronicle is applying? - * @param {object} noteData - * @returns {boolean} - * @private - */ - _isCalendariaNoteEcho(noteData) { - if (!noteData) return false; - const fd = noteData.flagData || noteData; - const date = chronicleDateFromCalendariaStartDate(fd.startDate || fd); - const name = noteData.name || noteData.title || ''; - return this._echoGuard.isEcho({ - id: noteData.id ?? noteData.pageId, - key: date ? echoKey({ name, ...date }) : null, - }); - } - - /** - * Echo keys for a Chronicle event about to be written locally. An empty - * name also registers the placeholders the module substitutes for it. - * @param {object} data Chronicle event. - * @returns {string[]} - * @private - */ - _eventEchoKeys(data) { - const names = new Set([data.name || '']); - if (!data.name) { names.add('Event'); names.add('Untitled Note'); names.add('Untitled Event'); } - return [...names].map((name) => echoKey({ name, year: data.year, month: data.month, day: data.day })); } /** - * Initialize calendar sync. Detects which Foundry calendar module is - * active and registers appropriate hooks. * @param {import('./api-client.mjs').ChronicleAPI} api */ async init(api) { this._api = api; - - if (!getSetting('syncCalendar')) return; - - // Detect active calendar module. - if (game.modules.get('calendaria')?.active) { - this._calendarModule = 'calendaria'; - // Check for modern Calendaria API (v2+). - this._hasModernCalendariaApi = typeof globalThis.CALENDARIA?.api?.setDateTime === 'function'; - } else if (game.modules.get('foundryvtt-simple-calendar')?.active) { - this._calendarModule = 'simple-calendar'; - } - - if (!this._calendarModule) { - console.debug('Chronicle: No calendar module detected (Calendaria or SimpleCalendar). Calendar sync disabled.'); - return; - } - - this._registerHooks(); - console.debug(`Chronicle: Calendar sync initialized (${this._calendarModule} detected, modern API: ${this._hasModernCalendariaApi})`); } + /** Nothing is registered with Foundry, so there is nothing to release. */ + destroy() {} + /** * Handle incoming WebSocket messages for calendar events. * @param {object} msg */ async onMessage(msg) { - if (!getSetting('syncCalendar') || !this._calendarModule) return; + if (!getSetting('syncCalendar')) return; - // Structure signals run even while paused, ahead of the mismatch guard: - // they're the only broadcast that can clear a structure-mismatch pause - // (fixing the calendar in Chronicle is what emits - // `calendar.structure.updated`), so gating them behind the guard would - // make the pause unrecoverable without a world reload. The handler - // itself performs no writes into Foundry (see its doc comment). if (STRUCTURE_SIGNAL_TYPES.includes(msg?.type)) { await this._onChronicleStructureUpdated(msg.type); return; } - if (this._calendarSyncDisabled) return; // structure-mismatch guard (B-R2): no pull - - switch (msg.type) { + switch (msg?.type) { case 'calendar.date.advanced': - await this._onChronicaleDateAdvanced(msg.payload); + this._onChronicleDateAdvanced(msg.payload); break; case 'calendar.event.created': - await this._onChronicleEventCreated(msg.payload); + this._onChronicleEventCreated(msg.payload); break; case 'calendar.event.updated': - await this._onChronicleEventUpdated(msg.payload); + this._onChronicleEventUpdated(msg.payload); break; case 'calendar.event.deleted': - await this._onChronicleEventDeleted(msg.payload); + this._onChronicleEventDeleted(msg.payload); break; // --- Sub-resources ------------------------------------------------ // Display-level only: every branch below folds the payload into // `_subresourceState` for the dashboard and optionally posts a GM-only - // chat line. None of them writes a Chronicle value into the Foundry - // calendar's stored structure. + // chat line. case 'calendar.weather.changed': await this._onChronicleWeatherChanged(msg.payload); break; @@ -561,93 +280,24 @@ export class CalendarSync { } /** - * Handle a sync mapping received during initial sync. - * Stores calendar event mappings for later lookup. - * @param {object} mapping - */ - 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) { - await this._storeEventMapping(mapping.external_id, mapping.chronicle_id); - } - } - - /** - * Perform initial calendar sync on WebSocket connect. - * Fetches Chronicle calendar structure and syncs current date. + * Fetch Chronicle's calendar on WebSocket connect and cache it. + * @returns {Promise} true when a calendar was fetched. */ async onInitialSync() { - // @returns {Promise} true only when a date was actually applied - // locally — the dashboard's activity feed must not log a pull that - // didn't happen (no calendar, a mismatch pause, or a thrown error). - if (!getSetting('syncCalendar') || !this._calendarModule) return false; + if (!getSetting('syncCalendar')) return false; // A reconnect or manual pull gives a refused date push another chance: // the GM may have fixed the calendar or the key in between. resumeDatePush(); try { - // A mismatch pause does not return early: the structure is re-checked - // on every pull so fixing either calendar resumes sync without a - // world reload. this._chronicleCalendar = await this._api.get('/calendar'); if (!this._chronicleCalendar) { console.debug('Chronicle: No calendar configured for this campaign'); return false; } noteCalendarAnswerOk(); - - // Structure-mismatch guard (B-R2): if the active Foundry calendar's - // structure differs from Chronicle's, date coordinates are meaningless - // across the wire — pause calendar sync for the session (both - // directions) rather than push a Gregorian date into a custom - // calendar. Fails open: only a confirmed mismatch pauses; require both - // sides readable before comparing, so a degraded /calendar response - // (no months) doesn't report a false 0-vs-N mismatch. Covers both - // module paths — `_readActiveFoundryStructure` dispatches per module - // and returns null for an unreadable structure. - if (this._chronicleCalendar.months?.length > 0) { - const foundryStruct = this._readActiveFoundryStructure(); - if (foundryStruct) { - const cmp = compareCalendarStructures(this._chronicleCalendar, foundryStruct); - if (!cmp.match) { - if (!this._calendarSyncDisabled) { - this._pauseCalendarSyncForMismatch(this._chronicleCalendar, foundryStruct, cmp.detail); - } - return false; - } - this._clearMismatchPause(); - } - } - if (this._calendarSyncDisabled) return false; - - // Sync the current date from Chronicle to the Foundry calendar module. - // This is the "poll" apply path (GET /calendar on connect/reconnect, - // vs the WebSocket-driven _onChronicaleDateAdvanced below) — confirm - // back to Chronicle only when _setLocalDate reports a real apply. - const applied = await this._setLocalDate({ - year: this._chronicleCalendar.current_year, - month: this._chronicleCalendar.current_month, - day: this._chronicleCalendar.current_day, - hour: this._chronicleCalendar.current_hour, - minute: this._chronicleCalendar.current_minute, - }); - if (applied) { - await confirmAppliedDate(this._api, { - year: this._chronicleCalendar.current_year, - month: this._chronicleCalendar.current_month, - day: this._chronicleCalendar.current_day, - }); - } - - // Sync Chronicle calendar events to Calendaria notes (if using Calendaria). - if (this._calendarModule === 'calendaria') { - await this._syncChronicleEventsToCalendariaNotes(); - } - + // TODO(#95): apply the date to the built-in calendar, then + // confirmAppliedDate (_applied-date-confirm.mjs). console.debug('Chronicle: Calendar initial sync complete'); return true; } catch (err) { @@ -660,238 +310,50 @@ 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; - } - } - - /** - * Read the active SimpleCalendar calendar's structure (per-month day - * counts + weekday count) for the mismatch guard — the SimpleCalendar - * sibling of `_readActiveCalendariaStructure`. - * - * Exposes months via `numberOfDays` (base year; `numberOfLeapYearDays` is - * excluded, mirroring the Calendaria reader — see - * `compareCalendarStructures`). Prefers `getCurrentCalendar()`, falling - * back to `getAllMonths()`/`getAllWeekdays()`. 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 - */ - _readActiveSimpleCalendarStructure() { - try { - const sc = globalThis.SimpleCalendar?.api; - if (!sc) return null; - let months = []; - let weekdays = []; - let name = 'active Foundry calendar'; - const cal = typeof sc.getCurrentCalendar === 'function' ? sc.getCurrentCalendar() : null; - if (cal) { - months = readArrayLike(cal.months); - weekdays = readArrayLike(cal.weekdays); - name = cal.name || cal.id || name; - } - if (!months.length && typeof sc.getAllMonths === 'function') { - months = readArrayLike(sc.getAllMonths()); - } - if (!weekdays.length && typeof sc.getAllWeekdays === 'function') { - weekdays = readArrayLike(sc.getAllWeekdays()); - } - if (!months.length) return null; - return { - name, - monthDays: months.map((m) => Number(m?.numberOfDays ?? m?.days ?? 0)), - weekdayCount: weekdays.length, - }; - } catch (err) { - console.debug('Chronicle: could not read active SimpleCalendar structure', err?.message); - return null; - } - } - - /** - * Read the active Foundry calendar's structure for the mismatch guard, - * dispatching to the per-module reader. Returns null for an unknown - * module or an unreadable structure (guard fails open). - * @returns {{name:string, monthDays:number[], weekdayCount:number}|null} - * @private - */ - _readActiveFoundryStructure() { - if (this._calendarModule === 'calendaria') return this._readActiveCalendariaStructure(); - if (this._calendarModule === 'simple-calendar') return this._readActiveSimpleCalendarStructure(); - 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}`; - // The remedy must be "edit either calendar", never "import" or "author a - // new one" — Chronicle rejects importing a second calendar (409) and has - // no route to make an authored one the served default, so both would be - // unreachable advice. TODO(#95): once Chronicle lets a campaign choose - // which calendar is served, offer pointing this module at a different - // one. Pinned by tools/test-calendar-mismatch-remedy.mjs against this - // string and the dashboard's two banners. - const msg = `Chronicle Sync: calendar structures differ (${this._calendarMismatchDetail}). ` - + 'Calendar sync is paused for this session — edit either calendar so the two agree ' - + '(Chronicle: Calendar Settings → Months / Weekdays; Foundry: your calendar module), ' - + 'then reconnect or pull the calendar again; sync resumes by itself once they match. ' - + '(Journals, characters, and maps still sync.)'; - console.warn(msg); - try { globalThis.ui?.notifications?.warn(msg, { permanent: true }); } catch { /* headless */ } - } - - /** - * Lift a structure-mismatch pause once a re-check finds the calendars - * matching. No-op when not paused. - * @private - */ - _clearMismatchPause() { - // A refused date push may have been the same mismatch; let it try again. - resumeDatePush(); - if (!this._calendarSyncDisabled) return; - this._calendarSyncDisabled = false; - this._calendarMismatchDetail = null; - const msg = 'Chronicle Sync: the Chronicle calendar structure now matches the active ' - + 'Foundry calendar — calendar sync resumed for this session.'; - console.warn(msg); - try { globalThis.ui?.notifications?.info(msg); } catch { /* headless */ } - } - - /** - * Clean up hooks on destroy. - */ - destroy() { - this._unregisterHooks(); - } - - /** - * Remove all registered hooks. Safe to call even if no hooks are registered. - * @private + * Chronicle's current date as last pulled or broadcast. + * @returns {{year:number, month:number, day:number, hour:number, minute:number}|null} */ - _unregisterHooks() { - if (this._calendarModule === 'calendaria') { - // Modern Calendaria hooks. - if (this._boundHandlers.dateTimeChange) Hooks.off('calendaria.dateTimeChange', this._boundHandlers.dateTimeChange); - if (this._boundHandlers.noteCreated) Hooks.off('calendaria.noteCreated', this._boundHandlers.noteCreated); - if (this._boundHandlers.noteUpdated) Hooks.off('calendaria.noteUpdated', this._boundHandlers.noteUpdated); - if (this._boundHandlers.noteDeleted) Hooks.off('calendaria.noteDeleted', this._boundHandlers.noteDeleted); - // Legacy Calendaria hooks (for older versions). - if (this._boundHandlers.dateChange) Hooks.off('calendariaDateChange', this._boundHandlers.dateChange); - if (this._boundHandlers.eventCreate) Hooks.off('calendariaEventCreate', this._boundHandlers.eventCreate); - if (this._boundHandlers.eventUpdate) Hooks.off('calendariaEventUpdate', this._boundHandlers.eventUpdate); - if (this._boundHandlers.eventDelete) Hooks.off('calendariaEventDelete', this._boundHandlers.eventDelete); - } else if (this._calendarModule === 'simple-calendar') { - if (this._boundHandlers.dateChange) Hooks.off('simple-calendar-date-time-change', this._boundHandlers.dateChange); - if (this._boundHandlers.noteCreate) Hooks.off('createJournalEntry', this._boundHandlers.noteCreate); - if (this._boundHandlers.noteUpdate) Hooks.off('updateJournalEntry', this._boundHandlers.noteUpdate); - if (this._boundHandlers.noteDelete) Hooks.off('deleteJournalEntry', this._boundHandlers.noteDelete); - } - this._boundHandlers = {}; + get chronicleDate() { + const c = this._chronicleCalendar; + if (!c || c.current_year === undefined) return null; + return { + year: c.current_year, + month: c.current_month, + day: c.current_day, + hour: c.current_hour ?? 0, + minute: c.current_minute ?? 0, + }; } - // --- Hook Registration (adapter pattern) --- + // --- Chronicle → Foundry --- /** - * Register hooks for the detected calendar module. + * Record the date Chronicle advanced to. TODO(#95): apply it to the + * built-in calendar and confirm it back with confirmAppliedDate. + * @param {object} data - { year, month, day, hour, minute } * @private */ - _registerHooks() { - // Guard against duplicate listeners if init() is called multiple times - // (e.g., during reconnection). Remove any existing hooks first. - this._unregisterHooks(); - - if (this._calendarModule === 'calendaria') { - // Modern Calendaria hooks (v2+): dateTimeChange includes hour/minute, - // noteCreated/Updated/Deleted handle calendar notes. - this._boundHandlers.dateTimeChange = this._onCalendariaDateTimeChange.bind(this); - this._boundHandlers.noteCreated = this._onCalendariaNoteCreated.bind(this); - this._boundHandlers.noteUpdated = this._onCalendariaNoteUpdated.bind(this); - this._boundHandlers.noteDeleted = this._onCalendariaNoteDeleted.bind(this); - - Hooks.on('calendaria.dateTimeChange', this._boundHandlers.dateTimeChange); - Hooks.on('calendaria.noteCreated', this._boundHandlers.noteCreated); - Hooks.on('calendaria.noteUpdated', this._boundHandlers.noteUpdated); - Hooks.on('calendaria.noteDeleted', this._boundHandlers.noteDeleted); - - // Legacy Calendaria hooks (fallback for older versions). - this._boundHandlers.dateChange = this._onLocalDateChange.bind(this); - this._boundHandlers.eventCreate = this._onLocalEventCreate.bind(this); - this._boundHandlers.eventUpdate = this._onLocalEventUpdate.bind(this); - this._boundHandlers.eventDelete = this._onLocalEventDelete.bind(this); - - Hooks.on('calendariaDateChange', this._boundHandlers.dateChange); - Hooks.on('calendariaEventCreate', this._boundHandlers.eventCreate); - Hooks.on('calendariaEventUpdate', this._boundHandlers.eventUpdate); - Hooks.on('calendariaEventDelete', this._boundHandlers.eventDelete); - } else if (this._calendarModule === 'simple-calendar') { - this._boundHandlers.dateChange = this._onSimpleCalendarDateChange.bind(this); - Hooks.on('simple-calendar-date-time-change', this._boundHandlers.dateChange); - - // SimpleCalendar notes are JournalEntries with SC flags. Detect CRUD - // via standard Foundry journal hooks and check for SC flag presence. - this._boundHandlers.noteCreate = this._onSimpleCalendarNoteCreate.bind(this); - this._boundHandlers.noteUpdate = this._onSimpleCalendarNoteUpdate.bind(this); - this._boundHandlers.noteDelete = this._onSimpleCalendarNoteDelete.bind(this); - Hooks.on('createJournalEntry', this._boundHandlers.noteCreate); - Hooks.on('updateJournalEntry', this._boundHandlers.noteUpdate); - Hooks.on('deleteJournalEntry', this._boundHandlers.noteDelete); - } + _onChronicleDateAdvanced(data) { + if (!data || !this._chronicleCalendar) return; + const c = this._chronicleCalendar; + c.current_year = data.year; + c.current_month = data.month; + c.current_day = data.day; + if (data.hour !== undefined) c.current_hour = data.hour; + if (data.minute !== undefined) c.current_minute = data.minute; } - // --- Chronicle → Foundry --- - /** - * Update the local Foundry calendar date from Chronicle. This is the - * WebSocket-driven apply path (`calendar.date.advanced`) — confirm back to - * Chronicle only when `_setLocalDate` reports a real apply. - * @param {object} data - { year, month, day, hour, minute } + * Chronicle event created / updated / deleted. TODO(#95): mirror these into + * the built-in calendar; until it exists there is nothing to write to. + * @param {object} data - the Chronicle event (`{ id }` for a delete). * @private */ - async _onChronicaleDateAdvanced(data) { - if (!data) return; - const applied = await this._setLocalDate(data); - if (applied) { - await confirmAppliedDate(this._api, { year: data.year, month: data.month, day: data.day }); - } - } + _onChronicleEventCreated(data) {} + /** @private */ + _onChronicleEventUpdated(data) {} + /** @private */ + _onChronicleEventDeleted(data) {} // --- Chronicle → Foundry: sub-resources ----------------------------- @@ -946,12 +408,8 @@ export class CalendarSync { * Handle `calendar.weather.changed`. A `null` payload is a "refetch me" * ping from the weather-zone paths, so it triggers one `GET * /calendar/weather` instead of being discarded; otherwise it's the - * merged `WeatherInput` from `SetWeather`. - * - * Applies to Calendaria's weather setter when the active build exposes - * one (see `_applyWeatherToCalendaria`); otherwise (SimpleCalendar, or - * read-only Calendaria weather) falls back to the GM chat line. The - * dashboard panel updates either way. + * merged `WeatherInput` from `SetWeather`. The dashboard panel updates, and + * a GM chat line follows when enabled. * * @param {object|null} payload * @private @@ -973,50 +431,11 @@ export class CalendarSync { this._subresourceState, 'calendar.weather.changed', raw, ); - const appliedToModule = await this._applyWeatherToCalendaria(weather); - if (!appliedToModule && this._shouldAnnounce('calendar.weather.changed')) { + if (this._shouldAnnounce('calendar.weather.changed')) { this._announceToGM(this._subresourceState.weatherLine); } } - /** - * Push a weather reading into Calendaria when — and only when — its API - * exposes a setter. No known Calendaria build documents a weather setter - * (its weather is generated from zone/preset tables), so this probes the - * plausible names (`CALENDARIA_WEATHER_SETTERS`) and no-ops when none is - * present, returning false so the caller falls back to chat. - * - * @param {ReturnType} weather - * @returns {Promise} true when the reading was handed to Calendaria - * @private - */ - async _applyWeatherToCalendaria(weather) { - if (this._calendarModule !== 'calendaria' || !weather) return false; - try { - const api = globalThis.CALENDARIA?.api; - if (!api) return false; - const setter = CALENDARIA_WEATHER_SETTERS.find((n) => typeof api[n] === 'function'); - if (!setter) return false; - await api[setter]({ - label: weather.presetLabel, - temperature: weather.temperatureC, - windTier: weather.windTier, - windSpeed: weather.windSpeedKph, - windDirection: weather.windDirection, - precipitation: weather.precipType, - intensity: weather.precipIntensity, - zone: weather.zoneName, - description: weather.description, - }); - console.debug(`Chronicle: applied Chronicle weather via CALENDARIA.api.${setter}`); - return true; - } catch (err) { - // A failed apply must degrade to the chat line, not swallow the update. - console.debug('Chronicle: Calendaria weather apply failed, falling back to chat', err?.message); - return false; - } - } - /** * Handle the display-only sub-resource types: world state, season, era and * moon phase. Each folds into `_subresourceState` (which the dashboard's @@ -1025,7 +444,7 @@ export class CalendarSync { * * **No writes into Foundry.** Nothing here creates a note, mutates a * calendar, or touches a document. The world-state branch never - * auto-creates a same-day Calendaria note for celestial events: the + * auto-creates a same-day note for celestial events: the * payload carries no celestial detail and no stable event id, so a * re-broadcast could create duplicate, undeduplicable notes. * @@ -1041,146 +460,35 @@ export class CalendarSync { /** * Handle `calendar.structure.updated` and its granular siblings - * (`calendar.cycle.changed`, `calendar.festival.changed`). - * - * **Deliberately does NOT auto-apply the new structure**: Calendaria - * stores notes against month/day coordinates, so re-shaping the calendar - * underneath them would silently re-date every note in the world. - * Instead: refetch `GET /calendar`, re-run the same fail-open comparison - * `onInitialSync` runs (an unreadable structure on either side never - * pauses anything), then pause on incompatible or record the advisory - * `structure-changed` badge on still-compatible. Compatible AND - * previously paused for a mismatch clears the pause — this is the only - * recovery path that doesn't need a world reload. - * - * Runs even while paused — it's the one handler that must (see `onMessage`). + * (`calendar.cycle.changed`, `calendar.festival.changed`): refetch + * `GET /calendar` so the cached structure is current. * * @param {string} [type] - the signal type, for the log line. * @private */ async _onChronicleStructureUpdated(type = 'calendar.structure.updated') { - let cal = null; try { - cal = await this._api.get('/calendar'); + const cal = await this._api.get('/calendar'); + if (cal) this._chronicleCalendar = cal; } catch (err) { - console.debug('Chronicle: structure re-compare could not refetch /calendar', err?.message); - } - if (cal) this._chronicleCalendar = cal; - - const chronicleCal = this._chronicleCalendar; - // Fail open, exactly as onInitialSync does: no readable structure on - // either side means no verdict, so we neither pause nor un-pause. - if (!(chronicleCal?.months?.length > 0)) { - console.debug(`Chronicle: ${type} received; Chronicle structure unreadable — no re-compare.`); - return; - } - const foundryStruct = this._readActiveFoundryStructure(); - if (!foundryStruct) { - console.debug(`Chronicle: ${type} received; Foundry structure unreadable — no re-compare.`); - return; - } - - const cmp = compareCalendarStructures(chronicleCal, foundryStruct); - if (!cmp.match) { - this._structureChangedDetail = null; - if (!this._calendarSyncDisabled) { - this._pauseCalendarSyncForMismatch(chronicleCal, foundryStruct, cmp.detail); - } - return; - } - - // Compatible. Recover from a prior mismatch pause if there was one. - this._clearMismatchPause(); - - const chronicleShape = `${(chronicleCal.months || []).length}mo/${(chronicleCal.weekdays || []).length}wd`; - const foundryShape = `${(foundryStruct.monthDays || []).length}mo/${foundryStruct.weekdayCount ?? 0}wd`; - this._structureChangedDetail = - `Chronicle's calendar structure changed (${type}). Re-compared: still compatible ` - + `(Chronicle ${chronicleShape} vs Foundry ${foundryShape}). Month names, cycles, festivals ` - + 'and era boundaries are outside this comparison — re-check the calendar. ' - + 'The Foundry calendar was NOT modified.'; - console.debug(`Chronicle: ${this._structureChangedDetail}`); - } - - /** - * Create a local calendar event from Chronicle data. - * @param {object} data - Chronicle event object. - * @private - */ - async _onChronicleEventCreated(data) { - if (!data) return; - await this._createLocalEvent(data); - } - - /** - * Update a local calendar event from Chronicle data. - * @param {object} data - Chronicle event object. - * @private - */ - async _onChronicleEventUpdated(data) { - if (!data) return; - await this._updateLocalEvent(data); - } - - /** - * Delete a local calendar event from Chronicle data. - * @param {object} data - { id: eventId } - * @private - */ - async _onChronicleEventDeleted(data) { - if (!data) return; - await this._deleteLocalEvent(data); - } - - /** - * Whether the operator has opted the currently-active calendar (Calendaria or Simple Calendar) out - * of Chronicle sync (toggled from the Sync Calendar editor). Push handlers - * check this so a local-only calendar stops pushing date/note changes without - * disabling calendar sync globally. Defensive: any lookup failure → not - * excluded (fail open to existing behaviour). @returns {boolean} @private - */ - _isActiveCalendarExcluded() { - try { - const exclusions = getCalendarSyncExclusions(); - if (!exclusions.length) return false; - const cal = this._calendarModule === 'simple-calendar' - ? globalThis.SimpleCalendar?.api?.getCurrentCalendar?.() - : globalThis.CALENDARIA?.api?.getActiveCalendar?.(); - const id = cal?.metadata?.id || cal?.id || ''; - return !!id && exclusions.includes(id); - } catch { - return false; + console.debug(`Chronicle: ${type} received; could not refetch /calendar`, err?.message); } } - // --- Foundry → Chronicle (Calendaria Modern Hooks) --- + // --- Foundry → Chronicle --- /** - * Push date/time change from modern Calendaria (dateTimeChange hook) to Chronicle. - * This hook fires on every world time change and includes full date+time. - * @param {object} data - { year, month, dayOfMonth, hour, minute, second, ... } - * @private + * Push a date to Chronicle (PUT /calendar/date), behind the date-push + * guards: GM only, the rebuild blackout, a refused-push pause, and the + * real-time-calendar signal. Refusals pause the push with one notice + * rather than one error per tick. TODO(#95): call this from the built-in + * calendar's date-change hook. + * @param {{year:number, month:number, day:number, hour?:number, minute?:number}} date - 1-indexed month/day. */ - 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; - - // Calendaria's dateTimeChange payload nests the raw date components under - // `.current` (a spread of game.time.components), 0-indexed month/dayOfMonth - // with an already-adjusted year. `.current` also carries a day-of-YEAR - // `day` sibling that would wrongly trip chronicleDateFromCalendariaStartDate's - // "has `day` → already public" branch, so build a clean {year, month, - // dayOfMonth} stub when dayOfMonth is present and pass the source through - // otherwise, keeping the 0→1 correction in that one helper. - const src = (data && data.current) ? data.current : data; - if (!src) return; - const startDate = (src.dayOfMonth !== undefined) - ? { year: src.year, month: src.month, dayOfMonth: src.dayOfMonth } - : src; - const date = chronicleDateFromCalendariaStartDate(startDate); + async pushDate(date) { if (!date) return; + if (!getSetting('syncCalendar')) return; + if (!game.user.isGM) return; // Check before the pre-push probe so a known blackout costs zero requests. if (calendarBlackoutActive() || datePushPaused()) return; @@ -1191,846 +499,62 @@ export class CalendarSync { year: date.year, month: date.month, day: date.day, - hour: src.hour ?? 0, - minute: src.minute ?? 0, + hour: date.hour || 0, + minute: date.minute || 0, }); noteCalendarAnswerOk(); } catch (err) { // 400/403/422 refusals pause date push with one notice, not one error per tick. if (handleDatePushRefusal(err)) return; if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; } - // A 503 calendar_rebuilding arms the session guard and notifies once, - // rather than logging an error on every world-time tick. + // A 503 calendar_rebuilding arms the session guard and notifies once. if (handleIfCalendarRebuilding(err)) return; - console.error('Chronicle: Failed to push Calendaria date/time to Chronicle', err); + console.error('Chronicle: Failed to push date to Chronicle', err); } } /** - * Push new Calendaria note to Chronicle as a calendar event. - * @param {object} noteData - Calendaria note data from the hook. - * @private + * Fetch Chronicle's calendar events for the current year ±`yearSpan`, + * month by month (GET /calendar/events is month-filtered), deduped by id. + * Bounded, never unbounded history. TODO(#95): feed these to the built-in + * calendar on connect. + * @param {number} [yearSpan=1] + * @returns {Promise} */ - async _onCalendariaNoteCreated(noteData) { - if (this._isCalendariaNoteEcho(noteData)) 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); - if (!eventPayload) return; + async fetchEvents(yearSpan = 1) { + const coords = calendarEventFetchCoordinates(this._chronicleCalendar, yearSpan); + // 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]; - try { - const result = await this._api.post('/calendar/events', eventPayload); - if (result?.id && noteData.id) { - await this._storeEventMapping(noteData.id, result.id); + const seen = new Set(); + const events = []; + for (const coord of fetches) { + const path = coord + ? `/calendar/events?year=${coord.year}&month=${coord.month}` + : '/calendar/events'; + let payload; + try { + payload = await this._api.get(path); + } catch (err) { + console.debug('Chronicle: calendar events fetch failed for', path, err.message); + continue; + } + // GET /calendar/events returns an envelope { data:[...], total } — + // accept that AND a bare array, kept local so get()'s contract for + // other callers is unchanged. + const page = Array.isArray(payload) + ? payload + : (payload && Array.isArray(payload.data) ? payload.data : null); + if (!page) continue; + for (const event of page) { + // 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); + events.push(event); } - } catch (err) { - console.error('Chronicle: Failed to push Calendaria note to Chronicle', err); } - } - - /** - * Push Calendaria note update to Chronicle. - * @param {object} noteData - Calendaria note data from the hook. - * @private - */ - async _onCalendariaNoteUpdated(noteData) { - if (this._isCalendariaNoteEcho(noteData)) 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); - if (!chronicleId) { - // Note exists in Calendaria but not in Chronicle — create it. - await this._onCalendariaNoteCreated(noteData); - return; - } - - const eventPayload = this._calendariaNoteToChronicleEvent(noteData); - if (!eventPayload) return; - - try { - await this._api.put(`/calendar/events/${chronicleId}`, eventPayload); - } catch (err) { - console.error('Chronicle: Failed to update Calendaria note in Chronicle', err); - } - } - - /** - * Push Calendaria note deletion to Chronicle. - * @param {object} noteData - Calendaria note data (at minimum { id }). - * @private - */ - async _onCalendariaNoteDeleted(noteData) { - if (this._isCalendariaNoteEcho(noteData)) 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; - if (!noteId) return; - - const chronicleId = this._getChronicleEventId(noteId); - if (!chronicleId) return; - - try { - await this._api.delete(`/calendar/events/${chronicleId}`); - await this._removeEventMapping(noteId); - } catch (err) { - console.warn('Chronicle: Failed to delete Calendaria note from Chronicle', err); - } - } - - /** - * Convert a Calendaria note object to a Chronicle calendar event payload. - * @param {object} noteData - Calendaria note data. - * @returns {object|null} Chronicle event body, or null if invalid. - * @private - */ - _calendariaNoteToChronicleEvent(noteData) { - if (!noteData) return null; - - // Prefer the authoritative note from the modern Calendaria API: getNote() - // returns toPublic (1-indexed) dates, the source of truth. The realtime - // note* hook payload instead carries raw 0-indexed dates; both shapes are - // normalized by chronicleDateFromCalendariaStartDate, keyed on the date - // SHAPE (dayOfMonth vs day) so there is no double-correction. - 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 */ } - } - - const startDate = flagData.startDate || flagData; - const date = chronicleDateFromCalendariaStartDate(startDate); - if (!date) return null; - - // Six keys, no more. PUT /calendar/events/:id is a partial update — - // absent preserves, explicit null clears, a value replaces (see - // API-CONTRACT.md). Do not widen this to echo other fields back: an - // echo re-arms the endpoint for the next writer and goes stale. - return { - name: name || 'Untitled Note', - year: date.year, - month: date.month, - day: date.day, - description: noteData.content || noteData.description || flagData.content || '', - // Must emit kebab-case ('gm-only') on the wire, per WIRE_VISIBILITY. - visibility: chronicleVisibilityFromCalendariaNote(noteData), - }; - } - - // --- Foundry → Chronicle (Legacy Calendaria Hooks) --- - - /** - * Push date change from legacy Calendaria to Chronicle. - * @param {object} dateData - { year, month, day } - * @private - */ - 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 - - // Check before the pre-push probe so a known blackout costs zero requests. - if (calendarBlackoutActive() || datePushPaused()) return; - - try { - if (await shouldSkipDatePush(this._api)) return; - await this._api.put('/calendar/date', { - year: dateData.year, - month: dateData.month, - day: dateData.day, - hour: dateData.hour || 0, - minute: dateData.minute || 0, - }); - noteCalendarAnswerOk(); - } catch (err) { - // 400/403/422 refusals pause date push with one notice, not one error per tick. - if (handleDatePushRefusal(err)) return; - if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; } - // A 503 calendar_rebuilding arms the session guard and notifies once, - // rather than logging an error on every world-time tick. - if (handleIfCalendarRebuilding(err)) return; - console.error('Chronicle: Failed to push date to Chronicle', err); - } - } - - /** - * Push date change from SimpleCalendar to Chronicle. - * SimpleCalendar provides a different hook payload format. - * @param {object} data - SimpleCalendar date-time-change hook data. - * @private - */ - 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, ... } - const date = data?.date || data; - if (!date) return; - - // Check before the pre-push probe so a known blackout costs zero requests. - if (calendarBlackoutActive() || datePushPaused()) return; - - try { - if (await shouldSkipDatePush(this._api)) return; - await this._api.put('/calendar/date', { - year: date.year, - // SimpleCalendar months are 0-indexed; Chronicle is 1-indexed. - month: (date.month ?? 0) + 1, - day: (date.day ?? 0) + 1, - hour: date.hour || 0, - minute: date.minute || 0, - }); - noteCalendarAnswerOk(); - } catch (err) { - // 400/403/422 refusals pause date push with one notice, not one error per tick. - if (handleDatePushRefusal(err)) return; - if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; } - // A 503 calendar_rebuilding arms the session guard and notifies once, - // rather than logging an error on every world-time tick. - if (handleIfCalendarRebuilding(err)) return; - console.error('Chronicle: Failed to push SimpleCalendar date to Chronicle', err); - } - } - - /** - * Push new event from legacy Calendaria to Chronicle. - * @param {object} eventData - * @private - */ - async _onLocalEventCreate(eventData) { - if (this._echoGuard.isEcho({ id: eventData?.id, key: echoKey(eventData) })) 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', { - name: eventData.name || 'Untitled Event', - year: eventData.year, - month: eventData.month, - day: eventData.day, - description: eventData.description || '', - visibility: 'everyone', - }); - - // Store the Chronicle event ID in the local module's data for later sync. - if (result?.id && eventData.id) { - await this._storeEventMapping(eventData.id, result.id); - } - } catch (err) { - console.error('Chronicle: Failed to push calendar event', err); - } - } - - /** - * Push event update from legacy Calendaria to Chronicle. - * @param {object} eventData - * @private - */ - async _onLocalEventUpdate(eventData) { - if (this._echoGuard.isEcho({ id: eventData?.id, key: echoKey(eventData) })) 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) { - // Event was created outside Chronicle; create it instead. - await this._onLocalEventCreate(eventData); - return; - } - - try { - // Narrow body on purpose: PUT /calendar/events/:id is a partial update - // (absent preserves, null clears, a value replaces — API-CONTRACT.md). - // Do not widen this to echo other fields back. - await this._api.put(`/calendar/events/${chronicleId}`, { - name: eventData.name || 'Untitled Event', - year: eventData.year, - month: eventData.month, - day: eventData.day, - description: eventData.description || '', - }); - } catch (err) { - console.error('Chronicle: Failed to update calendar event', err); - } - } - - /** - * Push event delete from legacy Calendaria to Chronicle. - * @param {object} eventData - * @private - */ - async _onLocalEventDelete(eventData) { - if (this._echoGuard.isEcho({ id: eventData?.id })) 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; - - try { - await this._api.delete(`/calendar/events/${chronicleId}`); - await this._removeEventMapping(eventData.id); - } catch (err) { - console.warn('Chronicle: Failed to delete calendar event', err); - } - } - - // --- SimpleCalendar Note CRUD (Foundry → Chronicle) --- - // SimpleCalendar notes are JournalEntries with SC flags. We detect note - // changes via standard Foundry journal hooks and check for flag presence. - - /** - * Handle creation of a JournalEntry that may be a SimpleCalendar note. - * Pushes new calendar events to Chronicle. - * @param {JournalEntry} journal - * @param {object} options - * @param {string} userId - * @private - */ - async _onSimpleCalendarNoteCreate(journal, options, userId) { - if (!game.user.isGM) return; - if (userId !== game.user.id) return; - if (this._calendarSyncDisabled) return; // same mismatch guard as Calendaria - if (this._isActiveCalendarExcluded()) return; - if (this._isSimpleCalendarNoteEcho(journal)) return; - - const scData = this._extractSimpleCalendarData(journal); - if (!scData) return; - - try { - const result = await this._api.post('/calendar/events', { - name: scData.name, - year: scData.year, - month: scData.month, - day: scData.day, - description: scData.description, - visibility: 'everyone', - }); - - if (result?.id) { - await this._storeEventMapping(journal.id, result.id); - await journal.setFlag(FLAG_SCOPE, 'calendarEventId', result.id); - } - } catch (err) { - console.error('Chronicle: Failed to push SimpleCalendar note to Chronicle', err); - } - } - - /** - * Handle update of a JournalEntry that may be a SimpleCalendar note. - * Pushes event changes to Chronicle. - * @param {JournalEntry} journal - * @param {object} change - * @param {object} options - * @param {string} userId - * @private - */ - async _onSimpleCalendarNoteUpdate(journal, change, options, userId) { - if (!game.user.isGM) return; - if (userId !== game.user.id) return; - if (this._calendarSyncDisabled) return; // same mismatch guard as Calendaria - if (this._isActiveCalendarExcluded()) return; - if (this._isSimpleCalendarNoteEcho(journal)) return; - - const scData = this._extractSimpleCalendarData(journal); - if (!scData) return; - - const chronicleId = this._getChronicleEventId(journal.id) - || journal.getFlag(FLAG_SCOPE, 'calendarEventId'); - - if (!chronicleId) { - // Note exists in SC but not in Chronicle — create it. - await this._onSimpleCalendarNoteCreate(journal, options, userId); - return; - } - - try { - // Narrow body ON PURPOSE — see _onLocalEventUpdate for why this stays - // five keys and must not grow an echo of the event's other columns. - await this._api.put(`/calendar/events/${chronicleId}`, { - name: scData.name, - year: scData.year, - month: scData.month, - day: scData.day, - description: scData.description, - }); - } catch (err) { - console.error('Chronicle: Failed to update SimpleCalendar note in Chronicle', err); - } - } - - /** - * Handle deletion of a JournalEntry that may be a SimpleCalendar note. - * Removes the corresponding Chronicle calendar event. - * @param {JournalEntry} journal - * @param {object} options - * @param {string} userId - * @private - */ - async _onSimpleCalendarNoteDelete(journal, options, userId) { - if (!game.user.isGM) return; - if (userId !== game.user.id) return; - if (this._echoGuard.isEcho({ id: journal?.id })) return; - if (this._calendarSyncDisabled) return; // same mismatch guard as Calendaria - if (this._isActiveCalendarExcluded()) return; - - // Check if this was a SC note we know about. - const chronicleId = this._getChronicleEventId(journal.id) - || journal.getFlag(FLAG_SCOPE, 'calendarEventId'); - if (!chronicleId) return; - - try { - await this._api.delete(`/calendar/events/${chronicleId}`); - await this._removeEventMapping(journal.id); - } catch (err) { - console.warn('Chronicle: Failed to delete SimpleCalendar note from Chronicle', err); - } - } - - /** - * Is this journal hook the echo of a SimpleCalendar note Chronicle is - * applying? Matches by journal id, or by name+date for a create whose id - * did not exist yet. - * @param {JournalEntry} journal - * @returns {boolean} - * @private - */ - _isSimpleCalendarNoteEcho(journal) { - const scData = this._extractSimpleCalendarData(journal); - return this._echoGuard.isEcho({ id: journal?.id, key: scData ? echoKey(scData) : null }); - } - - /** - * Extract calendar event data from a SimpleCalendar note JournalEntry. - * Returns null if the journal is not a SC note. - * @param {JournalEntry} journal - * @returns {object|null} - { name, year, month, day, description } - * @private - */ - _extractSimpleCalendarData(journal) { - // SimpleCalendar stores note data under its module flag namespace. - const scFlags = journal.flags?.['foundryvtt-simple-calendar'] - || journal.flags?.['simple-calendar']; - if (!scFlags) return null; - - // SC note data includes noteData with startDate. - const noteData = scFlags.noteData || scFlags; - const startDate = noteData.startDate || noteData; - - // Validate that we have date fields. - if (startDate.year === undefined && startDate.month === undefined) return null; - - return { - name: journal.name || 'Untitled Event', - // SC uses 0-indexed months/days; Chronicle uses 1-indexed. - year: startDate.year ?? 0, - month: (startDate.month ?? 0) + 1, - day: (startDate.day ?? 0) + 1, - description: noteData.content || noteData.description || '', - }; - } - - // --- Adapter Methods (abstract over Calendaria vs SimpleCalendar) --- - - /** - * Set the date on the active Foundry calendar module. - * Uses CALENDARIA.api.setDateTime() when available for full hour/minute support. - * - * The return value is a best-effort signal, not a real ack: neither - * Calendaria's setDateTime/setDate nor SimpleCalendar's setDate reports - * success. `true` means a setter for the detected module was invoked - * without throwing; `false` means no setter was available or it threw. - * @param {object} data - { year, month, day, hour, minute } - * @returns {Promise} true if a local setter was invoked and did not throw. - * @private - */ - async _setLocalDate(data) { - this._syncDepth++; - let applied = false; - try { - if (this._calendarModule === 'calendaria') { - if (this._hasModernCalendariaApi) { - // Modern Calendaria: setDateTime supports full date + time. - await CALENDARIA.api.setDateTime({ - year: data.year, - month: data.month, - day: data.day, - hour: data.hour ?? 0, - minute: data.minute ?? 0, - }); - applied = true; - } else if (game.Calendaria?.setDate) { - // Legacy Calendaria: setDate only supports date (no time). - await game.Calendaria.setDate({ - year: data.year, - month: data.month, - day: data.day, - }); - applied = true; - } - } else if (this._calendarModule === 'simple-calendar') { - const sc = SimpleCalendar?.api; - if (sc?.setDate) { - // Awaited so a promise-returning setDate is settled before this - // method reports success to its callers. - await sc.setDate({ - year: data.year, - // SimpleCalendar months are 0-indexed. - month: (data.month || 1) - 1, - day: (data.day || 1) - 1, - hour: data.hour || 0, - minute: data.minute || 0, - seconds: 0, - }); - applied = true; - } - } - } catch (err) { - console.error('Chronicle: Failed to set local calendar date', err); - applied = false; - } finally { - this._syncDepth--; - } - return applied; - } - - /** - * Create a calendar event in the active Foundry calendar module. - * For Calendaria, creates a note via CALENDARIA.api.createNote() (modern) - * or game.Calendaria.createEvent() (legacy). - * @param {object} data - Chronicle event data. - * @private - */ - async _createLocalEvent(data) { - // Held only for this event's own write so a GM's unrelated edits during a - // pull still push; the new note's id joins the scope once it exists. - return this._echoGuard.run( - { keys: this._eventEchoKeys(data) }, - (token) => this._createLocalEventUnguarded(data, token), - ); - } - - /** @private */ - async _createLocalEventUnguarded(data, token) { - const isPublic = isChronicleEventPublic(data); - if (this._calendarModule === 'calendaria') { - if (this._hasModernCalendariaApi) { - // Modern Calendaria: create a note (notes are the primary event type). - try { - const note = await CALENDARIA.api.createNote({ - name: data.name || 'Event', - content: data.description || '', - startDate: { - year: data.year, - month: data.month, - day: data.day, - }, - allDay: true, - gmOnly: !isPublic, - visibility: isPublic ? 'visible' : 'hidden', - openSheet: false, - }); - if (note?.id) { - this._echoGuard.addId(token, note.id); - await this._storeEventMapping(note.id, data.id); - } - } catch (err) { - console.error('Chronicle: Failed to create Calendaria note from Chronicle event', err); - } - } else if (game.Calendaria?.createEvent) { - // Legacy Calendaria has no hidden events, so a restricted one is - // not copied at all rather than shown to every player. - if (!isPublic) return; - const localEvent = await game.Calendaria.createEvent({ - name: data.name, - year: data.year, - month: data.month, - day: data.day, - description: data.description || '', - }); - if (localEvent?.id) { - this._echoGuard.addId(token, localEvent.id); - await this._storeEventMapping(localEvent.id, data.id); - } - } else { - console.debug('Chronicle: Calendaria createEvent/createNote API not available'); - } - } else if (this._calendarModule === 'simple-calendar') { - // SimpleCalendar events are journal entries with note flags. This path - // sets no player visibility, so a restricted event is not copied. - if (!isPublic) return; - const sc = SimpleCalendar?.api; - if (sc?.addNote) { - const note = await sc.addNote( - data.name || 'Event', - data.description || '', - { - year: data.year, - month: (data.month || 1) - 1, - day: (data.day || 1) - 1, - hour: 0, - minute: 0, - seconds: 0, - }, - { - year: data.end_year || data.year, - month: ((data.end_month || data.month) || 1) - 1, - day: ((data.end_day || data.day) || 1) - 1, - hour: 0, - minute: 0, - seconds: 0, - }, - true, // allDay - 0, // repeats (none) - ); - if (note?.id) { - this._echoGuard.addId(token, note.id); - await this._storeEventMapping(note.id, data.id); - // Store Chronicle event ID on the journal entry. - const journal = game.journal.get(note.id); - if (journal) { - await journal.setFlag(FLAG_SCOPE, 'calendarEventId', data.id); - } - } - } - } - } - - /** - * Update a calendar event in the active Foundry calendar module. - * @param {object} data - Chronicle event data with id. - * @private - */ - async _updateLocalEvent(data) { - return this._echoGuard.run( - { ids: [this._getLocalEventId(data.id)] }, - () => this._updateLocalEventUnguarded(data), - ); - } - - /** @private */ - async _updateLocalEventUnguarded(data) { - const isPublic = isChronicleEventPublic(data); - if (this._calendarModule === 'calendaria') { - const localId = this._getLocalEventId(data.id); - if (!localId) return; - - if (this._hasModernCalendariaApi) { - try { - await CALENDARIA.api.updateNote(localId, { - name: data.name, - content: data.description || '', - startDate: { - year: data.year, - month: data.month, - day: data.day, - }, - // Re-applied on every update so an event Chronicle narrows to - // the GM or to some players stops being visible to everyone. - visibility: isPublic ? 'visible' : 'hidden', - }); - } catch (err) { - console.error('Chronicle: Failed to update Calendaria note', err); - } - } else if (game.Calendaria?.updateEvent) { - // No hidden events here (see _createLocalEvent): a now-restricted - // event is removed from Foundry instead of staying visible. - if (!isPublic) { - await this._deleteLocalEvent(data); - return; - } - await game.Calendaria.updateEvent(localId, { - name: data.name, - year: data.year, - month: data.month, - day: data.day, - description: data.description || '', - }); - } - } else if (this._calendarModule === 'simple-calendar') { - // SimpleCalendar notes are journal entries — update name/content. A - // now-restricted event is removed, as for legacy Calendaria above. - if (!isPublic) { - await this._deleteLocalEvent(data); - return; - } - const localId = this._getLocalEventId(data.id); - if (localId) { - const journal = game.journal.get(localId); - if (journal) { - await journal.update({ name: data.name || journal.name }); - } - } - } - } - - /** - * Delete a calendar event from the active Foundry calendar module. - * @param {object} data - { id: chronicleEventId } - * @private - */ - async _deleteLocalEvent(data) { - const localId = this._getLocalEventId(data.id); - if (!localId) return; - return this._echoGuard.run({ ids: [localId] }, () => this._deleteLocalEventUnguarded(data, localId)); - } - - /** @private */ - async _deleteLocalEventUnguarded(data, localId) { - - if (this._calendarModule === 'calendaria') { - if (this._hasModernCalendariaApi) { - try { - await CALENDARIA.api.deleteNote(localId); - } catch (err) { - console.error('Chronicle: Failed to delete Calendaria note', err); - } - } else if (game.Calendaria?.deleteEvent) { - await game.Calendaria.deleteEvent(localId); - } - } else if (this._calendarModule === 'simple-calendar') { - // Delete the journal entry that represents this note. - const journal = game.journal.get(localId); - if (journal) { - await journal.delete(); - } - } - - await this._removeEventMapping(localId); - } - - // --- Initial Sync: Chronicle Events → Calendaria Notes --- - - /** - * Fetch all Chronicle calendar events and create corresponding Calendaria - * notes for any that don't already have local mappings. - * @private - */ - async _syncChronicleEventsToCalendariaNotes() { - if (!this._hasModernCalendariaApi && !game.Calendaria?.createEvent) return; - - // GET /calendar/events is month-filtered (defaults to the calendar's - // current year+month), so enumerate each month across the current year - // ±1 from the cached Chronicle structure — bounded, never unbounded. - 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; - // Each _createLocalEvent holds its own per-note echo scope, so a GM edit - // made between requests of this ~36-request walk is not dropped. - try { - for (const coord of fetches) { - const path = coord - ? `/calendar/events?year=${coord.year}&month=${coord.month}` - : '/calendar/events'; - let payload; - try { - payload = await this._api.get(path); - } catch (err) { - console.debug('Chronicle: back-catalog fetch failed for', path, err.message); - continue; - } - // GET /calendar/events returns an envelope { data:[...], total } like - // getNotes() unwraps elsewhere — accept that AND a bare array here, - // kept local so get()'s contract for other callers is unchanged. - const events = Array.isArray(payload) - ? payload - : (payload && Array.isArray(payload.data) ? payload.data : null); - if (!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 sync calendar events back-catalog', err.message); - } - } - - // --- Event Mapping Helpers --- - // Stores bidirectional mapping between local (Foundry) event IDs and - // Chronicle event IDs using the GM's user flags for persistence. - - /** - * Store a mapping between a local event ID and a Chronicle event ID. - * @param {string} localId - * @param {string} chronicleId - * @private - */ - async _storeEventMapping(localId, chronicleId) { - const mappings = this._getEventMappings(); - mappings[localId] = chronicleId; - mappings[`_rev_${chronicleId}`] = localId; - await game.user.setFlag(FLAG_SCOPE, 'calendarEventMappings', mappings); - } - - /** - * Get the Chronicle event ID for a local event. - * @param {string} localId - * @returns {string|null} - * @private - */ - _getChronicleEventId(localId) { - return this._getEventMappings()[localId] || null; - } - - /** - * Get the local event ID for a Chronicle event. - * @param {string} chronicleId - * @returns {string|null} - * @private - */ - _getLocalEventId(chronicleId) { - return this._getEventMappings()[`_rev_${chronicleId}`] || null; - } - - /** - * Remove an event mapping. - * @param {string} localId - * @private - */ - async _removeEventMapping(localId) { - const mappings = this._getEventMappings(); - const chronicleId = mappings[localId]; - delete mappings[localId]; - if (chronicleId) { - delete mappings[`_rev_${chronicleId}`]; - } - await game.user.setFlag(FLAG_SCOPE, 'calendarEventMappings', mappings); - } - - /** - * Get all event mappings from user flags. - * @returns {object} - * @private - */ - _getEventMappings() { - return game.user.getFlag(FLAG_SCOPE, 'calendarEventMappings') || {}; + return events; } } diff --git a/scripts/import-wizard.mjs b/scripts/import-wizard.mjs index 42e8a48..9a3f8ef 100644 --- a/scripts/import-wizard.mjs +++ b/scripts/import-wizard.mjs @@ -24,7 +24,6 @@ const STEPS = Object.freeze([ { key: 'types', labelKey: 'CHRONICLE.Wizard.Steps.Types', icon: 'fa-solid fa-layer-group' }, { key: 'tags', labelKey: 'CHRONICLE.Wizard.Steps.Tags', icon: 'fa-solid fa-tags' }, { key: 'characters', labelKey: 'CHRONICLE.Wizard.Steps.Characters', icon: 'fa-solid fa-users' }, - { key: 'calendar', labelKey: 'CHRONICLE.Wizard.Steps.Calendar', icon: 'fa-solid fa-calendar' }, { key: 'review', labelKey: 'CHRONICLE.Wizard.Steps.Review', icon: 'fa-solid fa-clipboard-check' }, ]); @@ -149,14 +148,11 @@ export class ImportWizard extends HandlebarsApplicationMixin(ApplicationV2) { /** Step 5: character actor import plan. */ this._characterPlan = []; - /** Step 6: calendar sync direction choice. */ - this._calendarDirection = 'skip'; - - /** Step 7: aggregated import plan built from all previous steps. */ + /** Step 6: aggregated import plan built from all previous steps. */ this._importPlan = null; - /** Step 7: live import progress tracking. */ + /** Step 6: live import progress tracking. */ this._importProgress = { total: 0, done: 0, errors: 0, log: [] }; /** Whether an import is currently running. */ @@ -189,13 +185,7 @@ export class ImportWizard extends HandlebarsApplicationMixin(ApplicationV2) { * @returns {Array<{key: string, labelKey: string, icon: string, index: number, enabled: boolean}>} */ _getSteps() { - const addons = this._connectionStatus?.addons ?? []; - const addonSlugs = new Set(addons.filter((a) => a.enabled).map((a) => a.slug)); const hasSystemMatch = !!this._connectionStatus?.systemMatch; - const hasCalendarModule = !!( - game.modules.get('calendaria')?.active || - game.modules.get('foundryvtt-simple-calendar')?.active - ); return STEPS.map((step, index) => { let enabled = true; @@ -208,11 +198,6 @@ export class ImportWizard extends HandlebarsApplicationMixin(ApplicationV2) { if (step.key === 'characters' && !hasSystemMatch) { enabled = false; } - // Calendar step: only if calendar addon is enabled AND a calendar module is active. - if (step.key === 'calendar') { - const calendarAddon = addonSlugs.has('calendar') || addonSlugs.has('calendars'); - enabled = calendarAddon && hasCalendarModule; - } return { ...step, index, enabled }; }); } @@ -458,7 +443,7 @@ export class ImportWizard extends HandlebarsApplicationMixin(ApplicationV2) { } // --------------------------------------------------------------------------- - // Step 7: Review & Import + // Step 6: Review & Import // --------------------------------------------------------------------------- /** @@ -587,7 +572,6 @@ export class ImportWizard extends HandlebarsApplicationMixin(ApplicationV2) { newTypeForm: this._newTypeForm, tagPlan: this._tagPlan, characterPlan: this._characterPlan, - calendarDirection: this._calendarDirection, importPlan: this._importPlan, importProgress: this._importProgress, importRunning: this._importRunning, @@ -657,13 +641,6 @@ export class ImportWizard extends HandlebarsApplicationMixin(ApplicationV2) { } }); } - - // Step 6: calendar direction radio buttons. - for (const radio of el.querySelectorAll('input[name="calendar-direction"]')) { - radio.addEventListener('change', (e) => { - this._calendarDirection = e.target.value; - }); - } } // --------------------------------------------------------------------------- diff --git a/scripts/journal-sync.mjs b/scripts/journal-sync.mjs index 3c596e9..babb918 100644 --- a/scripts/journal-sync.mjs +++ b/scripts/journal-sync.mjs @@ -946,14 +946,14 @@ export class JournalSync { // Skip if this journal was created by Chronicle sync. if (journal.getFlag(FLAG_SCOPE, 'entityId')) return; - // Skip journals owned by another sync domain: calendar modules and maps - // also persist as JournalEntries, and CalendarSync / MapSync mirror them - // to their own Chronicle resource. Pushed as pages they would fail (maps) + // Skip journals owned by another sync domain: a journal already mirrored + // to a calendar event and maps also persist as JournalEntries, and + // CalendarSync / MapSync own them. Pushed as pages they would fail (maps) // or be filed under an arbitrary type. The old Chronicle Notes folder is // set aside and must never become pages either. Mirrors // _isHandledByActorSync. if (isCalendarNoteJournal(journal)) { - console.debug(`Chronicle: Skipping journal "${journal.name}" — calendar note (owned by CalendarSync).`); + console.debug(`Chronicle: Skipping journal "${journal.name}" — calendar event link (owned by CalendarSync).`); return; } if (isOldNotesJournal(journal, FLAG_SCOPE)) { diff --git a/scripts/module.mjs b/scripts/module.mjs index 5d1bd9f..1710258 100644 --- a/scripts/module.mjs +++ b/scripts/module.mjs @@ -27,7 +27,6 @@ import { registerNpcPresence, npcSpotlightRelay } from './npc-presence.mjs'; import { negotiationMirror } from './negotiation-mirror.mjs'; import { registerMapSheetItems } from './map-sheet-items.mjs'; import { surfaceManifestRecoveryIfNeeded } from './update-info.mjs'; -import { openSyncCalendar } from './sync-calendar.mjs'; import { registerShopRoomSocket } from './shop-room-window.mjs'; import { notebookAvailable, openNotebook, registerPlayerNotebook } from './player-notebook.mjs'; import { addChronicleControls } from './_scene-controls.mjs'; @@ -48,8 +47,7 @@ let _statusIndicatorEl = null; * Open the Sync Dashboard, recreating the singleton if it isn't currently * rendered. An ApplicationV2 instance can be left in a non-re-renderable state * after a close (notably when the close interrupts an in-flight render), which - * made the dashboard intermittently fail to reopen. Mirrors the SyncCalendar - * singleton helper: bring an already-open window to the front; otherwise build, + * made the dashboard intermittently fail to reopen. Bring an already-open window to the front; otherwise build, * (re)bind, and render a fresh instance. `bind()` only sets a reference, so * recreating leaks nothing. * @returns {SyncDashboard|null} @@ -286,7 +284,7 @@ async function _runtimeValidateDescriptor() { /** * Add the Chronicle group to Foundry's scene controls toolbar: the Sync - * Dashboard, Sync Calendar and DM Screen for GMs, the Notebook for everyone once the + * Dashboard and DM Screen for GMs, the Notebook for everyone once the * world is connected to Chronicle. */ Hooks.on('getSceneControlButtons', (controls) => { @@ -297,12 +295,10 @@ Hooks.on('getSceneControlButtons', (controls) => { notebook: notebookAvailable(), run: { dashboard: openDashboard, - syncCalendar: () => { openSyncCalendar(); }, dmScreen: () => { openDMScreen(() => syncManager?.api ?? null); }, notebook: () => { openNotebook(); }, }, titles: { - syncCalendar: game.i18n.localize('CHRONICLE.SceneControl.SyncCalendar'), dmScreen: game.i18n.localize('CHRONICLE.SceneControl.DMScreen'), notebook: game.i18n.localize('CHRONICLE.SceneControl.Notebook'), }, diff --git a/scripts/settings.mjs b/scripts/settings.mjs index a711789..bc2696f 100644 --- a/scripts/settings.mjs +++ b/scripts/settings.mjs @@ -7,7 +7,6 @@ import { MODULE_ID } from './constants.mjs'; import { UpdateInfoApplication } from './update-info.mjs'; -import { SyncCalendarApplication } from './sync-calendar.mjs'; import { parseConnectLine } from './_connect-line.mjs'; /** @@ -224,16 +223,6 @@ export function registerSettings() { default: '{"excludedTypes":[],"excludedEntities":[]}', }); - // Internal: per-calendar sync opt-out. JSON array of Calendaria calendar ids - // the operator has chosen NOT to sync to Chronicle (toggled from the Sync - // Calendar editor). Empty by default → every active calendar syncs as before. - game.settings.register(MODULE_ID, 'calendarSyncExclusions', { - scope: 'world', - config: false, - type: String, - default: '[]', - }); - // ----------------------------------------------------------------------- // Sync Configuration settings (managed via Config tab in dashboard) // ----------------------------------------------------------------------- @@ -359,20 +348,6 @@ export function registerSettings() { type: UpdateInfoApplication, restricted: true, }); - - // "Sync Calendar" — GM-only view of the active Calendaria calendar with - // an always-on validation panel. i18n keys live under - // `CHRONICLE.Settings.SyncCalendarMenu.*`, distinct from - // `CHRONICLE.Settings.SyncCalendar.*` (the `syncCalendar` boolean - // toggle's own hint/name). - game.settings.registerMenu(MODULE_ID, 'syncCalendarMenu', { - name: game.i18n.localize('CHRONICLE.Settings.SyncCalendarMenu.Name'), - hint: game.i18n.localize('CHRONICLE.Settings.SyncCalendarMenu.Hint'), - label: game.i18n.localize('CHRONICLE.Settings.SyncCalendarMenu.Label'), - icon: 'fa-solid fa-calendar-days', - type: SyncCalendarApplication, - restricted: true, - }); } /** @@ -488,20 +463,6 @@ export async function setSyncExclusions(exclusions) { await setSetting('syncExclusions', JSON.stringify(exclusions)); } -/** - * Get the list of Calendaria calendar ids the operator has opted OUT of syncing - * to Chronicle. Empty array (default) means every active calendar syncs. - * @returns {string[]} - */ -export function getCalendarSyncExclusions() { - try { - const parsed = JSON.parse(getSetting('calendarSyncExclusions')); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - /** * Check if the module is properly configured (URL + key + campaign). * @returns {boolean} diff --git a/scripts/sync-calendar-condition-builder.mjs b/scripts/sync-calendar-condition-builder.mjs deleted file mode 100644 index a746201..0000000 --- a/scripts/sync-calendar-condition-builder.mjs +++ /dev/null @@ -1,287 +0,0 @@ -/** - * Visual conditionTree builder (pure logic). Calendaria's `conditionTree` - * schema is too rich for an inline picker, so this ships preset - * compositions for common workflows (every full moon, every 3rd weekday - * of winter, etc); advanced trees fall through to Calendaria's own builder - * via a "Need more?" link. Pure: preset → tree, tree validation, tree → - * summary; the Application class only renders presets and persists the tree. - * - * Calendaria silently ignores a `conditionTree` whose root isn't - * `{type: 'group'}` (their API-Reference). `validateTree()` flags this and - * the form's save path blocks on it. - * - * Tests: `tools/test-sync-calendar-condition-builder.mjs`. - */ - -// --------------------------------------------------------------------- -// Schema enums -// --------------------------------------------------------------------- - -/** Field names usable in v1 leaf conditions. */ -export const FIELDS = Object.freeze({ - WEEKDAY: 'weekday', - MONTH_DAY: 'monthDay', - MONTH: 'month', - YEAR: 'year', - SEASON: 'season', - MOON_PHASE: 'moonPhase', - CYCLE_STAGE: 'cycleStage', -}); - -/** Operators usable in v1 leaf conditions. */ -export const OPERATORS = Object.freeze({ - EQ: '==', - NEQ: '!=', - MOD: '%', - IN: 'in', -}); - -/** Group-mode enum (mirrors Calendaria's `conditionGroupModes`). */ -export const GROUP_MODES = Object.freeze({ - AND: 'and', - OR: 'or', -}); - -/** - * Preset identifiers. The UI renders one button per preset; each - * preset opens a tiny form (or applies a sane default) and produces a - * full conditionTree via `treeFromPreset(id, params)`. - */ -export const PRESETS = Object.freeze({ - EVERY_WEEKDAY: 'every-weekday', // params: {weekdayOrdinal: 1..N} - EVERY_NTH_OF_MONTH: 'every-nth-of-month', // params: {monthDay: 1..28} - EVERY_MONTH: 'every-month', // params: {monthOrdinal: 1..N} - EVERY_SEASON: 'every-season', // params: {seasonId: string} - EVERY_FULL_MOON: 'every-full-moon', // params: {moonIndex: 0..N-1} - EVERY_NEW_MOON: 'every-new-moon', // params: {moonIndex: 0..N-1} - EVERY_NTH_WEEKDAY_OF_SEASON: 'every-nth-weekday-of-season', // params: {weekdayOrdinal, seasonId} - EVERY_CYCLE_STAGE: 'every-cycle-stage', // params: {cycleStage: string} -}); - -// --------------------------------------------------------------------- -// Constructors -// --------------------------------------------------------------------- - -/** - * Build an empty root group. The form starts here; presets replace its - * `children` array. - */ -export function emptyRootGroup(mode = GROUP_MODES.AND) { - return { type: 'group', mode, children: [] }; -} - -/** - * Build a leaf condition. Calendaria stores conditions as - * `{type: 'condition', field, op, value, value2?}`. - */ -export function leaf(field, op, value, value2) { - const c = { type: 'condition', field, op, value }; - if (value2 !== undefined) c.value2 = value2; - return c; -} - -/** - * Build a tree from a preset. - * - * Throws on unknown preset id; returns `null` if required params are - * missing or invalid (caller surfaces the failure inline via the form's - * error list). - * - * @param {string} presetId - one of `PRESETS.*` - * @param {object} params - preset-specific params (see PRESETS const) - * @returns {object|null} root group, or null on bad params - */ -export function treeFromPreset(presetId, params = {}) { - switch (presetId) { - case PRESETS.EVERY_WEEKDAY: { - const n = Number(params.weekdayOrdinal); - if (!Number.isInteger(n) || n < 1) return null; - return wrapAnd([leaf(FIELDS.WEEKDAY, OPERATORS.EQ, n)]); - } - case PRESETS.EVERY_NTH_OF_MONTH: { - const n = Number(params.monthDay); - if (!Number.isInteger(n) || n < 1) return null; - return wrapAnd([leaf(FIELDS.MONTH_DAY, OPERATORS.EQ, n)]); - } - case PRESETS.EVERY_MONTH: { - const n = Number(params.monthOrdinal); - if (!Number.isInteger(n) || n < 1) return null; - return wrapAnd([leaf(FIELDS.MONTH, OPERATORS.EQ, n)]); - } - case PRESETS.EVERY_SEASON: { - if (typeof params.seasonId !== 'string' || !params.seasonId) return null; - return wrapAnd([leaf(FIELDS.SEASON, OPERATORS.EQ, params.seasonId)]); - } - case PRESETS.EVERY_FULL_MOON: { - const idx = Number(params.moonIndex); - if (!Number.isInteger(idx) || idx < 0) return null; - // Calendaria full-moon range is [0.5, 0.625]. We pin "== 0.5" as - // the canonical full-moon position; matchers usually allow a small - // tolerance band. If Calendaria's matcher is strict-equality this - // becomes a 1-day-per-cycle event, which is the expected semantic. - return wrapAnd([ - leafForMoon(idx, OPERATORS.EQ, 0.5), - ]); - } - case PRESETS.EVERY_NEW_MOON: { - const idx = Number(params.moonIndex); - if (!Number.isInteger(idx) || idx < 0) return null; - return wrapAnd([leafForMoon(idx, OPERATORS.EQ, 0)]); - } - case PRESETS.EVERY_NTH_WEEKDAY_OF_SEASON: { - const n = Number(params.weekdayOrdinal); - const sid = params.seasonId; - if (!Number.isInteger(n) || n < 1) return null; - if (typeof sid !== 'string' || !sid) return null; - return wrapAnd([ - leaf(FIELDS.WEEKDAY, OPERATORS.EQ, n), - leaf(FIELDS.SEASON, OPERATORS.EQ, sid), - ]); - } - case PRESETS.EVERY_CYCLE_STAGE: { - if (typeof params.cycleStage !== 'string' || !params.cycleStage) return null; - return wrapAnd([leaf(FIELDS.CYCLE_STAGE, OPERATORS.EQ, params.cycleStage)]); - } - default: - throw new Error(`Unknown preset: ${presetId}`); - } -} - -// --------------------------------------------------------------------- -// Validation -// --------------------------------------------------------------------- - -/** - * Validate a tree against v1's scope cap. Returns an array of i18n - * error keys (empty array = valid). - * - * The most important check: root MUST be `type: 'group'`. Calendaria - * silently ignores conditionTrees with a non-group root. Anything past - * that — v1 scope (fields + operators), depth cap (≤2 nested groups) — - * generates softer warnings the form surfaces as errors but doesn't - * block on if the operator wants to write it anyway (advanced editor - * fallback path). - * - * @param {*} tree - the candidate root - * @returns {string[]} i18n keys; empty if valid - */ -export function validateTree(tree) { - const errors = []; - if (!tree || typeof tree !== 'object') { - errors.push('CHRONICLE.SyncCalendar.Recurrence.Errors.NotAnObject'); - return errors; - } - if (tree.type !== 'group') { - errors.push('CHRONICLE.SyncCalendar.Recurrence.Errors.RootMustBeGroup'); - return errors; - } - const validModes = new Set(Object.values(GROUP_MODES)); - if (!validModes.has(tree.mode)) { - errors.push('CHRONICLE.SyncCalendar.Recurrence.Errors.InvalidGroupMode'); - } - if (!Array.isArray(tree.children) || tree.children.length === 0) { - errors.push('CHRONICLE.SyncCalendar.Recurrence.Errors.EmptyGroup'); - return errors; - } - walkChildren(tree.children, 1, errors); - return errors; -} - -function walkChildren(children, depth, errors) { - if (depth > 2) { - errors.push('CHRONICLE.SyncCalendar.Recurrence.Errors.TooDeep'); - return; - } - for (const child of children) { - if (!child || typeof child !== 'object') { - errors.push('CHRONICLE.SyncCalendar.Recurrence.Errors.InvalidChild'); - continue; - } - if (child.type === 'group') { - walkChildren(Array.isArray(child.children) ? child.children : [], depth + 1, errors); - continue; - } - if (child.type !== 'condition') { - errors.push('CHRONICLE.SyncCalendar.Recurrence.Errors.InvalidChildType'); - continue; - } - validateLeaf(child, errors); - } -} - -function validateLeaf(c, errors) { - const fields = new Set(Object.values(FIELDS)); - const ops = new Set(Object.values(OPERATORS)); - if (!fields.has(c.field)) { - // v1 doesn't ship `eclipse` or event-relative fields; flag, but - // don't block — operator may have built it in Calendaria's editor. - errors.push('CHRONICLE.SyncCalendar.Recurrence.Errors.FieldNotInV1Scope'); - } - if (!ops.has(c.op)) { - errors.push('CHRONICLE.SyncCalendar.Recurrence.Errors.OperatorNotInV1Scope'); - } - if (c.value === undefined || c.value === null || c.value === '') { - errors.push('CHRONICLE.SyncCalendar.Recurrence.Errors.MissingValue'); - } -} - -// --------------------------------------------------------------------- -// Human-readable summary -// --------------------------------------------------------------------- - -/** - * Produce a human-readable summary of a tree, for display in the - * form's recurrence section. Keeps the wording terse and unambiguous — - * the operator should be able to skim and confirm without opening the - * raw JSON. - * - * Resolution is best-effort: leaf values that reference structural ids - * (seasonId, cycleStage) appear as-is; the caller can post-process to - * substitute display names if it has the structure map handy. - * - * @param {object} tree - * @returns {string} terse summary - */ -export function treeToSummary(tree) { - if (!tree || typeof tree !== 'object') return '(no recurrence)'; - if (tree.type !== 'group') return '(invalid recurrence — root must be a group)'; - if (!Array.isArray(tree.children) || tree.children.length === 0) { - return '(empty group)'; - } - const joiner = tree.mode === GROUP_MODES.OR ? ' OR ' : ' AND '; - return tree.children.map(childToSummary).join(joiner); -} - -function childToSummary(c) { - if (!c || typeof c !== 'object') return '?'; - if (c.type === 'group') { - return `(${treeToSummary(c)})`; - } - if (c.type !== 'condition') return '?'; - return `${c.field} ${c.op} ${formatValue(c.value)}`; -} - -function formatValue(v) { - if (typeof v === 'string') return `"${v}"`; - if (typeof v === 'number') return String(v); - if (v === null || v === undefined) return '(none)'; - return JSON.stringify(v); -} - -// --------------------------------------------------------------------- -// Internals -// --------------------------------------------------------------------- - -function wrapAnd(children) { - return { type: 'group', mode: GROUP_MODES.AND, children }; -} - -/** - * Compose a moon-phase leaf. `moonIndex` would feed into a Calendaria - * matcher that compares `getMoonPhasePosition(moonIndex, currentDate)` - * against `value`. We encode the moon selection via `value2` (Calendaria - * supports a secondary operand on `moonPhase` leaves). - */ -function leafForMoon(moonIndex, op, value) { - return { type: 'condition', field: FIELDS.MOON_PHASE, op, value, value2: moonIndex }; -} diff --git a/scripts/sync-calendar-diagnostics.mjs b/scripts/sync-calendar-diagnostics.mjs deleted file mode 100644 index 63fd0f2..0000000 --- a/scripts/sync-calendar-diagnostics.mjs +++ /dev/null @@ -1,205 +0,0 @@ -/** - * `buildCalendarDiagnostics(input)` takes an already-collected, plain snapshot - * of the Sync Calendar's state and returns a Markdown report meant to be - * pasted into a bug report or AI tool: versions, active calendar structure, - * live validation findings, the selected day's detail, the API methods - * Calendaria exposes, recent sync errors, and relevant settings. - * - * Kept pure (no Foundry globals, never throws) so it unit-tests cleanly; the - * collection side that touches `CALENDARIA.api` lives in `sync-calendar.mjs`. - */ - -/** Severity ordering for grouping findings in the report. */ -const SEVERITY_ORDER = ['error', 'warning', 'info']; - -/** - * Coerce anything to a short, single-line display string. - * @param {*} v - * @returns {string} - */ -function oneLine(v) { - if (v == null) return ''; - if (typeof v === 'string') return v.replace(/\s+/g, ' ').trim(); - try { return JSON.stringify(v); } catch { return String(v); } -} - -/** - * Pretty-print a value as a fenced JSON block, guarding against cycles. - * @param {*} v - * @returns {string} - */ -function jsonBlock(v) { - let body; - try { body = JSON.stringify(v, null, 2); } catch { body = String(v); } - if (body === undefined) body = 'undefined'; - return '```json\n' + body + '\n```'; -} - -/** - * Render a `key: value` line, skipping empty values unless `keepEmpty`. - * @param {string} key - * @param {*} value - * @param {boolean} [keepEmpty] - * @returns {string|null} - */ -function kv(key, value, keepEmpty = true) { - const s = oneLine(value); - if (!s && !keepEmpty) return null; - return `- **${key}:** ${s || '—'}`; -} - -/** - * Build the diagnostics report. - * - * Every field is optional; missing data degrades to "—"/"(unknown)" instead - * of throwing, since the collector gathers each piece under its own try/catch. - * - * @param {object} [input] - * @param {string} [input.generatedAt] - ISO timestamp. - * @param {object} [input.versions] - { module, calendaria, schema, foundry, system, systemId }. - * @param {object} [input.calendar] - { name, id, version }. - * @param {object} [input.syncStatus] - { calendarSyncEnabled, syncEnabled, calendarModule, thisCalendarExcluded }. - * @param {object} [input.structureCounts] - months, weekdays, seasons, moons, eras, festivals, cycles, weatherZones. - * @param {object} [input.apiMethods] - map of method name → boolean (present on CALENDARIA.api). - * @param {object} [input.currentDateTime] - * @param {object} [input.selectedDate] - * @param {object} [input.dayDetail] - { notes, moonPhases, season, weatherCurrent, weatherForDate }. - * @param {Array} [input.findings] - [{ severity, code, message, fixHint }]. - * @param {Array} [input.recentErrors] - [{ time, message, endpoint, status }]. - * @param {object} [input.settings] - relevant sync settings snapshot. - * @param {object} [input.raw] - misc raw objects to append verbatim (weather, note sample, etc.). - * @returns {string} Markdown report. - */ -export function buildCalendarDiagnostics(input = {}) { - const i = input || {}; - const L = []; - - L.push('# Chronicle Sync — Calendar Diagnostics'); - L.push(''); - L.push('_Paste this whole block into Claude / a bug report. Generated by the Sync Calendar editor._'); - L.push(''); - L.push(kv('Generated', i.generatedAt || '(unknown)')); - L.push(''); - - // --- Environment / versions --- - const v = i.versions || {}; - L.push('## Environment'); - L.push(kv('Module', v.module)); - L.push(kv('Calendaria', v.calendaria)); - L.push(kv('Schema', v.schema)); - L.push(kv('Foundry', v.foundry)); - L.push(kv('Game system', v.systemId ? `${v.system || ''} (${v.systemId})` : v.system)); - L.push(''); - - // --- Sync status (the on/off question) --- - const s = i.syncStatus || {}; - L.push('## Sync status'); - L.push(kv('Calendar module detected', s.calendarModule || 'none')); - 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 --- - const cal = i.calendar || {}; - const sc = i.structureCounts || {}; - L.push('## Active calendar'); - L.push(kv('Name', cal.name)); - L.push(kv('Id', cal.id)); - L.push(kv('Version', cal.version, false) || ''); - L.push(''); - L.push('### Structure counts'); - L.push(kv('Months', sc.months)); - L.push(kv('Weekdays', sc.weekdays)); - L.push(kv('Seasons', sc.seasons)); - L.push(kv('Moons', sc.moons)); - L.push(kv('Eras', sc.eras)); - L.push(kv('Festivals', sc.festivals)); - L.push(kv('Cycles', sc.cycles)); - L.push(kv('Weather zones (custom climate zones)', sc.weatherZones)); - L.push(' > Note: "weather zones" counts custom climate regions only; per-day weather (e.g. a "Meteor Shower" preset) lives in the default zone and is not a zone.'); - L.push(''); - - // --- API capability probe --- - if (i.apiMethods && typeof i.apiMethods === 'object') { - L.push('## CALENDARIA.api methods available'); - const present = Object.keys(i.apiMethods).filter((k) => i.apiMethods[k]); - const missing = Object.keys(i.apiMethods).filter((k) => !i.apiMethods[k]); - L.push(kv('Present', present.join(', ') || 'none')); - L.push(kv('Missing', missing.join(', ') || 'none')); - L.push(''); - } - - // --- Selected day detail --- - const dd = i.dayDetail || {}; - L.push('## Selected day'); - L.push(kv('Date', i.selectedDate ? `${i.selectedDate.day}/${i.selectedDate.month}/${i.selectedDate.year}` : '—')); - L.push(kv('Current world date/time', i.currentDateTime)); - L.push(kv('Season', dd.season ? (dd.season.name || oneLine(dd.season)) : '—')); - L.push(kv('Notes on day', Array.isArray(dd.notes) ? `${dd.notes.length}${dd.notes.length ? ' — ' + dd.notes.map((n) => n.name).filter(Boolean).join(', ') : ''}` : '—')); - L.push(kv('Moon phases', Array.isArray(dd.moonPhases) ? dd.moonPhases.map((m) => `${m.moonName}: ${m.phaseName}`).join('; ') || '—' : '—')); - L.push(kv('Weather (current date)', dd.weatherCurrent ? (dd.weatherCurrent.label || oneLine(dd.weatherCurrent)) : '—')); - L.push(kv('Weather (selected date)', dd.weatherForDate ? (dd.weatherForDate.label || oneLine(dd.weatherForDate)) : '—')); - L.push(''); - - // --- Validation findings (the "bottom-left errors") --- - const findings = Array.isArray(i.findings) ? i.findings : []; - L.push(`## Validation findings (${findings.length})`); - if (findings.length === 0) { - L.push('_No findings._'); - } else { - for (const sev of SEVERITY_ORDER) { - const group = findings.filter((f) => f.severity === sev); - if (!group.length) continue; - L.push(`### ${sev.toUpperCase()} (${group.length})`); - for (const f of group) { - L.push(`- \`${f.code || '?'}\` — ${oneLine(f.message)}${f.fixHint ? ` _(fix: ${oneLine(f.fixHint)})_` : ''}`); - } - } - } - L.push(''); - - // --- Recent sync errors --- - const errs = Array.isArray(i.recentErrors) ? i.recentErrors : []; - L.push(`## Recent sync errors (${errs.length})`); - if (errs.length === 0) { - L.push('_None recorded._'); - } else { - for (const e of errs.slice(0, 25)) { - const meta = [e.endpoint, e.status].filter(Boolean).join(' '); - L.push(`- ${e.time ? `[${oneLine(e.time)}] ` : ''}${oneLine(e.message)}${meta ? ` (${meta})` : ''}`); - } - } - L.push(''); - - // --- Settings snapshot --- - if (i.settings && typeof i.settings === 'object') { - L.push('## Sync settings'); - for (const [k, val] of Object.entries(i.settings)) { - L.push(kv(k, typeof val === 'boolean' ? boolLabel(val) : val)); - } - L.push(''); - } - - // --- Raw appendix (exact object shapes for debugging) --- - if (i.raw && typeof i.raw === 'object' && Object.keys(i.raw).length) { - L.push('## Raw (verbatim object shapes)'); - L.push(jsonBlock(i.raw)); - L.push(''); - } - - return L.filter((line) => line !== null).join('\n'); -} - -/** - * Human label for a tri-state boolean (true/false/unknown). - * @param {*} b - * @returns {string} - */ -function boolLabel(b) { - if (b === true) return 'yes'; - if (b === false) return 'no'; - return '(unknown)'; -} diff --git a/scripts/sync-calendar-import-from-calendaria.mjs b/scripts/sync-calendar-import-from-calendaria.mjs deleted file mode 100644 index 2aad00a..0000000 --- a/scripts/sync-calendar-import-from-calendaria.mjs +++ /dev/null @@ -1,241 +0,0 @@ -/** - * Calendaria → Chronicle calendar-import transform (pure). Turns a full - * Calendaria calendar (`CALENDARIA.api.getCalendar(id)`) into a Chronicle - * create-calendar payload POSTed to `/api/v1/campaigns/:cid/calendar`. - * - * Maps: name, description, current date, months, weekdays, seasons, moons, - * eras. Skips cycles/festivals/weather zones — added via Chronicle's UI - * after import. - * - * One-shot: post-import, Chronicle is the source of truth and the existing - * two-way sync in `calendar-sync.mjs` handles date + events; structure - * fields are not kept in sync after import. - * - * Pure, no Foundry globals. Tests: - * `tools/test-sync-calendar-import-from-calendaria.mjs`. - */ - -/** - * Wire-contract version stamp, matched against Chronicle's create-payload - * shape so older Foundry clients can detect a contract mismatch. - */ -export const IMPORT_WIRE_VERSION = 1; - -/** - * Transform a Calendaria calendar into a Chronicle create-calendar - * payload. - * - * @param {object} calendariaCalendar - return of `CALENDARIA.api.getCalendar(id)` - * @returns {{ - * payload: object, - * summary: { mappedCounts: object, skipped: string[] }, - * }} - */ -export function transformCalendariaCalendar(calendariaCalendar) { - if (!calendariaCalendar || typeof calendariaCalendar !== 'object') { - throw new Error('Cannot transform: input is not a Calendaria calendar object.'); - } - - const months = readCollection(calendariaCalendar.months, 'months'); - const weekdays = readCollection(calendariaCalendar.days, 'days'); - const seasons = readCollection(calendariaCalendar.seasons, 'seasons'); - const moons = readCollection(calendariaCalendar.moons, 'moons'); - const eras = readCollection(calendariaCalendar.eras, 'eras'); - // Skipped — surfaced in summary. - const cycles = readCollection(calendariaCalendar.cycles, 'cycles'); - const festivals = readCollection(calendariaCalendar.festivals, 'festivals'); - const weather = (calendariaCalendar.weather?.zones && typeof calendariaCalendar.weather.zones === 'object') - ? Object.values(calendariaCalendar.weather.zones) - : []; - - const currentDate = calendariaCalendar.currentDate || {}; - const yearsBlock = calendariaCalendar.years || {}; - - const payload = { - schema_version: IMPORT_WIRE_VERSION, - source: 'calendaria', - source_id: calendariaCalendar.metadata?.id || '', - - name: String(calendariaCalendar.name || 'Imported Calendar'), - description: String(calendariaCalendar.description || ''), - - year_zero: toIntOr(yearsBlock.yearZero, 0), - current_year: toIntOr(currentDate.year, yearsBlock.yearZero ?? 0), - current_month: toIntOr(currentDate.month, 0) + 1, - current_day: toIntOr(currentDate.dayOfMonth, 0) + 1, - current_hour: toIntOr(currentDate.hour, 0), - current_minute: toIntOr(currentDate.minute, 0), - seconds_per_round: toIntOr(calendariaCalendar.secondsPerRound, 6), - allow_negative_years: !!yearsBlock.allowNegativeYears, - - months: months.sortedByOrdinal.map((m) => ({ - name: String(m.name || ''), - abbreviation: String(m.abbreviation || ''), - days: toIntOr(m.days, 0), - intercalary: !!m.intercalary, - ordinal: toIntOr(m.ordinal, null), - leap_extra_days: m.leapDays != null ? toIntOr(m.leapDays, 0) : null, - })), - - weekdays: weekdays.sortedByOrdinal.map((d) => ({ - name: String(d.name || ''), - abbreviation: String(d.abbreviation || ''), - rest_day: !!d.isRestDay, - ordinal: toIntOr(d.ordinal, null), - })), - - seasons: seasons.values.map((s) => ({ - name: String(s.name || ''), - abbreviation: String(s.abbreviation || ''), - color: String(s.color || ''), - icon: String(s.icon || ''), - seasonal_type: String(s.seasonalType || ''), - month_start: s.monthStart != null ? toIntOr(s.monthStart, null) : null, - month_end: s.monthEnd != null ? toIntOr(s.monthEnd, null) : null, - day_start: s.dayStart != null ? toIntOr(s.dayStart, null) : null, - day_end: s.dayEnd != null ? toIntOr(s.dayEnd, null) : null, - })), - - moons: moons.values.map((moon) => ({ - name: String(moon.name || ''), - color: String(moon.color || ''), - cycle_length: toIntOr(moon.cycleLength, 0), - cycle_day_adjust: toIntOr(moon.cycleDayAdjust, 0), - // We pass `phase_mode` through verbatim — Calendaria's enum is - // `'fixed' | 'randomized'`; Chronicle stores the string and feeds - // it back so Foundry can recover the original. - phase_mode: String(moon.phaseMode || 'fixed'), - cycle_variance: Number.isFinite(moon.cycleVariance) ? Number(moon.cycleVariance) : 0, - reference_phase: Number.isFinite(moon.referencePhase) ? Number(moon.referencePhase) : 0, - reference_date: moonReferenceDateToWire(moon.referenceDate), - // Phase table is opaque to Chronicle; ship the keyed object as an - // array of `{id, name, icon, start, end}` so round-tripping back - // into Calendaria stays feasible. - phases: extractPhases(moon.phases), - })), - - eras: eras.values.map((era) => ({ - name: String(era.name || ''), - abbreviation: String(era.abbreviation || ''), - start_year: toIntOr(era.startYear, null), - end_year: era.endYear != null ? toIntOr(era.endYear, null) : null, - format: String(era.format || 'suffix'), - })), - }; - - const skipped = []; - if (cycles.values.length > 0) skipped.push(`cycles (${cycles.values.length})`); - if (festivals.values.length > 0) skipped.push(`festivals (${festivals.values.length})`); - if (weather.length > 0) skipped.push(`weather zones (${weather.length})`); - - return { - payload, - summary: { - mappedCounts: { - months: payload.months.length, - weekdays: payload.weekdays.length, - seasons: payload.seasons.length, - moons: payload.moons.length, - eras: payload.eras.length, - }, - skipped, - }, - }; -} - -/** - * Pre-flight summary for the import button row — shown to the operator - * BEFORE they click the button, so they can pick the right calendar - * with full knowledge of what's lossy. - * - * @param {object} calendariaCalendar - * @returns {{ - * id: string, name: string, daysPerYear: number, - * monthCount: number, weekdayCount: number, moonCount: number, - * skipNotice: string, - * }} - */ -export function buildCalendarPreflightSummary(calendariaCalendar) { - if (!calendariaCalendar || typeof calendariaCalendar !== 'object') { - return { id: '', name: '', daysPerYear: 0, monthCount: 0, weekdayCount: 0, moonCount: 0, skipNotice: '' }; - } - const months = readCollection(calendariaCalendar.months, 'months'); - const weekdays = readCollection(calendariaCalendar.days, 'days'); - const moons = readCollection(calendariaCalendar.moons, 'moons'); - const cycles = readCollection(calendariaCalendar.cycles, 'cycles'); - const festivals = readCollection(calendariaCalendar.festivals, 'festivals'); - const weather = (calendariaCalendar.weather?.zones && typeof calendariaCalendar.weather.zones === 'object') - ? Object.values(calendariaCalendar.weather.zones).length - : 0; - const daysPerYear = months.values.reduce((s, m) => s + (Number(m.days) || 0), 0); - const skipParts = []; - if (cycles.values.length) skipParts.push(`cycles (${cycles.values.length})`); - if (festivals.values.length) skipParts.push(`festivals (${festivals.values.length})`); - if (weather) skipParts.push(`weather zones (${weather})`); - return { - id: calendariaCalendar.metadata?.id || '', - name: calendariaCalendar.name || '', - daysPerYear, - monthCount: months.values.length, - weekdayCount: weekdays.values.length, - moonCount: moons.values.length, - skipNotice: skipParts.length ? `Skips: ${skipParts.join(', ')}` : '', - }; -} - -// --------------------------------------------------------------------- -// Internals -// --------------------------------------------------------------------- - -/** - * Calendaria stores `months`/`days`/`seasons` as `{values: {id: {...}}}` - * (id-keyed objects). `moons` is a flat `{id: {...}}` object. `eras` is - * also flat. This helper normalizes everything into an `{values: [], sortedByOrdinal: []}` - * shape the transform can iterate. - */ -function readCollection(input, kind) { - if (!input || typeof input !== 'object') { - return { values: [], sortedByOrdinal: [] }; - } - const flat = (input.values && typeof input.values === 'object') ? input.values : input; - const values = Object.values(flat).filter((v) => v && typeof v === 'object'); - const sortedByOrdinal = values - .slice() - .sort((a, b) => { - const ao = Number(a.ordinal ?? 0); - const bo = Number(b.ordinal ?? 0); - if (Number.isFinite(ao) && Number.isFinite(bo)) return ao - bo; - return 0; - }); - // Suppress unused-arg lint warning. - void kind; - return { values, sortedByOrdinal }; -} - -function moonReferenceDateToWire(ref) { - if (!ref || typeof ref !== 'object') { - return { year: 0, month: 1, day: 1 }; - } - return { - year: toIntOr(ref.year, 0), - month: toIntOr(ref.month, 0) + 1, - day: toIntOr(ref.dayOfMonth, 0) + 1, - }; -} - -function extractPhases(phases) { - if (!phases || typeof phases !== 'object') return []; - return Object.entries(phases).map(([id, p]) => ({ - id, - name: String(p?.name || ''), - icon: String(p?.icon || ''), - start: Number.isFinite(p?.start) ? Number(p.start) : 0, - end: Number.isFinite(p?.end) ? Number(p.end) : 0, - })); -} - -function toIntOr(value, fallback) { - const n = Number(value); - if (!Number.isFinite(n)) return fallback; - return Math.trunc(n); -} diff --git a/scripts/sync-calendar-moon-strip.mjs b/scripts/sync-calendar-moon-strip.mjs deleted file mode 100644 index 27b39e0..0000000 --- a/scripts/sync-calendar-moon-strip.mjs +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Moon-phase strip builder (pure logic) for the Sync Calendar editor's - * month-view diagram. For each moon on the active calendar, builds a - * per-day phase-position array across the visible range; the application - * class renders these as color-coded horizontal strips. - * - * Pure aside from the injected `getPosition` lookup, so it's unit-testable - * independent of the ApplicationV2 shell. See - * `tools/test-sync-calendar-moon-strip.mjs`. - */ - -/** - * Build per-moon strip data for the visible range. Pure: same inputs → - * same outputs. `getPosition(moonIndex, date)` is - * `CALENDARIA.api.getMoonPhasePosition` in production, a deterministic - * stub in tests. - * - * @param {object} args - * @param {object[]} args.moons - Array of moon definitions, in order. Each - * needs at least `{name, color, cycleLength, phaseMode}` and may carry - * `phases` (id-keyed object) for phase-name lookup. - * @param {number} args.year - Year being rendered. - * @param {number} args.monthOrdinal - 1-indexed month being rendered. - * @param {number} args.daysInMonth - Number of days in that month. - * @param {(moonIndex: number, date: {year, month, dayOfMonth}) => number} args.getPosition - * Phase-position lookup, returns 0-1. - * @returns {{moons: Array<{ - * index: number, - * name: string, - * color: string, - * cycleLength: number, - * isRandomized: boolean, - * days: Array<{day: number, position: number, phaseName: string, isFull: boolean, isNew: boolean}> - * }>}} - */ -export function buildMoonStripData({ moons, year, monthOrdinal, daysInMonth, getPosition }) { - if (!Array.isArray(moons) || moons.length === 0) { - return { moons: [] }; - } - if (!Number.isFinite(daysInMonth) || daysInMonth < 1) { - return { moons: [] }; - } - if (typeof getPosition !== 'function') { - return { moons: [] }; - } - - const rows = moons.map((moon, index) => { - const phases = (moon && typeof moon.phases === 'object' && moon.phases !== null) - ? Object.values(moon.phases) - : []; - - const days = []; - for (let d = 1; d <= daysInMonth; d++) { - let position = 0; - try { - const raw = getPosition(index, { year, month: monthOrdinal, dayOfMonth: d }); - position = Number.isFinite(raw) ? clamp01(raw) : 0; - } catch { - position = 0; - } - const phaseName = phaseNameAt(phases, position); - days.push({ - day: d, - position, - phaseName, - isFull: isFullPosition(position), - isNew: isNewPosition(position), - }); - } - - return { - index, - name: moon?.name || `Moon ${index + 1}`, - color: moon?.color || '#888888', - cycleLength: Number(moon?.cycleLength ?? 0), - isRandomized: moon?.phaseMode === 'randomized', - days, - }; - }); - - return { moons: rows }; -} - -/** - * Pixel-to-day translator for click-to-day on the strip. - * - * Strip widths are CSS-determined at render time; this helper converts - * the click X (clientX - strip.left) to a 1-indexed day ordinal. Edge - * cases: clicks outside the strip clamp to 1 / daysInMonth. - * - * @param {number} clickXWithinStrip - X offset relative to strip's left edge. - * @param {number} stripWidthPx - Strip's rendered width. - * @param {number} daysInMonth - Number of days the strip spans. - * @returns {number} 1-indexed day. - */ -export function dayFromStripClick(clickXWithinStrip, stripWidthPx, daysInMonth) { - if (!Number.isFinite(daysInMonth) || daysInMonth < 1) return 1; - if (!Number.isFinite(stripWidthPx) || stripWidthPx <= 0) return 1; - const x = Number.isFinite(clickXWithinStrip) ? clickXWithinStrip : 0; - const ratio = Math.min(1, Math.max(0, x / stripWidthPx)); - const day = Math.floor(ratio * daysInMonth) + 1; - return Math.min(daysInMonth, Math.max(1, day)); -} - -/** - * Detect "convergence" days — those where two or more moons are - * simultaneously near-full. Used by the strip diagram to surface a - * pip on the day cell. Tyr's 33-day Ral + 125-day Guthay produce - * sparse but meaningful convergences; Therin's randomized Umbra - * produces noise we don't tag. - * - * @param {ReturnType} stripData - * @returns {number[]} Sorted unique 1-indexed days with ≥2 moons full. - */ -export function findConvergenceDays(stripData) { - if (!stripData || !Array.isArray(stripData.moons) || stripData.moons.length < 2) { - return []; - } - const determinist = stripData.moons.filter((m) => !m.isRandomized); - if (determinist.length < 2) return []; - - const daysSet = new Set(); - const dayCount = determinist[0].days.length; - for (let i = 0; i < dayCount; i++) { - let fulls = 0; - for (const m of determinist) { - if (m.days[i]?.isFull) fulls++; - if (fulls >= 2) break; - } - if (fulls >= 2) daysSet.add(determinist[0].days[i].day); - } - return Array.from(daysSet).sort((a, b) => a - b); -} - -// --------------------------------------------------------------------- -// Internals -// --------------------------------------------------------------------- - -function clamp01(n) { - if (n < 0) return 0; - if (n > 1) return 1; - return n; -} - -/** - * Position 0.5-0.625 is the canonical "full" band in Calendaria phase - * tables (all three fixture calendars place "Full" at exactly that - * range). We accept a small tolerance either side. - */ -function isFullPosition(position) { - return position >= 0.48 && position <= 0.65; -} - -/** - * Position near 0 (or wrapping near 1) is "new moon". Calendaria places - * "New" at 0-0.125 typically. - */ -function isNewPosition(position) { - return position < 0.12 || position > 0.98; -} - -/** - * Look up the phase name covering a position. Calendaria phases have - * `{start, end}` covering [0,1]; a position lands in exactly one (or - * none if the table is sparse). - */ -function phaseNameAt(phases, position) { - if (!Array.isArray(phases) || phases.length === 0) return ''; - for (const p of phases) { - const start = Number(p?.start ?? 0); - const end = Number(p?.end ?? 0); - if (position >= start && position < end) return p?.name || ''; - } - // Tail end: position exactly 1.0 should match the last phase. - const last = phases[phases.length - 1]; - if (last && Number(last.end ?? 0) === 1 && position >= Number(last.start ?? 0)) { - return last.name || ''; - } - return ''; -} diff --git a/scripts/sync-calendar-note-form.mjs b/scripts/sync-calendar-note-form.mjs deleted file mode 100644 index 1a53d2a..0000000 --- a/scripts/sync-calendar-note-form.mjs +++ /dev/null @@ -1,350 +0,0 @@ -/** - * Pure-function translation between the editor's note-form view-model and - * Calendaria's documented `CALENDARIA.api.createNote` / `updateNote` input - * shape. No Foundry globals, so it's independently unit-testable; the - * Application class stays a thin integration shell around it. - * - * Form schema (editor side): name, content (HTML), year/month/day (1-indexed - * start), endYear/endMonth/endDay (1-indexed end, null → same as start), - * hour/minute (0-indexed), endHour/endMinute, allDay, visibility - * ('visible'|'hidden'|'secret'), displayStyle ('icon'|'pip'|'banner'), icon, - * color (hex), categories (preset IDs). Translates to Calendaria's - * `createNote`/`updateNote` shape: name, content, startDate, endDate?, - * allDay, categories, icon, color, visibility, displayStyle, openSheet: false. - * - * Only the documented `CALENDARIA.api` options are handled here; the - * `gmOnly` boolean for Chronicle wire translation lives in `calendar-sync.mjs`. - */ - -export const VISIBILITY = Object.freeze({ - VISIBLE: 'visible', - HIDDEN: 'hidden', - SECRET: 'secret', -}); - -export const DISPLAY_STYLE = Object.freeze({ - ICON: 'icon', - PIP: 'pip', - BANNER: 'banner', -}); - -const VISIBILITY_VALUES = Object.values(VISIBILITY); -const DISPLAY_STYLE_VALUES = Object.values(DISPLAY_STYLE); - -/** - * Empty / default form, anchored to the operator's selected day. - * - * @param {{year:number, month:number, day:number, - * endYear?:number|null, endMonth?:number|null, endDay?:number|null}} anchor - * @returns {object} form - */ -export function defaultFormForDate(anchor) { - const year = Number(anchor?.year ?? 0); - const month = Math.max(1, Number(anchor?.month ?? 1)); - const day = Math.max(1, Number(anchor?.day ?? 1)); - const hasRange = - anchor?.endYear != null && anchor?.endMonth != null && anchor?.endDay != null; - return { - name: '', - content: '', - year, month, day, - endYear: hasRange ? Number(anchor.endYear) : null, - endMonth: hasRange ? Number(anchor.endMonth) : null, - endDay: hasRange ? Number(anchor.endDay) : null, - hour: 0, minute: 0, - endHour: null, endMinute: null, - allDay: true, - visibility: VISIBILITY.VISIBLE, - displayStyle: DISPLAY_STYLE.ICON, - icon: '', - color: '', - categories: [], - // Null means "fires once on startDate"; a populated conditionTree - // means "fires on every matching date". - conditionTree: null, - }; -} - -/** - * Pull form values out of a Calendaria note stub. - * - * Defensive: any field may be missing on a stub returned by an older - * Calendaria version. Falls back to safe defaults for missing fields so - * the editor renders a coherent form even when the stub is partial. - * - * @param {object|null} note — return value of `CALENDARIA.api.getNote(id)` - * (or a `noteCreated`/`noteUpdated` hook stub). - * @returns {object} form - */ -export function formFromNote(note) { - const fallback = defaultFormForDate({ year: 0, month: 1, day: 1 }); - if (!note || typeof note !== 'object') return fallback; - - // Calendaria stubs vary: top-level + nested `flagData` are both seen in - // calendar-sync.mjs. Merge with top-level winning. - const f = (note.flagData && typeof note.flagData === 'object') ? note.flagData : {}; - const start = note.startDate || f.startDate || {}; - const end = note.endDate || f.endDate || null; - - const name = pickString(note.name, note.title, ''); - const content = pickString(note.content, f.content, ''); - - const year = toIntOr(start.year, fallback.year); - const month = toIntOr(start.month, fallback.month); - const day = toIntOr(start.day ?? start.dayOfMonth, fallback.day); - - let endYear = null, endMonth = null, endDay = null; - if (end && typeof end === 'object') { - endYear = toIntOr(end.year, null); - endMonth = toIntOr(end.month, null); - endDay = toIntOr(end.day ?? end.dayOfMonth, null); - } - - const hour = toIntOr(start.hour, 0); - const minute = toIntOr(start.minute, 0); - const endHour = end ? toIntOr(end.hour, null) : null; - const endMinute = end ? toIntOr(end.minute, null) : null; - - const allDay = note.allDay ?? f.allDay ?? true; - - return { - name, - content, - year, month, day, - endYear, endMonth, endDay, - hour, minute, - endHour, endMinute, - allDay: !!allDay, - visibility: coerceVisibility(note.visibility ?? f.visibility), - displayStyle: coerceDisplayStyle(note.displayStyle ?? f.displayStyle), - icon: pickString(note.icon, f.icon, ''), - color: pickString(note.color, f.color, ''), - categories: coerceCategories(note.categories ?? f.categories), - // Surfaces an existing recurrence tree verbatim; validation/display - // happens in the builder module, not here. - conditionTree: extractConditionTree(note, f), - }; -} - -/** - * Convert a form to the options object accepted by - * `CALENDARIA.api.createNote` / `updateNote`. - * - * Strips empty optional fields so Calendaria's defaults apply. Date+time - * are emitted 1-indexed for month/day per Calendaria's public API - * convention. - * - * @param {object} form - * @returns {object} options for `createNote` / `updateNote` - */ -export function noteOptionsFromForm(form) { - if (!form || typeof form !== 'object') { - throw new TypeError('noteOptionsFromForm: form must be an object'); - } - - const startDate = { - year: Math.trunc(Number(form.year)), - month: Math.trunc(Number(form.month)), - day: Math.trunc(Number(form.day)), - }; - if (!form.allDay) { - startDate.hour = clampInt(form.hour, 0, 23); - startDate.minute = clampInt(form.minute, 0, 59); - } - - const hasEndDate = - form.endYear != null && - form.endMonth != null && - form.endDay != null; - let endDate = null; - if (hasEndDate) { - endDate = { - year: Math.trunc(Number(form.endYear)), - month: Math.trunc(Number(form.endMonth)), - day: Math.trunc(Number(form.endDay)), - }; - if (!form.allDay) { - endDate.hour = clampInt(form.endHour ?? form.hour, 0, 23); - endDate.minute = clampInt(form.endMinute ?? form.minute, 0, 59); - } - } - - const options = { - name: String(form.name ?? '').trim(), - content: String(form.content ?? ''), - startDate, - allDay: !!form.allDay, - visibility: coerceVisibility(form.visibility), - displayStyle: coerceDisplayStyle(form.displayStyle), - // Skip openSheet — the editor opens its own form; do NOT pop the - // Calendaria note sheet over our window. - openSheet: false, - }; - - if (endDate) options.endDate = endDate; - - const icon = String(form.icon ?? '').trim(); - const color = String(form.color ?? '').trim(); - if (icon) options.icon = icon; - if (color) options.color = color; - - const categories = coerceCategories(form.categories); - if (categories.length > 0) options.categories = categories; - - // Passes through verbatim; omitted (not an empty object) means "no - // recurrence" per Calendaria's create/update API. - if (form.conditionTree && typeof form.conditionTree === 'object') { - options.conditionTree = form.conditionTree; - } - - return options; -} - -/** - * Validate a form. Returns an array of error messages (empty if valid). - * Validation is structural — date semantics (e.g. month > calendar's - * month count) are deferred to Calendaria, which knows the calendar. - * - * @param {object} form - * @returns {string[]} - */ -export function validateForm(form) { - const errors = []; - if (!form || typeof form !== 'object') { - return ['CHRONICLE.SyncCalendar.NoteForm.Errors.NotAnObject']; - } - if (!String(form.name ?? '').trim()) { - errors.push('CHRONICLE.SyncCalendar.NoteForm.Errors.NameRequired'); - } - if (!Number.isFinite(Number(form.year))) { - errors.push('CHRONICLE.SyncCalendar.NoteForm.Errors.YearInvalid'); - } - const month = Number(form.month); - if (!Number.isFinite(month) || month < 1) { - errors.push('CHRONICLE.SyncCalendar.NoteForm.Errors.MonthInvalid'); - } - const day = Number(form.day); - if (!Number.isFinite(day) || day < 1) { - errors.push('CHRONICLE.SyncCalendar.NoteForm.Errors.DayInvalid'); - } - - const hasEnd = - form.endYear != null && - form.endMonth != null && - form.endDay != null; - if (hasEnd) { - const endYear = Number(form.endYear); - const endMonth = Number(form.endMonth); - const endDay = Number(form.endDay); - if (!Number.isFinite(endYear) || !Number.isFinite(endMonth) || !Number.isFinite(endDay)) { - errors.push('CHRONICLE.SyncCalendar.NoteForm.Errors.EndDateInvalid'); - } else if ( - // End strictly before start at any granularity → error. - endYear < Number(form.year) || - (endYear === Number(form.year) && endMonth < Number(form.month)) || - (endYear === Number(form.year) && endMonth === Number(form.month) && endDay < Number(form.day)) - ) { - errors.push('CHRONICLE.SyncCalendar.NoteForm.Errors.EndBeforeStart'); - } - } - - if (!form.allDay) { - const h = Number(form.hour), m = Number(form.minute); - if (!Number.isInteger(h) || h < 0 || h > 23) { - errors.push('CHRONICLE.SyncCalendar.NoteForm.Errors.HourInvalid'); - } - if (!Number.isInteger(m) || m < 0 || m > 59) { - errors.push('CHRONICLE.SyncCalendar.NoteForm.Errors.MinuteInvalid'); - } - } - - if (!VISIBILITY_VALUES.includes(form.visibility)) { - errors.push('CHRONICLE.SyncCalendar.NoteForm.Errors.VisibilityInvalid'); - } - if (!DISPLAY_STYLE_VALUES.includes(form.displayStyle)) { - errors.push('CHRONICLE.SyncCalendar.NoteForm.Errors.DisplayStyleInvalid'); - } - - return errors; -} - -/** - * Coerce a free-form categories input (array OR comma-separated string) - * into a deduplicated string[]. Exported for the form's tag-style input. - */ -export function coerceCategories(input) { - if (Array.isArray(input)) { - return dedupeStrings(input.map((c) => String(c).trim()).filter(Boolean)); - } - if (typeof input === 'string') { - return dedupeStrings(input.split(',').map((c) => c.trim()).filter(Boolean)); - } - return []; -} - -/** - * Pin a visibility value to the documented enum. Unknown / missing → - * `'visible'` (Calendaria's default). - */ -export function coerceVisibility(v) { - return VISIBILITY_VALUES.includes(v) ? v : VISIBILITY.VISIBLE; -} - -/** - * Pin a displayStyle value to the documented enum. Unknown / missing → - * `'icon'` (Calendaria's default). - */ -export function coerceDisplayStyle(s) { - return DISPLAY_STYLE_VALUES.includes(s) ? s : DISPLAY_STYLE.ICON; -} - -// --------------------------------------------------------------------- -// Internals -// --------------------------------------------------------------------- - -/** - * Recurrence tree lookup with the same top-level vs flagData fallback - * the rest of `formFromNote` uses. Returns null if neither carries a - * usable tree. - * - * @private - */ -function extractConditionTree(note, flagData) { - if (note?.conditionTree && typeof note.conditionTree === 'object') { - return note.conditionTree; - } - if (flagData?.conditionTree && typeof flagData.conditionTree === 'object') { - return flagData.conditionTree; - } - return null; -} - -function pickString(...candidates) { - for (const c of candidates) { - if (typeof c === 'string' && c.length > 0) return c; - } - return ''; -} - -function toIntOr(value, fallback) { - if (value == null) return fallback; - const n = Number(value); - return Number.isFinite(n) ? Math.trunc(n) : fallback; -} - -function clampInt(value, lo, hi) { - const n = Math.trunc(Number(value) || 0); - return Math.max(lo, Math.min(hi, n)); -} - -function dedupeStrings(arr) { - const seen = new Set(); - const out = []; - for (const s of arr) { - if (!seen.has(s)) { - seen.add(s); - out.push(s); - } - } - return out; -} diff --git a/scripts/sync-calendar-validation.mjs b/scripts/sync-calendar-validation.mjs deleted file mode 100644 index 93ea29a..0000000 --- a/scripts/sync-calendar-validation.mjs +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Chronicle Sync — Sync Calendar validation rule engine - * - * Pure-function rules over the Calendaria active-calendar object - * (`CALENDARIA.api.getActiveCalendar()`). Each rule returns `null` (passes) - * or a finding: `{severity: 'error'|'warning'|'info', code, message, - * fix_hint?, focus_target?}`. - * - * `runValidation` swallows per-rule exceptions so one buggy rule never - * blanks the panel. Bump `SCHEMA_VERSION` on any finding-shape change so - * the editor ignores stale persisted flag data. - */ - -export const SCHEMA_VERSION = 1; - -/** - * Run every rule against `cal`. Returns an array of findings. - * - * @param {object|null} cal — the Calendaria active-calendar object (from - * `CALENDARIA.api.getActiveCalendar()`). Null returns an empty array. - * @param {object} [ctx] - * @param {object|null} [ctx.currentDateTime] — output of - * `CALENDARIA.api.getCurrentDateTime()`; used by rules that compare runtime - * state to calendar definition. - * @returns {Array<{severity, code, message, fix_hint?, focus_target?}>} - */ -export function runValidation(cal, ctx = {}) { - if (!cal) return []; - const findings = []; - for (const rule of RULES) { - try { - const finding = rule(cal, ctx); - if (finding) findings.push(finding); - } catch (err) { - // A buggy rule must never blank the panel. Log + skip. - // eslint-disable-next-line no-console - console.warn(`Sync Calendar | rule "${rule.name}" crashed`, err); - } - } - return findings; -} - -// --------------------------------------------------------------------- -// Rules -// --------------------------------------------------------------------- -// Each rule is a named export so tests can import and call it directly. -// Localization is handled inline because the i18n keys are stable; the rule -// returns the localized string for the runtime renderer. - -/** - * R1 — Active weather zone references a zone that doesn't exist. - * - * Calendaria silently falls back to no-weather in this state, so the gap is - * otherwise invisible short of running a weather check. - */ -export function ruleActiveZoneExists(cal) { - const active = cal?.weather?.activeZone; - const zones = cal?.weather?.zones ?? {}; - if (!active) return null; - const ids = Object.values(zones) - .map((z) => z?.id) - .filter((id) => typeof id === 'string' && id.length > 0); - if (ids.includes(active)) return null; - return { - severity: 'warning', - code: 'WEATHER_ACTIVE_ZONE_MISSING', - message: `Active weather zone "${active}" is set but no zone with that id exists in the calendar.`, - fix_hint: 'Create a zone with this id, choose an existing zone, or clear activeZone.', - focus_target: 'weather', - }; -} - -/** - * R2 — Zero festivals authored. Advisory only — some calendars genuinely - * have none. - */ -export function ruleFestivalsEmpty(cal) { - const festivals = cal?.festivals ?? {}; - const count = Object.keys(festivals).length; - if (count > 0) return null; - return { - severity: 'info', - code: 'FESTIVALS_EMPTY', - message: 'No festivals authored. The festival list will be empty in widgets that surface festivals.', - fix_hint: 'Author festivals via Calendaria\'s Calendar Editor → Festivals tab (PR 4 will add an in-editor authoring flow).', - focus_target: 'festivals', - }; -} - -/** - * R3 — Season with `seasonalType: null`. Calendaria's astronomical anchors - * (equinox / solstice) silently skip these; sometimes intentional - * (interstitial season), sometimes a forgotten field. - */ -export function ruleSeasonInterstitial(cal) { - const seasons = cal?.seasons?.values ?? {}; - const interstitial = Object.entries(seasons) - .filter(([_id, s]) => s && s.seasonalType === null) - .map(([_id, s]) => s?.name || 'unnamed'); - if (interstitial.length === 0) return null; - return { - severity: 'info', - code: 'SEASON_INTERSTITIAL', - message: `Season(s) with seasonalType: null — astronomical anchors (equinox / solstice) will not resolve to: ${interstitial.join(', ')}.`, - fix_hint: 'Intentional? Mark it on the season. Otherwise set seasonalType to spring/summer/autumn/winter.', - focus_target: 'seasons', - }; -} - -/** - * R4 — Season month range wraps the year boundary (monthStart > monthEnd). - * Not necessarily a bug, but flagged for confirmation since it can also mean - * an inverted range. - */ -export function ruleSeasonWrapsYear(cal) { - const seasons = cal?.seasons?.values ?? {}; - const wrapped = Object.entries(seasons) - .filter(([_id, s]) => { - if (!s || s.monthStart == null || s.monthEnd == null) return false; - // Wraps when the start month index is greater than the end month index. - // Indexing here is 0-based per Calendaria's internal storage; the - // editor's display layer maps to 1-based ordinals before showing. - return Number(s.monthStart) > Number(s.monthEnd); - }) - .map(([_id, s]) => s?.name || 'unnamed'); - if (wrapped.length === 0) return null; - return { - severity: 'info', - code: 'SEASON_WRAPS_YEAR', - message: `Season(s) wrap the year boundary (monthStart > monthEnd): ${wrapped.join(', ')}.`, - fix_hint: 'Confirm intentional — interstitial seasons spanning year-end are valid; reversed ranges are not.', - focus_target: 'seasons', - }; -} - -/** - * R5 — Randomized moon with default `phaseSeed: 0`, the uninitialized value. - * The moon still works, but every campaign shares the same pseudorandom - * sequence until the seed is rerolled. - */ -export function ruleRandomizedMoonDefaultSeed(cal) { - const moons = cal?.moons ?? {}; - const suspect = Object.entries(moons) - .filter(([_id, m]) => m?.phaseMode === 'randomized' && Number(m?.phaseSeed ?? 0) === 0) - .map(([_id, m]) => m?.name || 'unnamed'); - if (suspect.length === 0) return null; - return { - severity: 'warning', - code: 'MOON_RANDOMIZED_DEFAULT_SEED', - message: `Randomized moon(s) with default phaseSeed: 0 — every world will share the same pseudorandom sequence: ${suspect.join(', ')}.`, - fix_hint: 'Reroll the seed in Calendaria\'s Moon editor (PR 5 will add an in-editor "regenerate seed" button).', - focus_target: 'moons', - }; -} - -/** - * R6 — Two or more moons sharing Calendaria's stock phase icon set, making - * them visually indistinguishable in the calendar UI. Low-priority advisory. - */ -export function ruleMoonIconsDuplicated(cal) { - const moons = cal?.moons ?? {}; - const entries = Object.entries(moons); - if (entries.length < 2) return null; - const usesGeneric = entries.filter(([_id, m]) => { - const phases = Object.values(m?.phases ?? {}); - if (phases.length === 0) return false; - return phases.every((p) => - typeof p?.icon === 'string' && - p.icon.startsWith('modules/calendaria/assets/moon-phases/'), - ); - }); - if (usesGeneric.length < 2) return null; - return { - severity: 'info', - code: 'MOON_ICONS_GENERIC_DUPLICATED', - message: `${usesGeneric.length} moons share Calendaria's stock phase icons — they will be visually indistinguishable in widgets that show all moons.`, - fix_hint: 'Set custom phase icons per moon in Calendaria\'s Moon editor.', - focus_target: 'moons', - }; -} - -/** - * R7 — Date format strings consisting entirely of bracket-escaped literals. - * Calendaria treats `[...]` as a literal-text escape, so a format like - * `"[W]"` renders the literal string `W` at runtime instead of the computed - * token — almost certainly not the intent. - * - * Detection: strip every `[...]` segment; if the remainder has no - * non-whitespace characters, the format is literal-only. A format mixing - * literals with real tokens (e.g. `"[Week] W [of] MMMM, Y"`) passes. - */ -export function ruleDateFormatLiteralString(cal) { - const formats = cal?.dateFormats ?? {}; - const offenders = Object.entries(formats) - .filter(([_k, v]) => typeof v === 'string' && v.length > 0) - .filter(([_k, v]) => v.replace(/\[[^\]]*\]/g, '').trim() === '') - .map(([k]) => k); - if (offenders.length === 0) return null; - return { - severity: 'warning', - code: 'DATE_FORMAT_LITERAL_ONLY', - message: `Date format(s) consist entirely of bracket-escaped literals — they will render as plain text, not as computed tokens: ${offenders.join(', ')}.`, - fix_hint: 'Remove the [brackets] around tokens you want computed (e.g. "YYYY" not "[YYYY]"). Use brackets only for literal text mixed with tokens.', - focus_target: 'dateFormats', - }; -} - -/** - * R8 — Reference date with `dayOfMonth: 0` on a moon. Calendaria stores - * `dayOfMonth` 0-indexed internally (the public API and editor are - * 1-indexed), so a literal `0` means "day 1" to the engine but reads as - * suspicious to a human editing raw JSON. - */ -export function ruleReferenceDateDayZero(cal) { - const moons = cal?.moons ?? {}; - const suspect = Object.entries(moons) - .filter(([_id, m]) => Number(m?.referenceDate?.dayOfMonth ?? -1) === 0) - .map(([_id, m]) => m?.name || 'unnamed'); - if (suspect.length === 0) return null; - return { - severity: 'info', - code: 'MOON_REFERENCE_DAY_ZERO', - message: `Moon(s) with referenceDate.dayOfMonth: 0 — internally this is "day 1" (0-indexed) but reads as "day 0" to humans: ${suspect.join(', ')}.`, - fix_hint: 'Confirm intent. PR 5 will display all date fields 1-indexed in the editor.', - focus_target: 'moons', - }; -} - -/** - * R9 — `description` present at both the top level and under `metadata`, - * and the two strings disagree. Cosmetic but signals export inconsistency. - */ -export function ruleDescriptionDoubled(cal) { - const top = typeof cal?.description === 'string' ? cal.description.trim() : ''; - const meta = typeof cal?.metadata?.description === 'string' ? cal.metadata.description.trim() : ''; - if (!top || !meta) return null; - if (top === meta) return null; - return { - severity: 'info', - code: 'DESCRIPTION_DOUBLED', - message: 'Top-level "description" and "metadata.description" both exist and disagree.', - fix_hint: 'Pick one. PR 5 will collapse them into a single description field in the editor.', - focus_target: 'overview', - }; -} - -/** - * R10 — Calendar has a persisted `currentDate` but it sits at the default - * (year <= yearZero, no time advance recorded). Strong signal the world has - * never been advanced since creation. Advisory only. - */ -export function ruleLastAdvancedNever(cal) { - const cd = cal?.currentDate; - if (!cd || typeof cd !== 'object') return null; - const yearZero = Number(cal?.years?.yearZero ?? 0); - const year = Number(cd.year ?? 0); - const dom = Number(cd.dayOfMonth ?? cd.day ?? 0); - const month = Number(cd.month ?? 0); - const hour = Number(cd.hour ?? 0); - const minute = Number(cd.minute ?? 0); - // Default state — year at or below yearZero, all time components zero. - if (year > yearZero) return null; - if (month !== 0) return null; - if (dom !== 0) return null; - if (hour !== 0 || minute !== 0) return null; - return { - severity: 'info', - code: 'CALENDAR_LAST_ADVANCED_NEVER', - message: 'Calendar has never been advanced — currentDate is at the default for a freshly-created calendar.', - fix_hint: 'Advance the calendar with Calendaria\'s MiniCal / HUD, or set the initial date in PR 5\'s structure editor.', - focus_target: 'overview', - }; -} - -/** - * Stable rule order. Each rule listed here gets called by `runValidation`. - * The order matters only for display — rules render in this sequence. - */ -const RULES = [ - ruleActiveZoneExists, - ruleFestivalsEmpty, - ruleSeasonInterstitial, - ruleSeasonWrapsYear, - ruleRandomizedMoonDefaultSeed, - ruleMoonIconsDuplicated, - ruleDateFormatLiteralString, - ruleReferenceDateDayZero, - ruleDescriptionDoubled, - ruleLastAdvancedNever, -]; - -/** - * Test-only export so unit tests can iterate over every rule by reference. - * The runtime never imports `ALL_RULES` — it goes through `runValidation`. - */ -export const ALL_RULES = Object.freeze(RULES.slice()); diff --git a/scripts/sync-calendar.mjs b/scripts/sync-calendar.mjs deleted file mode 100644 index a5e1d26..0000000 --- a/scripts/sync-calendar.mjs +++ /dev/null @@ -1,1494 +0,0 @@ -/** - * Chronicle Sync — Sync Calendar editor - * - * GM-only ApplicationV2: 3-pane view of the active Calendaria calendar with - * an always-on validation panel, year/month toggle, a writable day - * inspector (create/edit/delete notes via `CALENDARIA.api`), drag-select - * multi-day event creation, moon-phase strip, recurrence builder. Structure - * editing and inline category creation are still deferred. - * - * Writes go through `CALENDARIA.api` only, never Calendaria's internal - * settings; reads are wrapped in try/catch for a degraded-mode render when - * Calendaria is missing or broken. Writes reach Chronicle automatically via - * `scripts/calendar-sync.mjs`'s existing hook handlers. - * - * The Application class is a thin shell; validation and form translation - * live in separately-unit-tested modules. - * - * Naming: the UI label is "Sync Calendar" because Calendaria already has a - * "Chronicle" widget. - */ - -import { MODULE_ID, FLAG_SCOPE } from './constants.mjs'; -import { runValidation, SCHEMA_VERSION } from './sync-calendar-validation.mjs'; -import { - defaultFormForDate, - formFromNote, - noteOptionsFromForm, - validateForm, - VISIBILITY, - DISPLAY_STYLE, -} from './sync-calendar-note-form.mjs'; -import { - buildMoonStripData, - dayFromStripClick, - findConvergenceDays, -} from './sync-calendar-moon-strip.mjs'; -import { - PRESETS, - treeFromPreset, - validateTree, - treeToSummary, -} from './sync-calendar-condition-builder.mjs'; -import { - transformCalendariaCalendar, - buildCalendarPreflightSummary, -} from './sync-calendar-import-from-calendaria.mjs'; -import { buildCalendarDiagnostics } from './sync-calendar-diagnostics.mjs'; -import { getSetting, setSetting, getCalendarSyncExclusions } from './settings.mjs'; -import { isCalendarNoteJournal } from './calendar-sync.mjs'; -import { calendarStateFromError } from './_calendar-probe-state.mjs'; -import { confirmDialog } from './_dialogs.mjs'; - -const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; - -/** - * Cap on hook re-render frequency. Calendaria fires `dateTimeChange` on - * every world-time tick (including real-time clock visualTicks); we don't - * need to re-render at 60 fps. 250 ms is enough for human-perceived - * snappiness. - */ -const RENDER_THROTTLE_MS = 250; - -export class SyncCalendarApplication extends HandlebarsApplicationMixin(ApplicationV2) { - static DEFAULT_OPTIONS = { - id: 'sync-calendar', - classes: ['sync-calendar'], - tag: 'div', - window: { - title: 'CHRONICLE.SyncCalendar.Title', - resizable: true, - contentClasses: ['sync-calendar-content'], - }, - position: { width: 1100, height: 700 }, - actions: { - // Navigation - 'select-month': SyncCalendarApplication.#onSelectMonth, - 'select-year': SyncCalendarApplication.#onSelectYear, - 'jump-today': SyncCalendarApplication.#onJumpToday, - 'goto-month': SyncCalendarApplication.#onGotoMonth, - 'back-to-year': SyncCalendarApplication.#onBackToYear, - 'select-day': SyncCalendarApplication.#onSelectDay, - // Event CRUD - 'add-event': SyncCalendarApplication.#onAddEvent, - 'edit-event': SyncCalendarApplication.#onEditEvent, - 'save-event': SyncCalendarApplication.#onSaveEvent, - 'delete-event': SyncCalendarApplication.#onDeleteEvent, - 'cancel-form': SyncCalendarApplication.#onCancelForm, - // Recurrence builder - 'apply-recurrence-preset': SyncCalendarApplication.#onApplyRecurrencePreset, - 'clear-recurrence': SyncCalendarApplication.#onClearRecurrence, - // Empty-state import-from-Calendaria - 'import-calendar': SyncCalendarApplication.#onImportCalendar, - 'recheck-chronicle': SyncCalendarApplication.#onRecheckChronicle, - // Diagnostics & maintenance - 'copy-diagnostics': SyncCalendarApplication.#onCopyDiagnostics, - 'toggle-calendar-sync': SyncCalendarApplication.#onToggleCalendarSync, - 'cleanup-calendar-notes': SyncCalendarApplication.#onCleanupCalendarNotes, - // Misc - 'focus-target': SyncCalendarApplication.#onFocusTarget, - }, - }; - - static PARTS = { - body: { - template: 'modules/chronicle-sync/templates/sync-calendar.hbs', - }, - }; - - constructor(options = {}) { - super(options); - /** Selected date — 1-indexed shape `{year, month, day}`. */ - this._selectedDate = null; - /** 'year' or 'month' — year overview vs. month day-grid. */ - this._viewMode = 'year'; - /** 'none' | 'add' | 'edit' — controls right-pane form rendering. */ - this._formMode = 'none'; - /** Form values, set when entering 'add' or 'edit' mode. */ - this._formData = null; - /** When editing, the Calendaria note's page id we'll PUT against. */ - this._formNoteId = null; - /** Validation messages from the last save attempt; cleared on field edit. */ - this._formErrors = []; - /** True while a save / delete is in flight — prevents double-submit. */ - this._formBusy = false; - - /** Drag-select state — engaged in month view via pointer events. */ - this._dragStartDay = null; - this._dragEndDay = null; - this._dragActive = false; - - this._calendariaVersion = ''; - this._activeCalendarId = ''; - this._hooksRegistered = false; - this._hookHandlers = {}; - this._renderPending = false; - - /** - * Chronicle-side calendar presence state. One of: - * - 'unknown' — not yet probed - * - 'present' — Chronicle has a calendar for this campaign - * - 'absent' — Chronicle responded but said no calendar - * - 'unreachable' — fetch failed (config, network, auth) - * - 'no-create-api' — POST /calendar returned 404/405 (endpoint not deployed) - * The empty-state import banner renders when state is 'absent' AND - * Calendaria has calendars to import. - */ - this._chronicleCalendarState = 'unknown'; - /** Last import attempt's outcome for inline feedback. */ - this._lastImportResult = null; - /** Per-calendar import busy flags so the right button shows progress. */ - this._importBusyByCalendarId = new Set(); - } - - /** - * Render entry point. Builds the view-model that `sync-calendar.hbs` - * consumes. Defensive: every `CALENDARIA.api.*` call lives in a try/catch - * with a degraded-mode fallback. - * - * @override - */ - async _prepareContext(_options = {}) { - const api = globalThis.CALENDARIA?.api; - const calendariaModule = game.modules.get?.('calendaria'); - this._calendariaVersion = calendariaModule?.version || ''; - - // Fire-and-forget probe of Chronicle's calendar surface. We only - // probe on first render or after an explicit recheck — re-probing - // on every Calendaria hook tick would spam the API. - if (this._chronicleCalendarState === 'unknown') { - this.#probeChronicleCalendar(); - } - - if (!api || typeof api.getActiveCalendar !== 'function') { - const degradedReason = calendariaModule - ? 'CHRONICLE.SyncCalendar.Degraded.IncompatibleApi' - : 'CHRONICLE.SyncCalendar.Degraded.NoCalendaria'; - this._lastDiag = { degraded: true, degradedReason }; - return { - degraded: true, - degradedReason, - calendariaVersion: this._calendariaVersion, - schemaVersion: SCHEMA_VERSION, - importBanner: this.#buildImportBanner(api), - }; - } - - let cal = null; - let currentDateTime = null; - try { cal = api.getActiveCalendar() || null; } - catch (err) { console.warn('Sync Calendar | getActiveCalendar failed', err); } - try { currentDateTime = api.getCurrentDateTime?.() || null; } - catch (err) { console.warn('Sync Calendar | getCurrentDateTime failed', err); } - - if (!cal) { - this._lastDiag = { degraded: true, degradedReason: 'CHRONICLE.SyncCalendar.Degraded.NoActiveCalendar' }; - return { - degraded: true, - degradedReason: 'CHRONICLE.SyncCalendar.Degraded.NoActiveCalendar', - calendariaVersion: this._calendariaVersion, - schemaVersion: SCHEMA_VERSION, - importBanner: this.#buildImportBanner(api), - }; - } - - this._activeCalendarId = cal?.metadata?.id || cal?.id || ''; - - if (!this._selectedDate) { - this._selectedDate = this.#defaultSelectedDate(cal, currentDateTime); - } else { - this._selectedDate = this.#clampSelectedDate(cal, this._selectedDate); - } - - const months = readArrayLike(cal.monthsArray, cal.months?.values); - const weekdays = readArrayLike(cal.weekdaysArray, cal.days?.values); - const seasons = readArrayLike(cal.seasonsArray, cal.seasons?.values); - const moons = readArrayLike(cal.moonsArray, cal.moons); - const eras = readArrayLike(cal.erasArray, cal.eras); - const festivals = readArrayLike(cal.festivalsArray, cal.festivals); - const cycles = readArrayLike(cal.cyclesArray, cal.cycles); - const weatherZones = readArrayLike(cal.weatherZonesArray, cal.weather?.zones); - - // Validation findings (rule engine is pure; never throws upward). - const findings = runValidation(cal, { currentDateTime }); - const findingCountsBySeverity = findings.reduce((acc, f) => { - acc[f.severity] = (acc[f.severity] || 0) + 1; - return acc; - }, {}); - - const yearOverview = months.map((m, idx) => { - const monthIndex0 = monthIndexFromMonth(m, idx); - const monthOrdinal1 = monthOrdinalFromMonth(m, monthIndex0); - let noteCount = 0; - try { - const notes = api.getNotesForMonth?.(this._selectedDate.year, monthOrdinal1, { includeContent: false }); - if (Array.isArray(notes)) noteCount = notes.length; - } catch {} - const festivalsThisMonth = festivals.filter((f) => { - const fm = Number(f?.month ?? -1); - return fm === monthIndex0 || fm === monthOrdinal1; - }); - return { - id: m?.id || `month-${monthIndex0}`, - name: m?.name || 'Month', - abbreviation: m?.abbreviation || '', - days: Number(m?.days ?? 0), - leapDays: Number(m?.leapDays ?? 0), - ordinal: monthOrdinal1, - index0: monthIndex0, - noteCount, - festivalCount: festivalsThisMonth.length, - isSelected: monthOrdinal1 === this._selectedDate.month, - }; - }); - - // Month view — per-day cell with note count + drag-select handles. - const monthDetail = (this._viewMode === 'month') - ? this.#buildMonthDetail(api, yearOverview, festivals) - : null; - - // Moon-phase strip for month view. - const moonStrip = (this._viewMode === 'month' && monthDetail) - ? this.#buildMoonStripView(api, moons, monthDetail.year, monthDetail.monthOrdinal, monthDetail.daysInMonth) - : null; - - const dayDetail = this.#buildDayDetail(api, cal, moons, seasons); - - // Stash a snapshot for the diagnostics export (the `copy-diagnostics` - // action). Holds the raw findings + structure + selected-day detail so the - // handler doesn't have to recompute them. See #collectDiagnostics. - this._lastDiag = { - cal: { name: cal.name || 'Calendar', id: this._activeCalendarId, version: cal.metadata?.version || '' }, - currentDateTime, - selectedDate: this._selectedDate, - structureCounts: { - months: months.length, weekdays: weekdays.length, seasons: seasons.length, - moons: moons.length, eras: eras.length, festivals: festivals.length, - cycles: cycles.length, weatherZones: weatherZones.length, - }, - findings, - dayDetail, - }; - - const findingsView = findings.map((f) => ({ - severity: f.severity, - severityClass: `severity-${f.severity}`, - code: f.code, - message: f.message, - fixHint: f.fix_hint || '', - focusTarget: f.focus_target || '', - })); - - // Form view-model — only populated when form is open. - let formView = null; - if (this._formMode !== 'none' && this._formData) { - const tree = this._formData.conditionTree || null; - const recurrenceErrors = tree ? validateTree(tree) : []; - formView = { - mode: this._formMode, - noteId: this._formNoteId || '', - data: this._formData, - errors: this._formErrors.slice(), - busy: this._formBusy, - hasEndDate: this._formData.endDay != null && this._formData.endMonth != null && this._formData.endYear != null, - visibilityOptions: VISIBILITY_OPTIONS, - displayStyleOptions: DISPLAY_STYLE_OPTIONS, - categoriesText: Array.isArray(this._formData.categories) ? this._formData.categories.join(', ') : '', - recurrence: { - hasTree: !!tree, - summary: tree ? treeToSummary(tree) : '', - errors: recurrenceErrors, - presets: this.#buildRecurrencePresetOptions(moons, seasons), - }, - }; - } - - return { - degraded: false, - calendariaVersion: this._calendariaVersion, - schemaVersion: SCHEMA_VERSION, - - cal: { - name: cal.name || 'Calendar', - description: (cal.description || cal.metadata?.description || '').trim(), - id: this._activeCalendarId, - version: cal.metadata?.version || '', - }, - - currentDateTime, - selectedDate: this._selectedDate, - - viewMode: this._viewMode, - yearOverview, - monthDetail, - moonStrip, - dayDetail, - formView, - importBanner: this.#buildImportBanner(api), - - structureCounts: { - months: months.length, - weekdays: weekdays.length, - seasons: seasons.length, - moons: moons.length, - eras: eras.length, - festivals: festivals.length, - cycles: cycles.length, - weatherZones: weatherZones.length, - }, - - findings: findingsView, - findingCountsBySeverity, - findingTotal: findings.length, - - // Writes are enabled for events; structure editing is not yet wired. - writesEnabled: true, - - // Footer toolbar state: per-calendar sync toggle + global switch. - syncToggle: { - calendarId: this._activeCalendarId, - excluded: this.#isCalendarSyncExcluded(this._activeCalendarId), - globalEnabled: getSetting('syncCalendar'), - }, - }; - } - - /** - * Per-day cells for the selected month. Used by the month-view template - * to render the day grid where the operator clicks / drag-selects. - * - * @private - */ - #buildMonthDetail(api, yearOverview, festivals) { - const month = yearOverview.find((m) => m.ordinal === this._selectedDate.month); - if (!month) return null; - const daysInMonth = Math.max(1, Number(month.days) || 28); - const monthIndex0 = month.index0; - - // Fetch all notes for the month once; bucket per day. - let monthNotes = []; - try { - const notes = api.getNotesForMonth?.(this._selectedDate.year, month.ordinal, { includeContent: false }); - if (Array.isArray(notes)) monthNotes = notes; - } catch (err) { - console.warn('Sync Calendar | getNotesForMonth failed', err); - } - const notesByDay = new Map(); - for (const n of monthNotes) { - const day = Number( - n?.startDate?.day ?? - n?.flagData?.startDate?.day ?? - n?.flagData?.startDate?.dayOfMonth, - ); - if (Number.isFinite(day) && day >= 1) { - if (!notesByDay.has(day)) notesByDay.set(day, []); - notesByDay.get(day).push({ - id: n?.id || '', - name: n?.name || 'Untitled', - color: n?.color || n?.flagData?.color || '', - icon: n?.icon || n?.flagData?.icon || '', - }); - } - } - - const festivalDays = new Set( - festivals - .filter((f) => { - const fm = Number(f?.month ?? -1); - return fm === monthIndex0 || fm === month.ordinal; - }) - .map((f) => Number(f?.dayOfMonth ?? f?.day ?? -1)) - .filter((d) => Number.isFinite(d) && d >= 0) - // Festivals use 0-indexed dayOfMonth in some exports; bump to 1-indexed. - .map((d) => d + 1), - ); - - const days = []; - for (let d = 1; d <= daysInMonth; d++) { - const cellNotes = notesByDay.get(d) || []; - days.push({ - day: d, - ordinal: month.ordinal, - year: this._selectedDate.year, - isSelected: d === this._selectedDate.day, - isFestival: festivalDays.has(d), - noteCount: cellNotes.length, - notesPreview: cellNotes.slice(0, 3), - }); - } - return { - monthOrdinal: month.ordinal, - monthName: month.name, - monthAbbr: month.abbreviation, - year: this._selectedDate.year, - daysInMonth, - days, - }; - } - - /** - * Build the moon-strip view-model for the month view. Pure logic - * lives in `sync-calendar-moon-strip.mjs`; this method just plumbs - * Calendaria's `getMoonPhasePosition` through to the pure builder - * and surfaces convergence days. - * - * @private - */ - #buildMoonStripView(api, moons, year, monthOrdinal, daysInMonth) { - if (!Array.isArray(moons) || moons.length === 0) return null; - const getPosition = (idx, date) => { - try { return api.getMoonPhasePosition?.(idx, date) ?? 0; } - catch (err) { - console.warn('Sync Calendar | getMoonPhasePosition failed', err); - return 0; - } - }; - const data = buildMoonStripData({ moons, year, monthOrdinal, daysInMonth, getPosition }); - if (!data.moons.length) return null; - return { - moons: data.moons, - convergenceDays: findConvergenceDays(data), - }; - } - - /** - * Build the empty-state import banner view-model. Renders the list - * of Calendaria calendars + import buttons when Chronicle has no - * calendar for this campaign. Returns `null` if the banner - * shouldn't render (Chronicle has a calendar, Calendaria absent, - * etc.). - * - * @private - */ - #buildImportBanner(api) { - if (this._chronicleCalendarState === 'present') return null; - if (this._chronicleCalendarState === 'unknown') { - return { state: 'probing' }; - } - if (!api || typeof api.getAllCalendars !== 'function') { - // Calendaria absent — nothing to import from. - return { - state: this._chronicleCalendarState, - calendars: [], - lastResult: this._lastImportResult, - }; - } - let allCalendars = null; - try { allCalendars = api.getAllCalendars(); } - catch (err) { - console.warn('Sync Calendar | getAllCalendars failed', err); - return { - state: this._chronicleCalendarState, - calendars: [], - lastResult: this._lastImportResult, - }; - } - // Calendaria returns a Map. - const list = []; - if (allCalendars instanceof Map) { - for (const [, calObj] of allCalendars) { - const s = buildCalendarPreflightSummary(calObj); - if (s.id) list.push({ ...s, busy: this._importBusyByCalendarId.has(s.id) }); - } - } else if (allCalendars && typeof allCalendars === 'object') { - for (const calObj of Object.values(allCalendars)) { - const s = buildCalendarPreflightSummary(calObj); - if (s.id) list.push({ ...s, busy: this._importBusyByCalendarId.has(s.id) }); - } - } - return { - state: this._chronicleCalendarState, - calendars: list, - lastResult: this._lastImportResult, - }; - } - - /** - * Build the list of recurrence preset options the form surfaces in - * its recurrence section. Each entry is `{id, labelKey, params}` — - * the template renders a button per entry and the click handler - * passes the params object to `treeFromPreset`. - * - * Some presets need structural ids (a season id, a moon index); we - * compose one button per available structural element rather than - * forcing the operator to pick from a dropdown after clicking. - * - * @private - */ - #buildRecurrencePresetOptions(moons, seasons) { - const presets = []; - - presets.push({ - id: PRESETS.EVERY_NTH_OF_MONTH, - labelKey: 'CHRONICLE.SyncCalendar.Recurrence.Preset.EveryNthOfMonth', - paramsTemplate: { monthDay: 1 }, - }); - - // One "every full " + "every new " per defined moon. - if (Array.isArray(moons)) { - moons.forEach((moon, index) => { - const name = moon?.name || `Moon ${index + 1}`; - presets.push({ - id: PRESETS.EVERY_FULL_MOON, - labelKey: 'CHRONICLE.SyncCalendar.Recurrence.Preset.EveryFullMoon', - extraLabel: name, - paramsTemplate: { moonIndex: index }, - }); - presets.push({ - id: PRESETS.EVERY_NEW_MOON, - labelKey: 'CHRONICLE.SyncCalendar.Recurrence.Preset.EveryNewMoon', - extraLabel: name, - paramsTemplate: { moonIndex: index }, - }); - }); - } - - // One "every " per season. - if (Array.isArray(seasons)) { - seasons.forEach((s) => { - const seasonId = s?.id || s?.name || ''; - if (!seasonId) return; - presets.push({ - id: PRESETS.EVERY_SEASON, - labelKey: 'CHRONICLE.SyncCalendar.Recurrence.Preset.EverySeason', - extraLabel: s.name || seasonId, - paramsTemplate: { seasonId }, - }); - }); - } - - return presets; - } - - /** - * Fire-and-forget probe of Chronicle's `/calendar` endpoint. Sets - * `_chronicleCalendarState` based on the response, then re-renders - * so the import banner appears (or hides). Runs through the existing - * api-client so retry queue + error logging behavior is consistent. - * - * @private - */ - async #probeChronicleCalendar() { - const apiClient = this.#getChronicleApiClient(); - if (!apiClient || typeof apiClient.get !== 'function') { - this._chronicleCalendarState = 'unreachable'; - this.#scheduleRerender(); - return; - } - try { - const result = await apiClient.get('/calendar'); - this._chronicleCalendarState = result ? 'present' : 'absent'; - } catch (err) { - // Classify the failure so the import banner gives an actionable next step - // (404 → import a calendar, 401/403 → fix the token). See - // calendarStateFromError for the status-anchored classification. - this._chronicleCalendarState = calendarStateFromError(err); - } - this.#scheduleRerender(); - } - - /** - * Resolve Chronicle's REST api-client via the module's public api - * surface. The Sync Calendar editor doesn't hold a direct reference - * to SyncManager — instead, we look it up the same way external - * code would, via `game.modules.get('chronicle-sync').api.getAPI()`. - * - * @private - */ - #getChronicleApiClient() { - try { - const mod = game.modules.get?.(MODULE_ID); - return mod?.api?.getAPI?.() || null; - } catch { return null; } - } - - /** - * Build the "selected day" inspector data: notes, moon phases, season, - * weather. Each API call individually guarded. - * - * @private - */ - #buildDayDetail(api, cal, moons, _seasons) { - const date = this._selectedDate; - const detail = { - year: date.year, - month: date.month, - day: date.day, - monthName: '', - weekdayName: '', - notes: [], - moonPhases: [], - season: null, - weather: null, - }; - - const months = readArrayLike(cal.monthsArray, cal.months?.values); - const monthEntry = months[date.month - 1] || null; - detail.monthName = monthEntry?.name || ''; - - try { - const notes = api.getNotesForDate?.(date.year, date.month, date.day, { includeContent: false }); - if (Array.isArray(notes)) { - detail.notes = notes.map((n) => ({ - id: n?.id || '', - name: n?.name || 'Untitled', - icon: n?.icon || n?.flagData?.icon || '', - color: n?.color || n?.flagData?.color || '', - visibility: n?.visibility || n?.flagData?.visibility || '', - })); - } - } catch (err) { console.warn('Sync Calendar | getNotesForDate failed', err); } - - try { - const phases = api.getAllMoonPhases?.() || []; - detail.moonPhases = (Array.isArray(phases) ? phases : []).map((p, idx) => ({ - moonName: p?.moonName || moons[idx]?.name || `Moon ${idx + 1}`, - phaseName: p?.phaseName || p?.name || '', - position: Number(p?.position ?? p?.phasePosition ?? 0), - color: moons[idx]?.color || '', - })); - } catch (err) { console.warn('Sync Calendar | getAllMoonPhases failed', err); } - - try { - const s = api.getCurrentSeason?.() || null; - if (s) detail.season = { name: s.name || '', color: s.color || '', icon: s.icon || '' }; - } catch (err) { console.warn('Sync Calendar | getCurrentSeason failed', err); } - - // Weather: prefer the SELECTED day's weather so picking a day with e.g. a - // "Meteor Shower" preset surfaces it; fall back to the current date's - // weather. `getWeatherForDate` may be absent on some Calendaria builds — - // optional-chaining degrades gracefully (the diagnostics export reports - // which API methods are present). Calendaria resolves the active/default - // climate zone internally when zoneId is omitted. - let rawForDate = null; - let rawCurrent = null; - try { rawForDate = api.getWeatherForDate?.(date.year, date.month, date.day) || null; } - catch (err) { console.warn('Sync Calendar | getWeatherForDate failed', err); } - try { rawCurrent = api.getCurrentWeather?.() || null; } - catch { /* weather often unconfigured */ } - - const normalizeWeather = (w) => w ? { - label: w.preset_label || w.label || w.preset_id || w.id || '', - icon: w.icon || '', - color: w.color || '', - temperature: w.temperature_celsius ?? w.temperature ?? null, - description: w.description || '', - } : null; - - detail.weatherForDate = normalizeWeather(rawForDate); - detail.weatherCurrent = normalizeWeather(rawCurrent); - detail.weather = detail.weatherForDate || detail.weatherCurrent; - // Raw shapes stashed for the diagnostics export (field names vary by - // Calendaria version); the template only reads `detail.weather`. - detail._weatherForDateRaw = rawForDate; - detail._weatherCurrentRaw = rawCurrent; - - return detail; - } - - #defaultSelectedDate(cal, currentDateTime) { - if (currentDateTime && typeof currentDateTime === 'object') { - return { - year: Number(currentDateTime.year ?? cal?.years?.yearZero ?? 0), - month: Number(currentDateTime.month ?? 1), - day: Number(currentDateTime.dayOfMonth ?? currentDateTime.day ?? 1), - }; - } - return { year: Number(cal?.years?.yearZero ?? 0), month: 1, day: 1 }; - } - - #clampSelectedDate(cal, date) { - const months = readArrayLike(cal.monthsArray, cal.months?.values); - const monthCount = months.length || 1; - const month = Math.max(1, Math.min(monthCount, Number(date.month ?? 1))); - const monthEntry = months[month - 1]; - const dayCount = Number(monthEntry?.days ?? 28); - const day = Math.max(1, Math.min(dayCount, Number(date.day ?? 1))); - return { - year: Number(date.year ?? cal?.years?.yearZero ?? 0), - month, - day, - }; - } - - /** @override */ - _onRender(context, options) { - super._onRender?.(context, options); - this.#registerHooksOnce(); - this.#wireFormInputs(); - this.#wireDragSelect(); - this.#wireMoonStripClicks(); - try { - game.user?.setFlag?.(FLAG_SCOPE, 'syncCalendarLastSeen', { - schemaVersion: SCHEMA_VERSION, - calendarId: this._activeCalendarId, - calendariaVersion: this._calendariaVersion, - seenAt: new Date().toISOString(), - }); - } catch { /* user-flag is best-effort */ } - } - - /** - * Attach click handlers to moon-strip rows. ApplicationV2 actions - * only delegate `click` events to elements with `data-action`, but - * strip clicks need pixel-relative math so they live as direct - * listeners; pointer/non-button-click work always lives in `_onRender`. - * - * @private - */ - #wireMoonStripClicks() { - const root = this.element; - if (!root) return; - if (this._viewMode !== 'month') return; - const strips = root.querySelectorAll?.('.moon-strip[data-strip-days]') || []; - strips.forEach((stripEl) => { - stripEl.addEventListener('click', (evt) => { - const days = Number(stripEl.dataset.stripDays); - if (!Number.isFinite(days) || days < 1) return; - const rect = stripEl.getBoundingClientRect(); - const day = dayFromStripClick(evt.clientX - rect.left, rect.width, days); - this._selectedDate = { ...this._selectedDate, day }; - this.render(false); - }); - }); - } - - /** - * Attach `input` / `change` listeners to form fields. Updates - * `this._formData` on each keystroke so the operator's input persists - * across re-renders (e.g. a `dateTimeChange` hook firing mid-edit). - * - * @private - */ - #wireFormInputs() { - const root = this.element; - if (!root) return; - const inputs = root.querySelectorAll?.('[data-form-field]') || []; - inputs.forEach((el) => { - const field = el.dataset.formField; - if (!field) return; - const onInput = (evt) => this.#onFormFieldInput(field, evt?.target ?? el); - el.addEventListener('input', onInput); - el.addEventListener('change', onInput); - }); - } - - /** - * Attach pointer events to month-view day cells for drag-select. Each - * cell has a `data-day="N"` attribute; drag-select records the start - * and end days and opens the multi-day add form on pointerup. - * - * @private - */ - #wireDragSelect() { - const root = this.element; - if (!root) return; - if (this._viewMode !== 'month') return; - const cells = root.querySelectorAll?.('.day-cell') || []; - - const setHighlight = () => { - const lo = Math.min(this._dragStartDay ?? 0, this._dragEndDay ?? 0); - const hi = Math.max(this._dragStartDay ?? 0, this._dragEndDay ?? 0); - cells.forEach((c) => { - const d = Number(c.dataset?.day ?? -1); - c.classList.toggle('drag-range', d >= lo && d <= hi && this._dragActive); - }); - }; - - cells.forEach((cell) => { - cell.addEventListener('pointerdown', (evt) => { - evt.preventDefault(); - const d = Number(cell.dataset?.day); - if (!Number.isFinite(d)) return; - this._dragActive = true; - this._dragStartDay = d; - this._dragEndDay = d; - setHighlight(); - }); - cell.addEventListener('pointerenter', () => { - if (!this._dragActive) return; - const d = Number(cell.dataset?.day); - if (Number.isFinite(d)) { - this._dragEndDay = d; - setHighlight(); - } - }); - }); - - // Window-level pointerup so a drag that releases outside the cells - // still closes cleanly. - const onUp = () => { - if (!this._dragActive) return; - this._dragActive = false; - const lo = Math.min(this._dragStartDay ?? 0, this._dragEndDay ?? 0); - const hi = Math.max(this._dragStartDay ?? 0, this._dragEndDay ?? 0); - cells.forEach((c) => c.classList.remove('drag-range')); - window.removeEventListener('pointerup', onUp); - // Single click vs drag-select range: - this.#openAddEventForRange(lo, hi); - }; - window.addEventListener('pointerup', onUp); - } - - #onFormFieldInput(field, target) { - if (!this._formData) return; - let value; - if (target.type === 'checkbox') { - value = !!target.checked; - } else if (target.type === 'number') { - value = target.value === '' ? null : Number(target.value); - } else { - value = target.value ?? ''; - } - if (field === 'categories' && typeof value === 'string') { - value = value.split(',').map((s) => s.trim()).filter(Boolean); - } - this._formData = { ...this._formData, [field]: value }; - if (this._formErrors.length > 0) this._formErrors = []; - } - - #registerHooksOnce() { - if (this._hooksRegistered) return; - const rerender = () => this.#scheduleRerender(); - const subs = { - 'calendaria.calendarSwitched': rerender, - 'calendaria.remoteCalendarSwitch': rerender, - 'calendaria.calendarUpdated': rerender, - 'calendaria.dateTimeChange': rerender, - 'calendaria.dayChange': rerender, - 'calendaria.monthChange': rerender, - 'calendaria.yearChange': rerender, - 'calendaria.seasonChange': rerender, - 'calendaria.moonPhaseChange': rerender, - 'calendaria.restDayChange': rerender, - 'calendaria.remoteDateChange': rerender, - 'calendaria.noteCreated': rerender, - 'calendaria.noteUpdated': rerender, - 'calendaria.noteDeleted': rerender, - 'calendaria.weatherChange': rerender, - }; - for (const [name, fn] of Object.entries(subs)) { - try { Hooks.on(name, fn); this._hookHandlers[name] = fn; } - catch (err) { console.warn(`Sync Calendar | failed to register hook ${name}`, err); } - } - this._hooksRegistered = true; - } - - #unregisterHooks() { - for (const [name, fn] of Object.entries(this._hookHandlers)) { - try { Hooks.off(name, fn); } catch { /* best-effort */ } - } - this._hookHandlers = {}; - this._hooksRegistered = false; - } - - #scheduleRerender() { - if (this._renderPending) return; - this._renderPending = true; - setTimeout(() => { - this._renderPending = false; - if (this.rendered) this.render(false); - }, RENDER_THROTTLE_MS); - } - - /** @override */ - async close(options) { - this.#unregisterHooks(); - return super.close(options); - } - - // ------------------------------------------------------------------- - // Form lifecycle helpers - // ------------------------------------------------------------------- - - #enterAddMode(anchor) { - this._formMode = 'add'; - this._formNoteId = null; - this._formData = defaultFormForDate(anchor); - this._formErrors = []; - this._formBusy = false; - } - - #enterEditMode(noteId) { - const api = globalThis.CALENDARIA?.api; - let note = null; - try { note = api?.getNote?.(noteId) || null; } - catch (err) { console.warn('Sync Calendar | getNote failed', err); } - if (!note) { - ui.notifications.warn(game.i18n.localize('CHRONICLE.SyncCalendar.NoteForm.NotFound')); - return false; - } - this._formMode = 'edit'; - this._formNoteId = noteId; - this._formData = formFromNote(note); - this._formErrors = []; - this._formBusy = false; - return true; - } - - #exitForm() { - this._formMode = 'none'; - this._formNoteId = null; - this._formData = null; - this._formErrors = []; - this._formBusy = false; - } - - #openAddEventForRange(lo, hi) { - const month = this._selectedDate.month; - const year = this._selectedDate.year; - const anchor = lo === hi - ? { year, month, day: lo } - : { year, month, day: lo, endYear: year, endMonth: month, endDay: hi }; - this._selectedDate = { year, month, day: lo }; - this.#enterAddMode(anchor); - this.render(false); - } - - // ------------------------------------------------------------------- - // Actions - // ------------------------------------------------------------------- - - static async #onSelectMonth(_event, target) { - const month = Number(target?.dataset?.month ?? this._selectedDate?.month ?? 1); - this._selectedDate = { ...this._selectedDate, month, day: 1 }; - this.render(false); - } - - static async #onSelectYear(_event, target) { - const year = Number(target?.dataset?.year ?? this._selectedDate?.year ?? 0); - this._selectedDate = { ...this._selectedDate, year }; - this.render(false); - } - - static async #onJumpToday(_event, _target) { - let cdt = null; - try { cdt = globalThis.CALENDARIA?.api?.getCurrentDateTime?.() || null; } catch {} - if (cdt) { - this._selectedDate = { - year: Number(cdt.year ?? this._selectedDate?.year ?? 0), - month: Number(cdt.month ?? this._selectedDate?.month ?? 1), - day: Number(cdt.dayOfMonth ?? cdt.day ?? this._selectedDate?.day ?? 1), - }; - } - this.render(false); - } - - static async #onGotoMonth(_event, target) { - const month = Number(target?.dataset?.month ?? this._selectedDate?.month ?? 1); - this._selectedDate = { ...this._selectedDate, month, day: 1 }; - this._viewMode = 'month'; - this.render(false); - } - - static async #onBackToYear(_event, _target) { - this._viewMode = 'year'; - this.render(false); - } - - static async #onSelectDay(_event, target) { - const day = Number(target?.dataset?.day); - if (!Number.isFinite(day)) return; - this._selectedDate = { ...this._selectedDate, day }; - this.render(false); - } - - /** - * Open the form to add a new event on the selected day. Triggered by - * the "Add event" button in the day inspector. Drag-select uses a - * different path (`#wireDragSelect → #openAddEventForRange`). - */ - static async #onAddEvent(_event, _target) { - this.#enterAddMode(this._selectedDate); - this.render(false); - } - - static async #onEditEvent(_event, target) { - const noteId = target?.dataset?.noteId; - if (!noteId) return; - if (this.#enterEditMode(noteId)) this.render(false); - } - - static async #onCancelForm(_event, _target) { - this.#exitForm(); - this.render(false); - } - - /** - * Validate and save. Calls `CALENDARIA.api.createNote` or `updateNote` - * depending on the form mode. Defensive: API errors surface as inline - * `ui.notifications.error` + keep form open with errors visible. - */ - static async #onSaveEvent(_event, _target) { - if (this._formBusy) return; - if (!this._formData) return; - const errors = validateForm(this._formData); - // Also block save if the recurrence tree is structurally invalid. - if (this._formData.conditionTree) { - const recurrenceErrors = validateTree(this._formData.conditionTree); - // Calendaria's silent-ignore for non-group roots is the only - // truly fatal case; the rest are advisory. We surface RootMustBeGroup - // and EmptyGroup as save-blockers; advisory keys still render but - // don't block. - const blockers = recurrenceErrors.filter((k) => - k === 'CHRONICLE.SyncCalendar.Recurrence.Errors.RootMustBeGroup' || - k === 'CHRONICLE.SyncCalendar.Recurrence.Errors.EmptyGroup' || - k === 'CHRONICLE.SyncCalendar.Recurrence.Errors.NotAnObject', - ); - if (blockers.length > 0) errors.push(...blockers); - } - if (errors.length > 0) { - this._formErrors = errors; - this.render(false); - return; - } - const api = globalThis.CALENDARIA?.api; - if (!api) { - this._formErrors = ['CHRONICLE.SyncCalendar.NoteForm.Errors.ApiUnavailable']; - this.render(false); - return; - } - - let options; - try { options = noteOptionsFromForm(this._formData); } - catch (err) { - this._formErrors = ['CHRONICLE.SyncCalendar.NoteForm.Errors.OptionsBuild']; - console.warn('Sync Calendar | noteOptionsFromForm threw', err); - this.render(false); - return; - } - - this._formBusy = true; - this.render(false); - - try { - if (this._formMode === 'edit') { - if (typeof api.updateNote !== 'function') { - throw new Error('CALENDARIA.api.updateNote not available'); - } - await api.updateNote(this._formNoteId, options); - ui.notifications.info(game.i18n.localize('CHRONICLE.SyncCalendar.NoteForm.Updated')); - } else { - if (typeof api.createNote !== 'function') { - throw new Error('CALENDARIA.api.createNote not available'); - } - await api.createNote(options); - ui.notifications.info(game.i18n.localize('CHRONICLE.SyncCalendar.NoteForm.Created')); - } - this.#exitForm(); - } catch (err) { - console.error('Sync Calendar | save failed', err); - ui.notifications.error(game.i18n.localize('CHRONICLE.SyncCalendar.NoteForm.SaveFailed')); - this._formErrors = ['CHRONICLE.SyncCalendar.NoteForm.Errors.SaveFailed']; - } finally { - this._formBusy = false; - this.render(false); - } - } - - /** - * Delete an existing event. Confirmation prompt before the API call. - * Triggered from the day inspector list (data-action="delete-event" - * + data-note-id). - */ - static async #onDeleteEvent(_event, target) { - const noteId = target?.dataset?.noteId; - if (!noteId) return; - const api = globalThis.CALENDARIA?.api; - if (!api?.deleteNote) { - ui.notifications.error(game.i18n.localize('CHRONICLE.SyncCalendar.NoteForm.Errors.ApiUnavailable')); - return; - } - const confirmed = await Dialog?.confirm?.({ - title: game.i18n.localize('CHRONICLE.SyncCalendar.NoteForm.DeleteConfirmTitle'), - content: `

${game.i18n.localize('CHRONICLE.SyncCalendar.NoteForm.DeleteConfirmBody')}

`, - }); - if (!confirmed) return; - try { - await api.deleteNote(noteId); - ui.notifications.info(game.i18n.localize('CHRONICLE.SyncCalendar.NoteForm.Deleted')); - if (this._formNoteId === noteId) this.#exitForm(); - this.render(false); - } catch (err) { - console.error('Sync Calendar | delete failed', err); - ui.notifications.error(game.i18n.localize('CHRONICLE.SyncCalendar.NoteForm.DeleteFailed')); - } - } - - static async #onFocusTarget(_event, target) { - const focus = target?.dataset?.focus || ''; - if (!focus) return; - if (game?.user?.isGM === false) return; - // Structure focus is read-only; no navigation is wired. - } - - /** - * Apply a recurrence preset. The button carries `data-preset` plus a - * JSON-encoded `data-params` blob; we parse and run it through the - * pure builder. On bad params, the preset button reports inline. - */ - static async #onApplyRecurrencePreset(_event, target) { - if (!this._formData) return; - const presetId = target?.dataset?.preset || ''; - let params = {}; - try { params = JSON.parse(target?.dataset?.params || '{}'); } - catch { params = {}; } - let tree = null; - try { tree = treeFromPreset(presetId, params); } - catch (err) { - console.warn('Sync Calendar | bad preset', err); - return; - } - if (!tree) { - ui.notifications?.warn(game.i18n.localize('CHRONICLE.SyncCalendar.Recurrence.PresetBadParams')); - return; - } - this._formData = { ...this._formData, conditionTree: tree }; - if (this._formErrors.length > 0) this._formErrors = []; - this.render(false); - } - - static async #onClearRecurrence(_event, _target) { - if (!this._formData) return; - this._formData = { ...this._formData, conditionTree: null }; - this.render(false); - } - - /** - * POST a Calendaria calendar to Chronicle's per-campaign create endpoint. - * Graceful degradation: 404/405 means the endpoint isn't available on this - * Chronicle deployment — surface a clear message instead of a generic API - * error. - */ - static async #onImportCalendar(_event, target) { - const calendarId = target?.dataset?.calendarId || ''; - if (!calendarId) return; - if (this._importBusyByCalendarId.has(calendarId)) return; - const api = globalThis.CALENDARIA?.api; - if (!api?.getCalendar) { - this._lastImportResult = { ok: false, key: 'CHRONICLE.SyncCalendar.Import.CalendariaUnavailable' }; - this.render(false); - return; - } - let calObj = null; - try { calObj = api.getCalendar(calendarId) || null; } - catch (err) { - console.warn('Sync Calendar | getCalendar failed', err); - } - if (!calObj) { - this._lastImportResult = { ok: false, key: 'CHRONICLE.SyncCalendar.Import.CalendarNotFound', detail: calendarId }; - this.render(false); - return; - } - - let transformed; - try { transformed = transformCalendariaCalendar(calObj); } - catch (err) { - console.warn('Sync Calendar | transform failed', err); - this._lastImportResult = { ok: false, key: 'CHRONICLE.SyncCalendar.Import.TransformFailed' }; - this.render(false); - return; - } - - const apiClient = this.#getChronicleApiClient(); - if (!apiClient?.post) { - this._lastImportResult = { ok: false, key: 'CHRONICLE.SyncCalendar.Import.ChronicleUnreachable' }; - this.render(false); - return; - } - - this._importBusyByCalendarId.add(calendarId); - this.render(false); - - try { - await apiClient.post('/calendar', transformed.payload); - this._chronicleCalendarState = 'present'; - this._lastImportResult = { - ok: true, - key: 'CHRONICLE.SyncCalendar.Import.Success', - detail: calObj.name || calendarId, - }; - ui.notifications?.info(game.i18n.localize('CHRONICLE.SyncCalendar.Import.Success')); - } catch (err) { - const msg = String(err?.message || ''); - if (/\b404\b|\b405\b/.test(msg)) { - this._chronicleCalendarState = 'no-create-api'; - this._lastImportResult = { ok: false, key: 'CHRONICLE.SyncCalendar.Import.ChronicleTooOld' }; - } else { - this._lastImportResult = { ok: false, key: 'CHRONICLE.SyncCalendar.Import.PostFailed', detail: msg.slice(0, 200) }; - console.error('Sync Calendar | import POST failed', err); - } - } finally { - this._importBusyByCalendarId.delete(calendarId); - this.render(false); - } - } - - /** - * Re-probe Chronicle's calendar surface. Surfaced as a small refresh - * button on the empty-state banner so the operator can confirm the - * endpoint became available without closing the editor. - */ - static async #onRecheckChronicle(_event, _target) { - this._chronicleCalendarState = 'unknown'; - this._lastImportResult = null; - this.render(false); - } - - // --------------------------------------------------------------------------- - // Diagnostics & maintenance - // --------------------------------------------------------------------------- - - /** - * Copy a rich, AI-pasteable diagnostics report (versions, structure, ALL - * validation findings, the selected day's notes/moons/weather, the - * CALENDARIA.api capability probe, recent sync errors, and sync settings) to - * the clipboard. This is the "dump everything for Claude" button. - */ - static async #onCopyDiagnostics(_event, _target) { - try { - const text = this.#collectDiagnostics(); - await game.clipboard.copyPlainText(text); - ui.notifications?.info(game.i18n.localize('CHRONICLE.SyncCalendar.Diagnostics.Copied')); - } catch (err) { - console.error('Sync Calendar | diagnostics copy failed', err); - ui.notifications?.error(game.i18n.localize('CHRONICLE.SyncCalendar.Diagnostics.CopyFailed')); - } - } - - /** - * Gather the diagnostics input from the last-rendered context plus live - * probes (api methods, versions, settings, Chronicle error log) and render it - * via the pure {@link buildCalendarDiagnostics} builder. - * @returns {string} - */ - #collectDiagnostics() { - const api = globalThis.CALENDARIA?.api; - const diag = this._lastDiag || {}; - const chronicleModule = game.modules.get?.('chronicle-sync'); - const chronicleApi = chronicleModule?.api?.getAPI?.(); - - let recentErrors = []; - try { - const log = chronicleApi?.getErrorLog?.() || []; - recentErrors = (Array.isArray(log) ? log : []).slice(0, 25).map((e) => ({ - time: e.time || e.timestamp || '', - message: e.message || e.error || (typeof e === 'string' ? e : ''), - endpoint: e.endpoint || e.url || e.path || '', - status: e.status || e.code || '', - })); - } catch { /* error log is best-effort */ } - - const probe = (name) => typeof api?.[name] === 'function'; - - return buildCalendarDiagnostics({ - generatedAt: new Date().toISOString(), - degraded: !!diag.degraded, - versions: { - module: chronicleModule?.version || '', - calendaria: this._calendariaVersion || '', - schema: String(SCHEMA_VERSION), - foundry: game.version || game.release?.version || '', - system: game.system?.title || game.system?.id || '', - systemId: game.system?.id || '', - }, - calendar: diag.cal, - syncStatus: { - calendarModule: game.modules.get?.('calendaria')?.active ? 'calendaria' - : (game.modules.get?.('foundryvtt-simple-calendar')?.active ? 'simple-calendar' : 'none'), - calendarSyncEnabled: getSetting('syncCalendar'), - syncEnabled: getSetting('syncEnabled'), - thisCalendarExcluded: this.#isCalendarSyncExcluded(diag.cal?.id), - }, - structureCounts: diag.structureCounts, - apiMethods: { - getActiveCalendar: probe('getActiveCalendar'), - getCurrentDateTime: probe('getCurrentDateTime'), - getNotesForDate: probe('getNotesForDate'), - getNotesForMonth: probe('getNotesForMonth'), - getAllMoonPhases: probe('getAllMoonPhases'), - getCurrentSeason: probe('getCurrentSeason'), - getCurrentWeather: probe('getCurrentWeather'), - getWeatherForDate: probe('getWeatherForDate'), - getWeatherForPeriod: probe('getWeatherForPeriod'), - }, - currentDateTime: diag.currentDateTime, - selectedDate: diag.selectedDate, - dayDetail: diag.dayDetail, - findings: (diag.findings || []).map((f) => ({ - severity: f.severity, code: f.code, message: f.message, fixHint: f.fix_hint || '', - })), - recentErrors, - settings: { - syncEnabled: getSetting('syncEnabled'), - syncCalendar: getSetting('syncCalendar'), - syncJournals: getSetting('syncJournals'), - syncCharacters: getSetting('syncCharacters'), - conflictResolution: getSetting('conflictResolution'), - detectedSystem: getSetting('detectedSystem'), - }, - raw: { - weatherForDate: diag.dayDetail?._weatherForDateRaw ?? null, - weatherCurrent: diag.dayDetail?._weatherCurrentRaw ?? null, - }, - }); - } - - /** - * Toggle whether the active calendar is synced to Chronicle. Stores excluded - * calendar ids in the `calendarSyncExclusions` setting so the operator can - * keep some Calendaria calendars local-only. CalendarSync consults this list - * before pushing date/note changes. Falls back to toggling the global - * `syncCalendar` switch when no active calendar id is known. - */ - static async #onToggleCalendarSync(_event, _target) { - const id = this._lastDiag?.cal?.id || this._activeCalendarId || ''; - if (!id) { - // No calendar id — flip the global switch instead. - await setSetting('syncCalendar', !getSetting('syncCalendar')); - this.render(false); - return; - } - const excluded = new Set(getCalendarSyncExclusions()); - if (excluded.has(id)) excluded.delete(id); - else excluded.add(id); - await setSetting('calendarSyncExclusions', JSON.stringify([...excluded])); - const nowExcluded = excluded.has(id); - ui.notifications?.info(game.i18n.format('CHRONICLE.SyncCalendar.SyncToggle.Notify', { - state: game.i18n.localize(nowExcluded - ? 'CHRONICLE.SyncCalendar.SyncToggle.Off' - : 'CHRONICLE.SyncCalendar.SyncToggle.On'), - })); - this.render(false); - } - - /** Whether the given calendar id is excluded from Chronicle sync. */ - #isCalendarSyncExcluded(calendarId) { - if (!calendarId) return false; - return getCalendarSyncExclusions().includes(calendarId); - } - - /** - * Clean up Calendaria/SimpleCalendar note JournalEntries that an earlier - * build wrongly synced to Chronicle as entities ("Characters"). Unlinks each - * locally FIRST (so the resulting `entity.deleted` can't cascade back and - * delete the calendar note), then deletes the bogus entity in Chronicle. - * Confirmed before running; real character Actors are never touched. - */ - static async #onCleanupCalendarNotes(_event, _target) { - const chronicleApi = game.modules.get?.('chronicle-sync')?.api?.getAPI?.(); - if (!chronicleApi) { - ui.notifications?.error(game.i18n.localize('CHRONICLE.SyncCalendar.Cleanup.NoApi')); - return; - } - - const targets = (game.journal?.contents || []).filter( - (j) => j.getFlag('chronicle-sync', 'entityId') && isCalendarNoteJournal(j), - ); - if (targets.length === 0) { - ui.notifications?.info(game.i18n.localize('CHRONICLE.SyncCalendar.Cleanup.None')); - return; - } - - const names = targets.map((j) => j.name).filter(Boolean).join(', '); - const confirmed = await confirmDialog({ - title: game.i18n.localize('CHRONICLE.SyncCalendar.Cleanup.Title'), - content: `

${game.i18n.format('CHRONICLE.SyncCalendar.Cleanup.Confirm', { count: targets.length })}

` - + `

${foundry.utils.escapeHTML?.(names) ?? names}

`, - defaultYes: false, - }); - if (!confirmed) return; - - let removed = 0; - for (const journal of targets) { - const entityId = journal.getFlag('chronicle-sync', 'entityId'); - try { - // 1) Unlink locally FIRST so entity.deleted can't cascade-delete the note. - await journal.unsetFlag('chronicle-sync', 'entityId'); - await journal.unsetFlag('chronicle-sync', 'lastSync'); - await journal.unsetFlag('chronicle-sync', 'chronicleUpdatedAt'); - // 2) Delete the bogus entity in Chronicle. - await chronicleApi.delete(`/entities/${entityId}`); - removed++; - } catch (err) { - console.warn('Sync Calendar | cleanup failed for', journal?.name, err); - } - } - ui.notifications?.info(game.i18n.format('CHRONICLE.SyncCalendar.Cleanup.Done', { count: removed })); - this.render(false); - } -} - -/** - * Pre-built option lists for the form's ` - {{localize "CHRONICLE.Wizard.CalendarPull"}} - - - - - - - {{!-- ================================================================= --}} - {{!-- STEP 7: Review & Import --}} - {{!-- ================================================================= --}} -

{{localize "CHRONICLE.Wizard.Steps.Review"}}

{{#if importRunning}} diff --git a/templates/sync-calendar.hbs b/templates/sync-calendar.hbs deleted file mode 100644 index 20855dc..0000000 --- a/templates/sync-calendar.hbs +++ /dev/null @@ -1,621 +0,0 @@ -{{!-- - Sync Calendar — main template (PR 2: read + write). - - Receives the view-model from SyncCalendarApplication._prepareContext(). - Top-level mode branches: - - degraded: Calendaria missing / incompatible / no active calendar - - viewMode=year: 3-pane year overview (month cards in center) - - viewMode=month: 3-pane month day-grid (day cells in center) - Right-pane (rail-right) renders the day inspector OR the note form - depending on `formView`. - - Actions are wired via data-action="..." attributes that ApplicationV2 - binds to the actions map in DEFAULT_OPTIONS. Form-field inputs use - data-form-field="..." attributes that the application's #wireFormInputs - hook (in _onRender) listens to. - - Single-root wrapper (`
`) is required: - ApplicationV2's `_parsePartHTML` rejects any PARTS template that emits - more than one root HTML element across any branch of any conditional - ("Template part 'body' must render a single HTML element"). The - `else` branch below has three siblings (header + main + footer) and - must be wrapped. See F-PR3-4 in `.ai.md` and the CI guard at - `tools/test-template-roots.mjs`. ---}} - -
-{{#if degraded}} -
- -

{{localize degradedReason}}

- {{#if calendariaVersion}} -

{{localize "CHRONICLE.SyncCalendar.CalendariaVersion"}}: {{calendariaVersion}}

- {{/if}} - - {{#if importBanner}} - {{!-- Import banner shown even in degraded state if Chronicle has no calendar --}} -
- {{#if (eq importBanner.state "probing")}} -

{{localize "CHRONICLE.SyncCalendar.Import.Probing"}}

- {{else if (eq importBanner.state "absent")}} -

{{localize "CHRONICLE.SyncCalendar.Import.HeaderAbsent"}}

-

{{localize "CHRONICLE.SyncCalendar.Import.HintAbsent"}}

- {{#if importBanner.calendars.length}} -
    - {{#each importBanner.calendars as |cal|}} -
  • -
    - {{cal.name}} - {{cal.daysPerYear}} {{localize "CHRONICLE.SyncCalendar.DaysSuffix"}} · {{cal.monthCount}} mo · {{cal.weekdayCount}} wk · {{cal.moonCount}} moons - {{#if cal.skipNotice}}{{cal.skipNotice}}{{/if}} -
    - -
  • - {{/each}} -
- {{else}} -

{{localize "CHRONICLE.SyncCalendar.Import.NoCalendariaCalendars"}}

- {{/if}} - {{else if (eq importBanner.state "no-create-api")}} -

{{localize "CHRONICLE.SyncCalendar.Import.ChronicleTooOld"}}

- {{else if (eq importBanner.state "unreachable")}} -

{{localize "CHRONICLE.SyncCalendar.Import.Unreachable"}}

- {{else if (eq importBanner.state "auth")}} -

{{localize "CHRONICLE.SyncCalendar.Import.NotAuthorized"}}

- {{/if}} - {{#if importBanner.lastResult}} -

- {{localize importBanner.lastResult.key}} - {{#if importBanner.lastResult.detail}} — {{importBanner.lastResult.detail}}{{/if}} -

- {{/if}} - -
- {{/if}} -
-{{else}} -
-
-

{{cal.name}}

- {{#if cal.description}} -

{{cal.description}}

- {{/if}} -
-
- - - {{localize "CHRONICLE.SyncCalendar.SelectedDate"}}: - {{selectedDate.day}} {{dayDetail.monthName}}, {{selectedDate.year}} - - {{#if dayDetail.season}} - - {{dayDetail.season.name}} - - {{/if}} - {{#if findingTotal}} - - - {{findingTotal}} {{localize "CHRONICLE.SyncCalendar.Findings.Pill"}} - - {{/if}} - {{#if (eq viewMode "month")}} - - {{/if}} - -
-
- - {{#if importBanner}} - {{!-- Non-degraded but Chronicle still has no calendar — surface the - import banner above the main grid so the editor stays usable - for browsing while the operator decides which calendar to import. --}} -
- {{#if (eq importBanner.state "probing")}} -

{{localize "CHRONICLE.SyncCalendar.Import.Probing"}}

- {{else if (eq importBanner.state "absent")}} -

{{localize "CHRONICLE.SyncCalendar.Import.HeaderAbsent"}}

-

{{localize "CHRONICLE.SyncCalendar.Import.HintAbsent"}}

- {{#if importBanner.calendars.length}} -
    - {{#each importBanner.calendars as |cal|}} -
  • -
    - {{cal.name}} - {{cal.daysPerYear}} {{localize "CHRONICLE.SyncCalendar.DaysSuffix"}} · {{cal.monthCount}} mo · {{cal.weekdayCount}} wk · {{cal.moonCount}} moons - {{#if cal.skipNotice}}{{cal.skipNotice}}{{/if}} -
    - -
  • - {{/each}} -
- {{/if}} - {{else if (eq importBanner.state "no-create-api")}} -

{{localize "CHRONICLE.SyncCalendar.Import.ChronicleTooOld"}}

- {{else if (eq importBanner.state "unreachable")}} -

{{localize "CHRONICLE.SyncCalendar.Import.Unreachable"}}

- {{else if (eq importBanner.state "auth")}} -

{{localize "CHRONICLE.SyncCalendar.Import.NotAuthorized"}}

- {{/if}} - {{#if importBanner.lastResult}} -

- {{localize importBanner.lastResult.key}} - {{#if importBanner.lastResult.detail}} — {{importBanner.lastResult.detail}}{{/if}} -

- {{/if}} -
- {{/if}} - -
- - {{!-- LEFT RAIL: year navigator + structure summary + validation panel --}} - - - {{!-- CENTER: year overview OR month day-grid --}} -
- {{#if (eq viewMode "month")}} - {{!-- MONTH VIEW: day-grid (click + drag-select) --}} -
-

{{monthDetail.monthName}} ({{monthDetail.daysInMonth}} {{localize "CHRONICLE.SyncCalendar.DaysSuffix"}})

- {{localize "CHRONICLE.SyncCalendar.MonthViewHint"}} -
-
- {{#each monthDetail.days as |d|}} - - {{/each}} -
- {{#if moonStrip}} -
-

- {{localize "CHRONICLE.SyncCalendar.MoonStrip.Heading"}} -

- {{#each moonStrip.moons as |m|}} -
- {{m.name}}{{#if m.isRandomized}} {{/if}} -
- {{#each m.days as |d|}} - - {{/each}} -
-
- {{/each}} - {{#if moonStrip.convergenceDays.length}} -

- - {{localize "CHRONICLE.SyncCalendar.MoonStrip.ConvergenceLabel"}} - {{#each moonStrip.convergenceDays as |d|}}{{d}}{{#unless @last}}, {{/unless}}{{/each}} -

- {{/if}} -

{{localize "CHRONICLE.SyncCalendar.MoonStrip.ClickHint"}}

-
- {{/if}} -

{{localize "CHRONICLE.SyncCalendar.MonthViewFootnote"}}

- {{else}} - {{!-- YEAR VIEW: month cards --}} -
-

{{localize "CHRONICLE.SyncCalendar.YearOverview"}}

- {{localize "CHRONICLE.SyncCalendar.YearOverviewHint"}} -
-
- {{#each yearOverview as |m|}} -
-
- {{m.ordinal}}. - {{m.name}} - {{#if m.abbreviation}}{{m.abbreviation}}{{/if}} -
-
- {{m.days}} {{localize "CHRONICLE.SyncCalendar.DaysSuffix"}} - {{#if m.noteCount}}{{m.noteCount}} {{localize "CHRONICLE.SyncCalendar.NotesSuffix"}}{{/if}} - {{#if m.festivalCount}}★ {{m.festivalCount}}{{/if}} -
- -
- {{/each}} -
-

{{localize "CHRONICLE.SyncCalendar.YearOverviewFootnote"}}

- {{/if}} -
- - {{!-- RIGHT: day inspector OR note form --}} - - -
- -
- - -
-{{/if}} -
diff --git a/templates/sync-dashboard.hbs b/templates/sync-dashboard.hbs index 6c40907..f3b75b0 100644 --- a/templates/sync-dashboard.hbs +++ b/templates/sync-dashboard.hbs @@ -863,51 +863,8 @@ {{!-- CALENDAR TAB --}} {{!-- ================================================================= --}}
- {{!-- FM-CAL-DASHBOARD-LINK: surface the Sync Calendar editor. The - dashboard's Calendar tab is a status panel; the Sync Calendar - app is the full editor (import / events / moon strip / - validation). Surface it here so the operator doesn't have to - dig through Game Settings → Module Settings to find it. --}} -
- -

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

-
{{#if calendar.available}} - {{#if calendar.isPaused}} -
- -
- Calendar sync paused — structure mismatch. -
{{calendar.syncStateDetail}}
- {{!-- THE REMEDY IS AN EDIT. "Import or author the matching calendar - in Chronicle" was unreachable both ways: the create endpoint - 409s whenever the campaign has any calendar (always true here), - and an authored calendar is not is_default with no control - anywhere to make it one, so this module keeps being served the - old one. Editing either calendar's months/weekdays IS one page - away. See tools/test-calendar-mismatch-remedy.mjs. --}} -
Edit either calendar so the two agree — in Chronicle, Calendar Settings → Months / Weekdays; in Foundry, your calendar module. Journals, characters, and maps still sync.
-
-
- {{else if calendar.isIncompatible}} -
- -
- Calendar structures don’t match. -
{{calendar.syncStateDetail}}
-
The dates below aren’t comparable across the wire. Sync will pause on the next world reload — edit either calendar so the two agree, in Chronicle’s Calendar Settings (Months / Weekdays) or in your Foundry calendar module.
-
-
- {{/if}}
-
- - Calendar Module: {{calendar.module}} -
-
Chronicle
@@ -919,61 +876,11 @@ {{calendar.chronicleDate.hour}}:{{#if (lt calendar.chronicleDate.minute 10)}}0{{/if}}{{calendar.chronicleDate.minute}}
- -
-
Foundry
- {{#if calendar.localDate}} -
- Day {{calendar.localDate.day}}, Month {{calendar.localDate.month}} -
-
Year {{calendar.localDate.year}}
-
- {{calendar.localDate.hour}}:{{#if (lt calendar.localDate.minute 10)}}0{{/if}}{{calendar.localDate.minute}} -
- {{else}} -
Unable to read
- {{/if}} -
-
- -
- {{#if calendar.isPaused}} - Sync Paused - {{else if calendar.isIncompatible}} - Incompatible Structures - {{else if calendar.isStructureChanged}} - {{!-- FM-SYNC-SUBRESOURCES-P1: Chronicle broadcast structure.updated - this session; the re-compare came back compatible. Advisory — - sync keeps running, but the badge stops claiming a clean - "In Sync" the operator hasn't eyeballed. The Foundry calendar - was NOT modified. --}} - Structure Changed — Re-check - {{else if calendar.isDrift}} - {{#if (eq calendar.syncDirection "chronicle-ahead")}} - Date Drift — Chronicle Ahead - {{else if (eq calendar.syncDirection "foundry-ahead")}} - Date Drift — Foundry Ahead - {{else}} - Out of Sync - {{/if}} - {{else}} - In Sync - {{/if}} -
- -
- -
{{!-- FM-SYNC-SUBRESOURCES-P1: Chronicle world state. Weather, season, era and moon phases folded from the WebSocket stream this - session. Read-only — nothing here is written back into the - Foundry calendar. --}} + session. Read-only. --}}
@@ -1026,12 +933,6 @@ {{else if calendar.noCampaignCalendar}}

No calendar configured for this campaign in Chronicle.

- {{else if calendar.enabled}} - {{#unless calendar.module}} - -

No calendar module detected.

-

Install Calendaria or Simple Calendar to enable calendar sync.

- {{/unless}} {{else}}

Calendar sync is disabled. Enable it in Module Settings.

@@ -1278,15 +1179,6 @@
- {{!-- Calendar Diagnostics Export --}} -

Calendar Diagnostics

-

{{localize "CHRONICLE.Dashboard.StatusTab.CopyCalendarDiagnosticsHint"}}

-
- - -
diff --git a/tools/test-applied-date-confirm.mjs b/tools/test-applied-date-confirm.mjs index 7f7c061..a33b19b 100644 --- a/tools/test-applied-date-confirm.mjs +++ b/tools/test-applied-date-confirm.mjs @@ -4,14 +4,8 @@ * 1. `_applied-date-confirm.mjs`'s pure helpers: 404/405 classification, * the once-per-session debug-log tolerance, and confirmAppliedDate's * request shape + non-throwing swallow behavior. - * 2. `calendar-sync.mjs::_setLocalDate` reports whether it actually - * invoked a local calendar-module setter (Calendaria modern/legacy, - * SimpleCalendar) without throwing. - * 3. Both apply call sites — the poll path (`onInitialSync`) and the - * WebSocket-driven path (`_onChronicaleDateAdvanced`) — confirm ONLY on - * real apply-success: never on a bare fetch, never on apply failure or - * a no-op (no calendar-module setter available). - * 4. The confirm POST does not re-enter the `_syncDepth` reentrancy guard. + * 2. `calendar-sync.mjs` posts no confirmation: it applies no date to + * Foundry, so there is nothing to confirm yet. */ import test from 'node:test'; @@ -41,7 +35,7 @@ const { CalendarSync } = await import('../scripts/calendar-sync.mjs'); function makeCalendarSync(overrides) { return Object.assign( Object.create(CalendarSync.prototype), - { _hasModernCalendariaApi: true, _syncDepth: 0, _calendarSyncDisabled: false, _isActiveCalendarExcluded: () => false }, + {}, overrides, ); } @@ -169,194 +163,57 @@ test('confirmAppliedDate: null api or date is a no-op, never throws', async () = await assert.doesNotReject(confirmAppliedDate(makeApi(), null)); }); -// ── calendar-sync.mjs: _setLocalDate reports whether a real apply happened ── +// ── calendar-sync.mjs: nothing is applied, so nothing is confirmed ───────── +// TODO(#95): once the built-in calendar applies Chronicle's date, confirm it +// here only after a real apply. -test('_setLocalDate: Calendaria modern API — setDateTime invoked, resolves true', async () => { - let called = null; - globalThis.CALENDARIA = { api: { setDateTime: async (d) => { called = d; } } }; - const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true }); - const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1, hour: 8, minute: 0 }); - assert.equal(applied, true); - assert.deepEqual(called, { year: 1492, month: 3, day: 1, hour: 8, minute: 0 }); -}); - -test('_setLocalDate: Calendaria legacy API — setDate invoked, resolves true', async () => { - globalThis.game.Calendaria = { setDate: async () => {} }; - const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: false }); - const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); - assert.equal(applied, true); - delete globalThis.game.Calendaria; -}); - -test('_setLocalDate: Calendaria detected but neither API surface present — no-op, resolves false', async () => { - globalThis.CALENDARIA = undefined; - delete globalThis.game.Calendaria; - const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: false }); - const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); - assert.equal(applied, false); -}); - -test('_setLocalDate: Calendaria setDateTime throws — logs the error, resolves false', async () => { - globalThis.CALENDARIA = { api: { setDateTime: async () => { throw new Error('boom'); } } }; - const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true }); - const original = console.error; - let logged = null; - console.error = (...args) => { logged = args; }; - let applied; - try { - applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); - } finally { - console.error = original; - } - assert.equal(applied, false); - assert.ok(logged); -}); - -test('_setLocalDate: SimpleCalendar — setDate invoked (and awaited), resolves true', async () => { - let called = null; - globalThis.SimpleCalendar = { api: { setDate: async (d) => { called = d; } } }; - const cs = makeCalendarSync({ _calendarModule: 'simple-calendar' }); - const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1, hour: 8, minute: 0 }); - assert.equal(applied, true); - assert.deepEqual(called, { year: 1492, month: 2, day: 0, hour: 8, minute: 0, seconds: 0 }); -}); - -test('_setLocalDate: SimpleCalendar detected but no setDate — no-op, resolves false', async () => { - globalThis.SimpleCalendar = { api: {} }; - const cs = makeCalendarSync({ _calendarModule: 'simple-calendar' }); - const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); - assert.equal(applied, false); -}); +function makeSync({ chronicleCalendar, api } = {}) { + return makeCalendarSync({ + _api: api ?? makeApi({ getImpl: (path) => (path === '/calendar' ? chronicleCalendar : null) }), + }); +} -test('_setLocalDate: no calendar module detected at all — resolves false', async () => { - const cs = makeCalendarSync({ _calendarModule: null }); - const applied = await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); - assert.equal(applied, false); +test('onInitialSync: caches Chronicle’s calendar and posts no confirm (no apply happened)', async () => { + const chronicleCalendar = { current_year: 1492, current_month: 3, current_day: 1, current_hour: 8, current_minute: 0, months: [] }; + const cs = makeSync({ chronicleCalendar }); + globalThis.game.settings.get = () => true; // syncCalendar on + assert.equal(await cs.onInitialSync(), true); + assert.deepEqual(cs.chronicleDate, { year: 1492, month: 3, day: 1, hour: 8, minute: 0 }); + assert.equal(cs._api.posts.length, 0); }); -test('_setLocalDate: the _syncDepth reentrancy guard still wraps the call (increments then decrements)', async () => { - globalThis.CALENDARIA = { - api: { - setDateTime: async function () { - assert.equal(this === undefined ? cs._syncDepth : cs._syncDepth, 1, '_syncDepth is 1 while the setter runs'); - }, - }, - }; - const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true, _syncDepth: 0 }); - assert.equal(cs._syncDepth, 0); - await cs._setLocalDate({ year: 1492, month: 3, day: 1 }); - assert.equal(cs._syncDepth, 0, '_syncDepth unwound after the call'); -}); - -// ── _onChronicaleDateAdvanced (WebSocket-driven apply path) ──────────────── - -test('_onChronicaleDateAdvanced: confirms ONLY after a real apply', async () => { - globalThis.CALENDARIA = { api: { setDateTime: async () => {} } }; - const api = makeApi(); - const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true, _api: api }); - await cs._onChronicaleDateAdvanced({ year: 1492, month: 3, day: 1, hour: 8, minute: 0 }); - assert.deepEqual(api.posts, [ - { path: '/calendar/date/confirm', body: { year: 1492, month: 3, day: 1 } }, - ]); -}); - -test('_onChronicaleDateAdvanced: does NOT confirm when apply is a no-op (no calendar-module setter)', async () => { - globalThis.CALENDARIA = undefined; - delete globalThis.game.Calendaria; - const api = makeApi(); - const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: false, _api: api }); - await cs._onChronicaleDateAdvanced({ year: 1492, month: 3, day: 1 }); - assert.equal(api.posts.length, 0); +test('onInitialSync: no Chronicle calendar configured — resolves false, no confirm', async () => { + const cs = makeSync({ chronicleCalendar: null }); + globalThis.game.settings.get = () => true; + assert.equal(await cs.onInitialSync(), false); + assert.equal(cs._api.posts.length, 0); }); -test('_onChronicaleDateAdvanced: does NOT confirm when the local setter throws (apply failure)', async () => { - globalThis.CALENDARIA = { api: { setDateTime: async () => { throw new Error('boom'); } } }; - const api = makeApi(); - const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true, _api: api }); +test('onInitialSync: a failed fetch resolves false without throwing', async () => { + const api = makeApi({ getImpl: () => { throw new Error('Chronicle API error 500: boom'); } }); + const cs = makeSync({ api }); + globalThis.game.settings.get = () => true; const original = console.error; console.error = () => {}; try { - await cs._onChronicaleDateAdvanced({ year: 1492, month: 3, day: 1 }); + assert.equal(await cs.onInitialSync(), false); } finally { console.error = original; } - assert.equal(api.posts.length, 0); -}); - -test('_onChronicaleDateAdvanced: null data is a no-op (no apply, no confirm)', async () => { - const api = makeApi(); - const cs = makeCalendarSync({ _calendarModule: 'calendaria', _api: api }); - await cs._onChronicaleDateAdvanced(null); - assert.equal(api.posts.length, 0); -}); - -test('_onChronicaleDateAdvanced: confirm never re-enters the _syncDepth reentrancy guard', async () => { - globalThis.CALENDARIA = { api: { setDateTime: async () => {} } }; - const depths = []; - const api = makeApi({ - postImpl(path, body) { - // The confirm POST fires strictly after _setLocalDate's finally block - // has unwound the guard back to 0 — a confirm call that re-entered the - // guard (or ran DURING the apply) would observe a nonzero depth here. - depths.push(cs._syncDepth); - return null; - }, - }); - const cs = makeCalendarSync({ _calendarModule: 'calendaria', _hasModernCalendariaApi: true, _api: api, _syncDepth: 0 }); - await cs._onChronicaleDateAdvanced({ year: 1492, month: 3, day: 1 }); - assert.deepEqual(depths, [0]); - assert.equal(cs._syncDepth, 0); }); -// ── onInitialSync (poll apply path) ───────────────────────────────────────── -// Uses the simple-calendar module so the Calendaria-only structure-mismatch -// guard (B-R2) never engages — out of scope here, covered by -// tools/test-calendar-sync-hotfix.mjs. - -function makeInitialSyncHarness({ chronicleCalendar, setDateImpl, postImpl } = {}) { - const api = makeApi({ - getImpl: (path) => (path === '/calendar' ? chronicleCalendar : null), - postImpl, - }); - globalThis.SimpleCalendar = { api: { setDate: setDateImpl ?? (async () => {}) } }; - const cs = makeCalendarSync({ - _calendarModule: 'simple-calendar', - _api: api, - getSetting: undefined, - }); - return { cs, api }; -} - -test('onInitialSync: confirms the applied date after a successful poll-path apply', async () => { - const chronicleCalendar = { - current_year: 1492, current_month: 3, current_day: 1, current_hour: 8, current_minute: 0, - months: [], - }; - const { cs, api } = makeInitialSyncHarness({ chronicleCalendar }); - // getSetting('syncCalendar') is read at the top of onInitialSync via the - // module-level import; stub it through globalThis.game.settings.get. - globalThis.game.settings.get = () => true; - await cs.onInitialSync(); - assert.deepEqual(api.posts, [ - { path: '/calendar/date/confirm', body: { year: 1492, month: 3, day: 1 } }, - ]); -}); - -test('onInitialSync: does NOT confirm when the poll-path apply is a no-op', async () => { - const chronicleCalendar = { - current_year: 1492, current_month: 3, current_day: 1, current_hour: 8, current_minute: 0, - months: [], - }; - const { cs, api } = makeInitialSyncHarness({ chronicleCalendar, setDateImpl: undefined }); - globalThis.SimpleCalendar = { api: {} }; // no setDate — apply is a no-op - globalThis.game.settings.get = () => true; - await cs.onInitialSync(); - assert.equal(api.posts.length, 0); +test('onInitialSync: syncCalendar off — no request at all', async () => { + const cs = makeSync({ chronicleCalendar: {} }); + globalThis.game.settings.get = () => false; + assert.equal(await cs.onInitialSync(), false); + assert.equal(cs._api.gets.length, 0); }); -test('onInitialSync: no Chronicle calendar configured — no apply attempted, no confirm', async () => { - const { cs, api } = makeInitialSyncHarness({ chronicleCalendar: null }); +test('calendar.date.advanced: updates the cached date and posts no confirm', async () => { + const cs = makeSync({}); + cs._chronicleCalendar = { current_year: 1492, current_month: 3, current_day: 1 }; globalThis.game.settings.get = () => true; - await cs.onInitialSync(); - assert.equal(api.posts.length, 0); + await cs.onMessage({ type: 'calendar.date.advanced', payload: { year: 1492, month: 3, day: 2, hour: 8, minute: 0 } }); + assert.equal(cs.chronicleDate.day, 2); + assert.equal(cs._api.posts.length, 0); }); diff --git a/tools/test-calendar-backcatalog-fix.mjs b/tools/test-calendar-backcatalog-fix.mjs index a1d9cda..6154b6a 100644 --- a/tools/test-calendar-backcatalog-fix.mjs +++ b/tools/test-calendar-backcatalog-fix.mjs @@ -1,13 +1,12 @@ -// test-calendar-backcatalog-fix.mjs — regression tests for calendar-sync.mjs. +// test-calendar-backcatalog-fix.mjs — off-DOM tests for CalendarSync's pull of +// Chronicle's calendar events and date (scripts/calendar-sync.mjs). // // Run: node --test tools/test-calendar-backcatalog-fix.mjs // -// Pins: back-catalog unwraps Chronicle's { data, total } envelope; the -// Calendaria dateTimeChange push reads the raw 0-indexed components nested -// under `.current` and corrects them +1, ignoring the day-of-YEAR `day` -// sibling in game.time.components; the echo-suppression guard is a reentrant -// depth counter so a WS event resolving mid-back-catalog can't unmask the -// loop. Also covers two fixes to compareCalendarStructures. +// Pins: the events fetch unwraps Chronicle's { data, total } envelope and +// dedupes by id across month windows; the fetch coordinates are bounded to the +// current year ±span; the cached date follows the initial pull and the +// `calendar.date.advanced` broadcast. import test from 'node:test'; import assert from 'node:assert/strict'; @@ -24,205 +23,122 @@ globalThis.game = globalThis.game || { }; globalThis.Hooks = globalThis.Hooks || { on: () => {}, off: () => {} }; -const { compareCalendarStructures, CalendarSync } = await import('../scripts/calendar-sync.mjs'); +const { calendarEventFetchCoordinates, CalendarSync } = await import('../scripts/calendar-sync.mjs'); -// Build a CalendarSync WITHOUT running the constructor; seed _syncDepth: 0 to -// mirror the reentrant-guard init. +// Build a CalendarSync WITHOUT running the constructor. function makeCalendarSync(overrides) { - return Object.assign( - Object.create(CalendarSync.prototype), - { _hasModernCalendariaApi: true, _syncDepth: 0 }, - overrides, - ); + return Object.assign(Object.create(CalendarSync.prototype), overrides); } -// ── Item 1: BLOCKER — envelope unwrap ──────────────────────────────────────── +// ── fetch coordinates ──────────────────────────────────────────────────────── -function makeBackcatalog(getImpl, overrides) { - const created = []; - const cs = makeCalendarSync(Object.assign({ +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 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 }), []); +}); + +// ── events fetch ───────────────────────────────────────────────────────────── + +function makeFetcher(getImpl) { + const paths = []; + const cs = makeCalendarSync({ // 1 month × (current year ±1) = 3 fetch windows. _chronicleCalendar: { current_year: 1492, months: [{ days: 30 }] }, - _api: { get: getImpl }, - _getLocalEventId: () => null, - _createLocalEvent(e) { created.push(e); return Promise.resolve(); }, - }, overrides)); - return { cs, created }; + _api: { get: async (path) => { paths.push(path); return getImpl(path); } }, + }); + return { cs, paths }; } -test('BLOCKER: { data, total } envelope with 2 distinct events across 2 windows → 2 notes', async () => { +test('{ data, total } envelope with 2 distinct events across 2 windows → 2 events', async () => { let call = 0; - const { cs, created } = makeBackcatalog(async () => { + const { cs, paths } = makeFetcher(async () => { call += 1; if (call === 1) return { data: [{ id: 'a' }], total: 1 }; if (call === 2) return { data: [{ id: 'b' }], total: 1 }; return { data: [], total: 0 }; }); - await cs._syncChronicleEventsToCalendariaNotes(); - assert.deepEqual(created.map((e) => e.id).sort(), ['a', 'b']); + const events = await cs.fetchEvents(); + assert.deepEqual(events.map((e) => e.id).sort(), ['a', 'b']); + assert.deepEqual(paths, [ + '/calendar/events?year=1491&month=1', + '/calendar/events?year=1492&month=1', + '/calendar/events?year=1493&month=1', + ]); }); -test('BLOCKER: a recurring event surfacing in every window is created ONCE (dedupe by id)', async () => { - const { cs, created } = makeBackcatalog(async () => ({ data: [{ id: 'recur' }], total: 1 })); - await cs._syncChronicleEventsToCalendariaNotes(); - assert.equal(created.length, 1); +test('a recurring event surfacing in every window is returned ONCE (dedupe by id)', async () => { + const { cs } = makeFetcher(async () => ({ data: [{ id: 'recur' }], total: 1 })); + assert.equal((await cs.fetchEvents()).length, 1); }); -test('BLOCKER: a bare-array response is still tolerated (back-compat)', async () => { - const { cs, created } = makeBackcatalog(async () => [{ id: 'x' }]); - await cs._syncChronicleEventsToCalendariaNotes(); - assert.equal(created.length, 1); +test('a bare-array response is still tolerated (back-compat)', async () => { + const { cs } = makeFetcher(async () => [{ id: 'x' }]); + assert.equal((await cs.fetchEvents()).length, 1); }); -test('BLOCKER: a non-array, non-envelope body skips the window without throwing', async () => { +test('a non-array, non-envelope body skips the window without throwing', async () => { for (const body of [null, {}, { unexpected: true }, { data: 'nope' }, 42]) { - const { cs, created } = makeBackcatalog(async () => body); - await cs._syncChronicleEventsToCalendariaNotes(); - assert.equal(created.length, 0, `body ${JSON.stringify(body)} must create nothing`); + const { cs } = makeFetcher(async () => body); + assert.equal((await cs.fetchEvents()).length, 0, `body ${JSON.stringify(body)} must yield nothing`); } }); -// ── Item 2: HIGH — dateTimeChange push (nested .current, raw 0-indexed) ─────── - -function makeDatePush(overrides) { - const puts = []; - const cs = makeCalendarSync(Object.assign({ - _calendarSyncDisabled: false, - _isActiveCalendarExcluded: () => false, - _api: { put: async (path, body) => { puts.push({ path, body }); return { id: 'ok' }; } }, - }, overrides)); - return { cs, puts }; -} - -test('HIGH: nested .current raw payload → 1-indexed wire date; day-of-YEAR `day` sibling ignored', async () => { - const { cs, puts } = makeDatePush(); - // March 15 1492: raw month 2 / dayOfMonth 14, with a day-of-year `day` (=73) - // that MUST NOT be used as the day-of-month. - await cs._onCalendariaDateTimeChange({ - current: { year: 1492, month: 2, dayOfMonth: 14, day: 73, hour: 9, minute: 30, second: 5 }, - previous: {}, diff: 1, +test('a failing window is skipped, the rest still fetch', async () => { + let call = 0; + const { cs } = makeFetcher(async () => { + call += 1; + if (call === 1) throw new Error('502'); + return { data: [{ id: `e${call}` }], total: 1 }; }); - assert.equal(puts.length, 1); - assert.equal(puts[0].path, '/calendar/date'); - assert.deepEqual(puts[0].body, { year: 1492, month: 3, day: 15, hour: 9, minute: 30 }); + assert.deepEqual((await cs.fetchEvents()).map((e) => e.id), ['e2', 'e3']); }); -test('HIGH: the January-1 zero case → 1/1, never an invalid 0/0', async () => { - const { cs, puts } = makeDatePush(); - await cs._onCalendariaDateTimeChange({ current: { year: 1492, month: 0, dayOfMonth: 0, day: 0, hour: 0, minute: 0 } }); - assert.deepEqual(puts[0].body, { year: 1492, month: 1, day: 1, hour: 0, minute: 0 }); -}); - -test('HIGH: flat payload (no .current) still works via the data.current ?? data fallback', async () => { - const { cs, puts } = makeDatePush(); - await cs._onCalendariaDateTimeChange({ year: 1492, month: 5, dayOfMonth: 9, hour: 13, minute: 45 }); - assert.deepEqual(puts[0].body, { year: 1492, month: 6, day: 10, hour: 13, minute: 45 }); -}); - -test('HIGH: a genuinely public payload (has `day`, no `dayOfMonth`) passes through un-incremented', async () => { - const { cs, puts } = makeDatePush(); - await cs._onCalendariaDateTimeChange({ current: { year: 1492, month: 3, day: 15, hour: 1, minute: 2 } }); - assert.deepEqual(puts[0].body, { year: 1492, month: 3, day: 15, hour: 1, minute: 2 }); -}); - -test('HIGH: missing hour/minute default to 0', async () => { - const { cs, puts } = makeDatePush(); - await cs._onCalendariaDateTimeChange({ current: { year: 1492, month: 2, dayOfMonth: 14 } }); - assert.deepEqual(puts[0].body, { year: 1492, month: 3, day: 15, hour: 0, minute: 0 }); -}); - -test('HIGH: a payload with no usable date (year undefined) does NOT push', async () => { - const { cs, puts } = makeDatePush(); - await cs._onCalendariaDateTimeChange({ current: { month: 2, dayOfMonth: 14 } }); - assert.equal(puts.length, 0); -}); - -test('HIGH: guarded off — _syncing (depth>0), non-GM, and paused all suppress the push', async () => { - const payload = { current: { year: 1492, month: 2, dayOfMonth: 14 } }; - - const a = makeDatePush({ _syncDepth: 1 }); - await a.cs._onCalendariaDateTimeChange(payload); - assert.equal(a.puts.length, 0, 'in-flight sync suppresses the push'); - - game.user.isGM = false; - const b = makeDatePush(); - await b.cs._onCalendariaDateTimeChange(payload); - assert.equal(b.puts.length, 0, 'non-GM does not push'); - game.user.isGM = true; - - const c = makeDatePush({ _calendarSyncDisabled: true }); - await c.cs._onCalendariaDateTimeChange(payload); - assert.equal(c.puts.length, 0, 'structure-mismatch pause suppresses the push'); -}); - -// ── Item 3: MEDIUM — reentrant depth-counter guard ─────────────────────────── - -test('MEDIUM: _syncDepth nests — the guard stays active until the OUTERMOST scope exits', () => { - const cs = makeCalendarSync({ _syncDepth: 0 }); - assert.equal(cs._syncing, false); - cs._syncDepth += 1; // outer scope (e.g. back-catalog loop) - assert.equal(cs._syncing, true); - cs._syncDepth += 1; // inner scope (e.g. WS handler) - cs._syncDepth -= 1; // inner exits — must NOT unmask - assert.equal(cs._syncing, true, 'still held after the inner scope exits'); - cs._syncDepth -= 1; // outer exits - assert.equal(cs._syncing, false); -}); - -test('MEDIUM: a WS _onChronicleEventCreated mid-back-catalog neither unmasks nor over-holds the guard', async () => { - const seen = []; - let injected = false; +test('no cached structure falls back to one bare fetch', async () => { + const paths = []; const cs = makeCalendarSync({ - _chronicleCalendar: { current_year: 1492, months: [{ days: 30 }] }, - _api: { get: async () => ({ data: [{ id: 'e1', name: 'A', year: 1, month: 1, day: 1 }, { id: 'e2', name: 'B', year: 1, month: 1, day: 2 }], total: 2 }) }, - _getLocalEventId: () => null, - async _createLocalEventUnguarded(data) { - seen.push(this._echoGuard.isEcho({ key: this._eventEchoKeys(data)[0] })); - if (!injected) { - injected = true; - // A Chronicle WS event lands mid-loop and finishes: its release must - // not clear the loop's still-held scope for the current event. - await CalendarSync.prototype._onChronicleEventCreated.call(this, { id: 'ws-1', name: 'A', year: 1, month: 1, day: 1 }); - seen.push(this._echoGuard.isEcho({ key: this._eventEchoKeys(data)[0] })); - } - }, + _chronicleCalendar: null, + _api: { get: async (path) => { paths.push(path); return []; } }, }); - await cs._syncChronicleEventsToCalendariaNotes(); - assert.ok(seen.length >= 3); - assert.ok(seen.every((v) => v === true), 'each create stays guarded across the interleaved WS event'); - assert.equal(cs._echoGuard.isEcho({ key: cs._eventEchoKeys({ name: 'A', year: 1, month: 1, day: 1 })[0] }), false, - 'guard fully released after the loop'); -}); - -// ── Ride-alongs: compareCalendarStructures (RC-9) ──────────────────────────── - -const twelveMonths = new Array(12).fill({ days: 30 }); -const twelveMonthDays = new Array(12).fill(30); - -test('ride-along: an unreadable Foundry weekday list (count 0) does not pause when months match', () => { - const chronicle = { months: twelveMonths, weekdays: new Array(7).fill({}) }; - const foundry = { name: 'X', monthDays: twelveMonthDays, weekdayCount: 0 }; - assert.equal(compareCalendarStructures(chronicle, foundry).match, true); + await cs.fetchEvents(); + assert.deepEqual(paths, ['/calendar/events']); }); -test('ride-along: an unreadable Chronicle weekday list (0) also does not pause when months match', () => { - const chronicle = { months: twelveMonths, weekdays: [] }; - const foundry = { name: 'X', monthDays: twelveMonthDays, weekdayCount: 7 }; - assert.equal(compareCalendarStructures(chronicle, foundry).match, true); -}); +// ── cached date ────────────────────────────────────────────────────────────── -test('ride-along: a REAL weekday difference (both > 0) still pauses', () => { - const chronicle = { months: twelveMonths, weekdays: new Array(7).fill({}) }; - const foundry = { name: 'X', monthDays: twelveMonthDays, weekdayCount: 6 }; - const r = compareCalendarStructures(chronicle, foundry); - assert.equal(r.match, false); - assert.match(r.detail, /weekday count/); +test('chronicleDate reads the cached calendar and follows calendar.date.advanced', () => { + const cs = makeCalendarSync({ + _chronicleCalendar: { current_year: 1492, current_month: 3, current_day: 15, current_hour: 9, current_minute: 30 }, + }); + assert.deepEqual(cs.chronicleDate, { year: 1492, month: 3, day: 15, hour: 9, minute: 30 }); + cs._onChronicleDateAdvanced({ year: 1492, month: 3, day: 16, hour: 0, minute: 0 }); + assert.deepEqual(cs.chronicleDate, { year: 1492, month: 3, day: 16, hour: 0, minute: 0 }); }); -test('ride-along: a month mismatch still pauses even when weekdays are unreadable (fail-closed on months)', () => { - const chronicle = { months: twelveMonths, weekdays: new Array(7).fill({}) }; - const foundry = { name: 'X', monthDays: new Array(15).fill(24), weekdayCount: 0 }; - const r = compareCalendarStructures(chronicle, foundry); - assert.equal(r.match, false); - assert.match(r.detail, /month count/); +test('chronicleDate is null before any calendar was pulled; an advance then is ignored', () => { + const cs = makeCalendarSync({ _chronicleCalendar: null }); + assert.equal(cs.chronicleDate, null); + cs._onChronicleDateAdvanced({ year: 1, month: 1, day: 1 }); + assert.equal(cs.chronicleDate, null); + cs._onChronicleDateAdvanced(null); }); diff --git a/tools/test-calendar-blackout.mjs b/tools/test-calendar-blackout.mjs index 4025a30..ea83e26 100644 --- a/tools/test-calendar-blackout.mjs +++ b/tools/test-calendar-blackout.mjs @@ -144,7 +144,7 @@ test('THE STORM: 20 world-time ticks cost 1 request and 1 notice, not 40 and 20' put: async () => { requests++; throw apiError(503, REBUILD_BODY); }, }; - // Mirrors the shape of _onCalendariaDateTimeChange: check the session + // Mirrors the shape of CalendarSync.pushDate: check the session // guard, then probe, then push. for (let i = 0; i < 20; i++) { if (calendarBlackoutActive()) continue; diff --git a/tools/test-calendar-mismatch-remedy.mjs b/tools/test-calendar-mismatch-remedy.mjs deleted file mode 100644 index 94e650a..0000000 --- a/tools/test-calendar-mismatch-remedy.mjs +++ /dev/null @@ -1,129 +0,0 @@ -// Pins the calendar-structure-mismatch remedy text (the pause toast plus the -// dashboard's two Calendar-tab banners) to advice the operator can actually -// follow: edit either calendar's structure so the two match. "Import" and -// "author a new calendar" are NOT reachable — the campaign already has a -// calendar by construction whenever this mismatch fires, so Chronicle's -// create-calendar endpoint 409s, and a newly authored calendar is never made -// default (no caller sets it), so it never reaches the wire. Pointing the -// module at a different Chronicle calendar is a separate, still-open gap -// (#95) and must not be implied by this text either. All three strings are -// read from where they ship and held to the same verdict so they can't drift. - -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; - -const root = join(dirname(fileURLToPath(import.meta.url)), '..'); - -// --- Foundry global stubs --------------------------------------------------- -globalThis.foundry = globalThis.foundry || { - applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (b) => b } }, -}; -globalThis.game = globalThis.game || { - settings: { get: () => '', set: () => {}, register: () => {}, registerMenu: () => {} }, - i18n: { localize: (k) => k, format: (k) => k }, - modules: { get: () => null }, - users: [], - user: { id: 'u1', isGM: true }, - journal: { find: () => null }, -}; -globalThis.Hooks = globalThis.Hooks || { on: () => {}, once: () => {}, off: () => {} }; - -const { CalendarSync } = await import('../scripts/calendar-sync.mjs'); - -/** The permanent toast the pause path raises. */ -function toastText() { - const cs = new CalendarSync({}, {}); - let msg = ''; - const prevUI = globalThis.ui; - globalThis.ui = { notifications: { warn: (m) => { msg = m; } } }; - const prevWarn = console.warn; - console.warn = () => {}; - try { - cs._pauseCalendarSyncForMismatch( - { name: 'Harptos', months: new Array(12), weekdays: new Array(10) }, - { name: 'Gregorian', monthDays: new Array(12), weekdayCount: 7 }, - 'weekday count 10 vs 7', - ); - } finally { - console.warn = prevWarn; - globalThis.ui = prevUI; - } - return msg; -} - -/** The dashboard's two Calendar-tab mismatch hints, read from the template. */ -function bannerHints() { - const hbs = readFileSync(join(root, 'templates', 'sync-dashboard.hbs'), 'utf8'); - const block = hbs.slice(hbs.indexOf('calendar-mismatch-banner')); - const hints = [...block.matchAll(/
([\s\S]*?)<\/div>/g)].map((m) => m[1].trim()); - assert.equal(hints.length, 2, - 'expected exactly two calendar-mismatch action hints (paused + incompatible) — ' - + 'if the template grew a third, it has to be held to the same standard'); - return hints; -} - -/** - * The unreachable instructions, as patterns. Each is paired with the Chronicle - * source fact that closes it, so a future reader can re-verify rather than - * trust this file. - */ -const UNREACHABLE = [ - { - re: /\bimport(ing)?\b[^.]*\bcalendar\b|\bcalendar\b[^.]*\bimport(ing)?\b/i, - why: 'POST /api/v1/campaigns/:cid/calendar returns 409 calendar_already_exists ' - + 'whenever the campaign has ANY calendar, which is always true when a ' - + 'structure mismatch has been computed against one', - }, - { - re: /\bauthor\b|\bcreate a (new )?calendar\b|\bnew calendar\b/i, - why: 'calendarService.CreateCalendar sets IsDefault only for the FIRST ' - + 'calendar in a campaign, the module is served ' - + '`ORDER BY is_default DESC, sort_order ASC LIMIT 1`, and Chronicle\'s ' - + 'SetDefaultCalendar has no route, handler or control — so an authored ' - + 'calendar never reaches the wire', - }, -]; - -/** The reachable remedy has to be named, not merely implied. */ -const REACHABLE = /calendar settings|months|weekdays|structure|edit/i; - -test('the mismatch remedy never tells the operator to import or author a calendar', () => { - const texts = [['the pause toast', toastText()], ...bannerHints().map((h, i) => [`banner hint ${i + 1}`, h])]; - for (const [label, text] of texts) { - assert.ok(text && text.length > 0, `${label} is empty — there is nothing to check`); - for (const { re, why } of UNREACHABLE) { - assert.equal(re.test(text), false, - `${label} tells the operator to do something that cannot be done:\n` - + ` ${text}\n` - + ` ${why}`); - } - } -}); - -test('the mismatch remedy names a reachable action instead', () => { - const texts = [['the pause toast', toastText()], ...bannerHints().map((h, i) => [`banner hint ${i + 1}`, h])]; - for (const [label, text] of texts) { - assert.ok(REACHABLE.test(text), - `${label} removes the impossible advice without offering the possible one:\n` - + ` ${text}\n` - + ' Editing either calendar so the two structures match IS reachable — ' - + 'Chronicle\'s Calendar Settings owns months and weekdays, and Calendaria / ' - + 'Simple Calendar own the Foundry side. An empty remedy is honest but useless.'); - } -}); - -test('the pause toast still says sync is paused and what keeps working', () => { - // The two facts the remedy must not cost while it is being rewritten: the - // operator has to know calendar sync stopped, and that the rest did not. - const text = toastText(); - assert.match(text, /paused/i, 'the toast must still say calendar sync is paused'); - assert.match(text, /journals/i, - 'the toast must still say journals, characters and maps keep syncing — that ' - + 'sentence is what stops a structure mismatch reading as a dead integration'); - assert.match(text, /reconnect or pull/i, - 'a re-check on the next pull clears the pause, so the toast must say that and not ask for a reload'); - assert.doesNotMatch(text, /reload the world/i); -}); diff --git a/tools/test-calendar-note-journal.mjs b/tools/test-calendar-note-journal.mjs index 9e5994d..2afee8e 100644 --- a/tools/test-calendar-note-journal.mjs +++ b/tools/test-calendar-note-journal.mjs @@ -2,10 +2,10 @@ /** * Unit tests for `isCalendarNoteJournal` in `scripts/calendar-sync.mjs`. * - * Calendar-module notes (SimpleCalendar / Calendaria) are stored as Foundry - * JournalEntries and must be recognized so JournalSync skips them instead of - * POSTing them to /entities — entity_type_id:0 there resolves to the first - * entity type, so an unrecognized note would appear in the wrong entity list. + * A journal Chronicle already mirrored to a calendar event carries our own + * `calendarEventId` link flag and must be recognized so JournalSync skips it + * instead of POSTing it to /entities — entity_type_id:0 there resolves to the + * first entity type, so it would appear in the wrong entity list. * * Run: `node --test tools/test-calendar-note-journal.mjs` */ @@ -33,8 +33,6 @@ globalThis.Hooks = globalThis.Hooks || { on: () => {}, off: () => {} }; const { isCalendarNoteJournal, - CALENDARIA_FLAG_SCOPE, - SIMPLE_CALENDAR_FLAG_SCOPES, } = await import('../scripts/calendar-sync.mjs'); const FLAG_SCOPE = 'chronicle-sync'; @@ -54,42 +52,6 @@ function journalStub(flags = {}, { withGetFlag = false, chronicleFlags = null } return stub; } -// --------------------------------------------------------------------- -// Positive: calendar-module flags -// --------------------------------------------------------------------- - -test('Calendaria note (flags.calendaria.isCalendarNote) → true', () => { - // Shape produced by Calendaria's note-manager.mjs and festival-manager.mjs. - const j = journalStub({ calendaria: { calendarId: 'therin', isCalendarNote: true } }); - assert.equal(isCalendarNoteJournal(j), true); -}); - -test('Calendaria structure journal (flags.calendaria.isCalendarJournal) → true', () => { - assert.equal(isCalendarNoteJournal(journalStub({ calendaria: { isCalendarJournal: true } })), true); -}); - -test('Calendaria festival/holiday note (auto-seeded, isCalendarNote) → true', () => { - // The exact bug case: a seeded festival note carries isCalendarNote plus a - // linkedFestival descriptor. - const j = journalStub({ calendaria: { calendarId: 'therin', isCalendarNote: true, linkedFestival: { festivalKey: 'rebirth' } } }); - assert.equal(isCalendarNoteJournal(j), true); -}); - -test('SimpleCalendar note (foundryvtt-simple-calendar flag) → true', () => { - const j = journalStub({ 'foundryvtt-simple-calendar': { noteData: { startDate: { year: 1 } } } }); - assert.equal(isCalendarNoteJournal(j), true); -}); - -test('legacy simple-calendar flag namespace → true', () => { - assert.equal(isCalendarNoteJournal(journalStub({ 'simple-calendar': { note: true } })), true); -}); - -test('a bare calendaria flag WITHOUT the note markers → false (precise, no over-skip)', () => { - // e.g. an enricher cache or unrelated calendaria flag on a real worldbuilding - // journal must NOT be skipped from entity sync. - assert.equal(isCalendarNoteJournal(journalStub({ calendaria: { someEnricherCache: true } })), false); -}); - // --------------------------------------------------------------------- // Positive: our own calendarEventId link flag (both read paths) // --------------------------------------------------------------------- @@ -123,8 +85,26 @@ test('journal whose chronicle flags hold an entityId (a real entity) → false', assert.equal(isCalendarNoteJournal(j), false); }); -test('null SC flag value is not treated as ownership (typeof null === object guard)', () => { - assert.equal(isCalendarNoteJournal(journalStub({ 'simple-calendar': null })), false); +test('an unknown module flag is not a calendar note', () => { + assert.equal(isCalendarNoteJournal(journalStub({ 'some-calendar-module': { isCalendarNote: true } })), false); +}); + +// --------------------------------------------------------------------- +// Positive: notes a third-party calendar module left in the world are +// never pushed as entities, though the module no longer syncs them. +// --------------------------------------------------------------------- + +test('leftover third-party calendar notes are skipped', () => { + const cases = [ + [{ calendaria: { isCalendarNote: true } }, true], + [{ calendaria: { isCalendarJournal: true } }, true], + [{ calendaria: { enricher: 'x' } }, false], + [{ 'foundryvtt-simple-calendar': { noteData: {} } }, true], + [{ 'simple-calendar': {} }, true], + ]; + for (const [flags, want] of cases) { + assert.equal(isCalendarNoteJournal(journalStub(flags)), want, JSON.stringify(flags)); + } }); // --------------------------------------------------------------------- @@ -137,14 +117,3 @@ test('null / undefined / non-object inputs → false', () => { assert.equal(isCalendarNoteJournal('journal'), false); assert.equal(isCalendarNoteJournal(42), false); }); - -// --------------------------------------------------------------------- -// Constant shape -// --------------------------------------------------------------------- - -test('flag-scope constants are frozen and cover the supported modules', () => { - assert.equal(CALENDARIA_FLAG_SCOPE, 'calendaria'); - assert.ok(Object.isFrozen(SIMPLE_CALENDAR_FLAG_SCOPES)); - assert.ok(SIMPLE_CALENDAR_FLAG_SCOPES.includes('foundryvtt-simple-calendar')); - assert.ok(SIMPLE_CALENDAR_FLAG_SCOPES.includes('simple-calendar')); -}); diff --git a/tools/test-calendar-resilience.mjs b/tools/test-calendar-resilience.mjs index 668bb15..9a5268c 100644 --- a/tools/test-calendar-resilience.mjs +++ b/tools/test-calendar-resilience.mjs @@ -1,10 +1,9 @@ #!/usr/bin/env node /** * Calendar sync resilience: a bare 503 is not the rebuild blackout, the - * blackout and a structure-mismatch pause heal without a world reload, Chronicle's - * 400/422/403 refusals of a date push pause it with one notice, Simple Calendar - * notes obey the mismatch guard and exclusions, and the echo guard is scoped to - * the note being applied rather than global. + * blackout heals without a world reload, Chronicle's 400/422/403 refusals of a + * date push pause it with one notice, and the echo guard is scoped to the + * note being applied rather than global. */ import test from 'node:test'; @@ -45,7 +44,7 @@ function apiError(status, body) { function makeCS(overrides = {}) { return Object.assign( Object.create(CalendarSync.prototype), - { _hasModernCalendariaApi: true, _syncDepth: 0, _calendarSyncDisabled: false, _calendarModule: 'calendaria' }, + {}, overrides, ); } @@ -62,12 +61,13 @@ async function capture(fn) { } test.beforeEach(() => { + globalThis.game.settings.get = () => true; // syncCalendar on blackout._resetCalendarBlackoutForTests(); refusal._resetDatePushRefusalForTests(); _resetRealtimeDateGuardForTests(); }); -// ── Item 1: blackout only for calendar_rebuilding, self-healing ───────────── +// ── Blackout only for calendar_rebuilding, self-healing ───────────── test('a bare 503 (proxy, restart) is unreachable, not the rebuild blackout', () => { assert.equal(calendarStateFromError(apiError(503, 'Bad gateway')), 'unreachable'); @@ -102,58 +102,20 @@ test('a successful date push after the blackout window clears it', async () => { blackout.markCalendarRebuilding(null, Date.now() - blackout.BLACKOUT_RETRY_MS - 1); const api = { get: async () => ({}), put: async () => ({}) }; const cs = makeCS({ _api: api }); - await cs._onLocalDateChange({ year: 1, month: 1, day: 1 }); + await cs.pushDate({ year: 1, month: 1, day: 1 }); assert.equal(blackout.calendarBlackoutActive(Date.now() + 1), false); }); -// ── Item 2: mismatch pause clears on a pull ───────────────────────────────── - -test('onInitialSync re-checks structure while paused and resumes when it matches', async () => { - const cal = { months: [{ days: 30 }, { days: 30 }], weekdays: [{}, {}], current_year: 1, current_month: 1, current_day: 1 }; - const cs = makeCS({ - _calendarSyncDisabled: true, - _calendarMismatchDetail: 'x', - _api: { get: async () => cal, put: async () => ({}), post: async () => ({}) }, - _readActiveFoundryStructure: () => ({ name: 'F', monthDays: [30, 30], weekdayCount: 2 }), - _setLocalDate: async () => false, - }); - // getSetting('syncCalendar') reads game.settings.get; stub it truthy. - const prev = globalThis.game.settings.get; - globalThis.game.settings.get = () => true; - try { - const { notes } = await capture(() => cs.onInitialSync()); - assert.equal(cs._calendarSyncDisabled, false); - assert.equal(cs._calendarMismatchDetail, null); - assert.ok(notes.some((m) => /resumed/.test(m))); - } finally { globalThis.game.settings.get = prev; } -}); - -test('onInitialSync keeps the pause (and does not re-warn) while still mismatched', async () => { - const cal = { months: [{ days: 30 }], weekdays: [{}], current_year: 1 }; - const cs = makeCS({ - _calendarSyncDisabled: true, - _api: { get: async () => cal }, - _readActiveFoundryStructure: () => ({ name: 'F', monthDays: [31, 31], weekdayCount: 7 }), - }); - const prev = globalThis.game.settings.get; - globalThis.game.settings.get = () => true; - try { - const { notes } = await capture(async () => { assert.equal(await cs.onInitialSync(), false); }); - assert.equal(cs._calendarSyncDisabled, true); - assert.equal(notes.length, 0); - } finally { globalThis.game.settings.get = prev; } -}); - -// ── Items 3 + 4: 400/422/403 refusals ─────────────────────────────────────── +// ── 400/422/403 refusals ─────────────────────────────────────── for (const [status, text] of [[400, REJECT], [422, REJECT], [403, FORBID]]) { test(`a ${status} on a date push pauses it with one exact notice and no console.error`, async () => { const api = { get: async () => ({}), put: async () => { throw apiError(status, JSON.stringify({ error: 'x' })); }, puts: 0 }; const cs = makeCS({ _api: api }); const { notes, errors } = await capture(async () => { - await cs._onLocalDateChange({ year: 1, month: 1, day: 1 }); - await cs._onLocalDateChange({ year: 1, month: 1, day: 2 }); - await cs._onLocalDateChange({ year: 1, month: 1, day: 3 }); + await cs.pushDate({ year: 1, month: 1, day: 1 }); + await cs.pushDate({ year: 1, month: 1, day: 2 }); + await cs.pushDate({ year: 1, month: 1, day: 3 }); }); assert.deepEqual(notes, [text]); assert.equal(errors.length, 0); @@ -166,8 +128,8 @@ test('once paused, later date pushes cost no request', async () => { const api = { get: async () => ({}), put: async () => { puts++; throw apiError(400, '{}'); } }; const cs = makeCS({ _api: api }); await capture(async () => { - await cs._onLocalDateChange({ year: 1, month: 1, day: 1 }); - await cs._onLocalDateChange({ year: 1, month: 1, day: 2 }); + await cs.pushDate({ year: 1, month: 1, day: 1 }); + await cs.pushDate({ year: 1, month: 1, day: 2 }); }); assert.equal(puts, 1); }); @@ -179,39 +141,12 @@ test('a 422 about real-time tracking keeps the real-time notice, not the calenda test('a 500 is not a refusal and still logs as an error', async () => { const api = { get: async () => ({}), put: async () => { throw apiError(500, 'boom'); } }; const cs = makeCS({ _api: api }); - const { errors } = await capture(() => cs._onLocalDateChange({ year: 1, month: 1, day: 1 })); + const { errors } = await capture(() => cs.pushDate({ year: 1, month: 1, day: 1 })); assert.equal(errors.length, 1); assert.equal(refusal.datePushPaused(), false); }); -// ── Item 5: Simple Calendar notes obey mismatch guard + exclusions ────────── - -const scJournal = { id: 'j1', name: 'Fair', flags: { 'foundryvtt-simple-calendar': { noteData: { startDate: { year: 1, month: 0, day: 0 } } } }, - getFlag: () => null, setFlag: async () => {} }; - -for (const [label, method, args] of [ - ['create', '_onSimpleCalendarNoteCreate', [scJournal, {}, 'u1']], - ['update', '_onSimpleCalendarNoteUpdate', [scJournal, {}, {}, 'u1']], - ['delete', '_onSimpleCalendarNoteDelete', [scJournal, {}, 'u1']], -]) { - test(`SC note ${label}: no request while the mismatch pause is on`, async () => { - const calls = []; - const api = { post: async (...a) => calls.push(a), put: async (...a) => calls.push(a), delete: async (...a) => calls.push(a) }; - const cs = makeCS({ _calendarModule: 'simple-calendar', _calendarSyncDisabled: true, _api: api, _getChronicleEventId: () => 'c1' }); - await cs[method](...args); - assert.equal(calls.length, 0); - }); - - test(`SC note ${label}: no request for an excluded calendar`, async () => { - const calls = []; - const api = { post: async (...a) => calls.push(a), put: async (...a) => calls.push(a), delete: async (...a) => calls.push(a) }; - const cs = makeCS({ _calendarModule: 'simple-calendar', _api: api, _getChronicleEventId: () => 'c1', _isActiveCalendarExcluded: () => true }); - await cs[method](...args); - assert.equal(calls.length, 0); - }); -} - -// ── Item 6: per-note echo guard ────────────────────────────────────────────── +// ── Per-note echo guard ────────────────────────────────────────────── test('ApplyGuard: ids and keys are reference-counted and released', () => { const g = new ApplyGuard(); @@ -225,34 +160,6 @@ test('ApplyGuard: ids and keys are reference-counted and released', () => { assert.equal(g.isEcho({ id: 'n1' }), false); }); -test('during a pull, the applied note is ignored but an unrelated GM edit pushes', async () => { - const posts = []; - const cs = makeCS({ - _api: { post: async (p, b) => { posts.push(b); return { id: 'c9' }; } }, - _storeEventMapping: async () => {}, - _isActiveCalendarExcluded: () => false, - }); - globalThis.CALENDARIA = { api: { createNote: async (n) => { - await cs._onCalendariaNoteCreated({ id: 'echo-1', name: n.name, startDate: n.startDate }); - await cs._onCalendariaNoteCreated({ id: 'gm-1', name: 'GM edit', startDate: { year: 1492, month: 7, day: 7 } }); - return { id: 'echo-1' }; - } } }; - try { - await cs._createLocalEvent({ id: 'e1', name: 'Fair', year: 1492, month: 2, day: 3 }); - } finally { delete globalThis.CALENDARIA; } - assert.equal(posts.length, 1, 'only the unrelated GM note was pushed'); - assert.equal(posts[0].name, 'GM edit'); -}); - -test('a structure refetch no longer mutes date pushes', async () => { - const cs = makeCS({ - _api: { get: async () => null }, - _readActiveFoundryStructure: () => null, - }); - await cs._onChronicleStructureUpdated(); - assert.equal(cs._syncing, false); -}); - test('a paused date push resumes after resumeDatePush without a second notice', async () => { const m = await import('../scripts/_date-push-rejection.mjs'); m._resetDatePushRefusalForTests(); diff --git a/tools/test-calendar-subresource-routing.mjs b/tools/test-calendar-subresource-routing.mjs index 90e6f00..55245de 100644 --- a/tools/test-calendar-subresource-routing.mjs +++ b/tools/test-calendar-subresource-routing.mjs @@ -8,8 +8,7 @@ * 2. dm_only weather is NEVER exposed to players — announcements are GM * whispers only. * 3. `calendar.structure.updated` (and its cycle/festival siblings) - * re-compares, pauses on a new mismatch, un-pauses on recovery, and - * NEVER writes the structure into Foundry. + * refetches Chronicle's calendar into the cache. * 4. The `default:` branch logs an unhandled `calendar.*` type once per * session and stays silent on non-calendar traffic. * @@ -68,12 +67,6 @@ function makeSync(overrides = {}) { return Object.assign( Object.create(CalendarSync.prototype), { - _syncDepth: 0, - _calendarModule: 'calendaria', - _hasModernCalendariaApi: true, - _calendarSyncDisabled: false, - _calendarMismatchDetail: null, - _structureChangedDetail: null, _subresourceState: emptySubresourceState(), _loggedUnhandledTypes: new Set(), _chronicleCalendar: null, @@ -93,30 +86,13 @@ function reset() { settingValues.calendarAnnounceMoon = false; } -// A 12-month/7-weekday pair that compareCalendarStructures accepts. -const CHRONICLE_12x7 = { - name: 'Harptos', - months: Array.from({ length: 12 }, () => ({ days: 30 })), - weekdays: Array.from({ length: 7 }, (_, i) => ({ name: `d${i}` })), -}; -const FOUNDRY_12x7 = { - name: 'Harptos (Foundry)', - monthDays: Array.from({ length: 12 }, () => 30), - weekdayCount: 7, -}; -const FOUNDRY_15x6 = { - name: 'Therin', - monthDays: Array.from({ length: 15 }, () => 24), - weekdayCount: 6, -}; - // ── 1. Routing: each handled type reaches its handler ─────────────────────── test('every handled calendar.* type routes to its handler', async () => { reset(); const seen = []; const cs = makeSync({ - _onChronicaleDateAdvanced: async () => seen.push('date'), + _onChronicleDateAdvanced: async () => seen.push('date'), _onChronicleEventCreated: async () => seen.push('created'), _onChronicleEventUpdated: async () => seen.push('updated'), _onChronicleEventDeleted: async () => seen.push('deleted'), @@ -147,30 +123,6 @@ test('every handled calendar.* type routes to its handler', async () => { ]); }); -test('sub-resource messages are suppressed while calendar sync is paused', async () => { - reset(); - const seen = []; - const cs = makeSync({ - _calendarSyncDisabled: true, - _onChronicleWeatherChanged: async () => seen.push('weather'), - _onChronicleSubresourceChanged: async (t) => seen.push(t), - }); - await cs.onMessage({ type: 'calendar.weather.changed', payload: { preset_label: 'Clear' } }); - await cs.onMessage({ type: 'calendar.season.changed', payload: { name: 'Spring' } }); - assert.deepEqual(seen, [], 'a paused module must not apply sub-resource state'); -}); - -test('structure signals are processed EVEN WHILE PAUSED — the only recovery path', async () => { - reset(); - let called = 0; - const cs = makeSync({ - _calendarSyncDisabled: true, - _onChronicleStructureUpdated: async () => { called += 1; }, - }); - await cs.onMessage({ type: 'calendar.structure.updated', payload: null }); - assert.equal(called, 1, 'structure.updated must run ahead of the pause guard'); -}); - test('sub-resource routing is off entirely when syncCalendar is disabled', async () => { reset(); settingValues.syncCalendar = false; @@ -184,7 +136,7 @@ test('sub-resource routing is off entirely when syncCalendar is disabled', async test('SECURITY: weather announcements are GM whispers, never public chat', async () => { reset(); - const cs = makeSync({ _calendarModule: 'simple-calendar' }); // no Calendaria setter + const cs = makeSync(); await cs._onChronicleWeatherChanged({ preset_label: 'Unnatural darkness', zone_name: 'The Sunken Ward', @@ -201,7 +153,7 @@ test('SECURITY: weather announcements are GM whispers, never public chat', async test('SECURITY: no sub-resource branch ever posts an unwhispered ChatMessage', async () => { reset(); - const cs = makeSync({ _calendarModule: 'simple-calendar' }); + const cs = makeSync(); settingValues.calendarAnnounceMoon = true; // turn every announcement on await cs._onChronicleWeatherChanged({ preset_label: 'Blood rain' }); @@ -223,7 +175,7 @@ test('SECURITY: no sub-resource branch ever posts an unwhispered ChatMessage', a test('SECURITY: chat content is HTML-escaped at the boundary', async () => { reset(); - const cs = makeSync({ _calendarModule: 'simple-calendar' }); + const cs = makeSync(); await cs._onChronicleSubresourceChanged('calendar.season.changed', { name: '', }); @@ -234,7 +186,7 @@ test('SECURITY: chat content is HTML-escaped at the boundary', async () => { test('each announcement respects its own world setting', async () => { reset(); - const cs = makeSync({ _calendarModule: 'simple-calendar' }); + const cs = makeSync(); // Moon is OFF by default — state still updates, chat stays quiet. await cs._onChronicleSubresourceChanged('calendar.moon.phase_changed', { @@ -254,48 +206,10 @@ test('each announcement respects its own world setting', async () => { assert.equal(cs._subresourceState.era.name, 'Fifth Age', 'panel updates regardless of the chat toggle'); }); -// ── weather: Calendaria apply vs chat fallback ────────────────────────────── - -test('weather is applied via a Calendaria setter when one exists — no chat line', async () => { - reset(); - const applied = []; - globalThis.CALENDARIA = { api: { setWeather: (d) => { applied.push(d); } } }; - const cs = makeSync({ _calendarModule: 'calendaria' }); - await cs._onChronicleWeatherChanged({ preset_label: 'Heavy snow', temperature_celsius: -8 }); - delete globalThis.CALENDARIA; - - assert.equal(applied.length, 1, 'handed to Calendaria'); - assert.equal(applied[0].label, 'Heavy snow'); - assert.equal(applied[0].temperature, -8); - assert.equal(chatCalls.length, 0, 'no duplicate chat line once applied to the module'); -}); - -test('weather degrades to chat when Calendaria exposes no setter', async () => { - reset(); - globalThis.CALENDARIA = { api: { getCurrentWeather: () => ({}) } }; // reads only - const cs = makeSync({ _calendarModule: 'calendaria' }); - await cs._onChronicleWeatherChanged({ preset_label: 'Heavy snow' }); - delete globalThis.CALENDARIA; - - assert.equal(chatCalls.length, 1, 'read-only Calendaria falls back to chat'); - assert.match(chatCalls[0].content, /Heavy snow/); -}); - -test('a failing Calendaria setter degrades to chat rather than losing the update', async () => { - reset(); - globalThis.CALENDARIA = { api: { setWeather: () => { throw new Error('boom'); } } }; - const cs = makeSync({ _calendarModule: 'calendaria' }); - await cs._onChronicleWeatherChanged({ preset_label: 'Hail' }); - delete globalThis.CALENDARIA; - - assert.equal(chatCalls.length, 1, 'the update must survive an apply failure'); -}); - test('a null weather payload (zone-change ping) refetches GET /calendar/weather', async () => { reset(); const gets = []; const cs = makeSync({ - _calendarModule: 'simple-calendar', _api: { get: async (p) => { gets.push(p); @@ -315,93 +229,38 @@ test('a null weather payload (zone-change ping) refetches GET /calendar/weather' test('a null weather payload with a failing refetch is a quiet no-op', async () => { reset(); const cs = makeSync({ - _calendarModule: 'simple-calendar', _api: { get: async () => { throw new Error('502'); } }, }); await cs._onChronicleWeatherChanged(null); assert.equal(chatCalls.length, 0); - assert.equal(cs._syncDepth, 0, 'the reentrant guard must unwind even on the error path'); }); -// ── 3. structure.updated → re-compare, never auto-apply ───────────────────── +// ── 3. structure.updated → refetch into the cache ─────────────────────────── -test('structure.updated re-runs the comparison and pauses on a NEW incompatibility', async () => { +test('structure.updated refetches /calendar into the cache', async () => { reset(); - let structureWrites = 0; - const cs = makeSync({ - _api: { get: async () => CHRONICLE_12x7 }, - _readActiveFoundryStructure: () => FOUNDRY_15x6, - // Any call to the date/structure writers would be an auto-apply. - _setLocalDate: async () => { structureWrites += 1; return true; }, - }); - await cs._onChronicleStructureUpdated('calendar.structure.updated'); - - assert.equal(cs._calendarSyncDisabled, true, 'a now-incompatible structure must pause'); - assert.match(cs._calendarMismatchDetail, /12mo\/7wd/); - assert.equal(cs._structureChangedDetail, null, 'the advisory does not co-exist with a pause'); - assert.equal(structureWrites, 0, 'NO auto-apply — the Foundry calendar is never rewritten'); - assert.ok(notices.some((n) => n.level === 'warn'), 'the operator is warned once'); -}); - -test('structure.updated sets the advisory badge when the re-compare stays compatible', async () => { - reset(); - const cs = makeSync({ - _api: { get: async () => CHRONICLE_12x7 }, - _readActiveFoundryStructure: () => FOUNDRY_12x7, - }); + const fresh = { name: 'Harptos', months: [{ days: 30 }] }; + const cs = makeSync({ _api: { get: async () => fresh } }); await cs._onChronicleStructureUpdated('calendar.structure.updated'); - - assert.equal(cs._calendarSyncDisabled, false, 'a compatible structure must not pause'); - assert.match(cs._structureChangedDetail, /still compatible/); - assert.match(cs._structureChangedDetail, /NOT modified/, 'the detail states no write happened'); + assert.equal(cs._chronicleCalendar, fresh); }); -test('structure.updated CLEARS a prior mismatch pause once the structures match again', async () => { +test('structure.updated survives a /calendar fetch failure and keeps the cached calendar', async () => { reset(); + const cached = { name: 'Harptos', months: [{ days: 30 }] }; const cs = makeSync({ - _calendarSyncDisabled: true, - _calendarMismatchDetail: 'Chronicle: Harptos 12mo/7wd · Foundry: Therin 15mo/6wd — month count', - _api: { get: async () => CHRONICLE_12x7 }, - _readActiveFoundryStructure: () => FOUNDRY_12x7, - }); - await cs._onChronicleStructureUpdated('calendar.structure.updated'); - - assert.equal(cs._calendarSyncDisabled, false, 'the pause must lift when its cause is gone'); - assert.equal(cs._calendarMismatchDetail, null); - assert.ok(notices.some((n) => n.level === 'info' && /resumed/.test(n.m)), 'the GM is told sync resumed'); -}); - -test('structure.updated fails OPEN when either structure is unreadable', async () => { - reset(); - // Foundry side unreadable. - const a = makeSync({ - _api: { get: async () => CHRONICLE_12x7 }, - _readActiveFoundryStructure: () => null, - }); - await a._onChronicleStructureUpdated('calendar.structure.updated'); - assert.equal(a._calendarSyncDisabled, false, 'unreadable Foundry structure must not pause'); - assert.equal(a._structureChangedDetail, null, 'and must not claim a verdict either'); - - // Chronicle side unreadable. - const b = makeSync({ - _api: { get: async () => ({ name: 'degraded', months: [] }) }, - _readActiveFoundryStructure: () => FOUNDRY_12x7, + _chronicleCalendar: cached, + _api: { get: async () => { throw new Error('offline'); } }, }); - await b._onChronicleStructureUpdated('calendar.structure.updated'); - assert.equal(b._calendarSyncDisabled, false); - assert.equal(b._structureChangedDetail, null); + await cs._onChronicleStructureUpdated('calendar.cycle.changed'); + assert.equal(cs._chronicleCalendar, cached); }); -test('structure re-compare survives a /calendar fetch failure using the cached structure', async () => { +test('structure signals never write into Foundry or post chat', async () => { reset(); - const cs = makeSync({ - _chronicleCalendar: CHRONICLE_12x7, - _api: { get: async () => { throw new Error('offline'); } }, - _readActiveFoundryStructure: () => FOUNDRY_12x7, - }); - await cs._onChronicleStructureUpdated('calendar.cycle.changed'); - assert.match(cs._structureChangedDetail, /calendar\.cycle\.changed/); - assert.equal(cs._syncDepth, 0); + const cs = makeSync({ _api: { get: async () => ({ months: [{ days: 30 }] }) } }); + await cs.onMessage({ type: 'calendar.structure.updated', payload: null }); + assert.equal(chatCalls.length, 0); }); // ── 4. default: log-once for unhandled calendar.* types ───────────────────── @@ -450,7 +309,7 @@ test('routed types never fall into the unhandled log', async () => { _onChronicleWeatherChanged: async () => {}, _onChronicleSubresourceChanged: async () => {}, _onChronicleStructureUpdated: async () => {}, - _onChronicaleDateAdvanced: async () => {}, + _onChronicleDateAdvanced: async () => {}, _onChronicleEventCreated: async () => {}, _onChronicleEventUpdated: async () => {}, _onChronicleEventDeleted: async () => {}, @@ -467,15 +326,3 @@ test('routed types never fall into the unhandled log', async () => { } assert.deepEqual(logs.filter((l) => l.includes('unhandled calendar')), []); }); - -// ── reentrancy discipline ─────────────────────────────────────────────────── - -test('every sub-resource handler unwinds the reentrant _syncDepth guard', async () => { - reset(); - const cs = makeSync({ _calendarModule: 'simple-calendar' }); - await cs._onChronicleWeatherChanged({ preset_label: 'Clear' }); - await cs._onChronicleSubresourceChanged('calendar.season.changed', { name: 'Spring' }); - await cs._onChronicleStructureUpdated('calendar.structure.updated'); - assert.equal(cs._syncDepth, 0, 'a leaked depth would mask every later local hook'); - assert.equal(cs._syncing, false); -}); diff --git a/tools/test-calendar-sync-hotfix.mjs b/tools/test-calendar-sync-hotfix.mjs deleted file mode 100644 index b182e6a..0000000 --- a/tools/test-calendar-sync-hotfix.mjs +++ /dev/null @@ -1,219 +0,0 @@ -// test-calendar-sync-hotfix.mjs — off-DOM tests for calendar-sync.mjs: date -// off-by-one, back-catalog pagination, the structure-mismatch guard, and -// _calendariaNoteToChronicleEvent wiring. -// -// Run: node --test tools/test-calendar-sync-hotfix.mjs -// -// Date normalization: raw Calendaria 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) { - // _syncDepth: 0 mirrors the constructor's reentrant-guard init (a depth - // counter read through the _syncing getter). Object.create skips the - // constructor, so seed it here. - return Object.assign( - Object.create(CalendarSync.prototype), - { _hasModernCalendariaApi: false, _syncDepth: 0 }, - 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 suppresses the echo of each note it creates, and only that note', async () => { - const echoed = []; - const unrelatedEcho = []; - globalThis.CALENDARIA = { api: { createNote: async (n) => { - // The noteCreated hook fires synchronously inside createNote. - echoed.push(cs._isCalendariaNoteEcho({ name: n.name, startDate: n.startDate })); - unrelatedEcho.push(cs._isCalendariaNoteEcho({ name: 'GM note', startDate: { year: 1492, month: 5, day: 5 } })); - return { id: 'local-1' }; - } } }; - const cs = makeCalendarSync({ - _calendarModule: 'calendaria', - _hasModernCalendariaApi: true, - _chronicleCalendar: { current_year: 1492, months: new Array(12).fill({ days: 30 }) }, - _api: { get: async () => ({ data: [{ id: 'e1', name: 'Fair', year: 1492, month: 2, day: 3 }], total: 1 }) }, - _getLocalEventId: () => null, - _storeEventMapping: async () => {}, - }); - await cs._syncChronicleEventsToCalendariaNotes(); - assert.ok(echoed.length > 0, 'at least one event was created (envelope was unwrapped)'); - assert.ok(echoed.every((v) => v === true), 'the created note\'s own hook echo is suppressed'); - assert.ok(unrelatedEcho.every((v) => v === false), 'an unrelated GM note is NOT suppressed'); - assert.equal(cs._isCalendariaNoteEcho({ name: 'Fair', startDate: { year: 1492, month: 2, day: 3 } }), false, - 'guard fully released after the loop'); - delete globalThis.CALENDARIA; -}); - -// ── 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/); -}); diff --git a/tools/test-calendar-sync-state.mjs b/tools/test-calendar-sync-state.mjs index d0fef33..f263c69 100644 --- a/tools/test-calendar-sync-state.mjs +++ b/tools/test-calendar-sync-state.mjs @@ -47,9 +47,9 @@ test('module not paused but structures differ → incompatible-structures with c assert.match(r.detail, /month count/); }); -test('incompatible-structures is reachable even when the fail-open module never paused (regression: SC path)', () => { - // The exact fail-open scenario fix 2 + fix 3 target: SimpleCalendar world, module - // read the structure too late to pause, but the dashboard can compare now. +test('incompatible-structures is reachable even when the fail-open module never paused (regression: fail-open path)', () => { + // The fail-open scenario: the module read the structure too late to pause, + // but the dashboard can compare now. const r = classifyCalendarSyncState({ paused: false, structureCmp: { match: false, detail: 'weekday count (Chronicle 7 vs Foundry 10)' }, diff --git a/tools/test-calendar-sync-visibility.mjs b/tools/test-calendar-sync-visibility.mjs index 2e9b1d6..83e19b4 100644 --- a/tools/test-calendar-sync-visibility.mjs +++ b/tools/test-calendar-sync-visibility.mjs @@ -2,19 +2,19 @@ /** * Unit tests for the wire-visibility helpers in `scripts/calendar-sync.mjs`. * - * Foundry must emit kebab `'gm-only'` on the wire, not the storage-side - * underscore `'gm_only'` — Chronicle stores `gm_only` and translates both - * ways, but the wire value is the canonical contract. + * The wire value is kebab `'gm-only'`, not the storage-side underscore + * `'gm_only'` — Chronicle stores `gm_only` and translates both ways, but the + * wire value is the canonical contract. */ import test from 'node:test'; import assert from 'node:assert/strict'; // Stub Foundry globals that calendar-sync.mjs transitively imports -// (calendar-sync imports settings.mjs → imports update-info.mjs + -// sync-calendar.mjs, which both reference `foundry.applications.api` -// at module top level). The pure helpers we're testing don't touch -// any of these — the stubs only need to satisfy import-time references. +// (calendar-sync imports settings.mjs → imports update-info.mjs, which +// references `foundry.applications.api` at module top level). The pure +// helpers we're testing don't touch any of these — the stubs only need to +// satisfy import-time references. globalThis.foundry = globalThis.foundry || { applications: { api: { @@ -33,7 +33,6 @@ globalThis.Hooks = globalThis.Hooks || { on: () => {}, off: () => {} }; const { WIRE_VISIBILITY, - chronicleVisibilityFromCalendariaNote, isWireVisibilityGmOnly, isChronicleEventPublic, } = await import('../scripts/calendar-sync.mjs'); @@ -57,62 +56,6 @@ test('WIRE_VISIBILITY does NOT contain the storage-side underscore', () => { `wire enum must never contain storage form 'gm_only', got: ${values.join(', ')}`); }); -// --------------------------------------------------------------------- -// chronicleVisibilityFromCalendariaNote — emission (Foundry → Chronicle) -// --------------------------------------------------------------------- - -test('emit: gmOnly:true note → wire "gm-only" (kebab)', () => { - const v = chronicleVisibilityFromCalendariaNote({ gmOnly: true }); - assert.equal(v, 'gm-only'); - // Negative pin: the underscore form must NEVER be emitted. - assert.notEqual(v, 'gm_only'); -}); - -test('emit: gmOnly:false note → wire "everyone"', () => { - assert.equal(chronicleVisibilityFromCalendariaNote({ gmOnly: false }), 'everyone'); -}); - -test('emit: visibility:"hidden" note → wire "gm-only"', () => { - const v = chronicleVisibilityFromCalendariaNote({ visibility: 'hidden' }); - assert.equal(v, 'gm-only'); -}); - -test('emit: visibility:"secret" note → wire "gm-only"', () => { - const v = chronicleVisibilityFromCalendariaNote({ visibility: 'secret' }); - assert.equal(v, 'gm-only'); -}); - -test('emit: visibility:"visible" note → wire "everyone"', () => { - assert.equal(chronicleVisibilityFromCalendariaNote({ visibility: 'visible' }), 'everyone'); -}); - -test('emit: gmOnly takes precedence over visibility', () => { - // gmOnly:false should override visibility:'hidden' (the operator - // explicitly set gmOnly). - assert.equal( - chronicleVisibilityFromCalendariaNote({ gmOnly: false, visibility: 'hidden' }), - 'everyone', - ); - assert.equal( - chronicleVisibilityFromCalendariaNote({ gmOnly: true, visibility: 'visible' }), - 'gm-only', - ); -}); - -test('emit: flagData-nested visibility surfaces', () => { - assert.equal( - chronicleVisibilityFromCalendariaNote({ flagData: { visibility: 'hidden' } }), - 'gm-only', - ); -}); - -test('emit: null / non-object / empty defaults to "everyone"', () => { - assert.equal(chronicleVisibilityFromCalendariaNote(null), 'everyone'); - assert.equal(chronicleVisibilityFromCalendariaNote(undefined), 'everyone'); - assert.equal(chronicleVisibilityFromCalendariaNote('string'), 'everyone'); - assert.equal(chronicleVisibilityFromCalendariaNote({}), 'everyone'); -}); - // --------------------------------------------------------------------- // isWireVisibilityGmOnly — consumption (Chronicle → Foundry) // --------------------------------------------------------------------- @@ -139,34 +82,6 @@ test('consume: missing / unknown values are NOT GM-only', () => { assert.equal(isWireVisibilityGmOnly('public'), false); }); -// --------------------------------------------------------------------- -// Regression: full Foundry → Chronicle event shape never carries underscore -// --------------------------------------------------------------------- - -test('regression: a GM-only note round-trips through wire helpers without underscore', () => { - // Mimics the shape `_calendariaNoteToChronicleEvent` would produce - // for a GM-only note. The actual private method instantiates the class - // (which needs a richer environment); the wire-emit helper is the - // testable seam. - const noteData = { name: 'Secret Meeting', gmOnly: true, visibility: 'hidden' }; - const wireVisibility = chronicleVisibilityFromCalendariaNote(noteData); - - // Build the same shape `_calendariaNoteToChronicleEvent` emits. - const wireEvent = { - name: noteData.name, - visibility: wireVisibility, - // Other fields elided. - }; - - // The full wire payload must contain "gm-only" and must NOT contain - // the underscore form anywhere. - const serialized = JSON.stringify(wireEvent); - assert.ok(serialized.includes('"gm-only"'), - `expected kebab "gm-only" in wire payload; got ${serialized}`); - assert.ok(!serialized.includes('"gm_only"'), - `wire payload must never contain underscore "gm_only"; got ${serialized}`); -}); - // --------------------------------------------------------------------- // isChronicleEventPublic — Chronicle → Foundry, fail closed // --------------------------------------------------------------------- diff --git a/tools/test-dashboard-calendar-data.mjs b/tools/test-dashboard-calendar-data.mjs new file mode 100644 index 0000000..bd0aba8 --- /dev/null +++ b/tools/test-dashboard-calendar-data.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * The dashboard's Calendar tab shows Chronicle's own date and world state: + * no Foundry calendar is read or compared, and an unavailable Chronicle + * calendar says why instead of reading as "no calendar". + * + * Run: node --test tools/test-dashboard-calendar-data.mjs + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +globalThis.foundry = globalThis.foundry || { + applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (base) => base } }, +}; +let syncCalendar = true; +globalThis.game = globalThis.game || { + settings: { get: (_scope, key) => (key === 'syncCalendar' ? syncCalendar : null), register: () => {}, registerMenu: () => {} }, + i18n: { localize: (k) => k, format: (k) => k }, + user: { isGM: true }, + modules: { get: () => null }, +}; +globalThis.Hooks = globalThis.Hooks || { on: () => {}, off: () => {}, once: () => {} }; + +const { SyncDashboard } = await import('../scripts/sync-dashboard.mjs'); +const { CalendarSync } = await import('../scripts/calendar-sync.mjs'); +const { reduceSubresourceState, emptySubresourceState } = await import('../scripts/_calendar-subresources.mjs'); + +function makeDashboard({ get, modules = [] }) { + return Object.assign(Object.create(SyncDashboard.prototype), { + _syncManager: { api: { get, dropLastErrorLogEntry() {} }, _modules: modules }, + }); +} + +// `api` is a getter on the dashboard; shadow it with the stub. +function withApi(dash, api) { + Object.defineProperty(dash, 'api', { value: api }); + return dash; +} + +test('shows Chronicle’s date and calendar name, with no Foundry date', async () => { + syncCalendar = true; + const api = { get: async () => ({ name: 'Harptos', current_year: 1492, current_month: 3, current_day: 15, current_hour: 9, current_minute: 5 }), dropLastErrorLogEntry() {} }; + const dash = withApi(makeDashboard({ get: api.get }), api); + const data = await dash._buildCalendarData(); + assert.equal(data.available, true); + assert.deepEqual(data.chronicleDate, { year: 1492, month: 3, day: 15, hour: 9, minute: 5, calendarName: 'Harptos' }); + assert.equal('localDate' in data, false); + assert.equal('module' in data, false); +}); + +test('projects the world state CalendarSync folded from the stream', async () => { + syncCalendar = true; + const cs = Object.assign(Object.create(CalendarSync.prototype), { + _subresourceState: reduceSubresourceState(emptySubresourceState(), 'calendar.season.changed', { name: 'Spring' }), + }); + const api = { get: async () => ({ current_year: 1, current_month: 1, current_day: 1 }), dropLastErrorLogEntry() {} }; + const dash = withApi(makeDashboard({ get: api.get, modules: [cs] }), api); + const data = await dash._buildCalendarData(); + assert.equal(data.worldState.has, true); + assert.equal(data.worldState.seasonName, 'Spring'); +}); + +test('calendar sync off → unavailable, no request', async () => { + syncCalendar = false; + let called = 0; + const api = { get: async () => { called += 1; return {}; } }; + const dash = withApi(makeDashboard({ get: api.get }), api); + const data = await dash._buildCalendarData(); + assert.equal(data.available, false); + assert.equal(data.enabled, false); + assert.equal(called, 0); +}); + +test('the rebuild 503 reads as rebuilding, a 404 as no calendar, anything else as unreachable', async () => { + syncCalendar = true; + const cases = [ + [Object.assign(new Error('Chronicle API error 503: {"error":"calendar_rebuilding"}'), { status: 503, code: 'calendar_rebuilding' }), 'calendarRebuilding'], + [Object.assign(new Error('Chronicle API error 404: {"error":"calendar_not_configured"}'), { status: 404 }), 'noCampaignCalendar'], + [Object.assign(new Error('Chronicle API error 500: boom'), { status: 500 }), 'calendarUnreachable'], + ]; + for (const [err, flag] of cases) { + const api = { get: async () => { throw err; }, dropLastErrorLogEntry() {} }; + const data = await withApi(makeDashboard({ get: api.get }), api)._buildCalendarData(); + assert.equal(data.available, false); + assert.equal(data[flag], true, flag); + } +}); diff --git a/tools/test-dashboard-local-calendar-date.mjs b/tools/test-dashboard-local-calendar-date.mjs deleted file mode 100644 index 9f62d20..0000000 --- a/tools/test-dashboard-local-calendar-date.mjs +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node -/** - * Regression pin: the dashboard's local-calendar-date read must use modern - * Calendaria's `globalThis.CALENDARIA.api` (`getCurrentDateTime`), not only - * the legacy `game.Calendaria.getDate()` — the legacy-only read returns null - * on Calendaria 1.x, which breaks the sync badge and the Push-date button. - * - * Static-source pin, like the other sync-dashboard tests, to avoid importing - * the heavy ApplicationV2 class. - * - * Run: node --test tools/test-dashboard-local-calendar-date.mjs - */ - -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const src = readFileSync(resolve(__dirname, '../scripts/sync-dashboard.mjs'), 'utf8'); - -/** Slice out a method body by brace-matching from its definition. */ -function methodBody(defAnchor) { - const start = src.indexOf(defAnchor); - assert.notEqual(start, -1, `method definition not found: ${defAnchor}`); - const braceStart = src.indexOf('{', start); - let depth = 0; - for (let i = braceStart; i < src.length; i++) { - if (src[i] === '{') depth++; - else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(braceStart, i + 1); } - } - throw new Error(`unbalanced braces for ${defAnchor}`); -} - -const body = methodBody('_getLocalCalendarDate(calModule) {'); - -test('_getLocalCalendarDate reads the modern Calendaria API (CALENDARIA.api.getCurrentDateTime)', () => { - assert.ok(body.includes('globalThis.CALENDARIA'), - 'must reference the modern globalThis.CALENDARIA.api surface'); - assert.ok(body.includes('getCurrentDateTime'), - 'must call getCurrentDateTime() — the method Calendaria 1.x exposes (per diagnostics)'); -}); - -test('_getLocalCalendarDate still handles the Calendaria branch', () => { - assert.ok(/calModule === 'Calendaria'/.test(body), - 'Calendaria branch must remain'); -}); - -test('_getLocalCalendarDate does not depend SOLELY on the legacy game.Calendaria.getDate', () => { - // game.Calendaria may remain as a fallback, but the modern path must exist so - // a Calendaria 1.x install resolves a date (the prior bug was legacy-only). - assert.ok(body.includes('globalThis.CALENDARIA'), - 'modern API path required; legacy game.Calendaria.getDate alone is the regressed shape'); -}); diff --git a/tools/test-import-wizard-steps.mjs b/tools/test-import-wizard-steps.mjs index 52b2b5f..8ad49a6 100644 --- a/tools/test-import-wizard-steps.mjs +++ b/tools/test-import-wizard-steps.mjs @@ -16,7 +16,7 @@ const hbs = await readFile(new URL('../templates/import-wizard.hbs', import.meta const stepKeys = [...src.matchAll(/\{\s*key:\s*'([a-z]+)',\s*labelKey:\s*'CHRONICLE\.Wizard\.Steps\./g)].map((m) => m[1]); test('step list has no maps step and ends with review', () => { - assert.deepEqual(stepKeys, ['connect', 'scan', 'types', 'tags', 'characters', 'calendar', 'review']); + assert.deepEqual(stepKeys, ['connect', 'scan', 'types', 'tags', 'characters', 'review']); }); test('template has one panel per step, numbered 0..n-1', () => { diff --git a/tools/test-lang-expand.mjs b/tools/test-lang-expand.mjs index a918295..d269b8c 100644 --- a/tools/test-lang-expand.mjs +++ b/tools/test-lang-expand.mjs @@ -125,36 +125,3 @@ test('lang/en.json has no duplicate-after-expansion paths', () => { ); } }); - -// Regression pins for specific keys prone to the dotted-vs-string -// collision. If these fail, run the sweep above to find all instances. - -test('regression: Visibility field label remains a flat string', () => { - const obj = JSON.parse(readFileSync(LANG_PATH, 'utf8')); - const v = obj?.CHRONICLE?.SyncCalendar?.NoteForm?.Visibility; - assert.equal(typeof v, 'string', 'NoteForm.Visibility must stay a string label, not an object'); -}); - -test('regression: VisibilityOption sub-object carries the three option labels', () => { - const obj = JSON.parse(readFileSync(LANG_PATH, 'utf8')); - const o = obj?.CHRONICLE?.SyncCalendar?.NoteForm?.VisibilityOption; - assert.equal(typeof o, 'object', 'NoteForm.VisibilityOption must be an object'); - assert.ok(o.Visible, 'VisibilityOption.Visible label missing'); - assert.ok(o.Hidden, 'VisibilityOption.Hidden label missing'); - assert.ok(o.Secret, 'VisibilityOption.Secret label missing'); -}); - -test('regression: DisplayStyle field label remains a flat string', () => { - const obj = JSON.parse(readFileSync(LANG_PATH, 'utf8')); - const v = obj?.CHRONICLE?.SyncCalendar?.NoteForm?.DisplayStyle; - assert.equal(typeof v, 'string', 'NoteForm.DisplayStyle must stay a string label, not an object'); -}); - -test('regression: DisplayStyleOption sub-object carries the three option labels', () => { - const obj = JSON.parse(readFileSync(LANG_PATH, 'utf8')); - const o = obj?.CHRONICLE?.SyncCalendar?.NoteForm?.DisplayStyleOption; - assert.equal(typeof o, 'object', 'NoteForm.DisplayStyleOption must be an object'); - assert.ok(o.Icon, 'DisplayStyleOption.Icon label missing'); - assert.ok(o.Pip, 'DisplayStyleOption.Pip label missing'); - assert.ok(o.Banner, 'DisplayStyleOption.Banner label missing'); -}); diff --git a/tools/test-partial-put-contract.mjs b/tools/test-partial-put-contract.mjs index 1377809..a575738 100644 --- a/tools/test-partial-put-contract.mjs +++ b/tools/test-partial-put-contract.mjs @@ -93,43 +93,21 @@ test('actor-sync: the only other key on the rename push is the concurrency token ); }); -test('calendar-sync: the Calendaria note payload stays six keys', () => { +test('calendar-sync: sends no PUT to /calendar/events; the date PUT stays five keys', () => { const src = read('scripts/calendar-sync.mjs'); - const idx = src.indexOf("name: name || 'Untitled Note',"); - assert.notEqual(idx, -1, 'the Calendaria note payload moved'); - const open = src.lastIndexOf('return {', idx); + // The module edits no event. A future event push must stay partial: only + // the fields a Foundry edit means, never an echo of the stored event. + assert.ok(!src.includes('this._api.put(`/calendar/events/'), 'an event update push appeared; pin its body here'); + const marker = "this._api.put('/calendar/date', "; + const idx = src.indexOf(marker); + assert.notEqual(idx, -1, 'the date push moved'); assert.deepEqual( - topLevelKeys(src, open), - ['day', 'description', 'month', 'name', 'visibility', 'year'].sort(), - 'the Calendaria note payload grew or shrank. A Foundry note edit means the name, the date, ' + - 'the body and the visibility — the server preserves everything absent, so echoing more is ' + - 'stale data waiting to be written.' + topLevelKeys(src, idx + marker.length), + ['day', 'hour', 'minute', 'month', 'year'], + 'the date push changed shape; it carries the date and nothing else.' ); }); -test('calendar-sync: every inline PUT body to /calendar/events stays five keys', () => { - const src = read('scripts/calendar-sync.mjs'); - // Find the PUT call sites by their URL, not by a field name — the create - // path uses the same field expressions and would otherwise be matched. - const marker = 'this._api.put(`/calendar/events/'; - const bodies = []; - for (let i = src.indexOf(marker); i !== -1; i = src.indexOf(marker, i + 1)) { - const comma = src.indexOf(', ', src.indexOf('`,', i)); - const after = src.slice(comma + 2, comma + 3); - if (after !== '{') continue; // e.g. the Calendaria path, which passes a variable - bodies.push(topLevelKeys(src, comma + 2)); - } - assert.equal(bodies.length, 2, 'expected exactly two inline PUT bodies (legacy Calendaria + SimpleCalendar)'); - for (const keys of bodies) { - assert.deepEqual( - keys, - ['day', 'description', 'month', 'name', 'year'], - 'an update push changed shape; keep it to what a Foundry note edit means. The server ' + - 'preserves every absent key now, so echoing more is stale data waiting to be written.' - ); - } -}); - test('the contract is documented where the endpoints are described', () => { // Whitespace-normalised: the doc is hard-wrapped, so a phrase may straddle // a line break without having changed. diff --git a/tools/test-realtime-date-signal.mjs b/tools/test-realtime-date-signal.mjs index 6c1ca0f..fc2b221 100644 --- a/tools/test-realtime-date-signal.mjs +++ b/tools/test-realtime-date-signal.mjs @@ -2,10 +2,7 @@ /** * `GET /calendar/date` carries `tracks_real_time` (the composed * `UsesRealTime()` predicate). This module pauses date-PUSH only - * (pull/event sync untouched) across all four push sites: - * - calendar-sync.mjs: _onCalendariaDateTimeChange, _onLocalDateChange, - * _onSimpleCalendarDateChange - * - sync-dashboard.mjs: _onPushDate + * (pull/event sync untouched) at the one push site, `CalendarSync.pushDate`. * * Covers: * 1. Skip push when tracks_real_time === true (fetch-before-push). @@ -13,15 +10,15 @@ * field at all — must not be treated as "blocked"). * 3. A 422 backstop rejection sets the guard WITHOUT throwing/logging as a * generic sync error. - * 4. The GM notice fires exactly once per session, shared across both - * calendar-sync.mjs and sync-dashboard.mjs call sites. + * 4. The GM notice fires exactly once per session, shared across + * CalendarSync instances. */ import test from 'node:test'; import assert from 'node:assert/strict'; -// Stub the Foundry globals calendar-sync.mjs / sync-dashboard.mjs touch at -// module-load time (mirrors tools/test-calendar-backcatalog-fix.mjs). +// Stub the Foundry globals calendar-sync.mjs touches at module-load time +// (mirrors tools/test-calendar-backcatalog-fix.mjs). globalThis.foundry = globalThis.foundry || { applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (base) => base } }, }; @@ -41,20 +38,15 @@ const { _resetRealtimeDateGuardForTests, } = await import('../scripts/_realtime-date-guard.mjs'); const { CalendarSync } = await import('../scripts/calendar-sync.mjs'); -const { SyncDashboard } = await import('../scripts/sync-dashboard.mjs'); function makeCalendarSync(overrides) { return Object.assign( Object.create(CalendarSync.prototype), - { _hasModernCalendariaApi: true, _syncDepth: 0, _calendarSyncDisabled: false, _isActiveCalendarExcluded: () => false }, + {}, overrides, ); } -function makeDashboard({ api, ...overrides }) { - return Object.assign(Object.create(SyncDashboard.prototype), { _syncManager: { api } }, overrides); -} - // A fake ChronicleAPI: `get` answers the fetch-before-push probe, `put` // records pushes (or throws, per test). function makeApi({ getResult, getError, putImpl } = {}) { @@ -78,6 +70,7 @@ function makeApi({ getResult, getError, putImpl } = {}) { test.beforeEach(() => { _resetRealtimeDateGuardForTests(); + globalThis.game.settings.get = () => true; // syncCalendar on globalThis.ui = { notifications: { warn: () => {} } }; }); @@ -141,17 +134,15 @@ test('notifyRealTimePushPaused: fires the notification exactly once per session' assert.equal(warnCount, 1); }); -// ── calendar-sync.mjs: the three hook-triggered push sites ───────────────── +// ── calendar-sync.mjs: the push site ─────────────────────────────────────── for (const [label, method, payload] of [ - ['_onCalendariaDateTimeChange', 'onCalendariaDateTimeChange', { current: { year: 1492, month: 2, dayOfMonth: 14, hour: 9, minute: 30 } }], - ['_onLocalDateChange', 'onLocalDateChange', { year: 1492, month: 3, day: 15, hour: 9, minute: 30 }], - ['_onSimpleCalendarDateChange', 'onSimpleCalendarDateChange', { date: { year: 1492, month: 2, day: 14, hour: 9, minute: 30 } }], + ['pushDate', 'pushDate', { year: 1492, month: 3, day: 15, hour: 9, minute: 30 }], ]) { test(`${label}: skips the push when GET /calendar/date reports tracks_real_time`, async () => { const api = makeApi({ getResult: { tracks_real_time: true } }); const cs = makeCalendarSync({ _api: api }); - await cs[`_${method}`](payload); + await cs[method](payload); assert.equal(api.puts.length, 0, 'must not PUT when the calendar tracks real time'); assert.deepEqual(api.gets, ['/calendar/date'], 'must probe before deciding to skip'); }); @@ -159,14 +150,14 @@ for (const [label, method, payload] of [ test(`${label}: proceeds when tracks_real_time is false/absent`, async () => { const api = makeApi({ getResult: { tracks_real_time: false } }); const cs = makeCalendarSync({ _api: api }); - await cs[`_${method}`](payload); + await cs[method](payload); assert.equal(api.puts.length, 1, 'must still PUT when the flag is false'); }); test(`${label}: legacy Chronicle (field entirely absent) still proceeds`, async () => { const api = makeApi({ getResult: { year: 1492, month: 3, day: 15 } }); const cs = makeCalendarSync({ _api: api }); - await cs[`_${method}`](payload); + await cs[method](payload); assert.equal(api.puts.length, 1); }); @@ -176,7 +167,7 @@ for (const [label, method, payload] of [ putImpl: () => { throw new Error('Chronicle API error 422: {"message":"real-time calendar is read-only for dates"}'); }, }); const cs = makeCalendarSync({ _api: api }); - await assert.doesNotReject(cs[`_${method}`](payload)); + await assert.doesNotReject(cs[method](payload)); }); test(`${label}: a genuine non-422 PUT failure still logs as an error (not swallowed as the guard)`, async () => { @@ -189,7 +180,7 @@ for (const [label, method, payload] of [ let loggedErr = null; console.error = (...args) => { loggedErr = args; }; try { - await assert.doesNotReject(cs[`_${method}`](payload)); + await assert.doesNotReject(cs[method](payload)); } finally { console.error = originalError; } @@ -197,78 +188,16 @@ for (const [label, method, payload] of [ }); } -// ── sync-dashboard.mjs: the manual push button ────────────────────────────── - -test('SyncDashboard._onPushDate: skips the push and does not log activity when tracks_real_time', async () => { - const api = makeApi({ getResult: { tracks_real_time: true } }); - const activities = []; - let rendered = false; - const dash = makeDashboard({ - api, - _detectCalendarModule: () => 'calendaria', - _getLocalCalendarDate: () => ({ year: 1492, month: 3, day: 15, hour: 9, minute: 30 }), - _logActivity: (...args) => activities.push(args), - render: () => { rendered = true; }, - }); - await dash._onPushDate(); - assert.equal(api.puts.length, 0); - assert.equal(activities.length, 0); - assert.equal(rendered, false); -}); +// ── The notice is a shared, session-scoped singleton ─────────────────────── -test('SyncDashboard._onPushDate: proceeds and logs activity when not real-time', async () => { - const api = makeApi({ getResult: { tracks_real_time: false } }); - const activities = []; - let rendered = false; - const dash = makeDashboard({ - api, - _detectCalendarModule: () => 'calendaria', - _getLocalCalendarDate: () => ({ year: 1492, month: 3, day: 15, hour: 9, minute: 30 }), - _logActivity: (...args) => activities.push(args), - render: () => { rendered = true; }, - }); - await dash._onPushDate(); - assert.equal(api.puts.length, 1); - assert.equal(activities.length, 1); - assert.equal(rendered, true); -}); - -test('SyncDashboard._onPushDate: a 422 backstop sets the guard without throwing or logging activity', async () => { - const api = makeApi({ - getResult: { tracks_real_time: false }, - putImpl: () => { throw new Error('Chronicle API error 422: real-time calendar'); }, - }); - const activities = []; - const dash = makeDashboard({ - api, - _detectCalendarModule: () => 'calendaria', - _getLocalCalendarDate: () => ({ year: 1492, month: 3, day: 15, hour: 9, minute: 30 }), - _logActivity: (...args) => activities.push(args), - render: () => {}, - }); - await assert.doesNotReject(dash._onPushDate()); - assert.equal(activities.length, 0, 'must not log a successful push that never happened'); -}); - -// ── Cross-module: the notice is a shared, session-scoped singleton ───────── - -test('the one-time notice is shared across calendar-sync.mjs AND sync-dashboard.mjs call sites', async () => { +test('the one-time notice is shared across CalendarSync instances', async () => { let warnCount = 0; globalThis.ui = { notifications: { warn: () => { warnCount += 1; } } }; - const api1 = makeApi({ getResult: { tracks_real_time: true } }); - const cs = makeCalendarSync({ _api: api1 }); - await cs._onCalendariaDateTimeChange({ current: { year: 1492, month: 2, dayOfMonth: 14, hour: 9, minute: 30 } }); + const date = { year: 1492, month: 3, day: 15, hour: 9, minute: 30 }; + await makeCalendarSync({ _api: makeApi({ getResult: { tracks_real_time: true } }) }).pushDate(date); assert.equal(warnCount, 1, 'first blocked push notifies'); - const api2 = makeApi({ getResult: { tracks_real_time: true } }); - const dash = makeDashboard({ - api: api2, - _detectCalendarModule: () => 'calendaria', - _getLocalCalendarDate: () => ({ year: 1492, month: 3, day: 15 }), - _logActivity: () => {}, - render: () => {}, - }); - await dash._onPushDate(); - assert.equal(warnCount, 1, 'the dashboard push reuses the same already-shown session notice'); + await makeCalendarSync({ _api: makeApi({ getResult: { tracks_real_time: true } }) }).pushDate(date); + assert.equal(warnCount, 1, 'a second instance reuses the already-shown session notice'); }); diff --git a/tools/test-scene-controls.mjs b/tools/test-scene-controls.mjs index 7538bc5..f9d88a8 100644 --- a/tools/test-scene-controls.mjs +++ b/tools/test-scene-controls.mjs @@ -4,8 +4,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { addChronicleControls } from '../scripts/_scene-controls.mjs'; -const run = { dashboard() {}, syncCalendar() {}, dmScreen() {}, notebook() {} }; -const titles = { syncCalendar: 'Open Sync Calendar', dmScreen: 'Open the DM Screen', notebook: 'Open my Chronicle notebook' }; +const run = { dashboard() {}, dmScreen() {}, notebook() {} }; +const titles = { dmScreen: 'Open the DM Screen', notebook: 'Open my Chronicle notebook' }; function names(controls) { if (Array.isArray(controls)) { @@ -17,8 +17,8 @@ function names(controls) { } const CASES = [ - ['GM, connected', { isGM: true, notebook: true }, ['dashboard', 'sync-calendar', 'dm-screen', 'notebook']], - ['GM, not connected', { isGM: true, notebook: false }, ['dashboard', 'sync-calendar', 'dm-screen']], + ['GM, connected', { isGM: true, notebook: true }, ['dashboard', 'dm-screen', 'notebook']], + ['GM, not connected', { isGM: true, notebook: false }, ['dashboard', 'dm-screen']], ['player, connected', { isGM: false, notebook: true }, ['notebook']], ['player, not connected', { isGM: false, notebook: false }, null], ]; @@ -45,8 +45,6 @@ test('v12 tools use onClick; v13 tools use onChange and name an activeTool', () addChronicleControls(obj, { isGM: true, notebook: true, run, titles }); const g = obj['chronicle-sync']; assert.equal(g.activeTool, 'dashboard'); - assert.equal(g.tools['sync-calendar'].onChange, run.syncCalendar); - assert.equal(g.tools['sync-calendar'].icon, 'fa-solid fa-calendar-days'); assert.equal(g.tools.notebook.title, titles.notebook); assert.equal(g.tools['dm-screen'].onChange, run.dmScreen); assert.equal(g.tools['dm-screen'].title, titles.dmScreen); diff --git a/tools/test-sync-calendar-condition-builder.mjs b/tools/test-sync-calendar-condition-builder.mjs deleted file mode 100644 index 2091ab0..0000000 --- a/tools/test-sync-calendar-condition-builder.mjs +++ /dev/null @@ -1,326 +0,0 @@ -#!/usr/bin/env node -/** - * Unit tests for the conditionTree pure builder. - * - * Run: `node --test tools/test-sync-calendar-condition-builder.mjs` - */ - -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { - FIELDS, - OPERATORS, - GROUP_MODES, - PRESETS, - emptyRootGroup, - leaf, - treeFromPreset, - validateTree, - treeToSummary, -} from '../scripts/sync-calendar-condition-builder.mjs'; - -// --------------------------------------------------------------------- -// Constants / enums -// --------------------------------------------------------------------- - -test('FIELDS, OPERATORS, GROUP_MODES, PRESETS are frozen enums', () => { - assert.ok(Object.isFrozen(FIELDS)); - assert.ok(Object.isFrozen(OPERATORS)); - assert.ok(Object.isFrozen(GROUP_MODES)); - assert.ok(Object.isFrozen(PRESETS)); -}); - -test('OPERATORS covers v1 scope: ==, !=, %, in', () => { - assert.deepEqual( - Object.values(OPERATORS).sort(), - ['!=', '%', '==', 'in'], - ); -}); - -test('FIELDS covers v1 scope: weekday, monthDay, month, year, season, moonPhase, cycleStage', () => { - assert.deepEqual( - new Set(Object.values(FIELDS)), - new Set(['weekday', 'monthDay', 'month', 'year', 'season', 'moonPhase', 'cycleStage']), - ); -}); - -// --------------------------------------------------------------------- -// Constructors -// --------------------------------------------------------------------- - -test('emptyRootGroup returns a valid empty AND root', () => { - const t = emptyRootGroup(); - assert.equal(t.type, 'group'); - assert.equal(t.mode, 'and'); - assert.deepEqual(t.children, []); -}); - -test('emptyRootGroup accepts an OR mode', () => { - const t = emptyRootGroup(GROUP_MODES.OR); - assert.equal(t.mode, 'or'); -}); - -test('leaf produces a 3-key condition without value2 by default', () => { - const l = leaf('weekday', '==', 5); - assert.deepEqual(l, { type: 'condition', field: 'weekday', op: '==', value: 5 }); -}); - -test('leaf with value2 carries the secondary operand', () => { - const l = leaf('moonPhase', '==', 0.5, 0); - assert.equal(l.value2, 0); -}); - -// --------------------------------------------------------------------- -// treeFromPreset — happy paths (one per preset) -// --------------------------------------------------------------------- - -test('preset: every weekday (3rd of the week)', () => { - const t = treeFromPreset(PRESETS.EVERY_WEEKDAY, { weekdayOrdinal: 3 }); - assert.deepEqual(t, { - type: 'group', - mode: 'and', - children: [{ type: 'condition', field: 'weekday', op: '==', value: 3 }], - }); -}); - -test('preset: every 15th of every month', () => { - const t = treeFromPreset(PRESETS.EVERY_NTH_OF_MONTH, { monthDay: 15 }); - assert.deepEqual(t.children, [ - { type: 'condition', field: 'monthDay', op: '==', value: 15 }, - ]); -}); - -test('preset: every January (monthOrdinal 1)', () => { - const t = treeFromPreset(PRESETS.EVERY_MONTH, { monthOrdinal: 1 }); - assert.deepEqual(t.children, [ - { type: 'condition', field: 'month', op: '==', value: 1 }, - ]); -}); - -test('preset: every "spring" season', () => { - const t = treeFromPreset(PRESETS.EVERY_SEASON, { seasonId: 'spring-id' }); - assert.deepEqual(t.children, [ - { type: 'condition', field: 'season', op: '==', value: 'spring-id' }, - ]); -}); - -test('preset: every full moon (Lacrimosa, index 0)', () => { - const t = treeFromPreset(PRESETS.EVERY_FULL_MOON, { moonIndex: 0 }); - assert.equal(t.children.length, 1); - assert.deepEqual(t.children[0], { - type: 'condition', field: 'moonPhase', op: '==', value: 0.5, value2: 0, - }); -}); - -test('preset: every new moon (Umbra, index 2)', () => { - const t = treeFromPreset(PRESETS.EVERY_NEW_MOON, { moonIndex: 2 }); - assert.deepEqual(t.children[0], { - type: 'condition', field: 'moonPhase', op: '==', value: 0, value2: 2, - }); -}); - -test('preset: every 3rd weekday of winter season', () => { - const t = treeFromPreset(PRESETS.EVERY_NTH_WEEKDAY_OF_SEASON, { - weekdayOrdinal: 3, - seasonId: 'winter-id', - }); - assert.equal(t.children.length, 2); - assert.deepEqual(t.children[0], { type: 'condition', field: 'weekday', op: '==', value: 3 }); - assert.deepEqual(t.children[1], { type: 'condition', field: 'season', op: '==', value: 'winter-id' }); -}); - -test('preset: every cycleStage "feast-day"', () => { - const t = treeFromPreset(PRESETS.EVERY_CYCLE_STAGE, { cycleStage: 'feast-day' }); - assert.deepEqual(t.children, [ - { type: 'condition', field: 'cycleStage', op: '==', value: 'feast-day' }, - ]); -}); - -// --------------------------------------------------------------------- -// treeFromPreset — bad params return null -// --------------------------------------------------------------------- - -test('preset: missing weekdayOrdinal → null', () => { - assert.equal(treeFromPreset(PRESETS.EVERY_WEEKDAY, {}), null); - assert.equal(treeFromPreset(PRESETS.EVERY_WEEKDAY, { weekdayOrdinal: 0 }), null); - assert.equal(treeFromPreset(PRESETS.EVERY_WEEKDAY, { weekdayOrdinal: -1 }), null); - assert.equal(treeFromPreset(PRESETS.EVERY_WEEKDAY, { weekdayOrdinal: 1.5 }), null); -}); - -test('preset: missing monthDay → null', () => { - assert.equal(treeFromPreset(PRESETS.EVERY_NTH_OF_MONTH, {}), null); -}); - -test('preset: missing seasonId / empty string → null', () => { - assert.equal(treeFromPreset(PRESETS.EVERY_SEASON, {}), null); - assert.equal(treeFromPreset(PRESETS.EVERY_SEASON, { seasonId: '' }), null); - assert.equal(treeFromPreset(PRESETS.EVERY_SEASON, { seasonId: 42 }), null); -}); - -test('preset: missing moonIndex / negative → null', () => { - assert.equal(treeFromPreset(PRESETS.EVERY_FULL_MOON, {}), null); - assert.equal(treeFromPreset(PRESETS.EVERY_FULL_MOON, { moonIndex: -1 }), null); -}); - -test('preset: every-nth-weekday-of-season needs BOTH params', () => { - assert.equal(treeFromPreset(PRESETS.EVERY_NTH_WEEKDAY_OF_SEASON, { weekdayOrdinal: 1 }), null); - assert.equal(treeFromPreset(PRESETS.EVERY_NTH_WEEKDAY_OF_SEASON, { seasonId: 'x' }), null); -}); - -test('preset: cycleStage missing → null', () => { - assert.equal(treeFromPreset(PRESETS.EVERY_CYCLE_STAGE, {}), null); -}); - -test('preset: unknown id throws', () => { - assert.throws( - () => treeFromPreset('not-a-real-preset', {}), - /Unknown preset/, - ); -}); - -// --------------------------------------------------------------------- -// validateTree — Calendaria's root-group rule (pinned) -// --------------------------------------------------------------------- - -test('validateTree: empty/missing root yields NotAnObject', () => { - assert.deepEqual(validateTree(null), ['CHRONICLE.SyncCalendar.Recurrence.Errors.NotAnObject']); - assert.deepEqual(validateTree(undefined), ['CHRONICLE.SyncCalendar.Recurrence.Errors.NotAnObject']); - assert.deepEqual(validateTree('string'), ['CHRONICLE.SyncCalendar.Recurrence.Errors.NotAnObject']); -}); - -test('validateTree: NEGATIVE — non-group root flagged (silently ignored by Calendaria)', () => { - // This is THE check that matters most. Calendaria's matcher - // silently ignores conditionTrees with a non-group root — the - // operator gets a note that never fires. Our validator catches it - // before save. - const bad = { type: 'condition', field: 'weekday', op: '==', value: 1 }; - assert.deepEqual(validateTree(bad), ['CHRONICLE.SyncCalendar.Recurrence.Errors.RootMustBeGroup']); -}); - -test('validateTree: POSITIVE — well-formed group passes', () => { - const tree = treeFromPreset(PRESETS.EVERY_FULL_MOON, { moonIndex: 1 }); - assert.deepEqual(validateTree(tree), []); -}); - -test('validateTree: empty group flagged', () => { - const tree = emptyRootGroup(); - assert.deepEqual(validateTree(tree), ['CHRONICLE.SyncCalendar.Recurrence.Errors.EmptyGroup']); -}); - -test('validateTree: invalid group mode flagged', () => { - const tree = { type: 'group', mode: 'xor', children: [leaf('weekday', '==', 1)] }; - const errs = validateTree(tree); - assert.ok(errs.includes('CHRONICLE.SyncCalendar.Recurrence.Errors.InvalidGroupMode')); -}); - -test('validateTree: leaf with unknown field flagged (out of v1 scope)', () => { - const tree = { type: 'group', mode: 'and', children: [leaf('eclipse', '==', 'total')] }; - const errs = validateTree(tree); - assert.ok(errs.includes('CHRONICLE.SyncCalendar.Recurrence.Errors.FieldNotInV1Scope')); -}); - -test('validateTree: leaf with unknown operator flagged', () => { - const tree = { type: 'group', mode: 'and', children: [leaf('weekday', 'daysAgo', 7)] }; - const errs = validateTree(tree); - assert.ok(errs.includes('CHRONICLE.SyncCalendar.Recurrence.Errors.OperatorNotInV1Scope')); -}); - -test('validateTree: leaf with missing value flagged', () => { - const tree = { type: 'group', mode: 'and', children: [leaf('weekday', '==', '')] }; - const errs = validateTree(tree); - assert.ok(errs.includes('CHRONICLE.SyncCalendar.Recurrence.Errors.MissingValue')); -}); - -test('validateTree: nested group at depth 2 OK; depth 3 flagged', () => { - const deep = { - type: 'group', - mode: 'and', - children: [ - { type: 'group', mode: 'or', children: [ - { type: 'group', mode: 'and', children: [leaf('weekday', '==', 1)] }, - ] }, - ], - }; - const errs = validateTree(deep); - assert.ok(errs.includes('CHRONICLE.SyncCalendar.Recurrence.Errors.TooDeep')); -}); - -test('validateTree: depth-2 nesting passes', () => { - const tree = { - type: 'group', - mode: 'and', - children: [ - { type: 'group', mode: 'or', children: [ - leaf('weekday', '==', 1), - leaf('weekday', '==', 6), - ] }, - leaf('season', '==', 'winter-id'), - ], - }; - assert.deepEqual(validateTree(tree), []); -}); - -test('validateTree: non-object child flagged', () => { - const tree = { type: 'group', mode: 'and', children: ['not an object'] }; - const errs = validateTree(tree); - assert.ok(errs.includes('CHRONICLE.SyncCalendar.Recurrence.Errors.InvalidChild')); -}); - -test('validateTree: child with bogus type flagged', () => { - const tree = { type: 'group', mode: 'and', children: [{ type: 'rocketship', op: '==' }] }; - const errs = validateTree(tree); - assert.ok(errs.includes('CHRONICLE.SyncCalendar.Recurrence.Errors.InvalidChildType')); -}); - -// --------------------------------------------------------------------- -// treeToSummary — readable round-trip -// --------------------------------------------------------------------- - -test('summary: empty tree', () => { - assert.equal(treeToSummary(null), '(no recurrence)'); - assert.equal(treeToSummary({ type: 'group', mode: 'and', children: [] }), '(empty group)'); -}); - -test('summary: non-group root flagged', () => { - assert.equal( - treeToSummary({ type: 'condition', field: 'weekday', op: '==', value: 1 }), - '(invalid recurrence — root must be a group)', - ); -}); - -test('summary: single-leaf tree', () => { - const tree = treeFromPreset(PRESETS.EVERY_WEEKDAY, { weekdayOrdinal: 5 }); - assert.equal(treeToSummary(tree), 'weekday == 5'); -}); - -test('summary: multi-leaf AND', () => { - const tree = treeFromPreset(PRESETS.EVERY_NTH_WEEKDAY_OF_SEASON, { - weekdayOrdinal: 3, seasonId: 'winter-id', - }); - assert.equal(treeToSummary(tree), 'weekday == 3 AND season == "winter-id"'); -}); - -test('summary: OR group uses OR joiner', () => { - const tree = { - type: 'group', mode: 'or', - children: [leaf('weekday', '==', 1), leaf('weekday', '==', 6)], - }; - assert.equal(treeToSummary(tree), 'weekday == 1 OR weekday == 6'); -}); - -test('summary: moon-phase value renders numerically', () => { - const tree = treeFromPreset(PRESETS.EVERY_FULL_MOON, { moonIndex: 0 }); - assert.equal(treeToSummary(tree), 'moonPhase == 0.5'); -}); - -test('summary: nested group renders parenthesized', () => { - const tree = { - type: 'group', mode: 'and', - children: [ - { type: 'group', mode: 'or', children: [leaf('weekday', '==', 1), leaf('weekday', '==', 6)] }, - leaf('season', '==', 'winter-id'), - ], - }; - assert.equal(treeToSummary(tree), '(weekday == 1 OR weekday == 6) AND season == "winter-id"'); -}); diff --git a/tools/test-sync-calendar-diagnostics.mjs b/tools/test-sync-calendar-diagnostics.mjs deleted file mode 100644 index 3fc0cef..0000000 --- a/tools/test-sync-calendar-diagnostics.mjs +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env node -/** - * Unit tests for the pure diagnostics report builder in - * `scripts/sync-calendar-diagnostics.mjs`. - * - * Run: `node --test tools/test-sync-calendar-diagnostics.mjs` - * Uses Node's built-in `node:test` (Node ≥ 18); no Foundry globals needed — - * the builder is pure. - */ - -import test from 'node:test'; -import assert from 'node:assert/strict'; - -const { buildCalendarDiagnostics } = await import('../scripts/sync-calendar-diagnostics.mjs'); - -test('empty input never throws and produces a titled report', () => { - const out = buildCalendarDiagnostics(); - assert.match(out, /# Chronicle Sync — Calendar Diagnostics/); - assert.match(out, /## Validation findings \(0\)/); - assert.match(out, /## Sync status/); -}); - -test('null/garbage input is tolerated', () => { - assert.doesNotThrow(() => buildCalendarDiagnostics(null)); - assert.doesNotThrow(() => buildCalendarDiagnostics('nope')); - assert.doesNotThrow(() => buildCalendarDiagnostics(42)); -}); - -test('versions and calendar identity surface', () => { - const out = buildCalendarDiagnostics({ - versions: { module: '1.2.3', calendaria: '14.5', schema: '2', foundry: '13.300', system: 'D&D 5e', systemId: 'dnd5e' }, - calendar: { name: 'Calendar of Therin', id: 'therin' }, - }); - assert.match(out, /Calendaria:\*\* 14\.5/); - assert.match(out, /Calendar of Therin/); - assert.match(out, /dnd5e/); -}); - -test('findings are grouped by severity with counts', () => { - const out = buildCalendarDiagnostics({ - findings: [ - { severity: 'error', code: 'E1', message: 'bad thing', fixHint: 'do x' }, - { severity: 'warning', code: 'W1', message: 'iffy thing' }, - { severity: 'info', code: 'I1', message: 'fyi' }, - { severity: 'error', code: 'E2', message: 'another bad' }, - ], - }); - assert.match(out, /## Validation findings \(4\)/); - assert.match(out, /### ERROR \(2\)/); - assert.match(out, /### WARNING \(1\)/); - assert.match(out, /### INFO \(1\)/); - assert.match(out, /`E1` — bad thing _\(fix: do x\)_/); -}); - -test('weather-zone note clarifies the count semantics', () => { - const out = buildCalendarDiagnostics({ structureCounts: { weatherZones: 0 } }); - assert.match(out, /Weather zones \(custom climate zones\):\*\* 0/); - assert.match(out, /per-day weather .* is not a zone/i); -}); - -test('selected-day weather distinguishes current vs selected date', () => { - const out = buildCalendarDiagnostics({ - selectedDate: { year: 1492, month: 1, day: 5 }, - dayDetail: { - weatherCurrent: { label: 'Clear' }, - weatherForDate: { label: 'Meteor Shower' }, - notes: [{ name: 'Day of Rebirth' }], - moonPhases: [{ moonName: 'Selûne', phaseName: 'Full' }], - }, - }); - assert.match(out, /Weather \(current date\):\*\* Clear/); - assert.match(out, /Weather \(selected date\):\*\* Meteor Shower/); - assert.match(out, /Day of Rebirth/); - assert.match(out, /Selûne: Full/); -}); - -test('api method probe lists present and missing', () => { - const out = buildCalendarDiagnostics({ - apiMethods: { getWeatherForDate: false, getCurrentWeather: true, getAllMoonPhases: true }, - }); - assert.match(out, /Present:\*\* getCurrentWeather, getAllMoonPhases/); - assert.match(out, /Missing:\*\* getWeatherForDate/); -}); - -test('recent errors are listed and capped', () => { - const errors = Array.from({ length: 30 }, (_, n) => ({ time: `t${n}`, message: `err ${n}`, endpoint: '/x', status: 500 })); - const out = buildCalendarDiagnostics({ recentErrors: errors }); - assert.match(out, /## Recent sync errors \(30\)/); - assert.match(out, /err 0 \(\/x 500\)/); - // capped at 25 lines rendered - assert.ok(!out.includes('err 27')); -}); - -test('raw appendix is emitted as a json block when present', () => { - const out = buildCalendarDiagnostics({ raw: { weather: { preset_id: 'meteor_shower', zone: '_default' } } }); - assert.match(out, /## Raw \(verbatim object shapes\)/); - assert.match(out, /"preset_id": "meteor_shower"/); -}); - -test('boolean sync status renders yes/no/unknown', () => { - const out = buildCalendarDiagnostics({ syncStatus: { calendarSyncEnabled: true, syncEnabled: false } }); - assert.match(out, /Calendar sync enabled \(global\):\*\* yes/); - assert.match(out, /Master sync enabled:\*\* no/); - assert.match(out, /This calendar excluded from sync:\*\* \(unknown\)/); -}); diff --git a/tools/test-sync-calendar-discoverability.mjs b/tools/test-sync-calendar-discoverability.mjs deleted file mode 100644 index 0e285b8..0000000 --- a/tools/test-sync-calendar-discoverability.mjs +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env node -/** - * Static-source pins (string-grep, no Foundry runtime) for the two - * affordances that surface `SyncCalendarApplication` outside the settings - * menu: the dashboard's Calendar-tab "Open Sync Calendar" button - * (`data-action="open-sync-calendar"` wired to `openSyncCalendar()`), and - * the scene-controls `sync-calendar` tool in the `chronicle-sync` group. - * Guards against silent removal; behavior testing is operator-side. - */ - -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, resolve } from 'node:path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = resolve(__dirname, '..'); - -const dashboardTemplate = readFileSync(resolve(REPO_ROOT, 'templates/sync-dashboard.hbs'), 'utf8'); -const dashboardScript = readFileSync(resolve(REPO_ROOT, 'scripts/sync-dashboard.mjs'), 'utf8'); -const moduleScript = readFileSync(resolve(REPO_ROOT, 'scripts/module.mjs'), 'utf8'); -const langJson = JSON.parse(readFileSync(resolve(REPO_ROOT, 'lang/en.json'), 'utf8')); - -// --------------------------------------------------------------------- -// (1) Dashboard Calendar tab button -// --------------------------------------------------------------------- - -test('dashboard Calendar tab contains the Open Sync Calendar button', () => { - // The button lives at the top of the data-tab="calendar" block. - assert.match( - dashboardTemplate, - /data-tab="calendar"[\s\S]*?data-action="open-sync-calendar"/, - 'templates/sync-dashboard.hbs: ` + : ''); + const select = v.gm && !v.readOnly + ? `` + : ''; + return `${arrows(-1)} + + ${arrows(1)}${select}`; +} + +/** Draw (or remove) the bar for the current view. */ +export function renderCalendarBar() { + const v = currentView(); + if (!v) { + bar?.remove(); bar = null; closeMonth(); + return; + } + if (!bar) { + bar = document.createElement('div'); + bar.id = 'chronicle-calendar-bar'; + bar.setAttribute('role', 'group'); + bar.setAttribute('aria-label', t('Label')); + document.body.appendChild(bar); + bar.addEventListener('click', onBarClick); + bar.addEventListener('change', (ev) => { + if (ev.target.matches('.ccal-unit')) { step = ev.target.value; renderCalendarBar(); } + }); + } + bar.style.setProperty('--ccal-sky', skyGradient(v.calendar, v.date.hour)); + bar.classList.toggle('ccal-gm', v.gm); + bar.innerHTML = barHtml(v); + if (popover) renderMonth(); +} + +async function onBarClick(ev) { + const stepBtn = ev.target.closest('.ccal-step'); + if (stepBtn && source) { + stepBtn.disabled = true; + const ok = await source.step(step, Number(stepBtn.dataset.dir)); + if (!ok) ui.notifications.warn(t('StepRefused')); + renderCalendarBar(); + return; + } + if (ev.target.closest('.ccal-face')) { + if (popover) closeMonth(); else openMonth(); + } +} + +function openMonth() { + const v = currentView(); + if (!v) return; + shown = { y: v.date.year, m: v.date.month }; + popover = document.createElement('div'); + popover.id = 'chronicle-calendar-month'; + popover.setAttribute('role', 'dialog'); + popover.setAttribute('aria-label', t('MonthLabel')); + document.body.appendChild(popover); + popover.addEventListener('click', onMonthClick); + renderMonth(); + requestAnimationFrame(() => popover?.classList.add('open')); + setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0); + document.addEventListener('keydown', onKey, true); + bar?.querySelector('.ccal-face')?.setAttribute('aria-expanded', 'true'); +} + +function closeMonth() { + document.removeEventListener('pointerdown', onOutside, true); + document.removeEventListener('keydown', onKey, true); + popover?.remove(); + popover = null; + bar?.querySelector('.ccal-face')?.setAttribute('aria-expanded', 'false'); +} + +// Nothing in the month view is ever unsaved, so a click outside just closes it. +function onOutside(ev) { + if (popover?.contains(ev.target) || bar?.contains(ev.target)) return; + closeMonth(); +} + +function onKey(ev) { + if (ev.key === 'Escape' && popover) { ev.stopPropagation(); closeMonth(); } +} + +function monthEvents(v, y, m) { + const key = `${y}-${m}`; + if (v.gm && extraMonths.has(key)) return extraMonths.get(key); + return (v.events || []).filter((e) => e.year === y && e.month === m); +} + +/** GM: load a month outside the nearby window once, then redraw. */ +async function ensureMonth(v, y, m) { + if (!v.gm || !source?._api) return; + const key = `${y}-${m}`; + if (extraMonths.has(key) || (v.events || []).some((e) => e.year === y && e.month === m)) return; + extraMonths.set(key, []); + try { + const payload = await source._api.get(`/calendar/events?year=${y}&month=${m}`); + extraMonths.set(key, Array.isArray(payload) ? payload : (payload?.data || [])); + renderMonth(); + } catch (err) { + console.debug('Chronicle: month events read failed', err?.message); + } +} + +function renderMonth() { + const v = currentView(); + if (!popover || !v || !shown) { closeMonth(); return; } + const g = monthGrid(v.calendar, shown.y, shown.m); + const evs = monthEvents(v, shown.y, shown.m); + ensureMonth(v, shown.y, shown.m); + const isToday = (d) => shown.y === v.date.year && shown.m === v.date.month && d === v.date.day; + const cell = (d) => { + if (!d) return ''; + const list = eventsOn(evs, shown.y, shown.m, d); + const dots = list.slice(0, 3).map((e) => + `${esc(e.name)}`).join(''); + const more = list.length > 3 ? `${esc(t('More', { count: list.length - 3 }))}` : ''; + return `${d}${dots}${more}`; + }; + const head = g.headers.length ? `${g.headers.map((h) => `${esc(h)}`).join('')}` : ''; + const body = g.rows.map((r) => `${r.map(cell).join('')}`).join(''); + const gmTools = v.gm + ? `
+
` + : ''; + popover.innerHTML = `
+ +

${esc(monthName(v.calendar, shown.m))} ${shown.y}

+ +
+ ${head}${body}
+ ${gmTools}`; +} + +async function onMonthClick(ev) { + const btn = ev.target.closest('button[data-act]'); + if (!btn) return; + const v = currentView(); + if (!v) return; + const act = btn.dataset.act; + if (act === 'prev' || act === 'next') { + shown = shiftMonth(v.calendar, shown.y, shown.m, act === 'prev' ? -1 : 1); + renderMonth(); + } else if (act === 'set' && source) { + await setDateDialog(v); + } else if (act === 'add' && source) { + await addEventDialog(v); + } +} + +function dateFields(v, d, withTime) { + const months = v.calendar.months.map((m, i) => + ``).join(''); + const time = withTime + ? `
+ : +
` + : ''; + return `
+ + +
${time}`; +} + +function readDate(root) { + const f = root?.querySelector('form') ?? root; + const val = (n) => f?.querySelector(`[name="${n}"]`)?.value; + const opt = (n) => (val(n) === '' || val(n) === undefined ? null : Number(val(n))); + return { + year: Number(val('year')), month: Number(val('month')), day: Number(val('day')), + hour: opt('hour'), minute: opt('minute'), + name: val('name') || '', gmOnly: !!f?.querySelector('[name="gmOnly"]')?.checked, + }; +} + +async function setDateDialog(v) { + const res = await promptDialog({ + title: t('SetDate'), + content: `
${dateFields(v, v.date, true)}
`, + label: t('SetDate'), + callback: readDate, + }); + if (!res) return; + const ok = await source.setDate({ ...res, hour: res.hour ?? 0, minute: res.minute ?? 0 }); + if (!ok) ui.notifications.warn(t('SetRefused')); +} + +async function addEventDialog(v) { + const day = shown && !(shown.y === v.date.year && shown.m === v.date.month) + ? { year: shown.y, month: shown.m, day: 1 } : { ...v.date, hour: null, minute: null }; + const res = await promptDialog({ + title: t('AddEvent'), + content: `
+
+ ${dateFields(v, day, true)} +
+
`, + label: t('AddEvent'), + callback: readDate, + }); + if (!res) return; + if (!res.name.trim()) { ui.notifications.warn(t('NameNeeded')); return; } + const ok = await source.addEvent({ ...res, hour: Number.isFinite(res.hour) ? res.hour : undefined }); + if (ok) ui.notifications.info(t('EventAdded', { name: res.name.trim() })); + else ui.notifications.warn(t('AddRefused')); +} + +/** + * Mount the built-in calendar. Every user calls this; the GM passes the live + * CalendarSync, players draw from the shared snapshot. + * @param {import('./calendar-sync.mjs').CalendarSync|null} calendarSync + */ +export function registerCalendarBar(calendarSync) { + source = game.user.isGM ? calendarSync : null; + source?.onChange(() => { extraMonths.clear(); renderCalendarBar(); }); + Hooks.on('chronicleSyncCalendarSnapshot', () => renderCalendarBar()); + renderCalendarBar(); +} diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index 06c997d..b370fcb 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -2,15 +2,15 @@ * Chronicle Sync - Calendar * * Pulls Chronicle's calendar (current date, events, weather, season, era, - * moon phases) over REST and the WebSocket and exposes it to the dashboard. - * No Foundry calendar module is integrated, so nothing here writes a date or - * a note into Foundry. The guarded date push (`pushDate`) and the - * `calendar.*` message routing are what the built-in calendar plugs into. - * TODO(#95): apply Chronicle's date and events to the built-in calendar and - * call `pushDate` from its date-change hook. + * moon phases) over REST and the WebSocket. It feeds the built-in calendar + * (`calendar-bar.mjs`): the GM's view directly, and every player through a + * player-safe snapshot in the `calendarSnapshot` world setting, built from + * Chronicle's players-audience reads (`_calendar-view-model.mjs`). The GM's + * date moves and new events go back through `pushDate` and `addEvent`. */ -import { getSetting } from './settings.mjs'; +import { getSetting, setSetting } from './settings.mjs'; +import { buildPlayerSnapshot, clampDate, shiftMonth, stepDate } from './_calendar-view-model.mjs'; import { FLAG_SCOPE } from './constants.mjs'; import { shouldSkipDatePush, isRealTimeRejection, notifyRealTimePushPaused } from './_realtime-date-guard.mjs'; import { calendarBlackoutActive, handleIfCalendarRebuilding, noteCalendarAnswerOk } from './_calendar-blackout-guard.mjs'; @@ -175,8 +175,8 @@ export function isCalendarNoteJournal(journal) { } /** - * CalendarSync keeps Chronicle's calendar state for the dashboard and routes - * its `calendar.*` WebSocket types. + * CalendarSync keeps Chronicle's calendar state for the dashboard and the + * built-in calendar, and routes its `calendar.*` WebSocket types. */ export class CalendarSync { constructor() { @@ -201,6 +201,202 @@ export class CalendarSync { * @type {Set} */ this._loggedUnhandledTypes = new Set(); + + /** @type {object|null} Last GM-view GET /calendar/date (season, weather, moons). */ + this._dateInfo = null; + + /** @type {object[]} GM-view events for the months around the current date. */ + this._events = []; + + /** @type {Set} Built-in calendar listeners, called after each refresh. */ + this._listeners = new Set(); + + /** @type {ReturnType|null} */ + this._refreshTimer = null; + + /** JSON of the last snapshot written, so an unchanged one is not saved again. */ + this._lastSnapshotJson = null; + } + + /** + * Subscribe to calendar changes (the built-in calendar re-renders). + * @param {Function} fn + * @returns {Function} unsubscribe + */ + onChange(fn) { + (this._listeners ??= new Set()).add(fn); + return () => this._listeners.delete(fn); + } + + /** @private */ + _emitChange() { + for (const fn of this._listeners ?? []) { + try { fn(this); } catch (err) { console.debug('Chronicle: calendar listener failed', err?.message); } + } + } + + /** What the GM's calendar draws: structure, date, world state and every event. */ + get gmView() { + return { calendar: this._chronicleCalendar, date: this.chronicleDate, dateInfo: this._dateInfo, events: this._events }; + } + + /** + * Coalesce the refreshes a burst of WebSocket messages asks for into one. + * @param {number} [delay=400] + */ + scheduleRefresh(delay = 400) { + if (this._refreshTimer) clearTimeout(this._refreshTimer); + this._refreshTimer = setTimeout(() => { + this._refreshTimer = null; + this.refresh().catch((err) => console.debug('Chronicle: calendar refresh failed', err?.message)); + }, delay); + } + + /** + * Re-read the date, world state and nearby events, tell the built-in + * calendar, and publish the players' snapshot. GM only: players have no key. + */ + async refresh() { + if (!this._api || !game.user?.isGM || !getSetting('syncCalendar') || !this._chronicleCalendar) return; + try { + const info = await this._api.get('/calendar/date'); + if (info) { + this._dateInfo = info; + this._onChronicleDateAdvanced(info); + } + } catch (err) { + if (!handleIfCalendarRebuilding(err)) console.debug('Chronicle: calendar date read failed', err?.message); + } + this._events = (await this._fetchMonthsAround()).events; + this._emitChange(); + await this.publishPlayerSnapshot(); + } + + /** + * Events for the current month and one either side, the window the month + * view opens on. `audience` reads them as players see them. + * @param {string} [audience] + * @returns {Promise<{events: object[], confirmed: boolean}>} confirmed: every page echoed the audience. + * @private + */ + async _fetchMonthsAround(audience) { + const d = this.chronicleDate; + if (!d) return { events: [], confirmed: false }; + const seen = new Set(); + const events = []; + let confirmed = true; + for (const delta of [-1, 0, 1]) { + const { y, m } = shiftMonth(this._chronicleCalendar, d.year, d.month, delta); + const q = `year=${y}&month=${m}${audience ? `&audience=${audience}` : ''}`; + let payload; + try { + payload = await this._api.get(`/calendar/events?${q}`); + } catch (err) { + console.debug('Chronicle: calendar events read failed', err?.message); + confirmed = false; + continue; + } + if (audience && payload?.audience !== audience) confirmed = false; + const page = Array.isArray(payload) ? payload : (Array.isArray(payload?.data) ? payload.data : []); + for (const e of page) { + // A repeating event appears once per occurrence, all under one id. + const key = `${e?.id}|${e?.year}-${e?.month}-${e?.day}`; + if (!e || e.id == null || seen.has(key)) continue; + seen.add(key); + events.push(e); + } + } + return { events, confirmed }; + } + + /** + * Build the players' snapshot from players-audience reads and save it in + * the `calendarSnapshot` world setting, which every client receives. A + * calendar players can't see (404) publishes a hidden snapshot. + */ + async publishPlayerSnapshot() { + if (!this._api || !game.user?.isGM) return; + let calendar = null; + let dateInfo = null; + let events = []; + let eventsConfirmed = false; + try { + calendar = await this._api.get('/calendar?audience=players'); + if (calendar) { + dateInfo = await this._api.get('/calendar/date?audience=players'); + ({ events, confirmed: eventsConfirmed } = await this._fetchMonthsAround('players')); + } + } catch (err) { + if (err?.status !== 404) { + // Leave the last snapshot in place rather than blank the players' bar. + console.debug('Chronicle: players calendar read failed', err?.message); + return; + } + calendar = null; + } + const snap = buildPlayerSnapshot({ calendar, dateInfo, events, eventsConfirmed, isPublic: isChronicleEventPublic }); + const json = JSON.stringify(snap); + if (json === this._lastSnapshotJson) return; + this._lastSnapshotJson = json; + try { + await setSetting('calendarSnapshot', snap); + } catch (err) { + this._lastSnapshotJson = null; + console.debug('Chronicle: saving the players calendar failed', err?.message); + } + } + + /** + * Move Chronicle's date by `n` steps of `unit` ('hour' | 'day' | 'week'). + * @returns {Promise} true when Chronicle took the new date. + */ + async step(unit, n = 1) { + const from = this.chronicleDate; + if (!from || !this._chronicleCalendar) return false; + return this.setDate(stepDate(this._chronicleCalendar, from, unit, n)); + } + + /** + * Set Chronicle's date (GM). Refused dates and paused pushes return false. + * @param {{year:number, month:number, day:number, hour?:number, minute?:number}} date + * @returns {Promise} + */ + async setDate(date) { + const clean = clampDate(this._chronicleCalendar, date); + if (!clean) return false; + const ok = await this.pushDate(clean); + if (ok) { + this._onChronicleDateAdvanced(clean); + this._emitChange(); + this.scheduleRefresh(0); + } + return ok; + } + + /** + * Add an event to Chronicle's calendar (GM). Visibility is the wire form. + * @param {{name:string, year:number, month:number, day:number, hour?:number|null, minute?:number|null, gmOnly?:boolean}} ev + * @returns {Promise} + */ + async addEvent(ev) { + if (!this._api || !game.user?.isGM || !ev?.name?.trim()) return false; + const day = clampDate(this._chronicleCalendar, ev); + if (!day) return false; + const timed = Number.isFinite(ev.hour); + try { + await this._api.post('/calendar/events', { + name: ev.name.trim().slice(0, 200), + year: day.year, month: day.month, day: day.day, + all_day: !timed, + ...(timed ? { start_hour: day.hour, start_minute: day.minute } : {}), + visibility: ev.gmOnly ? WIRE_VISIBILITY.GM_ONLY : WIRE_VISIBILITY.EVERYONE, + }); + } catch (err) { + console.error('Chronicle: adding a calendar event failed', err); + return false; + } + this.scheduleRefresh(0); + return true; } /** @@ -210,8 +406,12 @@ export class CalendarSync { this._api = api; } - /** Nothing is registered with Foundry, so there is nothing to release. */ - destroy() {} + /** Stop a pending refresh and drop listeners. */ + destroy() { + if (this._refreshTimer) clearTimeout(this._refreshTimer); + this._refreshTimer = null; + this._listeners?.clear(); + } /** * Handle incoming WebSocket messages for calendar events. @@ -228,6 +428,8 @@ export class CalendarSync { switch (msg?.type) { case 'calendar.date.advanced': this._onChronicleDateAdvanced(msg.payload); + this._emitChange(); + this.scheduleRefresh(); break; case 'calendar.event.created': this._onChronicleEventCreated(msg.payload); @@ -245,12 +447,14 @@ export class CalendarSync { // chat line. case 'calendar.weather.changed': await this._onChronicleWeatherChanged(msg.payload); + this.scheduleRefresh(); break; case 'calendar.worldstate.changed': case 'calendar.season.changed': case 'calendar.era.changed': case 'calendar.moon.phase_changed': await this._onChronicleSubresourceChanged(msg.type, msg.payload); + this.scheduleRefresh(); break; default: @@ -296,8 +500,7 @@ export class CalendarSync { return false; } noteCalendarAnswerOk(); - // TODO(#95): apply the date to the built-in calendar, then - // confirmAppliedDate (_applied-date-confirm.mjs). + await this.refresh(); console.debug('Chronicle: Calendar initial sync complete'); return true; } catch (err) { @@ -328,8 +531,8 @@ export class CalendarSync { // --- Chronicle → Foundry --- /** - * Record the date Chronicle advanced to. TODO(#95): apply it to the - * built-in calendar and confirm it back with confirmAppliedDate. + * Record the date Chronicle is on. The built-in calendar reads it through + * `chronicleDate` after the refresh that follows. * @param {object} data - { year, month, day, hour, minute } * @private */ @@ -344,16 +547,15 @@ export class CalendarSync { } /** - * Chronicle event created / updated / deleted. TODO(#95): mirror these into - * the built-in calendar; until it exists there is nothing to write to. - * @param {object} data - the Chronicle event (`{ id }` for a delete). + * Chronicle event created / updated / deleted: re-read the month window. + * Events stay Chronicle's own; nothing is written into Foundry documents. * @private */ - _onChronicleEventCreated(data) {} + _onChronicleEventCreated() { this.scheduleRefresh(); } /** @private */ - _onChronicleEventUpdated(data) {} + _onChronicleEventUpdated() { this.scheduleRefresh(); } /** @private */ - _onChronicleEventDeleted(data) {} + _onChronicleEventDeleted() { this.scheduleRefresh(); } // --- Chronicle → Foundry: sub-resources ----------------------------- @@ -469,7 +671,10 @@ export class CalendarSync { async _onChronicleStructureUpdated(type = 'calendar.structure.updated') { try { const cal = await this._api.get('/calendar'); - if (cal) this._chronicleCalendar = cal; + if (cal) { + this._chronicleCalendar = cal; + this.scheduleRefresh(); + } } catch (err) { console.debug(`Chronicle: ${type} received; could not refetch /calendar`, err?.message); } @@ -481,20 +686,20 @@ export class CalendarSync { * Push a date to Chronicle (PUT /calendar/date), behind the date-push * guards: GM only, the rebuild blackout, a refused-push pause, and the * real-time-calendar signal. Refusals pause the push with one notice - * rather than one error per tick. TODO(#95): call this from the built-in - * calendar's date-change hook. + * rather than one error per tick. * @param {{year:number, month:number, day:number, hour?:number, minute?:number}} date - 1-indexed month/day. + * @returns {Promise} true when Chronicle took the date. */ async pushDate(date) { - if (!date) return; - if (!getSetting('syncCalendar')) return; - if (!game.user.isGM) return; + if (!date) return false; + if (!getSetting('syncCalendar')) return false; + if (!game.user.isGM) return false; // Check before the pre-push probe so a known blackout costs zero requests. - if (calendarBlackoutActive() || datePushPaused()) return; + if (calendarBlackoutActive() || datePushPaused()) return false; try { - if (await shouldSkipDatePush(this._api)) return; + if (await shouldSkipDatePush(this._api)) return false; await this._api.put('/calendar/date', { year: date.year, month: date.month, @@ -503,21 +708,22 @@ export class CalendarSync { minute: date.minute || 0, }); noteCalendarAnswerOk(); + return true; } catch (err) { // 400/403/422 refusals pause date push with one notice, not one error per tick. - if (handleDatePushRefusal(err)) return; - if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return; } + if (handleDatePushRefusal(err)) return false; + if (isRealTimeRejection(err)) { notifyRealTimePushPaused(); return false; } // A 503 calendar_rebuilding arms the session guard and notifies once. - if (handleIfCalendarRebuilding(err)) return; + if (handleIfCalendarRebuilding(err)) return false; console.error('Chronicle: Failed to push date to Chronicle', err); + return false; } } /** * Fetch Chronicle's calendar events for the current year ±`yearSpan`, * month by month (GET /calendar/events is month-filtered), deduped by id. - * Bounded, never unbounded history. TODO(#95): feed these to the built-in - * calendar on connect. + * Bounded, never unbounded history. * @param {number} [yearSpan=1] * @returns {Promise} */ diff --git a/scripts/module.mjs b/scripts/module.mjs index 1710258..6148e77 100644 --- a/scripts/module.mjs +++ b/scripts/module.mjs @@ -12,6 +12,7 @@ import { JournalSync } from './journal-sync.mjs'; import { MapSync } from './map-sync.mjs'; import { ShopWidget } from './shop-widget.mjs'; import { CalendarSync } from './calendar-sync.mjs'; +import { registerCalendarBar } from './calendar-bar.mjs'; import { ActorSync } from './actor-sync.mjs'; import { ItemSync } from './item-sync.mjs'; import { StashSync } from './stash-sync.mjs'; @@ -115,7 +116,8 @@ Hooks.once('ready', async () => { syncManager.registerModule(new JournalSync()); syncManager.registerModule(new MapSync()); syncManager.registerModule(new ShopWidget()); - syncManager.registerModule(new CalendarSync()); + const calendarSync = new CalendarSync(); + syncManager.registerModule(calendarSync); syncManager.registerModule(new ActorSync()); syncManager.registerModule(new ItemSync()); syncManager.registerModule(new StashSync()); @@ -143,6 +145,13 @@ Hooks.once('ready', async () => { } catch (err) { console.warn('Chronicle Sync | Problem reports unavailable', err); } + try { + // Chronicle's own calendar: the GM's bar reads calendarSync, players + // draw the snapshot it publishes. + registerCalendarBar(calendarSync); + } catch (err) { + console.warn('Chronicle Sync | Calendar bar unavailable', err); + } try { registerPlayerNotebook(); } catch (err) { diff --git a/scripts/settings.mjs b/scripts/settings.mjs index bc2696f..31fe8a8 100644 --- a/scripts/settings.mjs +++ b/scripts/settings.mjs @@ -170,6 +170,29 @@ export function registerSettings() { default: false, }); + // The built-in calendar as every player may see it, written by the GM's + // client from Chronicle's players-audience reads (players hold no key). + // World scope is deliberate: every client receives it, so it carries + // only what buildPlayerSnapshot keeps. + game.settings.register(MODULE_ID, 'calendarSnapshot', { + scope: 'world', + config: false, + type: Object, + default: {}, + onChange: () => Hooks.callAll('chronicleSyncCalendarSnapshot'), + }); + + game.settings.register(MODULE_ID, 'calendarTemperatureUnit', { + name: game.i18n.localize('CHRONICLE.Settings.CalendarTemperatureUnit.Name'), + hint: game.i18n.localize('CHRONICLE.Settings.CalendarTemperatureUnit.Hint'), + scope: 'client', + config: true, + type: String, + choices: { C: '°C', F: '°F' }, + default: 'C', + onChange: () => Hooks.callAll('chronicleSyncCalendarSnapshot'), + }); + // Internal: detected Chronicle system ID matched from Foundry's game.system.id. game.settings.register(MODULE_ID, 'detectedSystem', { scope: 'world', diff --git a/styles/chronicle-sync.css b/styles/chronicle-sync.css index 2925dbc..747e926 100644 --- a/styles/chronicle-sync.css +++ b/styles/chronicle-sync.css @@ -994,11 +994,6 @@ padding: 8px 0; } -.calendar-module-info { - font-size: 13px; - margin-bottom: 12px; -} - .calendar-date-compare { display: flex; gap: 16px; @@ -2271,3 +2266,52 @@ .sync-history .sh-error { color: #ff7b72; opacity: 1; } .sync-history .sh-more { padding: 6px; text-align: center; border-top: 1px solid rgba(255, 255, 255, 0.08); } @media (prefers-reduced-motion: reduce) { .sync-history .sh-caret { transition: none; } } + +/* Built-in calendar: date bar at the top centre and its month view. */ +#chronicle-calendar-bar { + position: fixed; top: 8px; left: 50%; transform: translateX(-50%); z-index: 60; + display: flex; align-items: center; gap: 4px; padding: 3px 6px; + border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.18); + background: var(--ccal-sky, #1b2350); color: #f4f1ea; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45); font-size: var(--font-size-13, 13px); + transition: background 1.2s ease; +} +#chronicle-calendar-bar button { background: none; border: none; color: inherit; line-height: 1; width: auto; margin: 0; } +#chronicle-calendar-bar .ccal-face { display: flex; align-items: center; gap: 8px; padding: 3px 10px; border-radius: 999px; cursor: pointer; } +#chronicle-calendar-bar .ccal-face:hover, #chronicle-calendar-bar .ccal-face:focus-visible { background: rgba(0, 0, 0, 0.25); } +#chronicle-calendar-bar .ccal-date { font-weight: 600; white-space: nowrap; } +#chronicle-calendar-bar .ccal-time { opacity: 0.85; font-variant-numeric: tabular-nums; } +#chronicle-calendar-bar .ccal-chip { padding: 1px 8px; border-radius: 999px; background: color-mix(in srgb, var(--ccal-chip) 55%, transparent); font-size: 0.9em; } +#chronicle-calendar-bar .ccal-moons { display: inline-flex; gap: 4px; opacity: 0.9; } +#chronicle-calendar-bar .ccal-step { padding: 4px 6px; border-radius: 50%; cursor: pointer; } +#chronicle-calendar-bar .ccal-step:hover:not(:disabled) { background: rgba(0, 0, 0, 0.25); } +#chronicle-calendar-bar .ccal-step:disabled { opacity: 0.4; cursor: default; } +#chronicle-calendar-bar .ccal-unit { height: 22px; width: auto; background: rgba(0, 0, 0, 0.3); color: inherit; border: none; border-radius: 6px; font-size: 0.9em; } + +#chronicle-calendar-month { + position: fixed; top: 46px; left: 50%; z-index: 61; width: min(560px, 94vw); + transform: translateX(-50%) translateY(-6px) scale(0.98); opacity: 0; transform-origin: top center; + transition: opacity 0.16s ease, transform 0.16s ease; + padding: 8px 10px 10px; border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(20, 22, 32, 0.96); color: #eee; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); +} +#chronicle-calendar-month.open { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } +#chronicle-calendar-month header { display: flex; align-items: center; justify-content: space-between; } +#chronicle-calendar-month header h3 { margin: 0; border: none; font-size: 1.05em; } +#chronicle-calendar-month button { width: auto; flex: 0 0 auto; background: rgba(255, 255, 255, 0.06); color: inherit; border: 1px solid rgba(255, 255, 255, 0.12); } +#chronicle-calendar-month .ccal-grid { width: 100%; table-layout: fixed; border-collapse: collapse; margin: 6px 0 0; background: none; border: none; } +#chronicle-calendar-month .ccal-grid th { font-size: 0.75em; font-weight: 500; opacity: 0.7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: none; color: inherit; } +#chronicle-calendar-month .ccal-grid td { vertical-align: top; height: 52px; padding: 2px 3px; border: 1px solid rgba(255, 255, 255, 0.06); overflow: hidden; } +#chronicle-calendar-month .ccal-grid tr:nth-child(even) { background: none; } +#chronicle-calendar-month .ccal-pad { background: rgba(0, 0, 0, 0.2); } +#chronicle-calendar-month .ccal-n { display: block; font-size: 0.8em; opacity: 0.8; } +#chronicle-calendar-month .ccal-today { outline: 2px solid #e0b25b; outline-offset: -2px; } +#chronicle-calendar-month .ccal-ev { display: block; font-size: 0.72em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid var(--ccal-ev); padding-left: 3px; margin-top: 1px; } +#chronicle-calendar-month .ccal-ev-gm { font-style: italic; opacity: 0.8; } +#chronicle-calendar-month .ccal-ev-gm::before { content: "\f023"; font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free"; font-weight: 900; font-style: normal; margin-right: 3px; font-size: 0.9em; } +#chronicle-calendar-month .ccal-more { display: block; font-size: 0.7em; opacity: 0.7; } +#chronicle-calendar-month footer { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; } +.ccal-form .form-fields input[type="number"] { max-width: 6em; } +@media (prefers-reduced-motion: reduce) { + #chronicle-calendar-bar, #chronicle-calendar-month { transition: none; } +} diff --git a/tools/test-calendar-builtin.mjs b/tools/test-calendar-builtin.mjs new file mode 100644 index 0000000..fe573bd --- /dev/null +++ b/tools/test-calendar-builtin.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/** + * CalendarSync's side of the built-in calendar: the players' snapshot comes + * only from players-audience reads and is saved only when it changes; a + * calendar players can't see publishes a hidden snapshot; step, setDate and + * addEvent write to Chronicle through the guarded routes. + * + * Run: `node --test tools/test-calendar-builtin.mjs` + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const settings = new Map([['syncCalendar', true]]); +const saved = []; +globalThis.foundry = globalThis.foundry || { applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: (b) => b } } }; +globalThis.game = { + settings: { + get: (_m, k) => settings.get(k), + set: async (_m, k, v) => { saved.push([k, v]); settings.set(k, v); }, + register: () => {}, + }, + i18n: { localize: (k) => k, format: (k) => k }, + user: { isGM: true }, + modules: { get: () => null }, +}; +globalThis.ui = { notifications: { info() {}, warn() {}, error() {} } }; +globalThis.Hooks = globalThis.Hooks || { on: () => {}, off: () => {}, callAll: () => {} }; + +const { CalendarSync } = await import('../scripts/calendar-sync.mjs'); + +const cal = { + current_year: 1492, current_month: 2, current_day: 30, current_hour: 23, current_minute: 0, + hours_per_day: 24, minutes_per_hour: 60, visibility: 'everyone', + months: [{ name: 'A', days: 30 }, { name: 'B', days: 30 }, { name: 'C', days: 30 }], + weekdays: [{ name: 'X' }, { name: 'Y' }], +}; + +function makeApi(routes) { + const calls = { get: [], put: [], post: [] }; + return { + calls, + async get(path) { + calls.get.push(path); + for (const [match, answer] of routes) { + if (path.startsWith(match)) { + if (answer instanceof Error) throw answer; + return typeof answer === 'function' ? answer(path) : answer; + } + } + return null; + }, + async put(path, body) { calls.put.push([path, body]); return {}; }, + async post(path, body) { calls.post.push([path, body]); return { id: 'new' }; }, + }; +} + +function makeSync(api) { + const cs = new CalendarSync(); + cs._api = api; + cs._chronicleCalendar = { ...cal }; + return cs; +} + +const err404 = Object.assign(new Error('Chronicle API error 404'), { status: 404 }); + +test('players snapshot is built only from players-audience reads', async () => { + saved.length = 0; + const api = makeApi([ + ['/calendar/date?audience=players', { year: 1492, month: 2, day: 30, hour: 23, audience: 'players', current_moon_phases: [{ moon_name: 'Seen' }] }], + ['/calendar/events?', (p) => (p.includes('audience=players') + ? { data: [{ id: 'p', name: 'Fair', year: 1492, month: 2, day: 3, visibility: 'everyone' }, { id: 'g', name: 'Plot', year: 1492, month: 2, day: 4, visibility: 'gm-only' }], audience: 'players' } + : { data: [] })], + ['/calendar?audience=players', cal], + ]); + const cs = makeSync(api); + await cs.publishPlayerSnapshot(); + const playerReads = api.calls.get.filter((p) => p !== '/calendar'); + assert.ok(playerReads.length >= 3); + for (const p of playerReads) assert.match(p, /audience=players/, `${p} must be a players read`); + assert.equal(saved.length, 1); + const [key, snap] = saved[0]; + assert.equal(key, 'calendarSnapshot'); + assert.deepEqual(snap.events.map((e) => e.id), ['p']); + assert.deepEqual(snap.moons.map((m) => m.name), ['Seen']); + await cs.publishPlayerSnapshot(); + assert.equal(saved.length, 1, 'an unchanged snapshot is not saved again'); +}); + +test('a calendar players cannot see publishes a hidden snapshot', async () => { + saved.length = 0; + const cs = makeSync(makeApi([['/calendar?audience=players', err404]])); + await cs.publishPlayerSnapshot(); + assert.equal(saved[0][1].hidden, true); +}); + +test('a failed players read keeps the last snapshot', async () => { + saved.length = 0; + const cs = makeSync(makeApi([['/calendar?audience=players', new Error('500')]])); + const original = console.debug; console.debug = () => {}; + try { await cs.publishPlayerSnapshot(); } finally { console.debug = original; } + assert.equal(saved.length, 0); +}); + +test('players never publish', async () => { + saved.length = 0; + game.user.isGM = false; + try { + const api = makeApi([['/calendar?audience=players', cal]]); + await makeSync(api).publishPlayerSnapshot(); + assert.equal(api.calls.get.length, 0); + assert.equal(saved.length, 0); + } finally { game.user.isGM = true; } +}); + +test('step moves the date through PUT /calendar/date', async () => { + const api = makeApi([['/calendar/date', { tracks_real_time: false }]]); + const cs = makeSync(api); + cs.scheduleRefresh = () => {}; + assert.equal(await cs.step('hour', 1), true); + assert.deepEqual(api.calls.put, [['/calendar/date', { year: 1492, month: 3, day: 1, hour: 0, minute: 0 }]]); + assert.deepEqual(cs.chronicleDate, { year: 1492, month: 3, day: 1, hour: 0, minute: 0 }); +}); + +test('setDate refuses a date the calendar cannot hold without a request', async () => { + const api = makeApi([]); + const cs = makeSync(api); + assert.equal(await cs.setDate({ year: 1492, month: 4, day: 1 }), false); + assert.equal(api.calls.put.length, 0); +}); + +test('addEvent posts a Chronicle event with wire visibility', async () => { + const api = makeApi([]); + const cs = makeSync(api); + cs.scheduleRefresh = () => {}; + assert.equal(await cs.addEvent({ name: ' Fair ', year: 1492, month: 1, day: 5 }), true); + assert.equal(await cs.addEvent({ name: 'Plot', year: 1492, month: 1, day: 5, hour: 9, minute: 30, gmOnly: true }), true); + assert.equal(await cs.addEvent({ name: ' ', year: 1492, month: 1, day: 5 }), false); + assert.deepEqual(api.calls.post, [ + ['/calendar/events', { name: 'Fair', year: 1492, month: 1, day: 5, all_day: true, visibility: 'everyone' }], + ['/calendar/events', { name: 'Plot', year: 1492, month: 1, day: 5, all_day: false, start_hour: 9, start_minute: 30, visibility: 'gm-only' }], + ]); +}); diff --git a/tools/test-calendar-view-model.mjs b/tools/test-calendar-view-model.mjs new file mode 100644 index 0000000..9977217 --- /dev/null +++ b/tools/test-calendar-view-model.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * The built-in calendar's pure logic (`scripts/_calendar-view-model.mjs`): + * stepping, labels, the month grid, and above all the player snapshot, which + * must carry nothing a player may not see. + * + * Run: `node --test tools/test-calendar-view-model.mjs` + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as VM from '../scripts/_calendar-view-model.mjs'; + +const cal = { + mode: 'fantasy', hours_per_day: 24, minutes_per_hour: 60, + months: [ + { name: 'Hammer', days: 30 }, { name: 'Midwinter', days: 1, is_intercalary: true }, + { name: 'Alturiak', days: 30 }, { name: 'Ches', days: 30, leap_year_days: 1 }, + ], + weekdays: Array.from({ length: 10 }, (_, i) => ({ name: `D${i + 1}` })), + leap_year_every: 4, leap_year_offset: 0, +}; + +test('stepDate carries hours, days and weeks across months and years', () => { + const cases = [ + [{ year: 1492, month: 1, day: 30, hour: 23, minute: 5 }, 'hour', 1, { year: 1492, month: 2, day: 1, hour: 0, minute: 5 }], + [{ year: 1492, month: 1, day: 1, hour: 0, minute: 0 }, 'hour', -1, { year: 1491, month: 4, day: 30, hour: 23, minute: 0 }], + [{ year: 1492, month: 4, day: 30, hour: 8, minute: 0 }, 'day', 1, { year: 1492, month: 4, day: 31, hour: 8, minute: 0 }], + [{ year: 1493, month: 4, day: 30, hour: 8, minute: 0 }, 'day', 1, { year: 1494, month: 1, day: 1, hour: 8, minute: 0 }], + [{ year: 1492, month: 1, day: 1, hour: 0, minute: 0 }, 'week', 1, { year: 1492, month: 1, day: 11, hour: 0, minute: 0 }], + [{ year: 1492, month: 1, day: 1, hour: 0, minute: 0 }, 'hour', 48, { year: 1492, month: 1, day: 3, hour: 0, minute: 0 }], + ]; + for (const [from, unit, n, want] of cases) { + assert.deepEqual(VM.stepDate(cal, from, unit, n), want, `${JSON.stringify(from)} ${unit} ${n}`); + } + const short = { ...cal, hours_per_day: 10 }; + assert.deepEqual(VM.stepDate(short, { year: 1, month: 1, day: 1, hour: 9, minute: 0 }, 'hour', 1), + { year: 1, month: 1, day: 2, hour: 0, minute: 0 }, 'uses the calendar\'s own day length'); + const d = { year: 1, month: 1, day: 1, hour: 0, minute: 0 }; + assert.equal(VM.stepDate(cal, d, 'fortnight', 1), d, 'unknown unit leaves the date alone'); +}); + +test('clampDate refuses dates the calendar cannot hold', () => { + assert.deepEqual(VM.clampDate(cal, { year: 1492, month: 4, day: 31, hour: 30, minute: -2 }), + { year: 1492, month: 4, day: 31, hour: 23, minute: 0 }); + assert.equal(VM.clampDate(cal, { year: 1493, month: 4, day: 31 }), null, 'no leap day outside a leap year'); + assert.equal(VM.clampDate(cal, { year: 1492, month: 5, day: 1 }), null); + assert.equal(VM.clampDate(cal, { year: 'x', month: 1, day: 1 }), null); +}); + +test('labels', () => { + const d = { year: 1492, month: 3, day: 7, hour: 9, minute: 5 }; + assert.equal(VM.formatDate(cal, d), '7 Alturiak 1492'); + assert.equal(VM.formatTime(cal, d), '09:05'); + assert.equal(VM.formatTemperature(20), '20°C'); + assert.equal(VM.formatTemperature(20, 'F'), '68°F'); + assert.equal(VM.formatTemperature(null), ''); + assert.equal(VM.weatherIconClass('rain'), 'fa-cloud-rain'); + assert.equal(VM.weatherIconClass(''), 'fa-cloud-sun'); + assert.equal(VM.safeColor('#a1b2c3'), '#a1b2c3'); + assert.equal(VM.safeColor('red;background:url(x)'), null); +}); + +test('monthGrid lays days under their weekdays; a festival month outside the week is one row', () => { + const g = VM.monthGrid(cal, 1492, 1); + assert.equal(g.headers.length, 10); + assert.equal(g.rows.flat().filter(Boolean).length, 30); + for (const row of g.rows) assert.equal(row.length, 10); + const restart = { ...cal, month_starts_new_week: true }; + const f = VM.monthGrid(restart, 1492, 2); + assert.equal(f.festival, true); + assert.deepEqual(f.rows, [[1]]); + const m = VM.monthGrid(restart, 1492, 3); + assert.equal(m.rows[0][0], 1, 'a restarting week puts day 1 in the first column'); +}); + +test('skyPhase follows the calendar\'s day length', () => { + assert.equal(VM.skyPhase(cal, 2), 'night'); + assert.equal(VM.skyPhase(cal, 6), 'dawn'); + assert.equal(VM.skyPhase(cal, 12), 'day'); + assert.equal(VM.skyPhase(cal, 19), 'dusk'); + assert.equal(VM.skyPhase({ hours_per_day: 10 }, 5), 'day'); +}); + +const isPublic = (e) => e.visibility === undefined || e.visibility === 'everyone'; + +test('player snapshot: hidden calendar shows nothing', () => { + assert.deepEqual(VM.buildPlayerSnapshot({ calendar: null, dateInfo: null, events: [], isPublic }), + { v: VM.SNAPSHOT_VERSION, hidden: true }); + assert.equal(VM.isUsableSnapshot({ v: VM.SNAPSHOT_VERSION, hidden: true }), false); +}); + +test('player snapshot copies only named fields and only public events', () => { + const s = VM.buildPlayerSnapshot({ + calendar: { ...cal, current_year: 1492, eras: [{ name: 'secret' }], moons: [{ name: 'Hidden' }], secret_field: 1 }, + dateInfo: { + year: 1492, month: 3, day: 7, hour: 9, minute: 5, audience: 'players', + current_season: { id: 1, name: 'Spring', color: '#88cc88' }, + current_weather: { preset_label: 'Rain', icon: 'rain', color: 'javascript:x', temperature_celsius: 11, zone_name: 'Underdark' }, + current_moon_phases: [{ moon_id: 4, moon_name: 'Selûne', phase_name: 'Full', phase_icon: 'moon', phase_position: 0.5 }], + }, + events: [ + { id: 'a', name: 'Fair', year: 1492, month: 3, day: 9, visibility: 'everyone', description: 'x', entity_id: 'e1' }, + { id: 'b', name: 'Ambush', year: 1492, month: 3, day: 9, visibility: 'gm-only' }, + ], + eventsConfirmed: true, + isPublic, + }); + assert.ok(VM.isUsableSnapshot(s)); + assert.deepEqual(Object.keys(s.calendar).sort(), ['hours_per_day', 'leap_year_every', 'leap_year_offset', 'minutes_per_hour', 'mode', 'month_starts_new_week', 'months', 'tracks_real_time', 'weekdays']); + assert.deepEqual(s.events.map((e) => e.id), ['a']); + assert.deepEqual(Object.keys(s.events[0]).sort(), ['all_day', 'color', 'day', 'id', 'month', 'name', 'start_hour', 'start_minute', 'year']); + assert.equal(s.weather.color, null, 'unsafe colours are dropped'); + assert.equal('zone_name' in s.weather, false); + assert.deepEqual(s.moons, [{ name: 'Selûne', phase_name: 'Full', phase_icon: 'moon' }]); + assert.deepEqual(s.date, { year: 1492, month: 3, day: 7, hour: 9, minute: 5 }); +}); + +test('player snapshot without an isPublic check keeps no events', () => { + const s = VM.buildPlayerSnapshot({ calendar: cal, dateInfo: { year: 1, month: 1, day: 1 }, events: [{ id: 'a', visibility: 'everyone' }] }); + assert.deepEqual(s.events, []); +}); + +test('player snapshot keeps at most SNAPSHOT_EVENT_LIMIT events', () => { + const many = Array.from({ length: VM.SNAPSHOT_EVENT_LIMIT + 5 }, (_, i) => ({ id: i, name: 'e', year: 1, month: 1, day: 1 })); + const s = VM.buildPlayerSnapshot({ calendar: cal, dateInfo: { year: 1, month: 1, day: 1 }, events: many, eventsConfirmed: true, isPublic }); + assert.equal(s.events.length, VM.SNAPSHOT_EVENT_LIMIT); +}); + +test('eventsOn picks one day, all-day first then by start time', () => { + const evs = [ + { id: 1, year: 1, month: 1, day: 2, start_hour: 9 }, + { id: 2, year: 1, month: 1, day: 2, all_day: true }, + { id: 3, year: 1, month: 1, day: 2, start_hour: 7, start_minute: 30 }, + { id: 4, year: 1, month: 1, day: 3 }, + ]; + assert.deepEqual(VM.eventsOn(evs, 1, 1, 2).map((e) => e.id), [2, 3, 1]); +}); + +test('an older Chronicle that ignored the players audience gets no moons, era or events', () => { + const s = VM.buildPlayerSnapshot({ + calendar: cal, + dateInfo: { year: 1, month: 1, day: 1, current_era: { name: 'Secret' }, current_moon_phases: [{ moon_name: 'Hidden' }], current_season: { name: 'Spring' } }, + events: [{ id: 'a', name: 'x', visibility: 'everyone' }], + eventsConfirmed: false, + isPublic, + }); + assert.equal(s.era, null); + assert.deepEqual(s.moons, []); + assert.deepEqual(s.events, []); + assert.equal(s.season.name, 'Spring', 'season and weather are the same for everyone'); +}); + +test('a calendar not open to every player gives a hidden snapshot', () => { + const dateInfo = { year: 1, month: 1, day: 1, audience: 'players' }; + const cases = [ + [{ ...cal, visibility: 'dm_only' }, true], + [{ ...cal, visibility: 'everyone', visibility_rules: '{"allowed_users":["u1"]}' }, true], + [{ ...cal, visibility: 'everyone', visibility_rules: '{"denied_users":["u1"]}' }, true], + [{ ...cal, visibility: 'everyone', visibility_rules: 'not json' }, true], + [{ ...cal, visibility: 'everyone', visibility_rules: '{"allowed_users":[]}' }, false], + [{ ...cal, visibility: 'everyone' }, false], + [cal, false], + ]; + for (const [c, hidden] of cases) { + assert.equal(VM.buildPlayerSnapshot({ calendar: c, dateInfo, isPublic }).hidden, hidden, JSON.stringify([c.visibility, c.visibility_rules])); + } +}); diff --git a/tools/test-chronicle-caldate.mjs b/tools/test-chronicle-caldate.mjs new file mode 100644 index 0000000..3c60238 --- /dev/null +++ b/tools/test-chronicle-caldate.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * `scripts/_chronicle-caldate.mjs` must place days, weekdays and leap days + * exactly where Chronicle's calendar page does. Always: a few fixed answers. + * With CHRONICLE_DIR pointing at a Chronicle checkout: every function is + * compared with Chronicle's own `CalDate` (static/js/widgets/calendar_view.js) + * across sample calendars and a spread of dates. + * + * Run: `node --test tools/test-chronicle-caldate.mjs` + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import vm from 'node:vm'; +import * as CD from '../scripts/_chronicle-caldate.mjs'; + +const harptos = { + mode: 'fantasy', + months: [ + { name: 'Hammer', days: 30 }, { name: 'Midwinter', days: 1, is_intercalary: true }, + { name: 'Alturiak', days: 30 }, { name: 'Ches', days: 30, leap_year_days: 1 }, + ], + weekdays: Array.from({ length: 10 }, (_, i) => ({ name: `D${i + 1}` })), + leap_year_every: 4, leap_year_offset: 0, +}; +const restart = { ...harptos, month_starts_new_week: true }; +const gregorian = { + mode: 'reallife', tracks_real_time: true, + months: Array.from({ length: 12 }, (_, i) => ({ name: `M${i + 1}`, days: 31 })), + weekdays: Array.from({ length: 7 }, (_, i) => ({ name: `W${i}` })), +}; +const CALS = { harptos, restart, gregorian }; + +test('fixed answers', () => { + assert.equal(CD.monthDays(harptos, 3, 1492), 31); // leap year adds Ches's day + assert.equal(CD.monthDays(harptos, 3, 1493), 30); + assert.equal(CD.monthDays(gregorian, 1, 2024), 29); // real-time follows the real calendar + assert.equal(CD.weekdayCol(restart, 1492, 2, 1), -1); // festival day outside the week + assert.equal(CD.weekdayCol(restart, 1492, 3, 1), 0); + assert.deepEqual(CD.addDays(harptos, { y: 1492, m: 1, d: 30 }, 1), { y: 1492, m: 2, d: 1 }); + assert.deepEqual(CD.addDays(harptos, { y: 1492, m: 1, d: 1 }, -1), { y: 1491, m: 4, d: 30 }); + assert.deepEqual(CD.shiftMonth(harptos, 1492, 4, 1), { y: 1493, m: 1 }); +}); + +const chronicleDir = process.env.CHRONICLE_DIR; +const viewFile = chronicleDir && join(resolve(chronicleDir), 'static', 'js', 'widgets', 'calendar_view.js'); + +/** Chronicle's `var CalDate = {…};` block, evaluated on its own. */ +function loadChronicleCalDate(file) { + const src = readFileSync(file, 'utf8'); + const start = src.indexOf('var CalDate = {'); + assert.ok(start >= 0, 'var CalDate not found in calendar_view.js'); + const end = src.indexOf('\n };', start); + assert.ok(end > start, 'end of CalDate not found'); + const ctx = {}; + vm.runInNewContext(`${src.slice(start, end + 5)}\nthis.CalDate = CalDate;`, ctx); + return ctx.CalDate; +} + +test('matches Chronicle\'s CalDate (needs CHRONICLE_DIR)', { skip: !(viewFile && existsSync(viewFile)) && 'CHRONICLE_DIR not set' }, () => { + const ch = loadChronicleCalDate(viewFile); + const names = Object.keys(CD).filter((k) => typeof CD[k] === 'function'); + for (const n of names) assert.equal(typeof ch[n], 'function', `Chronicle's CalDate has no ${n}`); + for (const [cname, cal] of Object.entries(CALS)) { + for (const year of [-3, 0, 1, 3, 4, 1491, 1492, 2023, 2024]) { + assert.equal(CD.isLeapYear(cal, year), ch.isLeapYear(cal, year), `${cname} isLeapYear ${year}`); + assert.equal(CD.leapYearsBefore(cal, year), ch.leapYearsBefore(cal, year), `${cname} leapYearsBefore ${year}`); + for (let m = 1; m <= cal.months.length; m++) { + assert.equal(CD.monthDays(cal, m - 1, year), ch.monthDays(cal, m - 1, year), `${cname} monthDays ${year}-${m}`); + assert.deepEqual(CD.shiftMonth(cal, year, m, 5), { ...ch.shiftMonth(cal, year, m, 5) }); + assert.deepEqual(CD.shiftMonth(cal, year, m, -7), { ...ch.shiftMonth(cal, year, m, -7) }); + for (const d of [1, 2, 15, 30]) { + if (d > CD.monthDays(cal, m - 1, year)) continue; + const at = `${cname} ${year}-${m}-${d}`; + assert.equal(CD.dayIndex(cal, year, m, d), ch.dayIndex(cal, year, m, d), `dayIndex ${at}`); + assert.equal(CD.weekdayCol(cal, year, m, d), ch.weekdayCol(cal, year, m, d), `weekdayCol ${at}`); + for (const delta of [1, -1, 40, -400]) { + assert.deepEqual(CD.addDays(cal, { y: year, m, d }, delta), { ...ch.addDays(cal, { y: year, m, d }, delta) }, `addDays ${at} ${delta}`); + } + } + } + } + } +}); From b24e785fdf39913a7e0c9fb0b10b9eae20872b22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 06:44:45 +0000 Subject: [PATCH 3/7] Calendar bar: keep players' events through a failed read, follow sync toggles A single failed month read no longer publishes an empty event list to players; turning sync or calendar sync off hides the bar on every client at once; Escape no longer swallows a dialog's own Escape; a month whose events failed to load is retried when reopened. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn --- scripts/calendar-bar.mjs | 5 ++++- scripts/calendar-sync.mjs | 15 +++++++++++---- scripts/settings.mjs | 4 ++++ tools/test-calendar-builtin.mjs | 13 +++++++++++++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/scripts/calendar-bar.mjs b/scripts/calendar-bar.mjs index 59e9f15..e531f75 100644 --- a/scripts/calendar-bar.mjs +++ b/scripts/calendar-bar.mjs @@ -164,7 +164,8 @@ function onOutside(ev) { } function onKey(ev) { - if (ev.key === 'Escape' && popover) { ev.stopPropagation(); closeMonth(); } + // Not stopped: an open Set date or Add event dialog still gets its Escape. + if (ev.key === 'Escape' && popover) closeMonth(); } function monthEvents(v, y, m) { @@ -184,6 +185,8 @@ async function ensureMonth(v, y, m) { extraMonths.set(key, Array.isArray(payload) ? payload : (payload?.data || [])); renderMonth(); } catch (err) { + // Forget the month so reopening it tries again. + extraMonths.delete(key); console.debug('Chronicle: month events read failed', err?.message); } } diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index b370fcb..8abce03 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -276,15 +276,17 @@ export class CalendarSync { * Events for the current month and one either side, the window the month * view opens on. `audience` reads them as players see them. * @param {string} [audience] - * @returns {Promise<{events: object[], confirmed: boolean}>} confirmed: every page echoed the audience. + * @returns {Promise<{events: object[], confirmed: boolean, failed: boolean}>} confirmed: every page + * echoed the audience; failed: a page could not be read. * @private */ async _fetchMonthsAround(audience) { const d = this.chronicleDate; - if (!d) return { events: [], confirmed: false }; + if (!d) return { events: [], confirmed: false, failed: false }; const seen = new Set(); const events = []; let confirmed = true; + let failed = false; for (const delta of [-1, 0, 1]) { const { y, m } = shiftMonth(this._chronicleCalendar, d.year, d.month, delta); const q = `year=${y}&month=${m}${audience ? `&audience=${audience}` : ''}`; @@ -294,6 +296,7 @@ export class CalendarSync { } catch (err) { console.debug('Chronicle: calendar events read failed', err?.message); confirmed = false; + failed = true; continue; } if (audience && payload?.audience !== audience) confirmed = false; @@ -306,7 +309,7 @@ export class CalendarSync { events.push(e); } } - return { events, confirmed }; + return { events, confirmed, failed }; } /** @@ -324,7 +327,11 @@ export class CalendarSync { calendar = await this._api.get('/calendar?audience=players'); if (calendar) { dateInfo = await this._api.get('/calendar/date?audience=players'); - ({ events, confirmed: eventsConfirmed } = await this._fetchMonthsAround('players')); + let failed; + ({ events, confirmed: eventsConfirmed, failed } = await this._fetchMonthsAround('players')); + // A passing read error would publish an empty list over the players' + // events; keep the last snapshot until a full read succeeds. + if (failed) return; } } catch (err) { if (err?.status !== 404) { diff --git a/scripts/settings.mjs b/scripts/settings.mjs index 31fe8a8..fce79aa 100644 --- a/scripts/settings.mjs +++ b/scripts/settings.mjs @@ -73,6 +73,8 @@ export function registerSettings() { config: true, type: Boolean, default: true, + // The built-in calendar shows or hides with sync, on every client. + onChange: () => Hooks.callAll('chronicleSyncCalendarSnapshot'), }); // Per-feature toggles. @@ -101,6 +103,8 @@ export function registerSettings() { config: true, type: Boolean, default: false, + // The built-in calendar shows or hides with sync, on every client. + onChange: () => Hooks.callAll('chronicleSyncCalendarSnapshot'), }); // Character sync toggle (requires matching game system). diff --git a/tools/test-calendar-builtin.mjs b/tools/test-calendar-builtin.mjs index fe573bd..784f168 100644 --- a/tools/test-calendar-builtin.mjs +++ b/tools/test-calendar-builtin.mjs @@ -141,3 +141,16 @@ test('addEvent posts a Chronicle event with wire visibility', async () => { ['/calendar/events', { name: 'Plot', year: 1492, month: 1, day: 5, all_day: false, start_hour: 9, start_minute: 30, visibility: 'gm-only' }], ]); }); + +test('a failed players events read keeps the last snapshot instead of blanking events', async () => { + saved.length = 0; + let n = 0; + const cs = makeSync(makeApi([ + ['/calendar/date?audience=players', { year: 1492, month: 2, day: 30, audience: 'players' }], + ['/calendar/events?', () => { n += 1; if (n === 2) throw new Error('500'); return { data: [], audience: 'players' }; }], + ['/calendar?audience=players', cal], + ])); + const original = console.debug; console.debug = () => {}; + try { await cs.publishPlayerSnapshot(); } finally { console.debug = original; } + assert.equal(saved.length, 0); +}); From e51358e0f49a9217be4a7001657ede4f19c6d90a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 06:50:19 +0000 Subject: [PATCH 4/7] Calendar: thin movable strip with a mini sky, day weather in the month Rebuilt to the second mockup's recommended picks. The date bar is now a thin strip over a small live sky (stars, snow or rain from Chronicle's weather, resting when the person steps away) that anyone can drag or shrink to a round clock, kept per person. The month view shows each day's sky and weather up to today, read from Chronicle's new day-weather route (players get it through the players-audience snapshot), and lists the month's events. The GM's arrows step an hour; Set date and Add event stay in the month. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn --- .ai.md | 6 +- API-CONTRACT.md | 14 +- CLAUDE.md | 2 +- TESTING.md | 25 ++- lang/en.json | 16 +- scripts/_calendar-view-model.mjs | 50 ++++- scripts/calendar-bar.mjs | 335 ++++++++++++++++++++--------- scripts/calendar-sync.mjs | 68 ++++-- scripts/settings.mjs | 10 + styles/chronicle-sync.css | 96 +++++---- tools/test-calendar-builtin.mjs | 13 ++ tools/test-calendar-view-model.mjs | 32 +++ 12 files changed, 479 insertions(+), 188 deletions(-) diff --git a/.ai.md b/.ai.md index ef6d53e..6bd5a9a 100644 --- a/.ai.md +++ b/.ai.md @@ -76,7 +76,7 @@ Pinned by `tools/test-sync-wire-fix.mjs`, `tools/test-calendar-sync-state.mjs`. | `module.json` | Module manifest. Foundry v12-v14. Optional dep: Monk's Enhanced Journal | | `scripts/module.mjs` | Entry point: settings on `init`, SyncManager on `ready` (GM only) | | `scripts/_keyed-queue.mjs`, `_journal-ownership.mjs`, `_journal-create.mjs`, `_sync-pull-walk.mjs`, `_actor-field-diff.mjs` | Pure helpers for the section above, each with a `tools/test-*.mjs` | -| `scripts/settings.mjs` | Settings shown in Foundry's Module Settings: `apiUrl`, `campaignId`, `syncEnabled`, `syncJournals`/`syncMaps`/`syncCalendar`/`syncCharacters` (world), `apiKey`, `connectLine` and `calendarTemperatureUnit` (client), `skipIncomingSanitization`, `calendarAnnounce*`. Hidden (`config: false`): `calendarSnapshot` (world, the players' calendar), `lastSyncTime`, `changeFeedCursor`, `journalCreateTypeId`, `detectedSystem`, `userMappings`, `stashesAvailable`, `syncExclusions`, `syncDirections`, `syncPermissions`, `defaultOwnership`, `dmOnlyHidden`, `conflictResolution`, `autoSync`, `excludedTags`, `excludedNamePattern`, `wizardCompleted`, and the client-scoped `notesGrants`, `dashboardActiveTab`, `dashboardCollapsedTypes`. Menus: Update Source | +| `scripts/settings.mjs` | Settings shown in Foundry's Module Settings: `apiUrl`, `campaignId`, `syncEnabled`, `syncJournals`/`syncMaps`/`syncCalendar`/`syncCharacters` (world), `apiKey`, `connectLine` and `calendarTemperatureUnit` (client), `skipIncomingSanitization`, `calendarAnnounce*`. Hidden (`config: false`): `calendarSnapshot` (world, the players' calendar), `calendarBarPlace` (client), `lastSyncTime`, `changeFeedCursor`, `journalCreateTypeId`, `detectedSystem`, `userMappings`, `stashesAvailable`, `syncExclusions`, `syncDirections`, `syncPermissions`, `defaultOwnership`, `dmOnlyHidden`, `conflictResolution`, `autoSync`, `excludedTags`, `excludedNamePattern`, `wizardCompleted`, and the client-scoped `notesGrants`, `dashboardActiveTab`, `dashboardCollapsedTypes`. Menus: Update Source | | `scripts/constants.mjs` | FLAG_SCOPE, MODULE_ID | | `scripts/api-client.mjs` | REST (Bearer) + WebSocket (auto-reconnect, message queue, event emitter) | | `scripts/sync-manager.mjs` | Orchestrator: owns API client, routes WS, sync mapping lookups | @@ -101,7 +101,7 @@ Pinned by `tools/test-sync-wire-fix.mjs`, `tools/test-calendar-sync-state.mjs`. | `scripts/calendar-sync.mjs` | Pulls Chronicle's calendar (date, events, weather/season/era/moons) and routes `calendar.*` WS types; `pushDate` is the guarded `PUT /calendar/date`; `step`/`setDate`/`addEvent` serve the built-in calendar, and `publishPlayerSnapshot` saves the players' `calendarSnapshot` from `?audience=players` reads. Writes no Foundry documents. `tools/test-calendar-builtin.mjs`, `tools/test-calendar-backcatalog-fix.mjs`, `tools/test-calendar-resilience.mjs`, `tools/test-calendar-subresource-routing.mjs` | | `scripts/_realtime-date-guard.mjs` | `shouldSkipDatePush`, `isRealTimeRejection` (422 backstop), `notifyRealTimePushPaused`. `tools/test-realtime-date-signal.mjs` | | `scripts/_applied-date-confirm.mjs` | See "Applied-date confirmation" above. `tools/test-applied-date-confirm.mjs` | -| `scripts/calendar-bar.mjs` | Built-in calendar: date bar at the top centre (date, time, season, weather, moons, tinted by the hour) and a month view. GM: step arrows, Set date, Add event via CalendarSync; players draw the `calendarSnapshot` setting. Live checks only (TESTING.md) | +| `scripts/calendar-bar.mjs` | Built-in calendar: a thin strip (date, time, weather over a mini sky that rests when idle) that anyone can drag or shrink to a clock (`calendarBarPlace`, client), and a month view where past days show their sky and weather. GM: hour arrows, Set date, Add event via CalendarSync; players draw the `calendarSnapshot` setting. Live checks only (TESTING.md) | | `scripts/_calendar-view-model.mjs` | Pure logic for the bar: `stepDate`, `clampDate`, labels, `monthGrid`, sky colours, `buildPlayerSnapshot` (players-audience reads only, field by field, public events only). `tools/test-calendar-view-model.mjs` | | `scripts/_chronicle-caldate.mjs` | Port of Chronicle's `CalDate` day maths (leap days, weekdays, festival months). `tools/test-chronicle-caldate.mjs` compares it with Chronicle when CHRONICLE_DIR is set | | `scripts/_calendar-sync-state.mjs` | `classifyCalendarSyncState`; priority paused > structure mismatch > date drift > in-sync, fails open; unused until a Foundry calendar exists. `tools/test-calendar-sync-state.mjs` | @@ -170,7 +170,7 @@ Full request/response schemas, WebSocket protocol, auth: **API-CONTRACT.md**. | Stashes | `GET /stashes/downtime`, `GET /stashes/requests`, `POST /stashes/requests/:moveId/approve\|decline`; a player's `GET /stashes/view`, `GET /stashes/history`, `POST /stashes/moves` are relayed through the GM's client with `actingUserId` | `stash-sync.mjs`, `_stash-relay.mjs` | | Calendar | `GET /calendar`, `GET/PUT /calendar/date` (`tracks_real_time` gates push), `POST /calendar/date/confirm` (optional), `POST /calendar/advance`, `POST /calendar/advance-time` | `calendar-sync.mjs`, `_realtime-date-guard.mjs`, `_applied-date-confirm.mjs` | | Calendar Events | `GET /calendar/events` (month-filtered), `POST /calendar/events` (Add event) | `calendar-sync.mjs` | -| Calendar, players' view | `GET /calendar`, `/calendar/date`, `/calendar/events` with `?audience=players` (date and events echo `"audience":"players"`) | `CalendarSync.publishPlayerSnapshot` | +| Calendar, players' view | `GET /calendar`, `/calendar/date`, `/calendar/events`, `/calendar/weather/days` with `?audience=players` (all but `/calendar` echo `"audience":"players"`) | `CalendarSync.publishPlayerSnapshot` | | Calendar Sub-Resources | `GET/PUT /calendar/{seasons,moons,eras,event-categories,cycles,festivals,weather}` | Not yet used | | Calendar Structure | `GET /calendar/structure`, `PUT /calendar/{settings,months,weekdays}`, `GET /calendar/export`, `POST /calendar/import` | Not yet used | | Sync Mappings | `GET/POST/DELETE /sync/mappings`, `GET /sync/lookup`, `GET /sync/pull`, `GET /sync/changes`, `POST /sync` | `sync-manager.mjs`, all sync modules | diff --git a/API-CONTRACT.md b/API-CONTRACT.md index 0056673..f39762f 100644 --- a/API-CONTRACT.md +++ b/API-CONTRACT.md @@ -925,13 +925,17 @@ Lists events for a month. Query: `?year=1492&month=3` or `?entity_id=uuid`. **Used by:** `calendar-sync.mjs` → `fetchEvents`, one request per month across the current year ±1, and the built-in calendar's month window (current month ±1). -**Players' view.** `GET /calendar`, `GET /calendar/date` and `GET /calendar/events` -take `?audience=players`: Chronicle filters the read for an anonymous player +**Day weather.** `GET /calendar/weather/days?year=&month=` returns `{data,total}` of +that month's day readings (`year`, `month`, `day`, `icon`, `preset_label`, …); below +the Director only days up to today. **Used by:** the built-in calendar's month view. + +**Players' view.** `GET /calendar`, `GET /calendar/date`, `GET /calendar/events` and +`GET /calendar/weather/days` take `?audience=players`: Chronicle filters the read for an anonymous player (hidden moons, secret eras, GM-only, per-player and unannounced events left out) -whatever the key's role, and the date and events answers carry +whatever the key's role, and the date, events and day-weather answers carry `"audience": "players"`. An older Chronicle ignores the parameter and answers -with the GM's view; without the echo the module leaves moons, era and events -out of the players' snapshot. **Used by:** `CalendarSync.publishPlayerSnapshot`. +with the GM's view; without the echo the module leaves moons, era, events and +day weather out of the players' snapshot. **Used by:** `CalendarSync.publishPlayerSnapshot`. Re-verify by: 2026-11-04 (Chronicle `internal/plugins/syncapi/calendar_api_handler.go` `readViewer`; lands with Chronicle #1084) #### POST /calendar/events diff --git a/CLAUDE.md b/CLAUDE.md index 6b43be3..7cb904f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Install/update flow: also `.ai.md` → "Chronicle Integration — Install & Upda - `_syncing` guard against infinite loops: a boolean. - Adapters implement `toChronicleFields()`/`fromChronicleFields()`; REST uses Bearer auth via `api-client.mjs`. - **API key is CLIENT-scoped, never world-scoped** (a world setting syncs to every client). `migrateApiKeyToClientScope()` migrates legacy values. `tools/test-api-key-scope.mjs`. -- **List responses are bare array or `{"data":[…],"total":N}`** — unwrap defensively everywhere. Envelope: `/entities`, `/entity-types`, `/systems`, `/addons`, `/tags`, `/relations/types`, `/calendar/events`. Bare: `/maps`, `/maps/:id/*`, `/members`, `/entities/:id/relations`, `/notes`. `tools/test-envelope-audit.mjs`. +- **List responses are bare array or `{"data":[…],"total":N}`** — unwrap defensively everywhere. Envelope: `/entities`, `/entity-types`, `/systems`, `/addons`, `/tags`, `/relations/types`, `/calendar/events`, `/calendar/weather/days`. Bare: `/maps`, `/maps/:id/*`, `/members`, `/entities/:id/relations`, `/notes`. `tools/test-envelope-audit.mjs`. - **Chronicle brings its own calendar; no Foundry calendar module is integrated.** `calendar-sync.mjs` pulls Chronicle's date, events and world state; `calendar-bar.mjs` draws the date bar and month view and writes dates and events back to Chronicle. Players have no key, so the GM's client publishes the `calendarSnapshot` world setting, built only from `?audience=players` reads that Chronicle echoes back (`buildPlayerSnapshot` in `_calendar-view-model.mjs`: copied by field, public events only). Day maths is Chronicle's own (`_chronicle-caldate.mjs`). `tools/test-calendar-view-model.mjs`, `tools/test-calendar-builtin.mjs`, `tools/test-chronicle-caldate.mjs`. - **Real-time calendars are read-only for dates**: `tracks_real_time` from `GET /calendar/date` pauses date-push only, via `scripts/_realtime-date-guard.mjs`. `tools/test-realtime-date-signal.mjs`. - **Calendar sub-resources are display-only** (dashboard + optional GM-whisper, never public); `structure.updated` only refetches the calendar. `scripts/_calendar-subresources.mjs`, `tools/test-calendar-subresources.mjs`, `tools/test-calendar-subresource-routing.mjs`. diff --git a/TESTING.md b/TESTING.md index 4fc7f39..82ed453 100644 --- a/TESTING.md +++ b/TESTING.md @@ -170,17 +170,22 @@ GM notice. See API-CONTRACT.md. Chronicle brings its own calendar; no Foundry calendar module is used. -### Built-in calendar (date bar) -- [ ] With Calendar sync on, a date bar sits at the top centre: weekday, date, time, season, weather and temperature, moons; its colour follows the hour (night, dawn, day, dusk) -- [ ] GM: the ‹ › arrows move the date by the chosen step (hour, day, week); Chronicle's calendar page shows the new date after a refresh +### Built-in calendar (strip) +- [ ] With Calendar sync on, a thin strip sits at the top centre: date, time and a weather icon over a small sky coloured for the hour; snow, rain or night stars move in it to match Chronicle +- [ ] Hover the strip -> it widens to show season, temperature and moons +- [ ] Drag the strip by its dots anywhere -> it stays there after a reload, and each player's spot is their own +- [ ] Click the clock icon -> the strip shrinks to a round clock; click the clock -> the strip comes back with the month open +- [ ] Leave the mouse still for 20 seconds -> the sky's motion stops; move it -> it starts again +- [ ] Click the date -> the month opens under the strip; days up to today show their sky colour and weather icon, later days are plain; event days have a dot and the month's events are listed below; ‹ › flips months; click outside or Esc closes it +- [ ] GM: the small ‹ › arrows move the time one hour; Chronicle's calendar page shows the new time after a refresh - [ ] GM: on a real-time calendar the arrows are greyed out and Set date is disabled -- [ ] Click the bar -> the month view opens under it with Chronicle's events; ‹ › flips months; click outside or Esc closes it -- [ ] GM: **Set date** in the month view moves Chronicle's date; a date the calendar doesn't have is refused with a warning -- [ ] GM: **Add event** creates the event in Chronicle (check the Chronicle calendar page); "GM only" makes it GM-only there -- [ ] Player client: the same bar and month view, without arrows, Set date or Add event; GM-only events, hidden moons and secret eras never appear -- [ ] Move the date in Chronicle -> the bar updates for the GM and every player -- [ ] A calendar hidden from players in Chronicle -> players see no bar -- [ ] Module Settings -> Calendar temperature unit °F -> only your bar switches to °F +- [ ] GM: **Set date…** in the month moves Chronicle's date; a date the calendar doesn't have is refused with a warning +- [ ] GM: **Add event** creates the event in Chronicle (check the Chronicle calendar page); "GM only" makes it GM-only there and shows "(GM only)" in the list +- [ ] Player client: the same strip and month, without arrows, Set date or Add event; GM-only events, hidden moons, secret eras and future days' weather never appear +- [ ] Move the date in Chronicle -> the strip updates for the GM and every player +- [ ] A calendar hidden from players in Chronicle -> players see no strip +- [ ] Turn Calendar sync off in Module Settings -> the strip disappears for everyone without a reload +- [ ] Module Settings -> Calendar temperature unit °F -> only your strip switches to °F ### Dashboard - [ ] Calendar tab shows Chronicle's calendar name, date (day, month, year) and time diff --git a/lang/en.json b/lang/en.json index c5b2e74..d5a587b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -23,20 +23,16 @@ "Label": "Chronicle calendar", "OpenMonth": "Open the month", "MonthLabel": "Chronicle calendar month", - "Back": "Back one {step}", - "Forward": "Forward one {step}", - "StepLabel": "Step size", - "Step": { - "hour": "hour", - "day": "day", - "week": "week" - }, + "BackHour": "Back one hour", + "ForwardHour": "Forward one hour", + "Drag": "Drag to move", + "Shrink": "Shrink to a clock", + "Clock": "Chronicle calendar: {date}, {time}. Click to open; drag to move", "RealTime": "This calendar follows real time, so its date can't be moved here.", - "StepRefused": "Chronicle didn't take the new date. The Calendar tab of the Chronicle dashboard says why.", + "StepRefused": "Chronicle didn't take the new time. The Calendar tab of the Chronicle dashboard says why.", "SetRefused": "Chronicle didn't take that date. Check that it exists in the calendar.", "PrevMonth": "Previous month", "NextMonth": "Next month", - "More": "+{count} more", "SetDate": "Set date", "AddEvent": "Add event", "Day": "Date", diff --git a/scripts/_calendar-view-model.mjs b/scripts/_calendar-view-model.mjs index 95812a4..09768a8 100644 --- a/scripts/_calendar-view-model.mjs +++ b/scripts/_calendar-view-model.mjs @@ -237,7 +237,9 @@ export function calendarOpenToPlayers(cal) { * @param {boolean} p.eventsConfirmed - every events page echoed the players audience. * @param {(e:object)=>boolean} p.isPublic - isChronicleEventPublic. */ -export function buildPlayerSnapshot({ calendar, dateInfo, events = [], eventsConfirmed = false, isPublic }) { +export function buildPlayerSnapshot({ + calendar, dateInfo, events = [], eventsConfirmed = false, isPublic, dayWeather = [], dayWeatherConfirmed = false, +}) { if (!calendarOpenToPlayers(calendar) || !dateInfo) return { v: SNAPSHOT_VERSION, hidden: true }; const filtered = dateInfo.audience === 'players'; const w = dateInfo.current_weather; @@ -271,9 +273,55 @@ export function buildPlayerSnapshot({ calendar, dateInfo, events = [], eventsCon })) : [], events: keep.slice(0, SNAPSHOT_EVENT_LIMIT).map(snapshotEvent), + dayWeather: dayWeatherConfirmed ? pastDayWeather(dayWeather, dateInfo) : [], }; } +/** + * Day readings up to `today`, reduced to what the month view draws. Chronicle + * already holds future days back from players; this drops them again so a + * forecast can never reach the snapshot. + */ +export function pastDayWeather(days, today) { + const t = today && [num(today.year), num(today.month), num(today.day)]; + if (!t || t.some((v) => v === null)) return []; + const onOrBefore = (d) => d.year < t[0] || (d.year === t[0] && (d.month < t[1] || (d.month === t[1] && d.day <= t[2]))); + return (days || []) + .map((d) => ({ year: num(d?.year), month: num(d?.month), day: num(d?.day), icon: str(d?.icon, 20), label: str(d?.preset_label, 80) || str(d?.description, 80) })) + .filter((d) => d.year !== null && d.month !== null && d.day !== null && onOrBefore(d)) + .slice(0, SNAPSHOT_EVENT_LIMIT); +} + +/** Background for a day cell: the sky that weather gives. */ +const DAY_SKY = Object.freeze({ + clear: 'linear-gradient(#9cc7ea, #dfeaf2)', + cloud: 'linear-gradient(#7d8fa3, #b9c3cc)', + rain: 'linear-gradient(#5b6b80, #9aa6b4)', + snow: 'linear-gradient(#c9d6e2, #eef1f4)', + storm: 'linear-gradient(#4b5a73, #8b97a6)', + fog: 'linear-gradient(#a7adb3, #d3d6d9)', +}); + +export function daySky(icon) { + return DAY_SKY[icon] || DAY_SKY.cloud; +} + +/** Which particles the strip's mini sky shows for a weather icon and hour. */ +export function skyParticles(cal, hour, icon) { + if (icon === 'snow') return 'snow'; + if (icon === 'rain' || icon === 'storm') return 'rain'; + const phase = skyPhase(cal, hour); + if ((phase === 'night') && icon !== 'fog') return 'stars'; + return 'none'; +} + +/** Keep a dragged strip on screen. */ +export function clampPlace(place, box, viewport) { + const x = Math.min(Math.max(Number(place?.x) || 0, 0), Math.max(viewport.width - box.width, 0)); + const y = Math.min(Math.max(Number(place?.y) || 0, 0), Math.max(viewport.height - box.height, 0)); + return { x: Math.round(x), y: Math.round(y) }; +} + /** The fields the month view shows for an event. */ export function snapshotEvent(e) { return { diff --git a/scripts/calendar-bar.mjs b/scripts/calendar-bar.mjs index e531f75..5e799be 100644 --- a/scripts/calendar-bar.mjs +++ b/scripts/calendar-bar.mjs @@ -1,20 +1,22 @@ /** * Chronicle Sync - Built-in calendar * - * Chronicle's own calendar inside Foundry: a date bar at the top centre of - * the screen (date, time, season, weather, moons, tinted by the hour) and a - * month view that opens from it. The GM's bar has the step arrows, Set date - * and Add event, all writing to Chronicle through CalendarSync; players see - * the same bar without controls, drawn from the `calendarSnapshot` world - * setting the GM's client publishes (players hold no key). Events stay - * Chronicle's own: nothing here creates a Foundry document. + * Chronicle's own calendar inside Foundry: a thin strip with the date, time + * and weather over a tiny live sky, which anyone can drag anywhere or shrink + * to a round clock (the spot is kept per person). Clicking the date opens the + * month, where each day up to today shows its sky and weather. The GM's strip + * has small arrows that step an hour, and the month has Set date and Add + * event; all of it writes to Chronicle through CalendarSync. Players draw + * from the `calendarSnapshot` world setting the GM's client publishes + * (players hold no key). Events stay Chronicle's own: nothing here creates a + * Foundry document. */ -import { getSetting } from './settings.mjs'; +import { getSetting, setSetting } from './settings.mjs'; import { promptDialog } from './_dialogs.mjs'; import { - STEP_UNITS, eventsOn, formatDate, formatTemperature, formatTime, isUsableSnapshot, - monthGrid, monthName, moonIconClass, safeColor, shiftMonth, skyGradient, weatherIconClass, weekdayName, + clampPlace, daySky, eventsOn, formatDate, formatTemperature, formatTime, isUsableSnapshot, + monthGrid, monthName, moonIconClass, shiftMonth, skyGradient, skyParticles, weatherIconClass, } from './_calendar-view-model.mjs'; const t = (key, data) => (data @@ -27,21 +29,23 @@ function esc(s) { .replace(/"/g, '"').replace(/'/g, '''); } +/** Time without a pointer or key before the mini sky comes to rest. */ +const REST_AFTER_MS = 20000; + /** @type {import('./calendar-sync.mjs').CalendarSync|null} the GM's live source. */ let source = null; /** @type {HTMLElement|null} */ -let bar = null; +let wid = null; /** @type {HTMLElement|null} */ let popover = null; /** Month the open month view shows. */ let shown = null; -/** GM: events fetched for months outside the nearby window, keyed "y-m". */ +/** GM: events and day weather fetched for months outside the nearby window, keyed "y-m". */ const extraMonths = new Map(); -let step = 'hour'; /** * What to draw: the GM's live view, or the players' snapshot. Null hides - * the bar (sync off, no calendar, or one players may not see). + * the calendar (sync off, no calendar, or one players may not see). */ function currentView() { if (!getSetting('syncEnabled') || !getSetting('syncCalendar')) return null; @@ -49,17 +53,16 @@ function currentView() { const v = source.gmView; if (v.calendar && v.date) { const info = v.dateInfo || {}; + const w = info.current_weather; return { gm: true, calendar: v.calendar, date: v.date, season: info.current_season || null, - weather: info.current_weather - ? { label: info.current_weather.preset_label || info.current_weather.description, icon: info.current_weather.icon, - color: safeColor(info.current_weather.color), temperature_celsius: info.current_weather.temperature_celsius } - : null, + weather: w ? { label: w.preset_label || w.description, icon: w.icon, temperature_celsius: w.temperature_celsius } : null, moons: (info.current_moon_phases || []).map((m) => ({ name: m.moon_name, phase_name: m.phase_name, phase_icon: m.phase_icon })), events: v.events, + dayWeather: v.dayWeather, readOnly: !!(info.tracks_real_time ?? v.calendar.tracks_real_time), }; } @@ -67,74 +70,177 @@ function currentView() { } const snap = getSetting('calendarSnapshot'); if (!isUsableSnapshot(snap)) return null; - return { gm: false, ...snap, readOnly: true }; + return { gm: false, ...snap, dayWeather: snap.dayWeather || [], readOnly: true }; +} + +function particlesHtml(kind) { + if (kind === 'none') return ''; + const n = kind === 'stars' ? 14 : 16; + let h = ''; + for (let i = 0; i < n; i++) { + const left = (i * (kind === 'stars' ? 7 : 13) + 3) % 98; + if (kind === 'stars') h += ``; + else h += ``; + } + return h; } -function barHtml(v) { +function moreText(v) { const unit = getSetting('calendarTemperatureUnit') || 'C'; - const wd = weekdayName(v.calendar, v.date); - const season = v.season?.name - ? `${esc(v.season.name)}` : ''; - const weather = v.weather - ? ` ${esc(formatTemperature(v.weather.temperature_celsius, unit))}` : ''; - const moons = (v.moons || []).slice(0, 3).map((m) => - ``).join(''); - const arrows = (dir) => (v.gm - ? `` + const parts = []; + if (v.season?.name) parts.push(esc(v.season.name)); + const temp = formatTemperature(v.weather?.temperature_celsius, unit); + if (temp) parts.push(esc(temp)); + for (const m of (v.moons || []).slice(0, 3)) { + parts.push(`${esc(m.name || '')} ${esc(m.phase_name || '')} `); + } + return parts.join(' · '); +} + +function stripHtml(v) { + const arrow = (dir) => (v.gm + ? `` : ''); - const select = v.gm && !v.readOnly - ? `` - : ''; - return `${arrows(-1)} - - ${arrows(1)}${select}`; + ${weather}${more ? `${more}` : ''} + ${arrow(1)} + `; } -/** Draw (or remove) the bar for the current view. */ +function dotHtml(v) { + const label = t('Clock', { date: formatDate(v.calendar, v.date), time: formatTime(v.calendar, v.date) }); + return ``; +} + +/** The person's saved spot and size; per client so each player keeps their own. */ +function savedPlace() { + const p = getSetting('calendarBarPlace'); + return p && typeof p === 'object' ? p : {}; +} + +function applyPlace() { + if (!wid) return; + const p = savedPlace(); + const box = wid.getBoundingClientRect(); + const fallback = { x: (window.innerWidth - box.width) / 2, y: 8 }; + const at = clampPlace(Number.isFinite(p.x) ? p : fallback, box, { width: window.innerWidth, height: window.innerHeight }); + wid.style.left = `${at.x}px`; + wid.style.top = `${at.y}px`; +} + +function savePlace(patch) { + setSetting('calendarBarPlace', { ...savedPlace(), ...patch }).catch((err) => { + console.debug('Chronicle: saving the calendar spot failed', err?.message); + }); +} + +/** Draw (or remove) the calendar for the current view. */ export function renderCalendarBar() { const v = currentView(); if (!v) { - bar?.remove(); bar = null; closeMonth(); + wid?.remove(); wid = null; closeMonth(); return; } - if (!bar) { - bar = document.createElement('div'); - bar.id = 'chronicle-calendar-bar'; - bar.setAttribute('role', 'group'); - bar.setAttribute('aria-label', t('Label')); - document.body.appendChild(bar); - bar.addEventListener('click', onBarClick); - bar.addEventListener('change', (ev) => { - if (ev.target.matches('.ccal-unit')) { step = ev.target.value; renderCalendarBar(); } - }); + if (!wid) { + wid = document.createElement('div'); + wid.id = 'chronicle-calendar-bar'; + wid.setAttribute('role', 'group'); + wid.setAttribute('aria-label', t('Label')); + document.body.appendChild(wid); + wid.addEventListener('click', onClick); + wid.addEventListener('pointerdown', onDragStart); + } + const mini = !!savedPlace().mini; + wid.classList.toggle('ccal-mini', mini); + wid.classList.toggle('ccal-gm', v.gm); + wid.style.setProperty('--ccal-sky', skyGradient(v.calendar, v.date.hour)); + wid.innerHTML = mini ? dotHtml(v) : stripHtml(v); + const sky = wid.querySelector('.ccal-sky'); + if (sky) sky.innerHTML = particlesHtml(skyParticles(v.calendar, v.date.hour, v.weather?.icon)); + applyPlace(); + if (popover) { renderMonth(); placeMonth(); } +} + +let drag = null; + +function onDragStart(ev) { + if (!ev.target.closest('.ccal-grip, .ccal-dot') || ev.button !== 0) return; + const box = wid.getBoundingClientRect(); + drag = { dx: ev.clientX - box.left, dy: ev.clientY - box.top, x0: ev.clientX, y0: ev.clientY, moved: false }; + wid.setPointerCapture(ev.pointerId); + wid.addEventListener('pointermove', onDragMove); + wid.addEventListener('pointerup', onDragEnd, { once: true }); + wid.addEventListener('pointercancel', onDragEnd, { once: true }); +} + +function onDragMove(ev) { + if (!drag) return; + // A few pixels of wobble is still a click, not a drag. + if (!drag.moved && Math.abs(ev.clientX - drag.x0) + Math.abs(ev.clientY - drag.y0) < 4) return; + drag.moved = true; + const at = clampPlace({ x: ev.clientX - drag.dx, y: ev.clientY - drag.dy }, wid.getBoundingClientRect(), + { width: window.innerWidth, height: window.innerHeight }); + wid.style.left = `${at.x}px`; + wid.style.top = `${at.y}px`; + if (popover) placeMonth(); +} + +function onDragEnd() { + wid?.removeEventListener('pointermove', onDragMove); + if (drag?.moved) { + savePlace({ x: wid.offsetLeft, y: wid.offsetTop }); + // The click that ends a drag must not also open or restore anything. + wid.addEventListener('click', (e) => e.stopPropagation(), { once: true, capture: true }); } - bar.style.setProperty('--ccal-sky', skyGradient(v.calendar, v.date.hour)); - bar.classList.toggle('ccal-gm', v.gm); - bar.innerHTML = barHtml(v); - if (popover) renderMonth(); + drag = null; } -async function onBarClick(ev) { +async function onClick(ev) { const stepBtn = ev.target.closest('.ccal-step'); if (stepBtn && source) { stepBtn.disabled = true; - const ok = await source.step(step, Number(stepBtn.dataset.dir)); + const ok = await source.step('hour', Number(stepBtn.dataset.dir)); if (!ok) ui.notifications.warn(t('StepRefused')); renderCalendarBar(); return; } - if (ev.target.closest('.ccal-face')) { + if (ev.target.closest('.ccal-shrink')) { + closeMonth(); + savePlace({ mini: true }); + return; + } + if (ev.target.closest('.ccal-dot')) { + // One click on the clock brings the strip back with the month open. + savePlace({ mini: false }); + setTimeout(openMonth, 0); + return; + } + if (ev.target.closest('.ccal-open')) { if (popover) closeMonth(); else openMonth(); } } +function placeMonth() { + if (!popover || !wid) return; + const box = wid.getBoundingClientRect(); + const w = popover.offsetWidth || 300; + const h = popover.offsetHeight || 260; + const below = box.bottom + 6 + h <= window.innerHeight; + popover.style.left = `${Math.max(4, Math.min(box.left, window.innerWidth - w - 4))}px`; + popover.style.top = `${below ? box.bottom + 6 : Math.max(4, box.top - h - 6)}px`; +} + function openMonth() { const v = currentView(); - if (!v) return; + if (!v || popover) return; shown = { y: v.date.year, m: v.date.month }; popover = document.createElement('div'); popover.id = 'chronicle-calendar-month'; @@ -143,10 +249,10 @@ function openMonth() { document.body.appendChild(popover); popover.addEventListener('click', onMonthClick); renderMonth(); - requestAnimationFrame(() => popover?.classList.add('open')); - setTimeout(() => document.addEventListener('pointerdown', onOutside, true), 0); + placeMonth(); + document.addEventListener('pointerdown', onOutside, true); document.addEventListener('keydown', onKey, true); - bar?.querySelector('.ccal-face')?.setAttribute('aria-expanded', 'true'); + wid?.querySelector('.ccal-open')?.setAttribute('aria-expanded', 'true'); } function closeMonth() { @@ -154,12 +260,12 @@ function closeMonth() { document.removeEventListener('keydown', onKey, true); popover?.remove(); popover = null; - bar?.querySelector('.ccal-face')?.setAttribute('aria-expanded', 'false'); + wid?.querySelector('.ccal-open')?.setAttribute('aria-expanded', 'false'); } // Nothing in the month view is ever unsaved, so a click outside just closes it. function onOutside(ev) { - if (popover?.contains(ev.target) || bar?.contains(ev.target)) return; + if (popover?.contains(ev.target) || wid?.contains(ev.target)) return; closeMonth(); } @@ -168,57 +274,74 @@ function onKey(ev) { if (ev.key === 'Escape' && popover) closeMonth(); } -function monthEvents(v, y, m) { +function monthData(v, y, m) { const key = `${y}-${m}`; if (v.gm && extraMonths.has(key)) return extraMonths.get(key); - return (v.events || []).filter((e) => e.year === y && e.month === m); + const inMonth = (x) => x.year === y && x.month === m; + return { events: (v.events || []).filter(inMonth), days: (v.dayWeather || []).filter(inMonth) }; } /** GM: load a month outside the nearby window once, then redraw. */ async function ensureMonth(v, y, m) { if (!v.gm || !source?._api) return; const key = `${y}-${m}`; - if (extraMonths.has(key) || (v.events || []).some((e) => e.year === y && e.month === m)) return; - extraMonths.set(key, []); + const near = (v.events || []).some((e) => e.year === y && e.month === m) + || (v.dayWeather || []).some((d) => d.year === y && d.month === m); + if (extraMonths.has(key) || near) return; + extraMonths.set(key, { events: [], days: [] }); try { - const payload = await source._api.get(`/calendar/events?year=${y}&month=${m}`); - extraMonths.set(key, Array.isArray(payload) ? payload : (payload?.data || [])); + const unwrap = (p) => (Array.isArray(p) ? p : (p?.data || [])); + const [events, days] = await Promise.all([ + source._api.get(`/calendar/events?year=${y}&month=${m}`), + source._api.get(`/calendar/weather/days?year=${y}&month=${m}`).catch(() => []), + ]); + extraMonths.set(key, { events: unwrap(events), days: unwrap(days) }); renderMonth(); } catch (err) { // Forget the month so reopening it tries again. extraMonths.delete(key); - console.debug('Chronicle: month events read failed', err?.message); + console.debug('Chronicle: month read failed', err?.message); } } +function isPast(v, y, m, d) { + const n = v.date; + return y < n.year || (y === n.year && (m < n.month || (m === n.month && d <= n.day))); +} + function renderMonth() { const v = currentView(); if (!popover || !v || !shown) { closeMonth(); return; } const g = monthGrid(v.calendar, shown.y, shown.m); - const evs = monthEvents(v, shown.y, shown.m); + const { events, days } = monthData(v, shown.y, shown.m); ensureMonth(v, shown.y, shown.m); - const isToday = (d) => shown.y === v.date.year && shown.m === v.date.month && d === v.date.day; + const wxByDay = new Map(days.map((d) => [d.day, d])); const cell = (d) => { - if (!d) return ''; - const list = eventsOn(evs, shown.y, shown.m, d); - const dots = list.slice(0, 3).map((e) => - `${esc(e.name)}`).join(''); - const more = list.length > 3 ? `${esc(t('More', { count: list.length - 3 }))}` : ''; - return `${d}${dots}${more}`; + if (!d) return '
'; + const list = eventsOn(events, shown.y, shown.m, d); + const wx = isPast(v, shown.y, shown.m, d) ? wxByDay.get(d) : null; + const today = shown.y === v.date.year && shown.m === v.date.month && d === v.date.day; + const cls = [today ? 'ccal-today' : '', wx ? 'ccal-wxday' : '', + list.length ? (list.every((e) => e.visibility === 'gm-only') ? 'ccal-gmev' : 'ccal-ev') : ''].filter(Boolean).join(' '); + const style = wx ? ` style="background:${daySky(wx.icon)}"` : ''; + const title = [wx?.label, ...list.map((e) => e.name)].filter(Boolean).join(' · '); + return `
${d}${wx ? `` : ''}
`; }; - const head = g.headers.length ? `${g.headers.map((h) => `${esc(h)}`).join('')}` : ''; - const body = g.rows.map((r) => `${r.map(cell).join('')}`).join(''); + const head = g.headers.length ? `
${g.headers.map((h) => `${esc(h)}`).join('')}
` : ''; + const cols = g.festival ? Math.max(1, Math.min(g.rows[0].length, 7)) : g.headers.length; + const body = `
${g.rows.flat().map(cell).join('')}
`; + const listed = [...events].sort((a, b) => (a.day - b.day)).slice(0, 8).map((e) => + `${Number(e.day) || ''} ${esc(e.name)}${e.visibility === 'gm-only' ? ` (${esc(t('GmOnly'))})` : ''}
`).join(''); const gmTools = v.gm - ? `
-
` + ? `
+
` : ''; popover.innerHTML = `
- -

${esc(monthName(v.calendar, shown.m))} ${shown.y}

- -
- ${head}${body}
- ${gmTools}`; + + ${esc(monthName(v.calendar, shown.m))} ${shown.y} + + ${head}${body} + ${listed || gmTools ? `
${listed}${gmTools}
` : ''}`; } async function onMonthClick(ev) { @@ -230,6 +353,7 @@ async function onMonthClick(ev) { if (act === 'prev' || act === 'next') { shown = shiftMonth(v.calendar, shown.y, shown.m, act === 'prev' ? -1 : 1); renderMonth(); + placeMonth(); } else if (act === 'set' && source) { await setDateDialog(v); } else if (act === 'add' && source) { @@ -237,18 +361,16 @@ async function onMonthClick(ev) { } } -function dateFields(v, d, withTime) { +function dateFields(v, d) { const months = v.calendar.months.map((m, i) => ``).join(''); - const time = withTime - ? `
- : -
` - : ''; return `
-
${time}`; +
+
+ : +
`; } function readDate(root) { @@ -265,7 +387,7 @@ function readDate(root) { async function setDateDialog(v) { const res = await promptDialog({ title: t('SetDate'), - content: `
${dateFields(v, v.date, true)}
`, + content: `
${dateFields(v, v.date)}
`, label: t('SetDate'), callback: readDate, }); @@ -281,7 +403,7 @@ async function addEventDialog(v) { title: t('AddEvent'), content: `
- ${dateFields(v, day, true)} + ${dateFields(v, day)}
`, label: t('AddEvent'), @@ -294,6 +416,23 @@ async function addEventDialog(v) { else ui.notifications.warn(t('AddRefused')); } +/** + * Like every looping animation in Chronicle, the mini sky slows to still + * when the person steps away and starts again when they come back. + */ +function registerRest() { + let timer = null; + const rest = () => document.body.classList.add('ccal-rest'); + const wake = () => { + document.body.classList.remove('ccal-rest'); + clearTimeout(timer); + timer = setTimeout(rest, REST_AFTER_MS); + }; + for (const ev of ['pointermove', 'keydown', 'wheel']) document.addEventListener(ev, wake, { passive: true }); + document.addEventListener('visibilitychange', () => (document.hidden ? rest() : wake())); + wake(); +} + /** * Mount the built-in calendar. Every user calls this; the GM passes the live * CalendarSync, players draw from the shared snapshot. @@ -303,5 +442,7 @@ export function registerCalendarBar(calendarSync) { source = game.user.isGM ? calendarSync : null; source?.onChange(() => { extraMonths.clear(); renderCalendarBar(); }); Hooks.on('chronicleSyncCalendarSnapshot', () => renderCalendarBar()); + window.addEventListener('resize', () => applyPlace()); + registerRest(); renderCalendarBar(); } diff --git a/scripts/calendar-sync.mjs b/scripts/calendar-sync.mjs index 8abce03..faa12b9 100644 --- a/scripts/calendar-sync.mjs +++ b/scripts/calendar-sync.mjs @@ -208,6 +208,9 @@ export class CalendarSync { /** @type {object[]} GM-view events for the months around the current date. */ this._events = []; + /** @type {object[]} Day weather readings for the months around the current date. */ + this._dayWeather = []; + /** @type {Set} Built-in calendar listeners, called after each refresh. */ this._listeners = new Set(); @@ -237,7 +240,10 @@ export class CalendarSync { /** What the GM's calendar draws: structure, date, world state and every event. */ get gmView() { - return { calendar: this._chronicleCalendar, date: this.chronicleDate, dateInfo: this._dateInfo, events: this._events }; + return { + calendar: this._chronicleCalendar, date: this.chronicleDate, dateInfo: this._dateInfo, + events: this._events, dayWeather: this._dayWeather ?? [], + }; } /** @@ -268,23 +274,25 @@ export class CalendarSync { if (!handleIfCalendarRebuilding(err)) console.debug('Chronicle: calendar date read failed', err?.message); } this._events = (await this._fetchMonthsAround()).events; + this._dayWeather = (await this._readMonthsAround('/calendar/weather/days')).items; this._emitChange(); await this.publishPlayerSnapshot(); } /** - * Events for the current month and one either side, the window the month - * view opens on. `audience` reads them as players see them. + * Read a month-filtered list (events or day weather) for the current month + * and one either side, the window the month view opens on. `audience` + * reads it as players see it. + * @param {string} path - '/calendar/events' or '/calendar/weather/days'. * @param {string} [audience] - * @returns {Promise<{events: object[], confirmed: boolean, failed: boolean}>} confirmed: every page - * echoed the audience; failed: a page could not be read. + * @returns {Promise<{items: object[], confirmed: boolean, failed: boolean}>} confirmed: every + * page echoed the audience; failed: a page could not be read. * @private */ - async _fetchMonthsAround(audience) { + async _readMonthsAround(path, audience) { const d = this.chronicleDate; - if (!d) return { events: [], confirmed: false, failed: false }; - const seen = new Set(); - const events = []; + if (!d) return { items: [], confirmed: false, failed: false }; + const items = []; let confirmed = true; let failed = false; for (const delta of [-1, 0, 1]) { @@ -292,23 +300,37 @@ export class CalendarSync { const q = `year=${y}&month=${m}${audience ? `&audience=${audience}` : ''}`; let payload; try { - payload = await this._api.get(`/calendar/events?${q}`); + payload = await this._api.get(`${path}?${q}`); } catch (err) { - console.debug('Chronicle: calendar events read failed', err?.message); confirmed = false; + // A 404 is an older Chronicle without the route: nothing to show, not a failure. + if (err?.status === 404) continue; + console.debug(`Chronicle: ${path} read failed`, err?.message); failed = true; continue; } if (audience && payload?.audience !== audience) confirmed = false; const page = Array.isArray(payload) ? payload : (Array.isArray(payload?.data) ? payload.data : []); - for (const e of page) { - // A repeating event appears once per occurrence, all under one id. - const key = `${e?.id}|${e?.year}-${e?.month}-${e?.day}`; - if (!e || e.id == null || seen.has(key)) continue; - seen.add(key); - events.push(e); - } + items.push(...page.filter((x) => x && typeof x === 'object')); } + return { items, confirmed, failed }; + } + + /** + * Events around the current month, one entry per occurrence (a repeating + * event appears once per date, all under one id). + * @param {string} [audience] + * @private + */ + async _fetchMonthsAround(audience) { + const { items, confirmed, failed } = await this._readMonthsAround('/calendar/events', audience); + const seen = new Set(); + const events = items.filter((e) => { + const key = `${e.id}|${e.year}-${e.month}-${e.day}`; + if (e.id == null || seen.has(key)) return false; + seen.add(key); + return true; + }); return { events, confirmed, failed }; } @@ -323,6 +345,8 @@ export class CalendarSync { let dateInfo = null; let events = []; let eventsConfirmed = false; + let dayWeather = []; + let dayWeatherConfirmed = false; try { calendar = await this._api.get('/calendar?audience=players'); if (calendar) { @@ -332,6 +356,10 @@ export class CalendarSync { // A passing read error would publish an empty list over the players' // events; keep the last snapshot until a full read succeeds. if (failed) return; + const wx = await this._readMonthsAround('/calendar/weather/days', 'players'); + if (wx.failed) return; + dayWeather = wx.items; + dayWeatherConfirmed = wx.confirmed; } } catch (err) { if (err?.status !== 404) { @@ -341,7 +369,9 @@ export class CalendarSync { } calendar = null; } - const snap = buildPlayerSnapshot({ calendar, dateInfo, events, eventsConfirmed, isPublic: isChronicleEventPublic }); + const snap = buildPlayerSnapshot({ + calendar, dateInfo, events, eventsConfirmed, isPublic: isChronicleEventPublic, dayWeather, dayWeatherConfirmed, + }); const json = JSON.stringify(snap); if (json === this._lastSnapshotJson) return; this._lastSnapshotJson = json; diff --git a/scripts/settings.mjs b/scripts/settings.mjs index fce79aa..3376d29 100644 --- a/scripts/settings.mjs +++ b/scripts/settings.mjs @@ -186,6 +186,16 @@ export function registerSettings() { onChange: () => Hooks.callAll('chronicleSyncCalendarSnapshot'), }); + // Where this person dragged the calendar strip, and whether it is shrunk + // to the clock. Client scope: each player keeps their own spot. + game.settings.register(MODULE_ID, 'calendarBarPlace', { + scope: 'client', + config: false, + type: Object, + default: {}, + onChange: () => Hooks.callAll('chronicleSyncCalendarSnapshot'), + }); + game.settings.register(MODULE_ID, 'calendarTemperatureUnit', { name: game.i18n.localize('CHRONICLE.Settings.CalendarTemperatureUnit.Name'), hint: game.i18n.localize('CHRONICLE.Settings.CalendarTemperatureUnit.Hint'), diff --git a/styles/chronicle-sync.css b/styles/chronicle-sync.css index 747e926..23ef7ef 100644 --- a/styles/chronicle-sync.css +++ b/styles/chronicle-sync.css @@ -2267,51 +2267,63 @@ .sync-history .sh-more { padding: 6px; text-align: center; border-top: 1px solid rgba(255, 255, 255, 0.08); } @media (prefers-reduced-motion: reduce) { .sync-history .sh-caret { transition: none; } } -/* Built-in calendar: date bar at the top centre and its month view. */ -#chronicle-calendar-bar { - position: fixed; top: 8px; left: 50%; transform: translateX(-50%); z-index: 60; - display: flex; align-items: center; gap: 4px; padding: 3px 6px; - border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.18); - background: var(--ccal-sky, #1b2350); color: #f4f1ea; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45); font-size: var(--font-size-13, 13px); - transition: background 1.2s ease; -} -#chronicle-calendar-bar button { background: none; border: none; color: inherit; line-height: 1; width: auto; margin: 0; } -#chronicle-calendar-bar .ccal-face { display: flex; align-items: center; gap: 8px; padding: 3px 10px; border-radius: 999px; cursor: pointer; } -#chronicle-calendar-bar .ccal-face:hover, #chronicle-calendar-bar .ccal-face:focus-visible { background: rgba(0, 0, 0, 0.25); } -#chronicle-calendar-bar .ccal-date { font-weight: 600; white-space: nowrap; } +/* Built-in calendar: a thin movable strip over a mini sky, and its month view. */ +#chronicle-calendar-bar { position: fixed; z-index: 60; touch-action: none; color: #f4f1ea; font-size: 11.5px; } +#chronicle-calendar-bar button { background: none; border: none; color: inherit; line-height: 1; width: auto; height: auto; margin: 0; padding: 0 2px; box-shadow: none; } +#chronicle-calendar-bar:not(.ccal-mini) { + display: flex; align-items: center; gap: 6px; height: 24px; padding: 0 6px 0 2px; border-radius: 12px; overflow: hidden; + white-space: nowrap; border: 1px solid rgba(255, 255, 255, 0.18); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45); +} +#chronicle-calendar-bar .ccal-sky { position: absolute; inset: 0; z-index: -1; overflow: hidden; background: var(--ccal-sky, #1b2350); transition: background 1.2s ease; } +#chronicle-calendar-bar:not(.ccal-mini) { isolation: isolate; } +#chronicle-calendar-bar .ccal-grip { cursor: grab; opacity: 0.55; padding: 0 3px; letter-spacing: -2px; font-size: 11px; } +#chronicle-calendar-bar .ccal-grip:active { cursor: grabbing; } +#chronicle-calendar-bar .ccal-open { cursor: pointer; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); } +#chronicle-calendar-bar .ccal-open b { font-weight: 600; } #chronicle-calendar-bar .ccal-time { opacity: 0.85; font-variant-numeric: tabular-nums; } -#chronicle-calendar-bar .ccal-chip { padding: 1px 8px; border-radius: 999px; background: color-mix(in srgb, var(--ccal-chip) 55%, transparent); font-size: 0.9em; } -#chronicle-calendar-bar .ccal-moons { display: inline-flex; gap: 4px; opacity: 0.9; } -#chronicle-calendar-bar .ccal-step { padding: 4px 6px; border-radius: 50%; cursor: pointer; } -#chronicle-calendar-bar .ccal-step:hover:not(:disabled) { background: rgba(0, 0, 0, 0.25); } -#chronicle-calendar-bar .ccal-step:disabled { opacity: 0.4; cursor: default; } -#chronicle-calendar-bar .ccal-unit { height: 22px; width: auto; background: rgba(0, 0, 0, 0.3); color: inherit; border: none; border-radius: 6px; font-size: 0.9em; } +#chronicle-calendar-bar .ccal-more { max-width: 0; overflow: hidden; opacity: 0.85; transition: max-width 0.35s ease; } +#chronicle-calendar-bar:hover .ccal-more, #chronicle-calendar-bar:focus-within .ccal-more { max-width: 260px; } +#chronicle-calendar-bar .ccal-step, #chronicle-calendar-bar .ccal-shrink { cursor: pointer; opacity: 0.85; font-size: 13px; } +#chronicle-calendar-bar .ccal-step:disabled { opacity: 0.35; cursor: default; } +#chronicle-calendar-bar.ccal-mini .ccal-dot { + position: relative; isolation: isolate; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; overflow: hidden; + cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.25); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45); font-size: 13px; + animation: ccal-pop 0.25s ease-out; +} +#chronicle-calendar-bar .ccal-star { position: absolute; width: 2px; height: 2px; border-radius: 50%; background: #fff; animation: ccal-twinkle 3s ease-in-out infinite; } +#chronicle-calendar-bar .ccal-snow { position: absolute; top: -4px; width: 2px; height: 3px; border-radius: 1px; background: #fff; opacity: 0.85; animation: ccal-fall 2.6s linear infinite; } +#chronicle-calendar-bar .ccal-rain { position: absolute; top: -6px; width: 1px; height: 6px; background: rgba(200, 220, 255, 0.8); animation: ccal-fall 0.9s linear infinite; } +body.ccal-rest #chronicle-calendar-bar .ccal-sky * { animation-play-state: paused; } #chronicle-calendar-month { - position: fixed; top: 46px; left: 50%; z-index: 61; width: min(560px, 94vw); - transform: translateX(-50%) translateY(-6px) scale(0.98); opacity: 0; transform-origin: top center; - transition: opacity 0.16s ease, transform 0.16s ease; - padding: 8px 10px 10px; border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.15); - background: rgba(20, 22, 32, 0.96); color: #eee; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); -} -#chronicle-calendar-month.open { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } -#chronicle-calendar-month header { display: flex; align-items: center; justify-content: space-between; } -#chronicle-calendar-month header h3 { margin: 0; border: none; font-size: 1.05em; } -#chronicle-calendar-month button { width: auto; flex: 0 0 auto; background: rgba(255, 255, 255, 0.06); color: inherit; border: 1px solid rgba(255, 255, 255, 0.12); } -#chronicle-calendar-month .ccal-grid { width: 100%; table-layout: fixed; border-collapse: collapse; margin: 6px 0 0; background: none; border: none; } -#chronicle-calendar-month .ccal-grid th { font-size: 0.75em; font-weight: 500; opacity: 0.7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: none; color: inherit; } -#chronicle-calendar-month .ccal-grid td { vertical-align: top; height: 52px; padding: 2px 3px; border: 1px solid rgba(255, 255, 255, 0.06); overflow: hidden; } -#chronicle-calendar-month .ccal-grid tr:nth-child(even) { background: none; } -#chronicle-calendar-month .ccal-pad { background: rgba(0, 0, 0, 0.2); } -#chronicle-calendar-month .ccal-n { display: block; font-size: 0.8em; opacity: 0.8; } -#chronicle-calendar-month .ccal-today { outline: 2px solid #e0b25b; outline-offset: -2px; } -#chronicle-calendar-month .ccal-ev { display: block; font-size: 0.72em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid var(--ccal-ev); padding-left: 3px; margin-top: 1px; } -#chronicle-calendar-month .ccal-ev-gm { font-style: italic; opacity: 0.8; } -#chronicle-calendar-month .ccal-ev-gm::before { content: "\f023"; font-family: "Font Awesome 6 Pro", "Font Awesome 6 Free"; font-weight: 900; font-style: normal; margin-right: 3px; font-size: 0.9em; } -#chronicle-calendar-month .ccal-more { display: block; font-size: 0.7em; opacity: 0.7; } -#chronicle-calendar-month footer { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; } + position: fixed; z-index: 61; width: 280px; color: #e8e2d4; font-size: 12px; + background: #2b2a27; border: 1px solid #6b5a2b; border-radius: 6px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6); + transform-origin: top left; animation: ccal-open 0.18s ease-out; +} +#chronicle-calendar-month header { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; background: #191816; border-bottom: 1px solid #6b5a2b; font-weight: 700; } +#chronicle-calendar-month header button { width: auto; flex: 0 0 auto; background: none; border: none; color: inherit; font-size: 14px; } +#chronicle-calendar-month .ccal-head, #chronicle-calendar-month .ccal-grid { display: grid; gap: 2px; padding: 0 6px; } +#chronicle-calendar-month .ccal-head { grid-auto-flow: column; grid-auto-columns: 1fr; padding-top: 4px; font-size: 9.5px; opacity: 0.7; } +#chronicle-calendar-month .ccal-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } +#chronicle-calendar-month .ccal-grid { padding-top: 4px; padding-bottom: 6px; } +#chronicle-calendar-month .ccal-grid div { position: relative; height: 30px; padding: 1px 3px; overflow: hidden; border-radius: 3px; font-size: 10px; background: rgba(255, 255, 255, 0.05); } +#chronicle-calendar-month .ccal-grid .ccal-pad { background: none; } +#chronicle-calendar-month .ccal-grid .ccal-wxday { color: #222; } +#chronicle-calendar-month .ccal-grid i { position: absolute; right: 2px; bottom: 2px; font-size: 9px; } +#chronicle-calendar-month .ccal-grid .ccal-today { outline: 2px solid #c9a227; } +#chronicle-calendar-month .ccal-grid .ccal-ev::after, #chronicle-calendar-month .ccal-grid .ccal-gmev::after { + content: ""; position: absolute; left: 3px; bottom: 3px; width: 5px; height: 5px; border-radius: 50%; background: #e06c75; +} +#chronicle-calendar-month .ccal-grid .ccal-gmev::after { background: #9a9a9a; } +#chronicle-calendar-month footer { padding: 6px 8px; border-top: 1px solid #444; line-height: 1.5; } +#chronicle-calendar-month .ccal-gmline { opacity: 0.75; } +#chronicle-calendar-month .ccal-tools { display: flex; gap: 6px; margin-top: 4px; } +#chronicle-calendar-month .ccal-tools button { width: auto; flex: 0 0 auto; border: 1px solid #6b5a2b; background: #3a3426; color: inherit; border-radius: 4px; padding: 2px 6px; font-size: 11px; line-height: 1.4; } .ccal-form .form-fields input[type="number"] { max-width: 6em; } +@keyframes ccal-twinkle { 50% { opacity: 0.25; } } +@keyframes ccal-fall { to { transform: translate(-6px, 30px); } } +@keyframes ccal-pop { from { transform: scale(0.4); opacity: 0; } } +@keyframes ccal-open { from { transform: scale(0.96); opacity: 0; } } @media (prefers-reduced-motion: reduce) { - #chronicle-calendar-bar, #chronicle-calendar-month { transition: none; } + #chronicle-calendar-bar *, #chronicle-calendar-month { animation: none !important; transition: none !important; } } diff --git a/tools/test-calendar-builtin.mjs b/tools/test-calendar-builtin.mjs index 784f168..708847c 100644 --- a/tools/test-calendar-builtin.mjs +++ b/tools/test-calendar-builtin.mjs @@ -154,3 +154,16 @@ test('a failed players events read keeps the last snapshot instead of blanking e try { await cs.publishPlayerSnapshot(); } finally { console.debug = original; } assert.equal(saved.length, 0); }); + +test('an older Chronicle without the day-weather route still publishes, with no day weather', async () => { + saved.length = 0; + const cs = makeSync(makeApi([ + ['/calendar/date?audience=players', { year: 1492, month: 2, day: 30, audience: 'players' }], + ['/calendar/events?', { data: [], audience: 'players' }], + ['/calendar/weather/days?', err404], + ['/calendar?audience=players', cal], + ])); + await cs.publishPlayerSnapshot(); + assert.equal(saved.length, 1); + assert.deepEqual(saved[0][1].dayWeather, []); +}); diff --git a/tools/test-calendar-view-model.mjs b/tools/test-calendar-view-model.mjs index 9977217..1cf6b6e 100644 --- a/tools/test-calendar-view-model.mjs +++ b/tools/test-calendar-view-model.mjs @@ -166,3 +166,35 @@ test('a calendar not open to every player gives a hidden snapshot', () => { assert.equal(VM.buildPlayerSnapshot({ calendar: c, dateInfo, isPublic }).hidden, hidden, JSON.stringify([c.visibility, c.visibility_rules])); } }); + +test('day weather reaches the snapshot only when confirmed, and never after today', () => { + const dateInfo = { year: 1492, month: 2, day: 10, audience: 'players' }; + const days = [ + { year: 1492, month: 2, day: 9, icon: 'snow', preset_label: 'Snow', locked: true }, + { year: 1492, month: 2, day: 10, icon: 'clear' }, + { year: 1492, month: 2, day: 11, icon: 'storm' }, + { year: 1492, month: 3, day: 1, icon: 'rain' }, + { year: 1491, month: 4, day: 30, icon: 'fog' }, + ]; + const s = VM.buildPlayerSnapshot({ calendar: cal, dateInfo, isPublic, dayWeather: days, dayWeatherConfirmed: true }); + assert.deepEqual(s.dayWeather.map((d) => `${d.year}-${d.month}-${d.day}`), ['1492-2-9', '1492-2-10', '1491-4-30']); + assert.deepEqual(Object.keys(s.dayWeather[0]).sort(), ['day', 'icon', 'label', 'month', 'year']); + const old = VM.buildPlayerSnapshot({ calendar: cal, dateInfo, isPublic, dayWeather: days, dayWeatherConfirmed: false }); + assert.deepEqual(old.dayWeather, []); +}); + +test('mini sky particles follow weather and hour', () => { + assert.equal(VM.skyParticles(cal, 12, 'snow'), 'snow'); + assert.equal(VM.skyParticles(cal, 12, 'storm'), 'rain'); + assert.equal(VM.skyParticles(cal, 23, 'clear'), 'stars'); + assert.equal(VM.skyParticles(cal, 12, 'clear'), 'none'); + assert.equal(VM.skyParticles(cal, 23, 'fog'), 'none'); +}); + +test('clampPlace keeps the strip on screen', () => { + const box = { width: 200, height: 24 }; + const vp = { width: 1000, height: 600 }; + assert.deepEqual(VM.clampPlace({ x: -5, y: 900 }, box, vp), { x: 0, y: 576 }); + assert.deepEqual(VM.clampPlace({ x: 950, y: 10 }, box, vp), { x: 800, y: 10 }); + assert.deepEqual(VM.clampPlace(null, box, vp), { x: 0, y: 0 }); +}); From 0b67777569a93c6e0080846f4eb165feff1bd9f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 06:53:03 +0000 Subject: [PATCH 5/7] Calendar strip is off until each person turns it on Nothing shows on the table by default; the strip is a per-person setting. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn --- TESTING.md | 3 ++- lang/en.json | 4 ++++ scripts/calendar-bar.mjs | 2 +- scripts/settings.mjs | 11 +++++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/TESTING.md b/TESTING.md index 82ed453..8b9874f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -171,7 +171,8 @@ GM notice. See API-CONTRACT.md. Chronicle brings its own calendar; no Foundry calendar module is used. ### Built-in calendar (strip) -- [ ] With Calendar sync on, a thin strip sits at the top centre: date, time and a weather icon over a small sky coloured for the hour; snow, rain or night stars move in it to match Chronicle +- [ ] With Calendar sync on and nothing else changed, no strip shows +- [ ] Module Settings -> Show the calendar strip on -> a thin strip sits at the top centre: date, time and a weather icon over a small sky coloured for the hour; snow, rain or night stars move in it to match Chronicle - [ ] Hover the strip -> it widens to show season, temperature and moons - [ ] Drag the strip by its dots anywhere -> it stays there after a reload, and each player's spot is their own - [ ] Click the clock icon -> the strip shrinks to a round clock; click the clock -> the strip comes back with the month open diff --git a/lang/en.json b/lang/en.json index d5a587b..ed0fcc3 100644 --- a/lang/en.json +++ b/lang/en.json @@ -93,6 +93,10 @@ "Name": "Announce season and era changes in chat", "Hint": "Whisper a GM-only chat line when the Chronicle calendar crosses a season or era boundary. Never posted to players." }, + "CalendarStrip": { + "Name": "Show the calendar strip", + "Hint": "A thin strip with Chronicle's date, time and weather over a small sky, which you can drag anywhere. For you only." + }, "CalendarTemperatureUnit": { "Name": "Calendar temperature unit", "Hint": "How the date bar shows Chronicle's temperature, for you only." diff --git a/scripts/calendar-bar.mjs b/scripts/calendar-bar.mjs index 5e799be..f26e4dd 100644 --- a/scripts/calendar-bar.mjs +++ b/scripts/calendar-bar.mjs @@ -48,7 +48,7 @@ const extraMonths = new Map(); * the calendar (sync off, no calendar, or one players may not see). */ function currentView() { - if (!getSetting('syncEnabled') || !getSetting('syncCalendar')) return null; + if (!getSetting('calendarStrip') || !getSetting('syncEnabled') || !getSetting('syncCalendar')) return null; if (game.user.isGM && source) { const v = source.gmView; if (v.calendar && v.date) { diff --git a/scripts/settings.mjs b/scripts/settings.mjs index 3376d29..7f6d90f 100644 --- a/scripts/settings.mjs +++ b/scripts/settings.mjs @@ -186,6 +186,17 @@ export function registerSettings() { onChange: () => Hooks.callAll('chronicleSyncCalendarSnapshot'), }); + // The calendar strip is an extra each person turns on; nothing shows by default. + game.settings.register(MODULE_ID, 'calendarStrip', { + name: game.i18n.localize('CHRONICLE.Settings.CalendarStrip.Name'), + hint: game.i18n.localize('CHRONICLE.Settings.CalendarStrip.Hint'), + scope: 'client', + config: true, + type: Boolean, + default: false, + onChange: () => Hooks.callAll('chronicleSyncCalendarSnapshot'), + }); + // Where this person dragged the calendar strip, and whether it is shrunk // to the clock. Client scope: each player keeps their own spot. game.settings.register(MODULE_ID, 'calendarBarPlace', { From d4c2931543196cfadb776340e743670b8d60cbde Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 07:04:57 +0000 Subject: [PATCH 6/7] Calendar strip can be locked in place Each person can lock the strip so it can't be dragged by accident; the lock is saved with their spot, per client. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn --- .ai.md | 2 +- TESTING.md | 1 + lang/en.json | 2 ++ scripts/calendar-bar.mjs | 14 ++++++++++++-- styles/chronicle-sync.css | 5 ++++- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.ai.md b/.ai.md index 6bd5a9a..5f80951 100644 --- a/.ai.md +++ b/.ai.md @@ -101,7 +101,7 @@ Pinned by `tools/test-sync-wire-fix.mjs`, `tools/test-calendar-sync-state.mjs`. | `scripts/calendar-sync.mjs` | Pulls Chronicle's calendar (date, events, weather/season/era/moons) and routes `calendar.*` WS types; `pushDate` is the guarded `PUT /calendar/date`; `step`/`setDate`/`addEvent` serve the built-in calendar, and `publishPlayerSnapshot` saves the players' `calendarSnapshot` from `?audience=players` reads. Writes no Foundry documents. `tools/test-calendar-builtin.mjs`, `tools/test-calendar-backcatalog-fix.mjs`, `tools/test-calendar-resilience.mjs`, `tools/test-calendar-subresource-routing.mjs` | | `scripts/_realtime-date-guard.mjs` | `shouldSkipDatePush`, `isRealTimeRejection` (422 backstop), `notifyRealTimePushPaused`. `tools/test-realtime-date-signal.mjs` | | `scripts/_applied-date-confirm.mjs` | See "Applied-date confirmation" above. `tools/test-applied-date-confirm.mjs` | -| `scripts/calendar-bar.mjs` | Built-in calendar: a thin strip (date, time, weather over a mini sky that rests when idle) that anyone can drag or shrink to a clock (`calendarBarPlace`, client), and a month view where past days show their sky and weather. GM: hour arrows, Set date, Add event via CalendarSync; players draw the `calendarSnapshot` setting. Live checks only (TESTING.md) | +| `scripts/calendar-bar.mjs` | Built-in calendar: a thin strip (date, time, weather over a mini sky that rests when idle) that anyone can drag, lock in place or shrink to a clock (`calendarBarPlace` `{x, y, mini, locked}`, client), and a month view where past days show their sky and weather. GM: hour arrows, Set date, Add event via CalendarSync; players draw the `calendarSnapshot` setting. Live checks only (TESTING.md) | | `scripts/_calendar-view-model.mjs` | Pure logic for the bar: `stepDate`, `clampDate`, labels, `monthGrid`, sky colours, `buildPlayerSnapshot` (players-audience reads only, field by field, public events only). `tools/test-calendar-view-model.mjs` | | `scripts/_chronicle-caldate.mjs` | Port of Chronicle's `CalDate` day maths (leap days, weekdays, festival months). `tools/test-chronicle-caldate.mjs` compares it with Chronicle when CHRONICLE_DIR is set | | `scripts/_calendar-sync-state.mjs` | `classifyCalendarSyncState`; priority paused > structure mismatch > date drift > in-sync, fails open; unused until a Foundry calendar exists. `tools/test-calendar-sync-state.mjs` | diff --git a/TESTING.md b/TESTING.md index 8b9874f..bc8bb62 100644 --- a/TESTING.md +++ b/TESTING.md @@ -175,6 +175,7 @@ Chronicle brings its own calendar; no Foundry calendar module is used. - [ ] Module Settings -> Show the calendar strip on -> a thin strip sits at the top centre: date, time and a weather icon over a small sky coloured for the hour; snow, rain or night stars move in it to match Chronicle - [ ] Hover the strip -> it widens to show season, temperature and moons - [ ] Drag the strip by its dots anywhere -> it stays there after a reload, and each player's spot is their own +- [ ] Click the lock on the strip -> the dots disappear and the strip (or its clock) cannot be dragged; click it again -> dragging works. The lock survives a reload and is per player - [ ] Click the clock icon -> the strip shrinks to a round clock; click the clock -> the strip comes back with the month open - [ ] Leave the mouse still for 20 seconds -> the sky's motion stops; move it -> it starts again - [ ] Click the date -> the month opens under the strip; days up to today show their sky colour and weather icon, later days are plain; event days have a dot and the month's events are listed below; ‹ › flips months; click outside or Esc closes it diff --git a/lang/en.json b/lang/en.json index ed0fcc3..10ed47a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -26,6 +26,8 @@ "BackHour": "Back one hour", "ForwardHour": "Forward one hour", "Drag": "Drag to move", + "Lock": "Lock in place", + "Unlock": "Unlock to move", "Shrink": "Shrink to a clock", "Clock": "Chronicle calendar: {date}, {time}. Click to open; drag to move", "RealTime": "This calendar follows real time, so its date can't be moved here.", diff --git a/scripts/calendar-bar.mjs b/scripts/calendar-bar.mjs index f26e4dd..f97e653 100644 --- a/scripts/calendar-bar.mjs +++ b/scripts/calendar-bar.mjs @@ -105,13 +105,14 @@ function stripHtml(v) { ? `` : ''; const more = moreText(v); return `
- + ${v.locked ? '' : ``} ${arrow(-1)} ${weather}${more ? `${more}` : ''} ${arrow(1)} + `; } @@ -158,8 +159,11 @@ export function renderCalendarBar() { wid.addEventListener('click', onClick); wid.addEventListener('pointerdown', onDragStart); } - const mini = !!savedPlace().mini; + const place = savedPlace(); + const mini = !!place.mini; + v.locked = !!place.locked; wid.classList.toggle('ccal-mini', mini); + wid.classList.toggle('ccal-locked', v.locked); wid.classList.toggle('ccal-gm', v.gm); wid.style.setProperty('--ccal-sky', skyGradient(v.calendar, v.date.hour)); wid.innerHTML = mini ? dotHtml(v) : stripHtml(v); @@ -173,6 +177,8 @@ let drag = null; function onDragStart(ev) { if (!ev.target.closest('.ccal-grip, .ccal-dot') || ev.button !== 0) return; + // Locked means it stays put; the clock still restores the strip on click. + if (savedPlace().locked) return; const box = wid.getBoundingClientRect(); drag = { dx: ev.clientX - box.left, dy: ev.clientY - box.top, x0: ev.clientX, y0: ev.clientY, moved: false }; wid.setPointerCapture(ev.pointerId); @@ -212,6 +218,10 @@ async function onClick(ev) { renderCalendarBar(); return; } + if (ev.target.closest('.ccal-lock')) { + savePlace({ locked: !savedPlace().locked }); + return; + } if (ev.target.closest('.ccal-shrink')) { closeMonth(); savePlace({ mini: true }); diff --git a/styles/chronicle-sync.css b/styles/chronicle-sync.css index 23ef7ef..7287712 100644 --- a/styles/chronicle-sync.css +++ b/styles/chronicle-sync.css @@ -2278,12 +2278,15 @@ #chronicle-calendar-bar:not(.ccal-mini) { isolation: isolate; } #chronicle-calendar-bar .ccal-grip { cursor: grab; opacity: 0.55; padding: 0 3px; letter-spacing: -2px; font-size: 11px; } #chronicle-calendar-bar .ccal-grip:active { cursor: grabbing; } +#chronicle-calendar-bar .ccal-lock { opacity: 0.55; font-size: 11px; } +#chronicle-calendar-bar .ccal-lock[aria-pressed="true"] { opacity: 0.9; } +#chronicle-calendar-bar.ccal-locked .ccal-dot { cursor: pointer; } #chronicle-calendar-bar .ccal-open { cursor: pointer; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); } #chronicle-calendar-bar .ccal-open b { font-weight: 600; } #chronicle-calendar-bar .ccal-time { opacity: 0.85; font-variant-numeric: tabular-nums; } #chronicle-calendar-bar .ccal-more { max-width: 0; overflow: hidden; opacity: 0.85; transition: max-width 0.35s ease; } #chronicle-calendar-bar:hover .ccal-more, #chronicle-calendar-bar:focus-within .ccal-more { max-width: 260px; } -#chronicle-calendar-bar .ccal-step, #chronicle-calendar-bar .ccal-shrink { cursor: pointer; opacity: 0.85; font-size: 13px; } +#chronicle-calendar-bar .ccal-step, #chronicle-calendar-bar .ccal-shrink, #chronicle-calendar-bar .ccal-lock { cursor: pointer; opacity: 0.85; font-size: 13px; } #chronicle-calendar-bar .ccal-step:disabled { opacity: 0.35; cursor: default; } #chronicle-calendar-bar.ccal-mini .ccal-dot { position: relative; isolation: isolate; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; overflow: hidden; From 716c9d9f51b276bf14537a8f68aa931a74bb4a04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 14:03:27 +0000 Subject: [PATCH 7/7] Calendar window: Chronicle's own calendar in a Foundry window A Calendar button next to the Notebook opens Chronicle's calendar page in a frame, over the player's notes grant; size and place are kept per player. Clicking the strip's date opens it, falling back to the small month view when the player isn't connected. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01UexL55BkZcztyC1eezXKfn --- .ai.md | 8 +-- API-CONTRACT.md | 5 +- TESTING.md | 11 +++- lang/en.json | 4 +- scripts/_scene-controls.mjs | 17 +++++-- scripts/calendar-bar.mjs | 8 ++- scripts/module.mjs | 8 +-- scripts/player-notebook.mjs | 96 ++++++++++++++++++++++++++++++++--- scripts/settings.mjs | 8 +++ tools/test-scene-controls.mjs | 17 +++++-- 10 files changed, 153 insertions(+), 29 deletions(-) diff --git a/.ai.md b/.ai.md index 5f80951..bdc0bbf 100644 --- a/.ai.md +++ b/.ai.md @@ -101,7 +101,7 @@ Pinned by `tools/test-sync-wire-fix.mjs`, `tools/test-calendar-sync-state.mjs`. | `scripts/calendar-sync.mjs` | Pulls Chronicle's calendar (date, events, weather/season/era/moons) and routes `calendar.*` WS types; `pushDate` is the guarded `PUT /calendar/date`; `step`/`setDate`/`addEvent` serve the built-in calendar, and `publishPlayerSnapshot` saves the players' `calendarSnapshot` from `?audience=players` reads. Writes no Foundry documents. `tools/test-calendar-builtin.mjs`, `tools/test-calendar-backcatalog-fix.mjs`, `tools/test-calendar-resilience.mjs`, `tools/test-calendar-subresource-routing.mjs` | | `scripts/_realtime-date-guard.mjs` | `shouldSkipDatePush`, `isRealTimeRejection` (422 backstop), `notifyRealTimePushPaused`. `tools/test-realtime-date-signal.mjs` | | `scripts/_applied-date-confirm.mjs` | See "Applied-date confirmation" above. `tools/test-applied-date-confirm.mjs` | -| `scripts/calendar-bar.mjs` | Built-in calendar: a thin strip (date, time, weather over a mini sky that rests when idle) that anyone can drag, lock in place or shrink to a clock (`calendarBarPlace` `{x, y, mini, locked}`, client), and a month view where past days show their sky and weather. GM: hour arrows, Set date, Add event via CalendarSync; players draw the `calendarSnapshot` setting. Live checks only (TESTING.md) | +| `scripts/calendar-bar.mjs` | Built-in calendar: a thin strip (date, time, weather over a mini sky that rests when idle) that anyone can drag, lock in place or shrink to a clock (`calendarBarPlace` `{x, y, mini, locked}`, client), and a month view where past days show their sky and weather. Clicking the date opens the calendar window (`player-notebook.mjs`), the month view only when the player isn't connected. GM: hour arrows, Set date, Add event via CalendarSync; players draw the `calendarSnapshot` setting. Live checks only (TESTING.md) | | `scripts/_calendar-view-model.mjs` | Pure logic for the bar: `stepDate`, `clampDate`, labels, `monthGrid`, sky colours, `buildPlayerSnapshot` (players-audience reads only, field by field, public events only). `tools/test-calendar-view-model.mjs` | | `scripts/_chronicle-caldate.mjs` | Port of Chronicle's `CalDate` day maths (leap days, weekdays, festival months). `tools/test-chronicle-caldate.mjs` compares it with Chronicle when CHRONICLE_DIR is set | | `scripts/_calendar-sync-state.mjs` | `classifyCalendarSyncState`; priority paused > structure mismatch > date drift > in-sync, fails open; unused until a Foundry calendar exists. `tools/test-calendar-sync-state.mjs` | @@ -115,9 +115,9 @@ Pinned by `tools/test-sync-wire-fix.mjs`, `tools/test-calendar-sync-state.mjs`. | `scripts/_inventory-plan.mjs` | Pure: the create/adopt/update/unlink plan that brings an actor's linked items in line with its relations | | `scripts/_inline-pictures.mjs` | Pictures inside Chronicle page text (`
`) ↔ Foundry: on pull a shared picture's src points at its local copy and a GM-only one (`ce-img--gm`) goes inside a native `
`, never copied, which the pull then stores as a placeholder like any GM-only text (`_gm-secrets.mjs`); on push every src goes back to `/media/` and every Chronicle picture inside a secret block goes back GM-only, whatever shape the editor left it in. A copy made while a picture was shared stays in the world's files if it later turns GM-only (no longer shown; Foundry has no file delete for modules). Pure. `tools/test-inline-pictures.mjs`, bench "pictures inside page text" | | `scripts/picture-store.mjs` | Local copies of those pictures in `worlds//chronicle-media/.` (Chronicle's signed links expire in minutes, so a journal can't point at Chronicle). GM-only: `GET /media/:id` for the signed link, a cookieless fetch on the `apiUrl` host only, PNG/JPEG/GIF/WebP/AVIF only, 25 MB cap, served type must match, `FilePicker.upload`. Copied once per id. `watchGMPictures` shows GM-only pictures on the GM's screen from a fresh signed link, as `gm-secret-view.mjs` fills their placeholders, outside editors. `tools/test-inline-pictures.mjs` | -| `scripts/player-notebook.mjs` | Notebook window and bottom-right "Jot notes" tab for every user: frames of Chronicle's own Journal and Jot pages (`/embed/campaigns/:id/notes/journal\|jots`), fed the player's notes grant over `postMessage`. First use opens Chronicle's Allow window. Tracks the newest open Chronicle-linked sheet so jots follow the page in view | +| `scripts/player-notebook.mjs` | Notebook window, Calendar window (size and place per player in `calendarWindowPlace`, client) and bottom-right "Jot notes" tab for every user: frames of Chronicle's own Journal, calendar and Jot pages (`/embed/campaigns/:id/notes/journal\|calendar\|jots`), fed the player's notes grant over `postMessage`. First use opens Chronicle's Allow window. Tracks the newest open Chronicle-linked sheet so jots follow the page in view | | `scripts/_notes-grant.mjs` | Pure checks for the notebook: Allow/frame URLs, the grant message (Chronicle origin, `cnt_` token, campaign, and the GM's member matching for this Foundry login), per-Foundry-user client storage, frame message source/origin, `PageTracker`. `tools/test-notes-grant.mjs` | -| `scripts/_scene-controls.mjs` | Builds the Chronicle scene-control group: Dashboard + DM Screen for GMs only, Notebook for everyone when connected. `tools/test-scene-controls.mjs` | +| `scripts/_scene-controls.mjs` | Builds the Chronicle scene-control group: Dashboard + DM Screen for GMs only, Notebook and Calendar for everyone when connected. `tools/test-scene-controls.mjs` | | `scripts/_gm-secrets.mjs` | GM-only text in Chronicle page text (``) and GM-only pictures become Foundry secret blocks on pull, each holding only a placeholder whose id is an HMAC (under the GM's API key) of its content; the content stays in the GM client's memory. On push placeholders get their content back (or the text is not sent), and everything in a secret block goes back GM-only; split paragraphs are joined again. Headings inside a secret block never become page breaks. `tools/test-gm-secrets.mjs` | | `scripts/gm-secret-view.mjs` | On the GM's client, fills each placeholder secret block on screen with its content from Chronicle; skips editors and never changes the saved page. | | `scripts/_set-aside.mjs` | Sync never deletes a Foundry journal for a Chronicle-side removal: an entity or note deleted in Chronicle, and the reconnect clean-up of journals that duplicate synced actors, unlink the journal and move it to one "Chronicle: removed" folder instead. `tools/test-set-aside.mjs` | @@ -266,7 +266,7 @@ payload contents beyond the `type` allowlist are not validated. | WebSocket (sync) | `?token=${apiKey}` query param | `wss://...${apiUrl host}/ws?token=...&client=foundry-module` | `api-client.mjs::_doConnect` only | | Module manifest fetch | Install-URL signed token in URL | `${apiUrl}/api/v1/campaigns/${campaignId}/foundry-vtt/module.json?token=...` | Foundry's update-check flow | | Module zip download | Install-URL signed token in URL | `${apiUrl}/api/v1/campaigns/${campaignId}/foundry-vtt/module.zip?token=...` | Foundry's installer | -| Player notebook frames | The player's own notes grant (`cnt_…`), handed to the frame by `postMessage` at Chronicle's origin; kept in the client-scoped `notesGrants` setting keyed by Foundry user | `${apiUrl}/embed/campaigns/${campaignId}/notes/...` (the frame then calls `/api/notes-app/...`) | `player-notebook.mjs` only; never the GM's `apiKey` | +| Player notebook and calendar frames | The player's own notes grant (`cnt_…`), handed to the frame by `postMessage` at Chronicle's origin; kept in the client-scoped `notesGrants` setting keyed by Foundry user | `${apiUrl}/embed/campaigns/${campaignId}/notes/...` (the frame then calls `/api/notes-app/...`) | `player-notebook.mjs` only; never the GM's `apiKey` | Different secrets, different validation paths — install-URL token is Chronicle-signed with campaign+expiration claims `/api/v1/` rejects; `apiKey` diff --git a/API-CONTRACT.md b/API-CONTRACT.md index f39762f..279ac57 100644 --- a/API-CONTRACT.md +++ b/API-CONTRACT.md @@ -1362,7 +1362,7 @@ Re-verify by: 2026-11-03 (Chronicle `internal/plugins/syncapi/stash_api_handler. ### Player notebook pages Not part of the REST API: the notebook (`scripts/player-notebook.mjs`, -checks in `scripts/_notes-grant.mjs`) frames two Chronicle web pages and never +checks in `scripts/_notes-grant.mjs`) frames Chronicle web pages and never uses the GM's sync key. Everything crosses `postMessage`, and each side checks the other's origin (Chronicle's, taken from `apiUrl`) before acting. @@ -1371,6 +1371,7 @@ the other's origin (Chronicle's, taken from `apiUrl`) before acting. | Allow window | `/campaigns/:id/notes/allow-app?origin=` | A pop-up where the player presses Allow. Replies `{type:"chronicle:notes-grant", token, userId, campaignId}` (token starts `cnt_`) or `{type:"chronicle:notes-grant-declined"}`. | | Notebook frame | `/embed/campaigns/:id/notes/journal` | The player's Journal. | | Jot frame | `/embed/campaigns/:id/notes/jots` | Jot notes for the page in view. | +| Calendar frame | `/embed/campaigns/:id/notes/calendar` | The campaign's default calendar, Chronicle's own page, as this player sees it (same grant; the frame reads `/api/notes-app/campaigns/:id/calendars/…`). An older Chronicle answers 404. | The module keeps a grant only when `campaignId` matches and the GM has matched the returned `userId` to this Foundry login (Members tab); an unmatched or @@ -1380,7 +1381,7 @@ module answers `chronicle:notes-token` with the token and current `entityId`), with `noteId`. Module to frame: `chronicle:notes-token`, `chronicle:jots-page` with `entityId`, `chronicle:open-note`. -Re-verify by: 2026-11-03 (Chronicle `internal/widgets/notes/app_grants_handler.go`, `allow_app.templ`, `static/js/notes_embed.js`) +Re-verify by: 2026-11-04 (Chronicle `internal/widgets/notes/app_grants_handler.go`, `allow_app.templ`, `static/js/notes_embed.js`, `internal/plugins/calendar/routes.go` `RegisterAppRoutes`) --- diff --git a/TESTING.md b/TESTING.md index bc8bb62..273f747 100644 --- a/TESTING.md +++ b/TESTING.md @@ -170,6 +170,15 @@ GM notice. See API-CONTRACT.md. Chronicle brings its own calendar; no Foundry calendar module is used. +### Calendar window +- [ ] Chronicle scene controls -> **Calendar** button (player and GM) -> first time, Chronicle's Allow window asks about "notes and calendar"; press Allow -> a Foundry window opens with Chronicle's own calendar page: the sky moving, day weather, moons, events +- [ ] A player already connected for the notebook -> **Calendar** opens straight away, no Allow window +- [ ] Player: GM-only events, hidden moons and secret eras never appear (compare with the same player signed in on the site) +- [ ] GM: Chronicle's editing works in the window (add an event, open it, change it); the change shows on the site after a refresh +- [ ] Resize the window from its corner -> the calendar redraws to fit (narrow: one-letter weekdays); close and reopen -> same size and place, per player +- [ ] Click a link to a page inside the calendar -> it opens on the Chronicle site in a new tab, not inside the window +- [ ] Disconnect the Foundry connection in Chronicle's Allow window list -> the calendar window says Chronicle turned it away; Connect brings it back + ### Built-in calendar (strip) - [ ] With Calendar sync on and nothing else changed, no strip shows - [ ] Module Settings -> Show the calendar strip on -> a thin strip sits at the top centre: date, time and a weather icon over a small sky coloured for the hour; snow, rain or night stars move in it to match Chronicle @@ -178,7 +187,7 @@ Chronicle brings its own calendar; no Foundry calendar module is used. - [ ] Click the lock on the strip -> the dots disappear and the strip (or its clock) cannot be dragged; click it again -> dragging works. The lock survives a reload and is per player - [ ] Click the clock icon -> the strip shrinks to a round clock; click the clock -> the strip comes back with the month open - [ ] Leave the mouse still for 20 seconds -> the sky's motion stops; move it -> it starts again -- [ ] Click the date -> the month opens under the strip; days up to today show their sky colour and weather icon, later days are plain; event days have a dot and the month's events are listed below; ‹ › flips months; click outside or Esc closes it +- [ ] Click the date -> the calendar window opens (as above). Not connected and "Not now" -> the small month opens under the strip instead; days up to today show their sky colour and weather icon, later days are plain; event days have a dot and the month's events are listed below; ‹ › flips months; click outside or Esc closes it - [ ] GM: the small ‹ › arrows move the time one hour; Chronicle's calendar page shows the new time after a refresh - [ ] GM: on a real-time calendar the arrows are greyed out and Set date is disabled - [ ] GM: **Set date…** in the month moves Chronicle's date; a date the calendar doesn't have is refused with a warning diff --git a/lang/en.json b/lang/en.json index 10ed47a..7567eb0 100644 --- a/lang/en.json +++ b/lang/en.json @@ -21,7 +21,7 @@ }, "CalendarBar": { "Label": "Chronicle calendar", - "OpenMonth": "Open the month", + "OpenCalendar": "Open the calendar", "MonthLabel": "Chronicle calendar month", "BackHour": "Back one hour", "ForwardHour": "Forward one hour", @@ -474,6 +474,7 @@ }, "SceneControl": { "Notebook": "Open my Chronicle notebook", + "Calendar": "Open the Chronicle calendar", "DMScreen": "Open the DM Screen" }, "DMScreen": { @@ -561,6 +562,7 @@ "Notebook": { "Title": "Notebook", "JotsTitle": "Jot notes", + "CalendarTitle": "Calendar", "Connect": "Connect to Chronicle", "NotConnected": "Your Chronicle notes aren't connected to Foundry yet. Connect once and Chronicle will ask you to press Allow.", "Rejected": "Chronicle no longer accepts this connection (it was disconnected, or your sign-in changed). Connect again to keep going.", diff --git a/scripts/_scene-controls.mjs b/scripts/_scene-controls.mjs index 2d9ff3b..9317125 100644 --- a/scripts/_scene-controls.mjs +++ b/scripts/_scene-controls.mjs @@ -1,8 +1,8 @@ /** * Pure builder for the Chronicle group in Foundry's scene controls. * - * GMs get the Sync Dashboard and DM Screen; the Notebook is for every - * user once the world is connected to Chronicle. The GM tools must never + * GMs get the Sync Dashboard and DM Screen; the Notebook and Calendar are + * for every user once the world is connected to Chronicle. The GM tools must never * reach a player: tools/test-scene-controls.mjs pins that. */ @@ -14,8 +14,8 @@ * @param {object} opts * @param {boolean} opts.isGM * @param {boolean} opts.notebook - the world is connected to Chronicle - * @param {{dashboard: Function, dmScreen: Function, notebook: Function}} opts.run - * @param {{dmScreen: string, notebook: string}} opts.titles - localized + * @param {{dashboard: Function, dmScreen: Function, notebook: Function, calendar?: Function}} opts.run + * @param {{dmScreen: string, notebook: string, calendar?: string}} opts.titles - localized */ export function addChronicleControls(controls, { isGM, notebook, run, titles }) { const tools = []; @@ -43,6 +43,15 @@ export function addChronicleControls(controls, { isGM, notebook, run, titles }) button: true, run: run.notebook, }); + if (run.calendar) { + tools.push({ + name: 'calendar', + title: titles.calendar, + icon: 'fa-solid fa-calendar-days', + button: true, + run: run.calendar, + }); + } } if (!tools.length) return; diff --git a/scripts/calendar-bar.mjs b/scripts/calendar-bar.mjs index f97e653..597e3ff 100644 --- a/scripts/calendar-bar.mjs +++ b/scripts/calendar-bar.mjs @@ -14,6 +14,7 @@ import { getSetting, setSetting } from './settings.mjs'; import { promptDialog } from './_dialogs.mjs'; +import { openCalendarWindow } from './player-notebook.mjs'; import { clampPlace, daySky, eventsOn, formatDate, formatTemperature, formatTime, isUsableSnapshot, monthGrid, monthName, moonIconClass, shiftMonth, skyGradient, skyParticles, weatherIconClass, @@ -107,7 +108,7 @@ function stripHtml(v) { return `
${v.locked ? '' : ``} ${arrow(-1)} - ${weather}${more ? `${more}` : ''} @@ -234,7 +235,10 @@ async function onClick(ev) { return; } if (ev.target.closest('.ccal-open')) { - if (popover) closeMonth(); else openMonth(); + if (popover) return closeMonth(); + // The date opens Chronicle's full calendar; the small month view is the + // fallback for a player who isn't connected (or says "Not now"). + if (!(await openCalendarWindow())) openMonth(); } } diff --git a/scripts/module.mjs b/scripts/module.mjs index 6148e77..9af82f5 100644 --- a/scripts/module.mjs +++ b/scripts/module.mjs @@ -29,7 +29,7 @@ import { negotiationMirror } from './negotiation-mirror.mjs'; import { registerMapSheetItems } from './map-sheet-items.mjs'; import { surfaceManifestRecoveryIfNeeded } from './update-info.mjs'; import { registerShopRoomSocket } from './shop-room-window.mjs'; -import { notebookAvailable, openNotebook, registerPlayerNotebook } from './player-notebook.mjs'; +import { notebookAvailable, openCalendarWindow, openNotebook, registerPlayerNotebook } from './player-notebook.mjs'; import { addChronicleControls } from './_scene-controls.mjs'; import { retireNotesFolder } from './_notes-folder.mjs'; import { FLAG_SCOPE, SYNC_OPTIONS } from './constants.mjs'; @@ -293,8 +293,8 @@ async function _runtimeValidateDescriptor() { /** * Add the Chronicle group to Foundry's scene controls toolbar: the Sync - * Dashboard and DM Screen for GMs, the Notebook for everyone once the - * world is connected to Chronicle. + * Dashboard and DM Screen for GMs, the Notebook and Calendar for everyone + * once the world is connected to Chronicle. */ Hooks.on('getSceneControlButtons', (controls) => { // openDashboard() recreates the dashboard when a prior close left the @@ -306,10 +306,12 @@ Hooks.on('getSceneControlButtons', (controls) => { dashboard: openDashboard, dmScreen: () => { openDMScreen(() => syncManager?.api ?? null); }, notebook: () => { openNotebook(); }, + calendar: () => { openCalendarWindow(); }, }, titles: { dmScreen: game.i18n.localize('CHRONICLE.SceneControl.DMScreen'), notebook: game.i18n.localize('CHRONICLE.SceneControl.Notebook'), + calendar: game.i18n.localize('CHRONICLE.SceneControl.Calendar'), }, }); }); diff --git a/scripts/player-notebook.mjs b/scripts/player-notebook.mjs index bf5f723..b08fa1f 100644 --- a/scripts/player-notebook.mjs +++ b/scripts/player-notebook.mjs @@ -1,10 +1,10 @@ /** - * Chronicle Sync - Player notebook and jot notes + * Chronicle Sync - Player notebook, jot notes and calendar window * - * Every user (players and GM) gets a Notebook button in the Chronicle - * scene controls and a small "Jot notes" tab in the bottom-right corner. - * Both show the player's own Chronicle notes in a frame of Chronicle's - * own pages, so the notebook is the site's Journal with nothing copied + * Every user (players and GM) gets Notebook and Calendar buttons in the + * Chronicle scene controls and a small "Jot notes" tab in the bottom-right + * corner. Each shows a frame of Chronicle's own pages as that player sees + * them (the Journal, the jots, the campaign calendar), with nothing copied * into Foundry documents. * * The first use opens Chronicle's Allow window; the token it hands back is @@ -13,7 +13,7 @@ */ import { FLAG_SCOPE, MODULE_ID } from './constants.mjs'; -import { getSetting, getUserMappings } from './settings.mjs'; +import { getSetting, getUserMappings, setSetting } from './settings.mjs'; import { allowUrl, checkGrantMessage, @@ -34,6 +34,9 @@ const pages = new PageTracker(); /** @type {NotebookApplication|null} */ let notebook = null; +/** @type {CalendarWindow|null} */ +let calendarWindow = null; + /** @type {FrameHost|null} the jot panel's frame, made on first open */ let jotHost = null; @@ -119,7 +122,7 @@ function connect(ctx) { */ class FrameHost { /** - * @param {'journal'|'jots'} mode + * @param {'journal'|'jots'|'calendar'} mode * @param {HTMLElement} container */ constructor(mode, container) { @@ -164,7 +167,7 @@ class FrameHost { const frame = document.createElement('iframe'); frame.className = 'chronicle-notes-frame'; frame.src = embedUrl(this.ctx.apiUrl, this.ctx.campaignId, this.mode); - frame.title = this.mode === 'jots' ? t('JotsTitle') : t('Title'); + frame.title = t({ jots: 'JotsTitle', calendar: 'CalendarTitle' }[this.mode] || 'Title'); this.container.replaceChildren(frame); this.frame = frame; } @@ -267,6 +270,83 @@ export async function openNotebook(noteId = '') { if (noteId) notebook.host?.openNote(noteId); } +/** + * The calendar window: Chronicle's own calendar page, as this player sees + * it, filling a normal Foundry window. Its size and place are each + * player's own. + */ +class CalendarWindow extends ApplicationV2 { + static DEFAULT_OPTIONS = { + id: 'chronicle-calendar-window', + classes: ['chronicle-notebook', 'chronicle-calendar-window'], + window: { title: 'CHRONICLE.Notebook.CalendarTitle', icon: 'fa-solid fa-calendar-days', resizable: true }, + position: { width: 760, height: 640 }, + }; + + constructor(options = {}) { + super({ ...options, position: { ...CalendarWindow.DEFAULT_OPTIONS.position, ...savedWindowPlace() } }); + } + + /** @override */ + async _renderHTML() { + return null; + } + + /** @override Make the frame once; a re-render must not reload it. */ + _replaceHTML(_result, content) { + if (this.host) return; + const box = document.createElement('div'); + box.className = 'chronicle-notes-host'; + content.replaceChildren(box); + this.host = new FrameHost('calendar', box); + this.host.start(); + } + + /** @override */ + _onClose(options) { + const { left, top, width, height } = this.position ?? {}; + setSetting('calendarWindowPlace', { left, top, width, height }).catch(() => {}); + this.host?.destroy(); + this.host = null; + super._onClose?.(options); + } +} + +/** The player's last window size and place, kept only when it is sane. */ +function savedWindowPlace() { + const p = getSetting('calendarWindowPlace'); + if (!p || typeof p !== 'object') return {}; + const out = {}; + for (const k of ['left', 'top', 'width', 'height']) { + if (Number.isFinite(p[k]) && p[k] >= 0) out[k] = Math.round(p[k]); + } + if (out.width < 320) delete out.width; + if (out.height < 280) delete out.height; + return out; +} + +/** + * Open the calendar window, connecting first if needed. Called from a + * click. Resolves true when the window is showing. + * @returns {Promise} + */ +export async function openCalendarWindow() { + const ctx = chronicle(); + if (!ctx) { + ui.notifications.warn(t('NotSetUp')); + return false; + } + if (!storedGrant(ctx) && !(await connect(ctx))) return false; + + if (calendarWindow?.rendered) { + calendarWindow.bringToFront?.(); + } else { + calendarWindow = new CalendarWindow(); + await calendarWindow.render({ force: true }); + } + return true; +} + /** Keep the jot tab just left of the right-hand sidebar, wherever it is. */ function placeJotTab() { const tab = document.getElementById('chronicle-jot-tab'); diff --git a/scripts/settings.mjs b/scripts/settings.mjs index 7f6d90f..31b2e5d 100644 --- a/scripts/settings.mjs +++ b/scripts/settings.mjs @@ -207,6 +207,14 @@ export function registerSettings() { onChange: () => Hooks.callAll('chronicleSyncCalendarSnapshot'), }); + // The calendar window's size and place, per player. + game.settings.register(MODULE_ID, 'calendarWindowPlace', { + scope: 'client', + config: false, + type: Object, + default: {}, + }); + game.settings.register(MODULE_ID, 'calendarTemperatureUnit', { name: game.i18n.localize('CHRONICLE.Settings.CalendarTemperatureUnit.Name'), hint: game.i18n.localize('CHRONICLE.Settings.CalendarTemperatureUnit.Hint'), diff --git a/tools/test-scene-controls.mjs b/tools/test-scene-controls.mjs index f9d88a8..a137ee7 100644 --- a/tools/test-scene-controls.mjs +++ b/tools/test-scene-controls.mjs @@ -4,8 +4,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { addChronicleControls } from '../scripts/_scene-controls.mjs'; -const run = { dashboard() {}, dmScreen() {}, notebook() {} }; -const titles = { dmScreen: 'Open the DM Screen', notebook: 'Open my Chronicle notebook' }; +const run = { dashboard() {}, dmScreen() {}, notebook() {}, calendar() {} }; +const titles = { dmScreen: 'Open the DM Screen', notebook: 'Open my Chronicle notebook', calendar: 'Open the Chronicle calendar' }; function names(controls) { if (Array.isArray(controls)) { @@ -17,9 +17,9 @@ function names(controls) { } const CASES = [ - ['GM, connected', { isGM: true, notebook: true }, ['dashboard', 'dm-screen', 'notebook']], + ['GM, connected', { isGM: true, notebook: true }, ['dashboard', 'dm-screen', 'notebook', 'calendar']], ['GM, not connected', { isGM: true, notebook: false }, ['dashboard', 'dm-screen']], - ['player, connected', { isGM: false, notebook: true }, ['notebook']], + ['player, connected', { isGM: false, notebook: true }, ['notebook', 'calendar']], ['player, not connected', { isGM: false, notebook: false }, null], ]; @@ -49,4 +49,13 @@ test('v12 tools use onClick; v13 tools use onChange and name an activeTool', () assert.equal(g.tools['dm-screen'].onChange, run.dmScreen); assert.equal(g.tools['dm-screen'].title, titles.dmScreen); assert.ok(!('run' in g.tools.notebook)); + assert.equal(g.tools.calendar.onChange, run.calendar); + assert.equal(g.tools.calendar.title, titles.calendar); +}); + +test('without a calendar opener there is no Calendar button', () => { + const controls = {}; + const { calendar, ...noCal } = run; + addChronicleControls(controls, { isGM: false, notebook: true, run: noCal, titles }); + assert.deepEqual(names(controls), ['notebook']); });