diff --git a/.cursor/rules/00-foundry-v13-basics.mdc b/.cursor/rules/00-foundry-v13-basics.mdc deleted file mode 100644 index 6e0078f..0000000 --- a/.cursor/rules/00-foundry-v13-basics.mdc +++ /dev/null @@ -1,23 +0,0 @@ ---- -description: Global guardrails for Foundry VTT v13 projects using App V2 -globs: "**/*" -alwaysApply: true ---- - -# Foundry V13 / App V2 – Global Rules - -You are working in a **Foundry VTT v13** module that **must** use the **ApplicationV2** framework and **DocumentSheetV2** sheets. - -## Absolutes -- **Always** target `ApplicationV2` and `DocumentSheetV2` APIs from v13 docs. -- **Never** introduce V1 classes or jQuery-era hooks (`Application`, `FormApplication`, `DocumentSheet`, `$html`, `activateListeners(html)`, `getData()` returning POJOs, etc.). -- Prefer modern DOM and event APIs (`addEventListener`, `EventTarget`) over jQuery. -- Use lifecycle overrides provided by V2: `_prepareContext`, `_renderHTML`, `_onFirstRender`, `_onRender`, `_preClose`, `_onChangeForm`, `submit()`, etc. -- For tabs, use the built-in `ApplicationV2.TABS` and `_getTabsConfig` rather than ad-hoc tab widgets. - -## Canonical references (v13) -- `ApplicationV2` methods and lifecycle (render, setPosition, submit, tabs, header controls). [oai_citation:0‡Foundry Virtual Tabletop](https://foundryvtt.com/api/classes/foundry.applications.api.ApplicationV2.html) -- `DocumentSheetV2` hierarchy, accessors (`document`, `form`), and lifecycle (`_prepareContext`, `_renderHTML`, `_prepareSubmitData`, etc.). [oai_citation:1‡Foundry Virtual Tabletop](https://foundryvtt.com/api/classes/foundry.applications.api.DocumentSheetV2.html) -- Release notes confirming v13’s App V2 migration focus. [oai_citation:2‡Foundry Virtual Tabletop](https://foundryvtt.com/releases/13.341?utm_source=chatgpt.com) - -When the user asks for examples, **generate only V2-style code** and call out any legacy snippet as an anti-pattern with a V2 rewrite. \ No newline at end of file diff --git a/.cursor/rules/00-foundry-v14-basics.mdc b/.cursor/rules/00-foundry-v14-basics.mdc new file mode 100644 index 0000000..cd51611 --- /dev/null +++ b/.cursor/rules/00-foundry-v14-basics.mdc @@ -0,0 +1,25 @@ +--- +description: Global guardrails for Foundry VTT v14 projects using App V2 +globs: "**/*" +alwaysApply: true +--- + +# Foundry V14 / App V2 – Global Rules + +You are working in a **Foundry VTT v14** module that **must** use the **ApplicationV2** framework and **DocumentSheetV2** sheets. + +## Absolutes +- **Always** target `ApplicationV2` and `DocumentSheetV2` APIs from v14 docs. +- **Never** introduce V1 classes or jQuery-era hooks (`Application`, `FormApplication`, `DocumentSheet`, `$html`, `activateListeners(html)`, `getData()` returning POJOs, etc.). +- Prefer modern DOM and event APIs (`addEventListener`, `EventTarget`) over jQuery. +- Use lifecycle overrides provided by V2: `_prepareContext`, `_renderHTML`, `_onFirstRender`, `_onRender`, `_preClose`, `_onChangeForm`, `submit()`, etc. +- For tabs, use the built-in `ApplicationV2.TABS` and `_getTabsConfig` rather than ad-hoc tab widgets. +- Only use methods documented as `@public` or `@protected` in the v14 API docs. Do not call `@private` or `@internal` methods. + +## Canonical references (v14) +- `ApplicationV2` methods and lifecycle: https://foundryvtt.com/api/v14/classes/foundry.applications.api.ApplicationV2.html +- `DocumentSheetV2` hierarchy and lifecycle: https://foundryvtt.com/api/v14/classes/foundry.applications.api.DocumentSheetV2.html +- `AbstractSidebarTab` (extends ApplicationV2): https://foundryvtt.com/api/v14/classes/foundry.applications.sidebar.AbstractSidebarTab.html +- V14 stable release notes: https://foundryvtt.com/releases/14.359 + +When the user asks for examples, **generate only V2-style code** and call out any legacy snippet as an anti-pattern with a V2 rewrite. diff --git a/.cursor/rules/10-applicationv2-patterns.mdc b/.cursor/rules/10-applicationv2-patterns.mdc index de3da59..9c7a344 100644 --- a/.cursor/rules/10-applicationv2-patterns.mdc +++ b/.cursor/rules/10-applicationv2-patterns.mdc @@ -1,6 +1,6 @@ --- -description: Patterns and snippets for ApplicationV2 windows (v13) -globs: "src/**" +description: Patterns and snippets for ApplicationV2 windows (v14) +globs: "scripts/**" alwaysApply: true --- @@ -9,29 +9,29 @@ alwaysApply: true ## Skeleton Use `DEFAULT_OPTIONS`, implement `_prepareContext` and `_renderHTML`, and wire events with `addEventListener`: -```ts -import { HandlebarsApplicationMixin } from "foundry/applications/api.mjs"; // if using HBS - -export class SyncManager extends HandlebarsApplicationMixin(ApplicationV2) { - static override DEFAULT_OPTIONS = { +```js +export class SyncManager extends foundry.applications.api.HandlebarsApplicationMixin( + foundry.applications.api.ApplicationV2 +) { + static DEFAULT_OPTIONS = { id: "archivist-sync-manager", window: { title: "Archivist: Sync Manager", resizable: true }, classes: ["archivist", "sync"], position: { width: 640, height: "auto" }, }; - override async _prepareContext(_options) { + static PARTS = { + form: { template: "modules/archivist-sync/templates/sync-manager.hbs" }, + }; + + async _prepareContext(_options) { return { sessions: await game.modules.get("archivist-sync")?.api?.listSessions?.() ?? [], }; } - override async _renderHTML(context, _options) { - // Return a string or Node for HandlebarsApplicationMixin, or mount your own DOM. - return await renderTemplate("modules/archivist-sync/templates/sync-manager.hbs", context); - } - - override async _onFirstRender(_context, _options) { + _onRender(context, options) { + super._onRender(context, options); this.element.querySelector("[data-action='refresh']") ?.addEventListener("click", () => this.render({ force: true })); } @@ -39,22 +39,21 @@ export class SyncManager extends HandlebarsApplicationMixin(ApplicationV2) { ``` ## Tabs (built-in) -Configure with `TABS` + `_getTabsConfig` instead of custom tab libs: +Configure with `TABS` + `_getTabsConfig`: -```ts +```js static TABS = { main: { group: "main", navSelector: ".tabs", contentSelector: ".content", initial: "overview" } }; -protected override _getTabsConfig(group: string) { return (SyncManager as any).TABS[group] ?? null; } ``` -See `ApplicationV2` tab APIs. [oai_citation:11‡Foundry Virtual Tabletop](https://foundryvtt.com/api/classes/foundry.applications.api.ApplicationV2.html) +See ApplicationV2 tab APIs: https://foundryvtt.com/api/v14/classes/foundry.applications.api.ApplicationV2.html ## Header controls -Use `_getHeaderControls()` to add window buttons (gear, help, etc.): +Use `_getHeaderControls()` to add window buttons: -```ts -protected override _getHeaderControls() { +```js +_getHeaderControls() { return [ ...super._getHeaderControls(), { @@ -68,42 +67,7 @@ protected override _getHeaderControls() { ``` ## Drag & Drop -Instantiate `DragDrop` in the constructor and wire targets via modern listeners—do not use V1 helpers. Keep handlers on `this.element` with `addEventListener` and translate drops into V2 updates. ---- -description: Patterns and snippets for ApplicationV2 windows (v13) -globs: "src/**" -alwaysApply: true ---- +Wire drag/drop handlers on `this.element` with `addEventListener` inside `_onRender`. Do not use V1 helpers. -# ApplicationV2 – How to structure windows - -## Skeleton -Use `DEFAULT_OPTIONS`, implement `_prepareContext` and `_renderHTML`, and wire events with `addEventListener`: - -```ts -import { HandlebarsApplicationMixin } from "foundry/applications/api.mjs"; // if using HBS - -export class SyncManager extends HandlebarsApplicationMixin(ApplicationV2) { - static override DEFAULT_OPTIONS = { - id: "archivist-sync-manager", - window: { title: "Archivist: Sync Manager", resizable: true }, - classes: ["archivist", "sync"], - position: { width: 640, height: "auto" }, - }; - - override async _prepareContext(_options) { - return { - sessions: await game.modules.get("myarchivist")?.api?.listSessions?.() ?? [], - }; - } - - override async _renderHTML(context, _options) { - // Return a string or Node for HandlebarsApplicationMixin, or mount your own DOM. - return await renderTemplate("modules/myarchivist/templates/sync-manager.hbs", context); - } - - override async _onFirstRender(context, options) { - this.element.querySelector("[data-action='refresh']") - ?.addEventListener("click", () => this.render({ force: true })); - } -} \ No newline at end of file +## Pop-out windows (v14) +V14 adds native pop-out support. Any `ApplicationV2` can call `attachWindow()` / `detachWindow()` to render in a separate browser window. No special code needed unless you need to customize the pop-out behavior. diff --git a/.cursor/rules/20-documentsheetv2-sheets.mdc b/.cursor/rules/20-documentsheetv2-sheets.mdc index f4f2674..237ed56 100644 --- a/.cursor/rules/20-documentsheetv2-sheets.mdc +++ b/.cursor/rules/20-documentsheetv2-sheets.mdc @@ -1,6 +1,6 @@ --- description: Rules and examples for DocumentSheetV2-based sheets (Actors, Items, Journals, custom) -globs: "src/**" +globs: "scripts/**" alwaysApply: true --- @@ -9,43 +9,36 @@ alwaysApply: true ## Sheet base Extend `DocumentSheetV2` directly (or a project `BaseSheet` that extends it). Use V2 accessors (`this.document`, `this.form`) and lifecycle: -```ts -import { HandlebarsApplicationMixin } from "foundry/applications/api.mjs"; +```js +class ArchivistBasePageSheetV2 extends foundry.applications.api.HandlebarsApplicationMixin( + foundry.applications.api.DocumentSheetV2 +) { + static DEFAULT_OPTIONS = { + id: "archivist-page-sheet-{id}", + classes: ["archivist-sync", "archivist-v2-sheet"], + sheetConfig: true, + window: { icon: "fa-solid fa-book", resizable: true }, + position: { width: 800, height: 700 }, + }; -export class FactionSheet extends HandlebarsApplicationMixin(DocumentSheetV2) { - static override DEFAULT_OPTIONS = { - id: "archivist-faction-sheet", - window: { title: "Faction" }, - classes: ["archivist", "sheet", "faction"], - position: { width: 600, height: "auto" }, - form: { handler: FactionSheet.prototype._onSubmit }, + static PARTS = { + form: { template: "modules/archivist-sync/templates/sheets/base.hbs" }, }; - get title() { return `Faction: ${this.document?.name ?? "Untitled"}`; } + get title() { return this.document?.name ?? "Untitled"; } - override async _prepareContext(_options) { + async _prepareContext(_options) { return { - faction: this.document, - links: this.document.getFlag("archivist-sync", "links") ?? [], + entry: this.document, + flags: this.document?.getFlag?.("archivist-sync", "archivist") || {}, }; } - override async _renderHTML(context, _options) { - return await renderTemplate("modules/archivist-sync/templates/faction.hbs", context); - } - - protected async _onSubmit(ev: SubmitEvent) { - ev.preventDefault(); - const data = this._prepareSubmitData(new FormData(this.form!)); - await this.document.update(data); - } - - protected override _prepareSubmitData(form: FormData) { - // Map HBS form fields to the document schema (v13). - return { - "system.description": form.get("description") ?? this.document.system?.description ?? "", - img: (form.get("img") as string) || this.document.img, - } as any; + _onRender(context, options) { + super._onRender(context, options); + const root = this.element; + root.addEventListener("drop", (ev) => this._onArchivistDrop(ev)); + root.addEventListener("dragover", (ev) => ev.preventDefault()); } } ``` @@ -54,16 +47,14 @@ export class FactionSheet extends HandlebarsApplicationMixin(DocumentSheetV2) { - Rendering & context: `_prepareContext`, `_renderHTML`, `_onFirstRender`, `_onRender`, `_postRender`. - Forms: `_onChangeForm`, `submit()`, `_prepareSubmitData`, `_processSubmitData`. - Accessors: `document`, `form`, `title`, `element`, `classList`. -All are described in `DocumentSheetV2`. [oai_citation:1‡Foundry Virtual Tabletop](https://foundryvtt.com/api/classes/foundry.applications.api.DocumentSheetV2.html) + +API docs: https://foundryvtt.com/api/v14/classes/foundry.applications.api.DocumentSheetV2.html ## Images & assets -Bind image fields to `document.img` (or relevant system path) and update via `this.document.update({ img })` in submit handlers—do **not** rely on V1 jQuery form traversal. +Bind image fields to `document.img` and update via `this.document.update({ img })` in submit handlers. Do not rely on V1 jQuery form traversal. ## Tabs and header controls -Reuse `ApplicationV2` mechanisms from the window base: `TABS`, `_getTabsConfig`, `_getHeaderControls`. [oai_citation:11‡Foundry Virtual Tabletop](https://foundryvtt.com/api/classes/foundry.applications.api.ApplicationV2.html) +Reuse `ApplicationV2` mechanisms: `TABS`, `_getTabsConfig`, `_getHeaderControls`. ## Journal-like content -For rich text, prefer v13 editors or ProseMirror-based editors exposed by the system. Avoid direct DOM HTML scraping; persist via `update` on the document. ---- -alwaysApply: true ---- +For rich text, use ProseMirror-based editors exposed by Foundry. Avoid direct DOM HTML scraping; persist via `update` on the document. diff --git a/.cursor/rules/30-legacy-apis-do-not-use.mdc b/.cursor/rules/30-legacy-apis-do-not-use.mdc index 5b91413..8be0506 100644 --- a/.cursor/rules/30-legacy-apis-do-not-use.mdc +++ b/.cursor/rules/30-legacy-apis-do-not-use.mdc @@ -1,5 +1,5 @@ --- -description: Explicit anti-patterns to block V1/legacy code in v13 modules +description: Explicit anti-patterns to block V1/legacy code in v14 modules globs: "**/*" alwaysApply: true --- @@ -7,18 +7,19 @@ alwaysApply: true # Do NOT use these in this project ## Disallowed classes (V1) -- `Application`, `FormApplication`, `DocumentSheet` (and any method like `getData()`, `activateListeners(html)`, `_updateObject(event, formData)` that expects jQuery/HTML args). [oai_citation:1‡Foundry Virtual Tabletop](https://foundryvtt.com/api/classes/foundry.applications.api.DocumentSheetV2.html) +- `Application`, `FormApplication`, `DocumentSheet` (V1 base classes). +- Any method signature from V1: `getData()`, `activateListeners(html)`, `_updateObject(event, formData)`. ## Disallowed idioms - jQuery-specific patterns: `$(...)`, `html.find(...)`, passing `html` objects into lifecycle hooks. -- V1 render signature assumptions (boolean `force` without V2 options). -- Manual tab widgets when `ApplicationV2` tabs exist. [oai_citation:11‡Foundry Virtual Tabletop](https://foundryvtt.com/api/classes/foundry.applications.api.ApplicationV2.html) +- V1 render signature assumptions (boolean `force` without V2 options object). +- Manual tab widgets when `ApplicationV2.TABS` exists. +- Manual `renderTemplate` + `innerHTML` mounting instead of using `HandlebarsApplicationMixin` with `PARTS`. +- Calling `@private` or `@internal` Foundry methods (see v14 Public API guidance). ## Required alternatives - Use `ApplicationV2` / `DocumentSheetV2` lifecycle methods. -- Use `addEventListener` on `this.element` or specific elements. +- Use `addEventListener` on `this.element` or specific elements inside `_onRender` / `_onFirstRender`. - Use `submit()` and `_prepareSubmitData` for forms. -- For context, compute in `_prepareContext`; for markup, use `_renderHTML`. ---- -alwaysApply: true ---- +- For context, compute in `_prepareContext`; for markup, define `PARTS` templates. +- For sidebar tabs, extend `AbstractSidebarTab` using V2 lifecycle (`_prepareContext`, `_renderHTML`, `_onActivate`, `_onDeactivate`). diff --git a/.cursor/rules/50-v14-sidebar-and-popouts.mdc b/.cursor/rules/50-v14-sidebar-and-popouts.mdc new file mode 100644 index 0000000..5e1a82a --- /dev/null +++ b/.cursor/rules/50-v14-sidebar-and-popouts.mdc @@ -0,0 +1,71 @@ +--- +description: How to build sidebar tabs and pop-out windows with V14 App V2 hooks +globs: "scripts/sidebar/**" +alwaysApply: false +--- + +# V14 Sidebar Tabs & Pop-outs + +## Sidebar tab implementation + +Custom sidebar tabs must extend `foundry.applications.sidebar.AbstractSidebarTab`, which is an `ApplicationV2` subclass. Use V2 lifecycle methods only: + +```js +export class AskChatSidebarTab extends foundry.applications.sidebar.AbstractSidebarTab { + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + id: "archivist-chat", + classes: ["archivist-chat-sidebar"], + } + ); + + async _prepareContext(_options) { + return { messages: this._getMessages() }; + } + + async _renderHTML(context, _options) { + return await foundry.applications.handlebars.renderTemplate( + "modules/archivist-sync/templates/ask-chat-window.hbs", + context + ); + } + + _onRender(context, options) { + super._onRender(context, options); + const root = this.element; + root.querySelector(".ask-form") + ?.addEventListener("submit", (e) => this._onSubmit(e)); + } + + _onActivate() { /* called when this tab becomes active */ } + _onDeactivate() { /* called when switching away from this tab */ } +} +``` + +## Registering the tab + +Register during `init` by adding to `Sidebar.TABS`: + +```js +Hooks.once("init", () => { + const Sidebar = foundry.applications.sidebar?.Sidebar; + if (Sidebar) { + Sidebar.TABS["archivist-chat"] = { + id: "archivist-chat", + title: "Archivist Chat", + icon: "fa-solid fa-sparkles", + tab: AskChatSidebarTab, + app: AskChatSidebarTab, + }; + } +}); +``` + +## Do NOT use +- `getData()` or `activateListeners()` on sidebar tabs — these are V1 patterns. +- Manual `innerHTML` replacement inside sidebar panels. + +## Pop-out windows (v14 native) + +Any `ApplicationV2` can pop out. Sidebar tabs can use `renderPopout()` to create a detached copy. The framework handles window management. Override `_onDetach` / `_onAttach` if you need custom behavior when popping out or reattaching. diff --git a/.cursor/rules/60-v14-manifest-and-public-api.mdc b/.cursor/rules/60-v14-manifest-and-public-api.mdc new file mode 100644 index 0000000..7f57f33 --- /dev/null +++ b/.cursor/rules/60-v14-manifest-and-public-api.mdc @@ -0,0 +1,45 @@ +--- +description: V14 manifest checklist and Public API guidance +globs: "module.json" +alwaysApply: false +--- + +# V14 Manifest & Public API + +## module.json checklist + +Required fields for a v14 module manifest: +- `id`: machine-readable unique package id (lowercase, no spaces) +- `title`: human-readable package title +- `version`: dot-separated version string (no leading "v") +- `type`: must be `"module"` +- `compatibility`: object with `minimum` and `verified` (optionally `maximum`) +- `esmodules`: array of ES module file paths + +Recommended fields: +- `authors`: array of `{ name, email?, url?, discord? }` objects +- `relationships`: `{ systems: [], requires: [], conflicts: [] }` +- `languages`: array of language data objects +- `styles`: array of CSS file paths +- `url`, `manifest`, `download`, `bugs`, `changelog`, `readme`, `license` + +Optional v14 additions: +- `media`: array of `PackageMediaData` for package gallery +- `persistentStorage`: boolean, preserves `/storage` folder on update +- `quickstart`: quick-start configuration +- `documentTypes`: additional document subtypes provided by this module + +## Public vs Private API + +V14 formally categorizes every method: +- `@public`: safe to call from modules, with deprecation guarantees +- `@protected`: safe to override in subclasses, but do not call externally +- `@private` / `#method`: internal only, no stability guarantees +- `@internal`: core-only, do not reference or override + +When in doubt, check the v14 API docs: https://foundryvtt.com/api/v14/ + +## DataModel changes + +- The `-=` and `==` special operation keys on `updateSource` are deprecated. Use `DataFieldOperator` values instead. +- `DataModel.cleanData` has been consolidated with new options for fine-tuning cleaning behavior. diff --git a/.cursor/rules/90-style-and-ux.mdc b/.cursor/rules/90-style-and-ux.mdc index 34a8724..fdd9ca8 100644 --- a/.cursor/rules/90-style-and-ux.mdc +++ b/.cursor/rules/90-style-and-ux.mdc @@ -1,14 +1,16 @@ --- -description: Consistent UX conventions for Archivist Foundry modules (v13) -globs: "src/**" +description: Consistent UX conventions for Archivist Foundry modules (v14) +globs: "scripts/**" alwaysApply: true --- -# UX / Style Conventions (v13) +# UX / Style Conventions -- **Window frame**: prefer standard V2 window with header controls; expose “Help” linking to module docs. -- **Tabs**: use `TABS` configuration; initial tab is “overview”. +- **Window frame**: prefer standard V2 window with header controls; expose "Help" linking to module docs. +- **Tabs**: use `TABS` configuration; initial tab is "overview" or "info" depending on context. - **Accessibility**: label form controls, use buttons with `type="button"` vs `type="submit"` intentionally. -- **Positioning**: Use `setPosition` updates rather than direct style mutations. [oai_citation:11‡Foundry Virtual Tabletop](https://foundryvtt.com/api/classes/foundry.applications.api.ApplicationV2.html) -- **Context menus**: use `_createContextMenu(...)` when needed, not custom right-click handlers. [oai_citation:12‡Foundry Virtual Tabletop](https://foundryvtt.com/api/classes/foundry.applications.api.ApplicationV2.html) -- **Performance**: keep `_prepareContext` lean; defer heavy fetches to `_onFirstRender` with a loading state. \ No newline at end of file +- **Positioning**: Use `setPosition` updates rather than direct style mutations. +- **Context menus**: use `_createContextMenu(...)` when needed, not custom right-click handlers. +- **Performance**: keep `_prepareContext` lean; defer heavy fetches to `_onFirstRender` with a loading state. +- **Pop-outs**: V14 supports native pop-out windows for `ApplicationV2`. Test that custom apps work when popped out. +- **Font Awesome**: V14 ships FA 7.2. Verify icon class names when adding new icons. diff --git a/.github/BETA_SETUP_SUMMARY.md b/.github/BETA_SETUP_SUMMARY.md index cbdef8d..016b479 100644 --- a/.github/BETA_SETUP_SUMMARY.md +++ b/.github/BETA_SETUP_SUMMARY.md @@ -24,15 +24,16 @@ The beta release system now provides **automatic updates** for testers using a s ### For Developers (staging branch): -1. Make changes and push to `staging` branch (no version bump required!) +1. Make a non-Markdown change and push to `staging` branch (no version bump required!) 2. Workflow automatically: - - Reads version from `module.json` (e.g., `1.3.0`) - - Creates `v1.3.0-beta.{BUILD_NUMBER}` release (auto-increments) + - Reads the base version from `module.json` (e.g., `2.0.0`) + - Creates `v2.0.0-beta.{BUILD_NUMBER}` release (auto-increments) + - Publishes release assets whose `module.json` version is `2.0.0-beta.{BUILD_NUMBER}` - Force-updates `beta-latest` tag to point to this release - - Updates `module.json` URLs to point to `beta-latest` - - Commits changes back to `staging` + - Restores `staging` to the base version while keeping `module.json` URLs pointed at `beta-latest` + - Commits metadata changes back to `staging` if needed -**Key Point:** You don't need to bump the version for each beta. The build number auto-increments with each push. Only update the version in `module.json` when you want to target a new release number (e.g., moving from `1.2.x` to `1.3.0`). +**Key Point:** You don't need to bump the version for each beta. The build number auto-increments with each qualifying push. Only update the base version in `module.json` when you want to target a new release number (for example, moving from `1.3.x` to `2.0.0`). ### For Beta Testers (users): @@ -90,6 +91,7 @@ The beta release system now provides **automatic updates** for testers using a s ### When merging staging to main: - Update `module.json` URLs from `beta-latest` to production URLs +- Keep `module.json.version` aligned with `package.json.version` - Make sure version numbers are correct - Update `CHANGELOG.md` if not already done @@ -99,9 +101,10 @@ The beta release system now provides **automatic updates** for testers using a s - Force-pushed on each beta release (this is intentional) ### Version numbering: -- Base version in `module.json`: `1.3.0` -- Versioned tag created: `v1.3.0-beta.5` -- Auto-update tag: `beta-latest` (points to `v1.3.0-beta.5`) +- Base version on `staging`: `2.0.0` +- Versioned release asset: `2.0.0-beta.5` +- Versioned tag created: `v2.0.0-beta.5` +- Auto-update tag: `beta-latest` (points to `v2.0.0-beta.5`) ## Benefits @@ -113,11 +116,11 @@ The beta release system now provides **automatic updates** for testers using a s ## Testing the Setup 1. Create a `staging` branch if it doesn't exist -2. Make a small change (any code change, even a comment) +2. Make a small non-Markdown change (any code or config change) 3. Commit and push to `staging` 4. Check GitHub Actions for workflow execution 5. Verify two releases appear: - - `v{VERSION}-beta.{BUILD_NUMBER}` (e.g., `v1.2.0-beta.15`) + - `v{VERSION}-beta.{BUILD_NUMBER}` (e.g., `v2.0.0-beta.15`) - `beta-latest` (updated to point to the new beta) 6. Test the beta-latest manifest URL in Foundry 7. Make another change and push → verify build number increments automatically @@ -125,4 +128,3 @@ The beta release system now provides **automatic updates** for testers using a s ## Questions? See `.github/RELEASE_WORKFLOW.md` for detailed documentation or open an issue. - diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index a38b4af..702973b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ If applicable, add screenshots to help explain your problem. ## Environment -- **Foundry VTT Version:** [e.g. v13.346] +- **Foundry VTT Version:** [e.g. v14.359] - **Module Version:** [e.g. 1.2.3] - **Game System:** [e.g. dnd5e v3.3.1] - **Browser:** [e.g. Chrome 120, Firefox 121] diff --git a/.github/RELEASE_WORKFLOW.md b/.github/RELEASE_WORKFLOW.md index 867ca05..17cf9c9 100644 --- a/.github/RELEASE_WORKFLOW.md +++ b/.github/RELEASE_WORKFLOW.md @@ -34,27 +34,29 @@ This module uses two separate GitHub Actions workflows for releases: **Workflow:** `.github/workflows/beta-release.yml` ### When it runs: -- Automatically triggered when `module.json` or `package.json` is pushed to the `staging` branch +- Automatically triggered when a non-Markdown change is pushed to the `staging` branch ### What it does: -1. ✅ Takes the version from `module.json` and appends `-beta.{BUILD_NUMBER}` -2. ✅ Updates `module.json` URLs to point to the beta release -3. ✅ Creates a GitHub **pre-release** (marked as beta) with tag `vX.Y.Z-beta.N` -4. ✅ Uploads `module.zip` and `module.json` as release assets -5. ✅ Commits the updated `module.json` back to `staging` branch -6. ❌ **Does NOT publish to Foundry VTT** (beta releases are manual install only) +1. ✅ Takes the base version from `module.json` and appends `-beta.{BUILD_NUMBER}` +2. ✅ Prepares beta release assets with `module.json.version = X.Y.Z-beta.N` +3. ✅ Updates release asset URLs to point to `beta-latest` +4. ✅ Creates a GitHub **pre-release** (marked as beta) with tag `vX.Y.Z-beta.N` +5. ✅ Uploads `module.zip` and `module.json` as release assets +6. ✅ Restores `module.json` on `staging` to the base version and beta URLs, then commits if needed +7. ❌ **Does NOT publish to Foundry VTT** (beta releases are manual install only) ### How to create a beta release: 1. Work on the `staging` branch -2. Make your changes (code, features, bug fixes, etc.) -3. Commit and push to `staging` +2. Make your changes (code, templates, JSON, assets, etc.) +3. Commit and push a non-Markdown change to `staging` 4. The workflow **automatically runs** and: - - Reads the version from `module.json` (e.g., `1.3.0`) - - Creates a release tagged `v1.3.0-beta.{BUILD_NUMBER}` (auto-incrementing) - - Updates `module.json` to point to `beta-latest` - - Commits the changes back to `staging` + - Reads the base version from `module.json` (e.g., `2.0.0`) + - Creates a release tagged `vX.Y.Z-beta.{BUILD_NUMBER}` (auto-incrementing) + - Publishes release assets whose `module.json` version is `X.Y.Z-beta.{BUILD_NUMBER}` + - Keeps the `staging` branch on the base version while pointing `manifest`/`download` at `beta-latest` + - Commits the metadata update back to `staging` if the branch still had production URLs -**Note:** You do NOT need to bump the version for each beta! The workflow uses the GitHub run number to auto-increment beta builds. Only update the version when you're ready to target a new release number. +**Note:** You do NOT need to bump the version for each beta. The workflow uses the GitHub run number to auto-increment beta builds. Only update the base version when you're ready to target a new release number. Markdown-only doc pushes are ignored. ### How users install beta releases: @@ -86,7 +88,7 @@ main (production releases) ### Typical workflow: 1. **Development:** Make changes on feature branches, merge to `staging` -2. **Beta Testing:** Push version bump to `staging` → creates beta release +2. **Beta Testing:** Push a non-Markdown change to `staging` → creates beta release 3. **Testing:** Testers install beta via manifest URL and provide feedback 4. **Release:** When ready, merge `staging` to `main` → creates production release @@ -103,7 +105,8 @@ main (production releases) ### Beta (staging): - **Versioned tag:** `v1.2.0-beta.5` (specific beta with full changelog) - **Auto-update tag:** `beta-latest` (always points to newest beta) -- Version in module.json: `1.2.0` (base version) +- **Release asset version:** `1.2.0-beta.5` +- **Version on `staging` after workflow:** `1.2.0` (base version) - Manifest URL: `/releases/download/beta-latest/module.json` (auto-updates!) - Download URL: `/releases/download/beta-latest/module.zip` (auto-updates!) @@ -112,28 +115,30 @@ main (production releases) - A versioned beta release (e.g., `v1.2.0-beta.5`) with specific changelog - An updated `beta-latest` release that points to the newest beta 2. The `beta-latest` tag is force-updated to point to the latest commit -3. Users who install with the `beta-latest` URL automatically get updates! +3. The uploaded beta manifest advertises the beta version (`X.Y.Z-beta.N`), so Foundry can detect updates +4. Users who install with the `beta-latest` URL automatically get updates --- ## 🛠️ Troubleshooting ### Beta workflow keeps creating new releases -- Each push to `staging` that changes `module.json` or `package.json` will create a new beta release +- Each non-Markdown push to `staging` will create a new beta release - The run number increments automatically, so each beta gets a unique tag - Use `[skip ci]` in commit messages to prevent workflow from running ### Module.json URLs are wrong after beta release -- The workflow automatically updates `module.json` and commits it back +- The workflow restores `module.json` on `staging` to the base version with beta URLs and commits it back if needed - Wait a moment for the commit to complete - Pull the latest changes from `staging` ### Want to test locally without creating a release -- Make changes but don't update the version numbers +- Keep the change local until you're ready to push - Or use `[skip ci]` in your commit message +- Markdown-only doc pushes are ignored by the beta workflow ### Merging staging to main -- The `staging` branch's `module.json` will have beta URLs: +- The `staging` branch's `module.json` should keep the base version, but it will have beta URLs: ```json "manifest": "https://github.com/camrun91/archivist-sync/releases/download/beta-latest/module.json", "download": "https://github.com/camrun91/archivist-sync/releases/download/beta-latest/module.zip" @@ -143,6 +148,7 @@ main (production releases) "manifest": "https://github.com/camrun91/archivist-sync/releases/latest/download/module.json", "download": "https://github.com/camrun91/archivist-sync/releases/download/v1.2.0/module.zip" ``` +- Make sure `module.json.version` matches `package.json.version` before pushing to `main` - The main branch should use `releases/latest/download/` for manifest (auto-updates) - Update the download URL to match the version you're releasing @@ -156,4 +162,3 @@ main (production releases) 4. **Use semantic versioning**: `MAJOR.MINOR.PATCH` 5. **Beta testing**: Share the beta manifest URL with trusted testers 6. **Production release**: Only merge to main when ready for public release - diff --git a/.github/WORKFLOW_DIAGRAM.md b/.github/WORKFLOW_DIAGRAM.md index ee0db30..e33aac8 100644 --- a/.github/WORKFLOW_DIAGRAM.md +++ b/.github/WORKFLOW_DIAGRAM.md @@ -17,13 +17,14 @@ │ STAGING BRANCH │ │ ────────────────────────────────────────────────────────────────────── │ │ │ -│ Push with version bump in module.json/package.json │ +│ Push non-Markdown change to staging │ │ ↓ │ │ 🤖 BETA-RELEASE.YML WORKFLOW │ │ │ │ │ ├─→ Create versioned release: v1.3.0-beta.5 │ │ │ └─→ Contains full changelog │ │ │ └─→ Tagged with specific version │ +│ │ └─→ Release assets advertise version 1.3.0-beta.5 │ │ │ │ │ ├─→ Update beta-latest tag (force push) │ │ │ └─→ Points to latest beta commit │ @@ -32,8 +33,8 @@ │ │ └─→ Stable URL for auto-updates │ │ │ └─→ Contains module.zip + module.json │ │ │ │ -│ └─→ Update module.json in staging │ -│ └─→ URLs point to beta-latest │ +│ └─→ Restore module.json in staging │ +│ └─→ Base version + URLs point to beta-latest │ │ │ └──────────────────────────────────────────────────────────────────────────┘ │ @@ -169,6 +170,7 @@ - **Staging branch** for beta testing - **Main branch** for production - **Automatic versioning** and tagging +- **Base version stays on `staging`** while release assets use `-beta.N` - **Dual releases** keep history while enabling auto-updates ### 🎯 For Production Users @@ -193,4 +195,3 @@ 4. **Prompts user** if newer version detected Because `beta-latest` points to the newest beta's `module.json` with an incremented version number, Foundry automatically detects updates! - diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index df2beb5..73ec019 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -5,10 +5,8 @@ on: branches: - staging paths-ignore: - - "**.md" - - ".github/RELEASE_WORKFLOW.md" - - ".github/BETA_SETUP_SUMMARY.md" - - ".github/WORKFLOW_DIAGRAM.md" + - "*.md" + - "**/*.md" jobs: beta-release: @@ -55,17 +53,20 @@ jobs: - name: Install dependencies run: npm ci - - name: Update module.json with beta URLs + - name: Prepare module.json for beta release assets run: | REPO="${{ github.repository }}" + BETA_VERSION="${{ steps.get_version.outputs.BETA_VERSION }}" - # Update manifest and download URLs to point to beta-latest (stable tag for updates) - jq --arg repo "$REPO" \ - '.manifest = "https://github.com/\($repo)/releases/download/beta-latest/module.json" | + # Beta release assets must advertise the beta version so Foundry detects updates. + jq --arg repo "$REPO" --arg beta_version "$BETA_VERSION" \ + '.version = $beta_version | + .manifest = "https://github.com/\($repo)/releases/download/beta-latest/module.json" | .download = "https://github.com/\($repo)/releases/download/beta-latest/module.zip"' \ module.json > module.json.tmp && mv module.json.tmp module.json - echo "✅ Updated module.json with beta-latest URLs (for automatic updates)" + echo "✅ Prepared module.json for beta release assets" + echo " Version: $(jq -r '.version' module.json)" echo " Manifest: $(jq -r '.manifest' module.json)" echo " Download: $(jq -r '.download' module.json)" @@ -90,12 +91,12 @@ jobs: - name: Extract changelog for beta release id: changelog run: | - VERSION="${{ steps.get_version.outputs.VERSION }}" + BASE_VERSION="${{ steps.get_version.outputs.BASE_VERSION }}" BETA_VERSION="${{ steps.get_version.outputs.BETA_VERSION }}" - # Try to extract changelog section for this version + # Try to extract changelog section for the base version targeted by this beta. if [ -f CHANGELOG.md ]; then - awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > release_notes.md + awk "/## \[$BASE_VERSION\]/,/## \[/" CHANGELOG.md | sed '$d' > release_notes.md fi # If release notes are empty or don't exist, use a default message @@ -184,6 +185,23 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Restore module.json for staging branch + run: | + REPO="${{ github.repository }}" + BASE_VERSION="${{ steps.get_version.outputs.BASE_VERSION }}" + + # Keep staging mergeable to main: base version on branch, beta URLs in metadata. + jq --arg repo "$REPO" --arg base_version "$BASE_VERSION" \ + '.version = $base_version | + .manifest = "https://github.com/\($repo)/releases/download/beta-latest/module.json" | + .download = "https://github.com/\($repo)/releases/download/beta-latest/module.zip"' \ + module.json > module.json.tmp && mv module.json.tmp module.json + + echo "✅ Restored module.json for staging branch" + echo " Version: $(jq -r '.version' module.json)" + echo " Manifest: $(jq -r '.manifest' module.json)" + echo " Download: $(jq -r '.download' module.json)" + - name: Commit updated module.json back to staging run: | # Check if module.json has changes @@ -191,7 +209,7 @@ jobs: echo "ℹ️ No changes to module.json, skipping commit" else git add module.json - git commit -m "chore: update module.json URLs for beta release ${{ steps.get_version.outputs.TAG }} [skip ci]" + git commit -m "chore: update staging beta metadata for ${{ steps.get_version.outputs.TAG }} [skip ci]" git push origin staging echo "✅ Pushed updated module.json to staging branch" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index f9399d0..e62e904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.3.13] - 2026-04-13 +## [2.0.0] - 2026-04-05 + +### Changed +- **Foundry V14 migration**: Module now targets Foundry VTT v14 (minimum 14.359, verified 14.359). +- Updated `module.json` for v14 manifest expectations, including `type: "module"` and v14 compatibility metadata. +- Kept the existing Archivist Chat sidebar implementation, but updated its runtime wiring so the chat button and panel continue to render correctly on v14. +- Updated startup and sidebar initialization logic for v14 while preserving the existing Journals tab controls and chat behavior. +- Updated all Cursor rules from v13 to v14 references with corrected file globs. ### Added -- Release to fix the release flow. +- New Cursor rules for v14 sidebar/pop-out patterns and manifest/public API guidance. +- Migration documentation at `docs/foundry-v14-migration.md`. ## [1.3.12] - 2026-01-12 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d0730a..b0b9554 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,16 +82,15 @@ See [.github/RELEASE_WORKFLOW.md](.github/RELEASE_WORKFLOW.md) for detailed docu **For Beta Testing:** 1. Merge your feature to `staging` -2. Push to `staging` → automatic beta release created with auto-incrementing build number -3. No version bump needed! (Only update version when targeting a new release) +2. Push a non-Markdown change to `staging` → automatic beta release created with auto-incrementing build number +3. No version bump needed for every beta; only update the base version when targeting a new release **For Production Release:** 1. Update `CHANGELOG.md` with release notes 2. Merge `staging` to `main` -3. Update versions in `module.json` and `package.json` if needed +3. Update `module.json` back to production URLs and make sure its version matches `package.json` 4. Push to `main` → automatic release + Foundry VTT publication ## Questions? If you have questions, please open an issue on GitHub. - diff --git a/README.md b/README.md index 423ce09..57f5ddb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/camrun91/archivist-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/camrun91/archivist-sync/actions/workflows/ci.yml) -Foundry VTT v13 module to connect your world to the Archivist service. It provides a guided setup wizard, an Archivist Hub for managing campaign content, real‑time bidirectional sync for entities and links, and an in‑client "Ask Archivist" sidebar chat with RAG support. +Foundry VTT v14 module to connect your world to the Archivist service. It provides a guided setup wizard, real‑time bidirectional sync for entities and links, and an in‑client "Ask Archivist" sidebar chat with RAG support. ## Features @@ -22,11 +22,9 @@ Foundry VTT v13 module to connect your world to the Archivist service. It provid 3. Restart Foundry and enable the module in your world’s Module Settings. ### Manifest URL -Use your repository’s release manifest URL (update the placeholder in `module.json`): +Use the stable production manifest URL: -`https://github.com/yourusername/archivist-sync/releases/latest/download/module.json` - -Tip: Also update the `url`, `manifest`, `download`, and `bugs` fields in `module.json` to point to your repo. +`https://github.com/camrun91/archivist-sync/releases/latest/download/module.json` ### Beta Releases (Testing) @@ -161,7 +159,7 @@ Examples (representative; bodies vary by type): ## Compatibility -- **Foundry VTT**: v13 (minimum 13.341; verified 13.346) +- **Foundry VTT**: v14 (minimum 14.359; verified 14.359) - **Game Systems**: System-agnostic with built-in adapters for common systems ## Security & Permissions diff --git a/docs/foundry-v14-migration.md b/docs/foundry-v14-migration.md new file mode 100644 index 0000000..f3627f2 --- /dev/null +++ b/docs/foundry-v14-migration.md @@ -0,0 +1,74 @@ +# Foundry V14 Migration Notes + +This document tracks the changes between Foundry VTT v13 and v14 that are relevant to the Archivist Sync module, what was updated in the codebase, and what to retest before release. + +## What changed in Foundry V14 + +### Core framework direction + +V14 keeps `ApplicationV2` and `DocumentSheetV2` as the canonical application framework. There is no new "V3" paradigm. The main additions are: + +- **Pop-out window support**: Any `ApplicationV2` instance can now be rendered in a separate browser window via `attachWindow()` / `detachWindow()`. This is a first-class feature added to the framework itself. +- **`AbstractSidebarTab` is a full `ApplicationV2` subclass**: In v14 the sidebar tab API is documented with V2 lifecycle methods (`_prepareContext`, `_renderHTML`, `_onActivate`, `_onDeactivate`). Legacy methods like `getData()` and `activateListeners()` are not part of the documented public API. +- **Public vs Private API enforcement**: The v14 docs formally distinguish `@public`, `@protected`, `@private`, and `@internal` methods. Modules should only call `@public` methods and only override `@protected` ones. +- **Font Awesome 7.2**: V14 ships with FA 7.2.0, so icon class names should be verified. + +### Deprecation completions + +V14 retired backward-compatible support for: + +- `CONFIG.ActiveEffect.legacyTransferral` (deprecated since V11). +- A large set of V12-era deprecations (see [issue #13436](https://github.com/foundryvtt/foundryvtt/issues/13436)). +- The `-=` and `==` special operation keys on `DataModel#updateSource` are deprecated in favor of `DataFieldOperator` values. + +### Manifest + +The `ModuleManifestData` interface in v14 is largely unchanged from v13, but now explicitly types `type: "module"` as a required field and adds optional fields like `media`, `persistentStorage`, `quickstart`, and `protected`. Existing manifests that omit `type` should add it. + +### Sidebar + +- `Sidebar.TABS` registration is still the mechanism for adding custom tabs, but the tab class must be a proper `AbstractSidebarTab` subclass using V2 lifecycle. +- New `PlaceableDirectory` and `PlaceableTab` sidebar tabs were added to core. + +### Measured Templates removal + +V14 removes `MeasuredTemplate` as a document type, replacing it with region-based templates. This does not affect Archivist Sync (we don't use templates). + +## What we changed + +### Module manifest (`module.json`) + +- Added `"type": "module"` field. +- Bumped `compatibility.minimum` to `14.359` and `compatibility.verified` to `14.359`. + +### Cursor rules + +- All `.cursor/rules/` files updated from v13 to v14 references, with corrected globs (`scripts/**` instead of `src/**`). +- Added `50-v14-sidebar-and-popouts.mdc` with sidebar tab and pop-out guidance. +- Added `60-v14-manifest-and-public-api.mdc` with manifest checklist and Public API guidance. + +### Chat / sidebar refactor + +- `AskChatSidebarTab` converted from legacy `getData()` / `activateListeners()` pattern to V2 lifecycle (`_prepareContext`, `_renderHTML`, `_onRender`). +- `AskChatWindow` converted from a plain helper class with manual DOM mounting to a proper `HandlebarsApplicationMixin(ApplicationV2)` implementation. + +### Integration audit + +- `DocumentSheetConfig.registerSheet` usage verified against v14 API (namespace is `foundry.applications.apps.DocumentSheetConfig`). +- jQuery compatibility branches in `archivist-sync.js` reviewed and cleaned up. +- Stale v13-specific comments removed throughout. + +## What to retest + +Before marking v14 compatibility as verified, smoke-test these flows: + +1. **Module load**: The module activates without console errors on a fresh v14 world. +2. **Settings registration**: All settings appear in Module Settings and function correctly. +3. **World Setup wizard**: Complete flow from API key entry through campaign import. +4. **Journal sheet registration**: All custom sheets (Entry, PC, NPC, Item, Location, Faction, Recap) open correctly from the journal directory. +5. **Sync dialog**: "Sync with Archivist" button opens the dialog and reconciliation works. +6. **Sidebar chat**: The Archivist Chat tab renders in the sidebar, accepts input, streams responses, and handles clear/copy actions. +7. **Pop-out behavior**: Verify the sidebar chat tab can be popped out (v14 native feature) without breaking. +8. **Real-time sync**: Create/update/delete actors, items, journal entries and verify API calls fire correctly. +9. **Drag-and-drop linking**: Drop sheets onto each other and verify link creation in both Foundry and Archivist. +10. **Font Awesome icons**: Verify all FA icon references still render (FA 7.2 ships with v14). diff --git a/lang/en.json b/lang/en.json index b742d46..a375c66 100644 --- a/lang/en.json +++ b/lang/en.json @@ -170,6 +170,8 @@ "locations": "Locations", "items": "Items", "recaps": "Recaps", + "journals": "Journals", + "quests": "Quests", "mappingOverride": "Mapping Override (JSON)", "importProgress": "Import Progress" }, @@ -229,7 +231,9 @@ "locationsPulled": "Locations pulled successfully", "charactersPulled": "Characters pulled successfully", "worldInitialized": "Foundry world has been initialized with Archivist for the first time", - "worldInitializedReset": "World initialization reset. Reloading to run setup again..." + "worldInitializedReset": "World initialization reset. Reloading to run setup again...", + "journalImportNotice": "Journal content syncs with Archivist. Folder structure is a point-in-time snapshot and not kept in sync.", + "questSynced": "Quest synced with Archivist." }, "errors": { "fetchFailed": "Failed to fetch worlds from Archivist API", @@ -314,6 +318,35 @@ "beginSync": "Begin Sync" } }, + "quest": { + "status": { + "planned": "Planned", + "inProgress": "In Progress", + "blocked": "Blocked", + "failed": "Failed", + "done": "Done" + }, + "category": { + "main": "Main", + "side": "Side", + "faction": "Faction", + "personal": "Personal" + }, + "objectiveStatus": { + "pending": "Pending", + "inProgress": "In Progress", + "completed": "Completed", + "failed": "Failed", + "blocked": "Blocked" + }, + "fields": { + "questGiver": "Quest Giver", + "successDefinition": "Success Definition", + "failureConditions": "Failure Conditions", + "nextAction": "Next Action", + "resolution": "Resolution" + } + }, "settings": { "realtimeSync": { "name": "Real‑Time Sync (auto‑update Archivist)", diff --git a/module.json b/module.json index 1b27201..54a1177 100644 --- a/module.json +++ b/module.json @@ -8,16 +8,16 @@ "email": "cameron.b.llewellyn@gmail.com" } ], - "version": "1.3.13", + "type": "module", + "version": "2.0.0", "compatibility": { - "minimum": "13.341", + "minimum": "14.359", "verified": "13.351" }, "relationships": { "systems": [], "requires": [], - "conflicts": [], - "flags": {} + "conflicts": [] }, "esmodules": [ "scripts/archivist-sync.js", @@ -54,8 +54,8 @@ } ], "url": "https://github.com/camrun91/archivist-sync", - "manifest": "https://github.com/camrun91/archivist-sync/releases/latest/download/module.json", - "download": "https://github.com/camrun91/archivist-sync/releases/latest/download/module.zip", + "manifest": "https://github.com/camrun91/archivist-sync/releases/download/beta-latest/module.json", + "download": "https://github.com/camrun91/archivist-sync/releases/download/beta-latest/module.zip", "license": "MIT", "readme": "https://github.com/camrun91/archivist-sync/blob/main/README.md", "bugs": "https://github.com/camrun91/archivist-sync/issues", diff --git a/package.json b/package.json index 9b3326d..2207580 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "archivist-sync", - "version": "1.3.13", + "version": "2.0.0", "description": "A simple Foundry VTT module for fetching world data from an API endpoint using an API key.", "type": "module", "scripts": { diff --git a/scripts/archivist-sync.js b/scripts/archivist-sync.js index e801cae..4d535b3 100644 --- a/scripts/archivist-sync.js +++ b/scripts/archivist-sync.js @@ -13,7 +13,6 @@ import { archivistApi } from './services/archivist-api.js'; import { Utils } from './modules/utils.js'; import { linkIndexer } from './modules/links/indexer.js'; import { AskChatWindow } from './dialogs/ask-chat-window.js'; -import AskChatSidebarTab from './sidebar/ask-chat-sidebar-tab.js'; import { ensureChatSlot } from './sidebar/ask-chat-tab.js'; import { SyncDialog } from './dialogs/sync-dialog.js'; import { WorldSetupDialog } from './dialogs/world-setup-dialog.js'; @@ -60,6 +59,8 @@ Hooks.once('init', async function () { LocationPageSheetV2, FactionPageSheetV2, RecapPageSheetV2, + JournalPageSheetV2, + QuestPageSheetV2, } = await import('./modules/sheets/page-sheet-v2.js'); const DSC = @@ -99,62 +100,29 @@ Hooks.once('init', async function () { types: ['base'], makeDefault: false, }); + DSC.registerSheet(JournalEntry, 'archivist-sync', JournalPageSheetV2, { + label: 'Archivist: Journal', + types: ['base'], + makeDefault: false, + }); + DSC.registerSheet(JournalEntry, 'archivist-sync', QuestPageSheetV2, { + label: 'Archivist: Quest', + types: ['base'], + makeDefault: false, + }); } catch (e) { console.error( '[Archivist Sync] Failed to register V2 DocumentSheet sheets', e ); } - // Register the Archivist Chat tab with the core Sidebar early so it renders its - // nav button and panel using the Application V2 TabGroup. Availability will be - // handled at runtime by showing/hiding the button and panel. - try { - const Sidebar = foundry.applications.sidebar?.Sidebar; - if (Sidebar) { - const label = - game.i18n?.localize?.('ARCHIVIST_SYNC.Menu.AskChat.Label') || - 'Archivist Chat'; - Sidebar.TABS = Sidebar.TABS || {}; - Sidebar.TABS['archivist-chat'] = { - id: 'archivist-chat', - title: label, - icon: 'fa-solid fa-sparkles', - group: 'primary', - tooltip: label, - tab: AskChatSidebarTab, - app: AskChatSidebarTab, - }; - } - } catch (_) { - /* no-op */ - } + // Sidebar.TABS registration removed — v14 does not support dynamic tab + // addition via this API. The chat button is injected at runtime by + // updateArchivistChatAvailability() in the ready hook. }); Hooks.once('setup', function () { - try { - console.log('[Archivist Sync] setup'); - } catch (_) {} - // Ensure registration also occurs here in case Sidebar wasn't ready during init - try { - const Sidebar = foundry.applications.sidebar?.Sidebar; - if (Sidebar) { - const label = - game.i18n?.localize?.('ARCHIVIST_SYNC.Menu.AskChat.Label') || - 'Archivist Chat'; - Sidebar.TABS = Sidebar.TABS || {}; - Sidebar.TABS['archivist-chat'] = Sidebar.TABS['archivist-chat'] || { - id: 'archivist-chat', - title: label, - icon: 'fa-solid fa-sparkles', - group: 'primary', - tooltip: label, - tab: AskChatSidebarTab, - app: AskChatSidebarTab, - }; - } - } catch (_) { - /* no-op */ - } + console.debug('[Archivist Sync] setup'); }); // Register Scene Controls immediately (outside ready) so it's available on reloads @@ -266,9 +234,7 @@ Hooks.once('ready', async function () { try { const sidebar = document.getElementById('sidebar'); const tabsNav = sidebar?.querySelector?.('#sidebar-tabs, nav.tabs'); - if (!tabsNav) { - return; - } + if (tabsNav) { const onClick = (ev) => { // CRITICAL: Ignore clicks on dice roll cards or any elements inside them @@ -335,6 +301,8 @@ Hooks.once('ready', async function () { }, 0); }; tabsNav.addEventListener('click', onClick); + + } // end if (tabsNav found for delegated renderer) } catch (e) { console.error('[Archivist Sync] Failed to install delegated renderer', e); } @@ -343,9 +311,7 @@ Hooks.once('ready', async function () { try { const sidebar = document.getElementById('sidebar'); const tabsNav = sidebar?.querySelector?.('#sidebar-tabs, nav.tabs'); - if (!tabsNav) { - return; - } + if (tabsNav) { const onOtherTabClick = (ev) => { // CRITICAL: Ignore clicks on dice roll cards or any elements inside them @@ -410,6 +376,8 @@ Hooks.once('ready', async function () { }, 0); }; tabsNav.addEventListener('click', onOtherTabClick); + + } // end if (tabsNav found for delegated cleanup) } catch (e) { console.error('[Archivist Sync] Failed to install delegated cleanup', e); } @@ -885,7 +853,12 @@ function updateArchivistChatAvailability() { }); li.appendChild(btn); const menu = tabsNav.querySelector('menu.flexcol') || tabsNav; - menu.appendChild(li); + const beforeNode = menu.lastElementChild; + if (beforeNode) { + menu.insertBefore(li, beforeNode); + } else { + menu.appendChild(li); + } } catch (e) { console.warn( '[Archivist Sync] Failed to inject fallback Sidebar tab button', @@ -1100,6 +1073,17 @@ function installRealtimeSyncListeners() { ...(image ? { image } : {}), campaign_id: worldId, }); + } else if (sheetType === 'quest') { + res = await archivistApi.createQuest(apiKey, { + worldId, + questName: entry.name || 'Quest', + }); + } else if (sheetType === 'journal') { + res = await archivistApi.createJournal(apiKey, { + world_id: worldId, + title: entry.name || 'Journal', + content: description, + }); } if (res.success && res.data?.id) { @@ -1219,6 +1203,7 @@ function installRealtimeSyncListeners() { const flags = parent?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; const sheetType = String(flags?.sheetType || '').toLowerCase(); if (!sheetType || !flags.archivistId) return; + const html = Utils.extractPageHtml(page); const payload = { description: Utils.toMarkdownIfHtml?.(html) || html }; @@ -1252,6 +1237,18 @@ function installRealtimeSyncListeners() { summary: payload.description, }); return; + } else if (sheetType === 'quest') { + res = await archivistApi.updateQuest(apiKey, flags.archivistId, { + questName: parent?.name || page.name, + }); + return; + } else if (sheetType === 'journal') { + res = await archivistApi.updateJournal(apiKey, { + id: flags.archivistId, + title: parent?.name || page.name, + content: Utils.toMarkdownIfHtml?.(html) || html, + }); + return; } if (res && !res?.success && res?.isDescriptionTooLong) { @@ -1294,6 +1291,10 @@ function installRealtimeSyncListeners() { await archivistApi.updateLocation(apiKey, id, { name }); } else if (st === 'faction') { await archivistApi.updateFaction(apiKey, id, { name }); + } else if (st === 'quest') { + await archivistApi.updateQuest(apiKey, id, { questName: name }); + } else if (st === 'journal') { + await archivistApi.updateJournal(apiKey, { id, title: name }); } } catch (e) { console.warn('[RTS] updateJournalEntry (title sync) failed', e); @@ -1374,6 +1375,10 @@ function installRealtimeSyncListeners() { await archivistApi.deleteLocation(apiKey, id); } else if (st === 'faction' && archivistApi.deleteFaction) { await archivistApi.deleteFaction(apiKey, id); + } else if (st === 'quest') { + await archivistApi.deleteQuest(apiKey, id); + } else if (st === 'journal') { + await archivistApi.deleteJournal(apiKey, id); } } catch (e) { console.warn('[RTS] preDeleteJournalEntry failed', e); diff --git a/scripts/dialogs/documentation-window.js b/scripts/dialogs/documentation-window.js index 7ba2555..93a775f 100644 --- a/scripts/dialogs/documentation-window.js +++ b/scripts/dialogs/documentation-window.js @@ -1,6 +1,6 @@ /** - * Documentation window that displays the README.md content - * Uses ApplicationV2 framework for Foundry v13 + * Documentation window that displays the README.md content. + * Uses ApplicationV2 framework. */ export class DocumentationWindow extends foundry.applications.api .ApplicationV2 { diff --git a/scripts/dialogs/sync-dialog.js b/scripts/dialogs/sync-dialog.js index 81fb955..7984d62 100644 --- a/scripts/dialogs/sync-dialog.js +++ b/scripts/dialogs/sync-dialog.js @@ -14,9 +14,10 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi constructor(options = {}) { super(options); this.isLoading = false; + this.syncProgress = null; this.model = { - diffs: [], // { type, id, name, journalId, changes: { name?, description?, image?, links? }, deleted?:boolean, selected:boolean } - imports: [], // { type, id, name, image, description, selected:boolean, createCore:boolean, coreType:'actor'|'item'|'scene'|null } + diffs: [], + imports: [], stats: { diffs: 0, imports: 0 }, }; this._scrollPosition = 0; @@ -48,11 +49,57 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi async _onRender(context, options) { await super._onRender?.(context, options); - // Restore scroll position after render const content = this.element?.querySelector?.('.sync-dialog-content'); if (content && this._scrollPosition !== undefined) { content.scrollTop = this._scrollPosition; } + this._updateSyncButtonState(); + + // Shift+click multi-select for checkboxes + this._lastToggleClick = null; + const rows = this.element?.querySelectorAll?.('tr[data-id]') || []; + for (const row of rows) { + const cb = row.querySelector('input[data-action="toggleRow"]'); + if (!cb) continue; + row.addEventListener('click', (e) => { + if (e.target.closest('[data-action="toggleCore"]')) return; + if (!e.shiftKey || !this._lastToggleClick) { + this._lastToggleClick = row; + return; + } + const allRows = [...this.element.querySelectorAll('tr[data-id]')]; + const startIdx = allRows.indexOf(this._lastToggleClick); + const endIdx = allRows.indexOf(row); + if (startIdx < 0 || endIdx < 0) return; + const [lo, hi] = startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx]; + const targetState = cb.checked; + for (let i = lo; i <= hi; i++) { + const r = allRows[i]; + const kind = r.dataset.kind; + const id = String(r.dataset.id || ''); + let modelRow; + if (kind === 'diff') modelRow = this.model.diffs.find((x) => String(x.id) === id); + else if (kind === 'import') modelRow = this.model.imports.find((x) => String(x.id) === id); + if (modelRow) { + modelRow.selected = targetState; + const rCb = r.querySelector('input[data-action="toggleRow"]'); + if (rCb) rCb.checked = targetState; + r.classList.toggle('selected', targetState); + } + } + this._lastToggleClick = row; + this._updateSyncButtonState(); + }); + } + } + + _updateSyncButtonState() { + const btn = this.element?.querySelector?.('[data-action="sync"]'); + if (!btn) return; + const hasSelected = + this.model.diffs.some((d) => d.selected) || + this.model.imports.some((i) => i.selected); + btn.disabled = !hasSelected; } _captureScrollPosition() { @@ -63,30 +110,30 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi } async _prepareContext() { - // If not initialized, show loading and trigger background fetch if (!this._initialized) { - // Trigger data load in background (don't await here) this._loadModel().then(() => { this._initialized = true; this.render({ force: true }); }); - // Return loading state immediately return { isLoading: true, diffs: [], imports: [], stats: { diffs: 0, imports: 0 }, - isGM: game.user?.isGM, + syncProgress: null, }; } - const ctx = { + const hasSelected = + this.model.diffs.some((d) => d.selected) || + this.model.imports.some((i) => i.selected); + return { isLoading: this.isLoading, diffs: this.model.diffs, imports: this.model.imports, stats: this.model.stats, - isGM: game.user?.isGM, + syncProgress: this.syncProgress || null, + hasSelected, }; - return ctx; } async _onSelectAll(event) { @@ -117,15 +164,18 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi if (!row) return; const kind = row?.dataset?.kind; const id = String(row?.dataset?.id || ''); + let modelRow; if (kind === 'diff') { - const diffRow = this.model.diffs.find((x) => String(x.id) === id); - if (diffRow) diffRow.selected = !diffRow.selected; + modelRow = this.model.diffs.find((x) => String(x.id) === id); } else if (kind === 'import') { - const importRow = this.model.imports.find((x) => String(x.id) === id); - if (importRow) importRow.selected = !importRow.selected; + modelRow = this.model.imports.find((x) => String(x.id) === id); } - this._captureScrollPosition(); - await this.render(); + if (!modelRow) return; + modelRow.selected = !modelRow.selected; + const cb = row.querySelector('input[type="checkbox"]'); + if (cb) cb.checked = modelRow.selected; + row.classList.toggle('selected', modelRow.selected); + this._updateSyncButtonState(); } async _onToggleCreateCore(event) { @@ -135,19 +185,14 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi const row = this.model.imports.find((x) => String(x.id) === id); if (!row || !row.coreType) return; row.createCore = !row.createCore; - this._captureScrollPosition(); - await this.render(); } async _onRefresh(event) { event?.preventDefault?.(); - // Set loading state to show the spinner this.isLoading = true; - await this.render(); try { await this._loadModel(true); } finally { - // Loading state will be set to false by _loadModel await this.render(); } } @@ -193,28 +238,60 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi await this.render(); return; } - console.log('[SyncDialog] ✓ Real-time sync successfully suppressed'); + console.debug('[SyncDialog] Real-time sync successfully suppressed'); try { - // Apply diffs + // Confirm deletions before proceeding + const deleteDiffs = selectedDiffs.filter((d) => d.deleted); + if (deleteDiffs.length > 0) { + const names = deleteDiffs.map((d) => d.name).join(', '); + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { title: 'Confirm Deletion' }, + content: `

${deleteDiffs.length} journal${deleteDiffs.length > 1 ? 's' : ''} will be permanently deleted:

${names}

This cannot be undone. Continue?

`, + yes: { label: 'Delete', icon: 'fas fa-trash' }, + no: { label: 'Cancel' }, + }); + if (!confirmed) { + this.isLoading = false; + await this.render(); + return; + } + } + + const total = selectedDiffs.length + selectedImports.length; + let processed = 0; + let failCount = 0; + this.syncProgress = { total, processed, current: '' }; + await this.render(); + for (const d of selectedDiffs) { + this.syncProgress.current = `${d.type}: ${d.name}`; + this.syncProgress.processed = processed; + await this.render(); try { await this._applyDiff(d); } catch (e) { - console.warn('[SyncDialog] applyDiff failed', e); + failCount++; + console.warn('[SyncDialog] applyDiff failed', d.name, e); } + processed++; } - // Apply imports for (const i of selectedImports) { + this.syncProgress.current = `${i.type}: ${i.name}`; + this.syncProgress.processed = processed; + await this.render(); try { await this._applyImport(i, campaignId, apiKey); } catch (e) { - console.warn('[SyncDialog] applyImport failed', e); + failCount++; + console.warn('[SyncDialog] applyImport failed', i.name, e); } + processed++; } - // Reorder all recaps after any sessionDate changes (from diffs or imports) + this.syncProgress = null; + const hasRecapDiffs = selectedDiffs.some( (d) => d.type === 'Session' && d.changes?.sessionDate ); @@ -237,7 +314,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi j.getFlag(CONFIG.MODULE_ID, 'sessionDate') || '' ).trim(); const t = iso ? new Date(iso).getTime() : NaN; - return Number.isFinite(t) ? t : Number.POSITIVE_INFINITY; // undated go to end + return Number.isFinite(t) ? t : Number.POSITIVE_INFINITY; })(), })); withDates.sort((a, b) => a.dateMs - b.dateMs); @@ -258,25 +335,27 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi } } - ui.notifications?.info?.('Archivist sync applied.'); - // Force-refresh core directories and any open Archivist windows so UI reflects new docs + const successCount = total - failCount; + if (failCount === 0) { + ui.notifications?.info?.(`Sync complete: ${successCount} item${successCount !== 1 ? 's' : ''} applied.`); + } else { + ui.notifications?.warn?.( + `Sync finished: ${successCount} succeeded, ${failCount} failed. See console for details.` + ); + } await this._refreshUIAfterSync?.(); - // Close the dialog after successful sync - this.close(); + await this._loadModel(true); } catch (error) { console.error('[SyncDialog] Sync failed:', error); ui.notifications?.error?.('Sync failed. See console for details.'); - // On error, reload model and stay open so user can retry await this._loadModel(true); - await this.render(); } finally { - // Resume real-time sync after apply + this.syncProgress = null; try { settingsManager.resumeRealtimeSync?.(); - console.log( - '[SyncDialog] ✓ Real-time sync resumed after sync operation' - ); + console.debug('[SyncDialog] Real-time sync resumed after sync operation'); } catch (_) {} + await this.render(); } } @@ -344,13 +423,15 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi return; } - const [chars, items, locs, facs, sessions, links] = await Promise.all([ + const [chars, items, locs, facs, sessions, links, journals, quests] = await Promise.all([ archivistApi.listCharacters(apiKey, campaignId), archivistApi.listItems(apiKey, campaignId), archivistApi.listLocations(apiKey, campaignId), archivistApi.listFactions(apiKey, campaignId), archivistApi.listSessions(apiKey, campaignId), archivistApi.listLinks(apiKey, campaignId), + archivistApi.listJournals(apiKey, campaignId), + archivistApi.listQuests(apiKey, campaignId), ]); const A = { characters: (chars?.success ? chars.data : []) || [], @@ -359,14 +440,18 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi factions: (facs?.success ? facs.data : []) || [], sessions: (sessions?.success ? sessions.data : []) || [], links: (links?.success ? links.data : []) || [], + journals: (journals?.success ? journals.data : []) || [], + quests: (quests?.success ? quests.data : []) || [], }; - console.log('[SyncDialog] Fetched Archivist data:', { + console.debug('[SyncDialog] Fetched Archivist data:', { characters: A.characters.length, items: A.items.length, locations: A.locations.length, factions: A.factions.length, sessions: A.sessions.length, links: A.links.length, + journals: A.journals.length, + quests: A.quests.length, }); const byId = { Character: new Map(A.characters.map((c) => [String(c.id), c])), @@ -374,6 +459,8 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi Location: new Map(A.locations.map((l) => [String(l.id), l])), Faction: new Map(A.factions.map((f) => [String(f.id), f])), Session: new Map(A.sessions.map((s) => [String(s.id), s])), + Journal: new Map(A.journals.map((j) => [String(j.id), j])), + Quest: new Map(A.quests.map((q) => [String(q.id), q])), }; // Compute outgoing links (from_id => [{ id: to_id, type: to_type }]) @@ -405,7 +492,11 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi ? 'Faction' : st === 'recap' || st === 'session' ? 'Session' - : null; + : st === 'journal' + ? 'Journal' + : st === 'quest' + ? 'Quest' + : null; if (!type) continue; const arch = byId[type].get(archId) || null; if (!arch) { @@ -425,9 +516,11 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi const archName = type === 'Character' ? arch.character_name || arch.name - : type === 'Session' + : type === 'Session' || type === 'Journal' ? arch.title || arch.name || '' - : arch.name || arch.title || ''; + : type === 'Quest' + ? arch.questName || arch.quest_name || arch.name || '' + : arch.name || arch.title || ''; if (String(j.name || '').trim() !== String(archName || '').trim()) { changes.name = { from: j.name, to: archName }; } @@ -437,7 +530,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi (j?.pages?.contents || []).find((p) => p.type === 'text') || null; const stored = Utils.extractPageHtml(textPage) || ''; const foundryPlain = Utils.toMarkdownIfHtml(stored); - const archMd = String((arch.description ?? arch.summary) || ''); + const archMd = String((arch.description ?? arch.summary ?? arch.content) || ''); const archHtml = Utils.markdownToStoredHtml(archMd); const archivistPlain = Utils.toMarkdownIfHtml(archHtml); @@ -545,7 +638,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi /* ignore */ } } - console.log('[SyncDialog] Linked Archivist IDs found in Foundry:', { + console.debug('[SyncDialog] Linked Archivist IDs found in Foundry:', { count: linkedIds.size, ids: Array.from(linkedIds).slice(0, 10), sample: Array.from(foundryJournalMap.entries()).slice(0, 5), @@ -597,8 +690,14 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi for (const l of A.locations) pushImport('Location', l); for (const f of A.factions) pushImport('Faction', f); for (const s of A.sessions) pushImport('Session', s); + for (const j of A.journals) pushImport('Journal', { ...j, name: j.title || 'Untitled' }); + for (const q of A.quests) + pushImport('Quest', { + ...q, + name: q.questName || q.quest_name || 'Quest', + }); - console.log('[SyncDialog] Import candidates:', { + console.debug('[SyncDialog] Import candidates:', { imports: imports.length, skipped: skipped.length, importSample: imports @@ -756,7 +855,11 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi ? 'faction' : row.type === 'Session' ? 'recap' - : null; + : row.type === 'Journal' + ? 'journal' + : row.type === 'Quest' + ? 'quest' + : null; if (!sheetType) return; // Convert markdown from Archivist to HTML for Foundry storage (sessions use summary) const markdownContent = String(row.description || row.summary || ''); @@ -779,6 +882,10 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi folderId = destinations.location; } else if (sheetType === 'faction' && destinations.faction) { folderId = destinations.faction; + } else if (sheetType === 'journal' && destinations.journal) { + folderId = destinations.journal; + } else if (sheetType === 'quest' && destinations.quest) { + folderId = destinations.quest; } else if (sheetType === 'recap') { // For sessions/recaps, use Recaps folder and preserve session_date ordering folderId = await Utils.ensureJournalFolder('Recaps'); @@ -789,7 +896,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi } } - console.log('[Sync Dialog] Importing to folder:', { + console.debug('[SyncDialog] Importing to folder:', { sheetType, folderId: folderId || 'root', name: row.name, @@ -809,6 +916,64 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi sort, }); if (!journal) return; + // For journals, set GM-only default permissions + if (sheetType === 'journal') { + try { + await journal.update( + { ownership: { default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE } }, + { render: false } + ); + } catch (_) {} + } + // For quests, fetch full quest data and store in flags + if (sheetType === 'quest' && apiKey && row.id) { + try { + let fullQuest = row; + try { + const resp = await archivistApi.getQuest(apiKey, row.id); + if (resp.success && resp.data) fullQuest = resp.data; + } catch (_) {} + const flags = journal.getFlag(CONFIG.MODULE_ID, 'archivist') || {}; + flags.questData = { + questName: fullQuest.questName || fullQuest.quest_name || '', + questGiver: fullQuest.questGiver || fullQuest.quest_giver || '', + questCategory: + fullQuest.questCategory || fullQuest.quest_category || 'n/a', + status: fullQuest.status || 'planned', + successDefinition: + fullQuest.successDefinition || fullQuest.success_definition || '', + failureConditions: + fullQuest.failureConditions || fullQuest.failure_conditions || '', + nextAction: fullQuest.nextAction || fullQuest.next_action || '', + resolution: fullQuest.resolution || '', + objectives: Array.isArray(fullQuest.objectives) ? fullQuest.objectives : [], + progressLog: Array.isArray(fullQuest.progressLog) + ? fullQuest.progressLog + : Array.isArray(fullQuest.progress_log) + ? fullQuest.progress_log + : Array.isArray(fullQuest.progressLogEntries) + ? fullQuest.progressLogEntries.map((e) => + typeof e === 'string' ? e : e.text || '' + ) + : Array.isArray(fullQuest.progress_log_entries) + ? fullQuest.progress_log_entries.map((e) => + typeof e === 'string' ? e : e.text || '' + ) + : [], + relatedCharacters: + fullQuest.relatedCharacters || fullQuest.related_characters || [], + relatedFactions: + fullQuest.relatedFactions || fullQuest.related_factions || [], + relatedLocations: + fullQuest.relatedLocations || fullQuest.related_locations || [], + relatedItems: + fullQuest.relatedItems || fullQuest.related_items || [], + firstSession: fullQuest.firstSession || fullQuest.first_session || null, + lastSession: fullQuest.lastSession || fullQuest.last_session || null, + }; + await journal.setFlag(CONFIG.MODULE_ID, 'archivist', flags); + } catch (_) {} + } // For locations, set parent relation from Archivist's parent_id if (sheetType === 'location' && row.parent_id) { try { @@ -1014,5 +1179,3 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi } catch (_) {} } } - -export const ArchivistSyncDialog = SyncDialog; diff --git a/scripts/dialogs/world-setup-dialog.js b/scripts/dialogs/world-setup-dialog.js index 6ef96a3..4878497 100644 --- a/scripts/dialogs/world-setup-dialog.js +++ b/scripts/dialogs/world-setup-dialog.js @@ -24,36 +24,17 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica selectedWorldId: '', selectedWorldName: '', setupComplete: false, - // Mapping removed - systemPreset: '', destinations: { pc: '', npc: '', item: '', location: '', faction: '' }, - // Step 4 selections - selections: { pcs: [], npcs: [], items: [], locations: [], factions: [] }, - // Step 5: Create Foundry core objects choices createFoundry: { actors: [], items: [], scenes: [] }, }; - // Mapping discovery caches removed - this.folderOptions = { - pc: [], - npc: [], - item: [], - location: [], - faction: [], - }; - // Mapping options removed (keep empty object to avoid legacy access) - this.mappingOptions = { actor: [], item: [] }; this.archivistCandidates = { characters: [], items: [], locations: [], factions: [], - }; - this.eligibleDocs = { - pcs: [], - npcs: [], - items: [], - locations: [], - factions: [], + journals: [], + journalFolders: [], + quests: [], }; this.syncPlan = { createInFoundry: [], createInArchivist: [], link: [] }; this.syncStatus = { @@ -193,25 +174,11 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica nextStep: WorldSetupDialog.prototype._onNextStep, prevStep: WorldSetupDialog.prototype._onPrevStep, validateApiKey: WorldSetupDialog.prototype._onValidateApiKey, - syncWorlds: WorldSetupDialog.prototype._onSyncWorlds, - selectWorld: WorldSetupDialog.prototype._onSelectWorld, openDocumentation: WorldSetupDialog.prototype._onOpenDocumentation, - // Step 3 actions loadCampaigns: WorldSetupDialog.prototype._onLoadCampaigns, createCampaign: WorldSetupDialog.prototype._onCreateCampaign, - campaignSelectChange: WorldSetupDialog.prototype._onCampaignSelectChange, - // Step 5 actions prepareSelections: WorldSetupDialog.prototype._onPrepareSelections, - toggleSelection: WorldSetupDialog.prototype._onToggleSelection, - changeMatch: WorldSetupDialog.prototype._onChangeMatch, - confirmSelections: WorldSetupDialog.prototype._onConfirmSelections, - selectAll: WorldSetupDialog.prototype._onSelectAll, - selectNone: WorldSetupDialog.prototype._onSelectNone, - // Step 6 actions beginSync: WorldSetupDialog.prototype._onBeginSync, - // Configuration file actions - downloadSampleConfig: WorldSetupDialog.prototype._onDownloadSampleConfig, - // Finalization completeSetup: WorldSetupDialog.prototype._onCompleteSetup, cancel: WorldSetupDialog.prototype._onCancel, }, @@ -227,366 +194,11 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica }, }; - /** - * Discover string properties in object model recursively - * @param {Object} obj - Object to traverse - * @param {string} basePath - Current path prefix - * @param {Array} results - Array to collect results - * @param {number} maxDepth - Maximum recursion depth - * @param {number} currentDepth - Current recursion depth - * @private - */ - _discoverStringProperties( - obj, - basePath = '', - results = [], - maxDepth = 6, - currentDepth = 0 - ) { - if (!obj || currentDepth >= maxDepth || typeof obj !== 'object') - return results; - - for (const [key, value] of Object.entries(obj)) { - const path = basePath ? `${basePath}.${key}` : key; - - if (typeof value === 'string' && value.trim().length > 0) { - // Only include non-empty string properties - results.push({ - path, - type: 'string', - sample: value.length > 50 ? value.substring(0, 47) + '...' : value, - }); - } else if (value && typeof value === 'object' && !Array.isArray(value)) { - // Recurse into nested objects - this._discoverStringProperties( - value, - path, - results, - maxDepth, - currentDepth + 1 - ); - } - } - - return results; - } - - /** - * Prepare mapping options by discovering string properties in Actor and Item models - * Uses temporary document creation to ensure complete property discovery - * @private - */ - async _prepareMappingOptions() { - try { - console.log('Archivist Sync | Discovering document properties...'); - - // Discover Actor properties by creating temporary actors - this.mappingOptions.actor = await this._discoverActorProperties(); - - // Discover Item properties by creating temporary items - this.mappingOptions.item = await this._discoverItemProperties(); - - // Sort options with priority for commonly used fields - const sortWithPriority = (options, priorityFields = []) => { - return options.sort((a, b) => { - const aPriority = priorityFields.indexOf(a.path); - const bPriority = priorityFields.indexOf(b.path); - - // If both are priority fields, sort by priority order - if (aPriority !== -1 && bPriority !== -1) { - return aPriority - bPriority; - } - - // Priority fields come first - if (aPriority !== -1) return -1; - if (bPriority !== -1) return 1; - - // Then sort by path depth (simpler first), then alphabetically - const depthDiff = a.path.split('.').length - b.path.split('.').length; - return depthDiff !== 0 ? depthDiff : a.path.localeCompare(b.path); - }); - }; - - const actorPriorityFields = [ - 'name', - 'img', - 'system.details.biography.value', - 'system.details.biography.public', - 'system.details.trait', - 'system.details.ideal', - 'system.details.bond', - 'system.details.flaw', - 'system.details.appearance', - 'system.description.value', - ]; - - const itemPriorityFields = [ - 'name', - 'img', - 'system.description.value', - 'system.description.short', - 'system.description.chat', - ]; - - this.mappingOptions.actor = sortWithPriority( - this.mappingOptions.actor, - actorPriorityFields - ); - this.mappingOptions.item = sortWithPriority( - this.mappingOptions.item, - itemPriorityFields - ); - - console.log( - `Archivist Sync | Property discovery complete: ${this.mappingOptions.actor.length} actor properties, ${this.mappingOptions.item.length} item properties` - ); - } catch (error) { - console.warn('Error preparing mapping options:', error); - // Ensure we have fallback options - this.mappingOptions.actor = [ - { path: 'name', type: 'string', sample: 'Actor name' }, - { path: 'img', type: 'string', sample: 'Actor image path' }, - ]; - this.mappingOptions.item = [ - { path: 'name', type: 'string', sample: 'Item name' }, - { path: 'img', type: 'string', sample: 'Item image path' }, - ]; - } - } - - /** - * Discover Actor properties by creating temporary actors of different types - * Uses the new Document() constructor (v13+) instead of deprecated { temporary: true } - * @private - */ - async _discoverActorProperties() { - const allProperties = new Map(); // Use Map to avoid duplicates by path - const actorTypes = ['character', 'npc']; // Common D&D 5e actor types - - // First, try to use existing actors - const existingActors = game.actors?.contents || []; - for (const actor of existingActors.slice(0, 2)) { - // Sample max 2 existing - try { - const actorData = actor.toObject(); - const properties = this._discoverStringProperties(actorData); - for (const prop of properties) { - allProperties.set(prop.path, prop); - } - console.log( - `Archivist Sync | Discovered ${properties.length} properties from existing ${actor.type} actor: ${actor.name}` - ); - } catch (error) { - console.warn( - `Error discovering properties from existing actor ${actor.name}:`, - error - ); - } - } - - // Try to discover properties from system data model templates instead of creating actors - for (const actorType of actorTypes) { - const hasExistingOfType = existingActors.some( - (a) => a.type === actorType - ); - - if (!hasExistingOfType) { - try { - console.log( - `Archivist Sync | Attempting system template discovery for ${actorType} actor` - ); - - // Try to access system data model template if available - let templateProperties = []; - - // Check if we can access the system's data model templates - if (game.system?.model?.Actor?.[actorType]) { - console.log( - `Archivist Sync | Found system template for ${actorType}` - ); - const template = game.system.model.Actor[actorType]; - templateProperties = this._discoverStringProperties(template); - - for (const prop of templateProperties) { - // Prefix with 'system.' since these are system model properties - const systemProp = { - ...prop, - path: prop.path.startsWith('system.') - ? prop.path - : `system.${prop.path}`, - sample: `${prop.sample} (from template)`, - }; - allProperties.set(systemProp.path, systemProp); - } - - console.log( - `Archivist Sync | Discovered ${templateProperties.length} properties from ${actorType} system template` - ); - } else { - // Fallback: use predefined property sets for this actor type - console.log( - `Archivist Sync | No system template found for ${actorType}, using fallback properties` - ); - const fallbackProperties = - this._getFallbackActorProperties(actorType); - for (const prop of fallbackProperties) { - allProperties.set(prop.path, prop); - } - console.log( - `Archivist Sync | Used ${fallbackProperties.length} fallback properties for ${actorType} actor` - ); - } - } catch (error) { - console.warn( - `Error discovering properties for ${actorType} actor:`, - error - ); - - // Final fallback: use predefined property sets - try { - const fallbackProperties = - this._getFallbackActorProperties(actorType); - for (const prop of fallbackProperties) { - allProperties.set(prop.path, prop); - } - console.log( - `Archivist Sync | Used fallback properties for ${actorType} actor after error` - ); - } catch (fallbackError) { - console.warn( - `Fallback also failed for ${actorType}:`, - fallbackError - ); - } - } - } - } - - // Add known D&D 5e paths that might not have been discovered - const knownPaths = [ - { path: 'name', sample: 'Actor name' }, - { path: 'img', sample: 'Actor image path' }, - { path: 'system.details.biography.value', sample: 'Full biography text' }, - { - path: 'system.details.biography.public', - sample: 'Public biography text', - }, - { path: 'system.description.value', sample: 'Description text' }, - { path: 'system.details.trait', sample: 'Personality traits' }, - { path: 'system.details.ideal', sample: 'Ideals' }, - { path: 'system.details.bond', sample: 'Bonds' }, - { path: 'system.details.flaw', sample: 'Flaws' }, - { path: 'system.details.appearance', sample: 'Physical appearance' }, - { path: 'system.details.background', sample: 'Character background' }, - { path: 'system.details.race', sample: 'Character race' }, - { path: 'system.details.class', sample: 'Character class' }, - { path: 'system.biography.value', sample: 'Biography (alt path)' }, - { - path: 'system.biography.public', - sample: 'Public biography (alt path)', - }, - ]; - - for (const pathInfo of knownPaths) { - if (!allProperties.has(pathInfo.path)) { - allProperties.set(pathInfo.path, { - path: pathInfo.path, - type: 'string', - sample: pathInfo.sample, - }); - } - } - - return Array.from(allProperties.values()); - } - - /** - * Get fallback properties for actor types when temporary document creation fails - * @private - */ - _getFallbackActorProperties(actorType) { - const baseProperties = [ - { path: 'name', type: 'string', sample: 'Actor name' }, - { path: 'img', type: 'string', sample: 'Actor image path' }, - ]; - - if (actorType === 'character') { - return [ - ...baseProperties, - { - path: 'system.details.biography.value', - type: 'string', - sample: 'Full biography text', - }, - { - path: 'system.details.trait', - type: 'string', - sample: 'Personality traits', - }, - { path: 'system.details.ideal', type: 'string', sample: 'Ideals' }, - { path: 'system.details.bond', type: 'string', sample: 'Bonds' }, - { path: 'system.details.flaw', type: 'string', sample: 'Flaws' }, - { - path: 'system.details.appearance', - type: 'string', - sample: 'Physical appearance', - }, - { - path: 'system.details.background', - type: 'string', - sample: 'Character background', - }, - ]; - } else if (actorType === 'npc') { - return [ - ...baseProperties, - { - path: 'system.details.biography.value', - type: 'string', - sample: 'Full biography text', - }, - { - path: 'system.details.biography.public', - type: 'string', - sample: 'Public biography text', - }, - { - path: 'system.description.value', - type: 'string', - sample: 'Description text', - }, - ]; - } - - return baseProperties; - } - /** * Prepare context data for template rendering * @returns {Object} Template data */ async _prepareContext() { - // Prepare folder options when needed - try { - const folders = game.folders?.contents || []; - const pick = (type) => - folders - .filter((f) => f.type === type) - .map((f) => ({ id: f.id, name: f.name, depth: f.depth || 0 })) - .sort((a, b) => a.depth - b.depth || a.name.localeCompare(b.name)); - this.folderOptions = { - pc: pick('Actor'), - npc: pick('Actor'), - item: pick('Item'), - location: pick('JournalEntry'), - faction: pick('JournalEntry'), - }; - } catch (_) { - /* no-op */ - } - - // Mapping step removed; skip legacy mapping preparation entirely - const contextData = { currentStep: this.currentStep, totalSteps: this.totalSteps, @@ -596,8 +208,6 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica foundryWorldDescription: game.world.description || '', worlds: this.worlds, setupData: this.setupData, - folderOptions: this.folderOptions, - mappingOptions: this.mappingOptions, archivistCandidates: this.archivistCandidates, syncPlan: this.syncPlan, syncStatus: this.syncStatus, @@ -607,7 +217,14 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica (this.syncStatus.processed / this.syncStatus.total) * 100 ) : 0, - steps: Array.from({ length: this.totalSteps }, (_, i) => i + 1), + steps: [ + { num: 1, label: 'Welcome' }, + { num: 2, label: 'API Key' }, + { num: 3, label: 'Campaign' }, + { num: 4, label: 'Reconcile' }, + { num: 5, label: 'Create' }, + { num: 6, label: 'Sync' }, + ], // Step-specific data isStep1: this.currentStep === 1, isStep2: this.currentStep === 2, @@ -689,6 +306,8 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica // Import-only buckets count.imp.factions = this.archivistCandidates?.factions?.length || 0; count.imp.recaps = this.archivistCandidates?.recaps?.length || 0; + count.imp.journals = this.archivistCandidates?.journals?.length || 0; + count.imp.quests = this.archivistCandidates?.quests?.length || 0; imp = count.imp; exp = count.exp; @@ -795,7 +414,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } catch (_) {} if (this.currentStep === 4) { - console.log('[World Setup] _prepareContext for Step 4:', { + console.debug('[World Setup] _prepareContext for Step 4:', { hasReconcileData: !!contextData.setupData?.reconcile, hasAnyCandidates: contextData.setupData?.hasAnyCandidates, isLoading: contextData.isLoading, @@ -853,14 +472,14 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica */ async _onNextStep(event) { event.preventDefault(); - console.log( + console.debug( '[World Setup] _onNextStep called, currentStep before increment:', this.currentStep ); // Special handling: if leaving step 4, must build sync plan first if (this.currentStep === 4 && this._canProceedFromCurrentStep()) { - console.log( + console.debug( '[World Setup] _onNextStep from step 4: building sync plan via _onConfirmSelections' ); await this._onConfirmSelections(event); @@ -873,13 +492,13 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica this._canProceedFromCurrentStep() ) { this.currentStep++; - console.log( + console.debug( '[World Setup] _onNextStep, currentStep after increment:', this.currentStep ); // Auto-prepare reconciliation data when entering Step 4 if (this.currentStep === 4) { - console.log( + console.debug( '[World Setup] _onNextStep triggering _onPrepareSelections for Step 4' ); await this._onPrepareSelections(event); @@ -887,7 +506,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } // Prepare create choices when entering Step 5 if (this.currentStep === 5) { - console.log( + console.debug( '[World Setup] _onNextStep preparing create choices for Step 5' ); await this._prepareCreateFoundryChoices(); @@ -896,7 +515,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } await this.render(); } else { - console.log('[World Setup] _onNextStep cannot proceed:', { + console.debug('[World Setup] _onNextStep cannot proceed:', { currentStep: this.currentStep, totalSteps: this.totalSteps, canProceed: this._canProceedFromCurrentStep(), @@ -999,16 +618,6 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } } - /** - * Handle sync worlds button click - * @param {Event} event - Click event - * @private - */ - async _onSyncWorlds(event) { - // Delegate to load campaigns (legacy compatibility) - return await this._onLoadCampaigns(event); - } - /** * Step 3: Load user's campaigns using validated API key */ @@ -1016,19 +625,13 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica event?.preventDefault?.(); try { this.isLoading = true; - try { - await this.render(); - } catch (_) { - /* ignore after close */ - } + await this.render(); const apiKey = this.setupData.apiKey || settingsManager.getApiKey(); if (!apiKey) throw new Error('Missing API key'); const resp = await archivistApi.fetchCampaignsList(apiKey); if (resp.success) { this.worlds = resp.data || []; - // Move to step 3 if not already there if (this.currentStep < 3) this.currentStep = 3; - await this.render(); } else { ui.notifications.error(resp.message || 'Failed to load campaigns'); } @@ -1102,14 +705,14 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica async _onPrepareSelections(event) { event?.preventDefault?.(); try { - console.log('[World Setup] Starting _onPrepareSelections'); + console.debug('[World Setup] Starting _onPrepareSelections'); this.isLoading = true; await this.render(); const apiKey = this.setupData.apiKey || settingsManager.getApiKey(); // Always prefer the explicit selection from Step 3; fallback to saved setting only if necessary const campaignId = this.setupData.selectedWorldId || settingsManager.getSelectedWorldId(); - console.log('[World Setup] Using campaignId:', campaignId); + console.debug('[World Setup] Using campaignId:', campaignId); if (!campaignId) { ui.notifications.warn( 'Please select a campaign in Step 3 before continuing.' @@ -1123,27 +726,33 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica const foundryActors = getAll(game.actors); const foundryItems = getAll(game.items); const foundryScenes = getAll(game.scenes); - console.log('[World Setup] Foundry docs:', { + console.debug('[World Setup] Foundry docs:', { actors: foundryActors.length, items: foundryItems.length, scenes: foundryScenes.length, }); // Archivist side - console.log('[World Setup] Fetching Archivist data...'); - const [chars, its, locs, facs, sessions] = await Promise.all([ + console.debug('[World Setup] Fetching Archivist data...'); + const [chars, its, locs, facs, sessions, journals, journalFolders, quests] = await Promise.all([ archivistApi.listCharacters(apiKey, campaignId), archivistApi.listItems(apiKey, campaignId), archivistApi.listLocations(apiKey, campaignId), archivistApi.listFactions(apiKey, campaignId), archivistApi.listSessions(apiKey, campaignId), + archivistApi.listJournals(apiKey, campaignId), + archivistApi.listJournalFolders(apiKey, campaignId), + archivistApi.listQuests(apiKey, campaignId), ]); - console.log('[World Setup] Archivist API responses:', { + console.debug('[World Setup] Archivist API responses:', { chars, its, locs, facs, sessions, + journals, + journalFolders, + quests, }); this.archivistCandidates.characters = chars.success ? chars.data || [] @@ -1154,16 +763,28 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica this.archivistCandidates.recaps = sessions.success ? sessions.data || [] : []; - console.log('[World Setup] Archivist docs:', { + this.archivistCandidates.journals = journals.success + ? journals.data || [] + : []; + this.archivistCandidates.journalFolders = journalFolders.success + ? journalFolders.data || [] + : []; + this.archivistCandidates.quests = quests.success + ? quests.data || [] + : []; + console.debug('[World Setup] Archivist docs:', { characters: this.archivistCandidates.characters.length, items: this.archivistCandidates.items.length, locations: this.archivistCandidates.locations.length, factions: this.archivistCandidates.factions.length, recaps: this.archivistCandidates.recaps.length, + journals: this.archivistCandidates.journals.length, + journalFolders: this.archivistCandidates.journalFolders.length, + quests: this.archivistCandidates.quests.length, }); // Build reconciliation model with initial matches and selections - console.log('[World Setup] Building reconciliation model...'); + console.debug('[World Setup] Building reconciliation model...'); const reconcile = this._buildReconciliationModel({ archivist: { characters: this.archivistCandidates.characters, @@ -1177,8 +798,8 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica scenes: foundryScenes, }, }); - console.log('[World Setup] Reconciliation model built:', reconcile); - console.log('[World Setup] Detailed reconciliation counts:', { + console.debug('[World Setup] Reconciliation model built:', reconcile); + console.debug('[World Setup] Detailed reconciliation counts:', { charactersArchivist: reconcile?.characters?.archivist?.length || 0, charactersFoundry: reconcile?.characters?.foundry?.length || 0, itemsArchivist: reconcile?.items?.archivist?.length || 0, @@ -1200,30 +821,24 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica (reconcile?.factions?.archivist?.length || 0) + (reconcile?.factions?.foundry?.length || 0) ); - console.log( + console.debug( '[World Setup] Has candidates:', this.setupData.hasAnyCandidates ); - console.log( + console.debug( '[World Setup] setupData.reconcile assigned:', this.setupData.reconcile ); - console.log( + console.debug( '[World Setup] Sample archivist character (first item):', reconcile?.characters?.archivist?.[0] ); - console.log( + console.debug( '[World Setup] Sample archivist location (first item):', reconcile?.locations?.archivist?.[0] ); - // stay on Selection step after refresh this.currentStep = 4; - try { - await this.render(); - } catch (_) { - /* ignore after close */ - } } catch (e) { console.error('Prepare selections error', e); ui.notifications.error('Failed to prepare selections'); @@ -1237,46 +852,6 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } } - _updateSelection(kind, id, updates) { - const arr = this.setupData.selections[kind] || []; - const idx = arr.findIndex((x) => x.id === id); - if (idx >= 0) Object.assign(arr[idx], updates); - } - - async _onToggleSelection(event) { - const el = event?.target; - const kind = el?.dataset?.kind; - const id = el?.dataset?.id; - if (!kind || !id) return; - this._updateSelection(kind, id, { selected: el.checked }); - } - - async _onChangeMatch(event) { - const el = event?.target; - const kind = el?.dataset?.kind; - const id = el?.dataset?.id; - if (!kind || !id) return; - this._updateSelection(kind, id, { - match: { id: el.value, label: el.options[el.selectedIndex]?.text || '' }, - }); - } - - async _onSelectAll(event) { - const kind = event?.target?.dataset?.kind; - if (!kind) return; - (this.setupData.selections[kind] || []).forEach((s) => (s.selected = true)); - await this.render(); - } - - async _onSelectNone(event) { - const kind = event?.target?.dataset?.kind; - if (!kind) return; - (this.setupData.selections[kind] || []).forEach( - (s) => (s.selected = false) - ); - await this.render(); - } - async _onConfirmSelections(event) { event?.preventDefault?.(); // Build plan from reconciliation model @@ -1455,6 +1030,14 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica plan.importFromArchivist.recaps = this.archivistCandidates.recaps?.length || 0; + // Journals (Archivist-only, import all with folder structure) + plan.importFromArchivist.journals = + this.archivistCandidates.journals?.length || 0; + + // Quests (Archivist-only, import all) + plan.importFromArchivist.quests = + this.archivistCandidates.quests?.length || 0; + this.syncPlan = plan; this.currentStep = 5; await this._prepareCreateFoundryChoices(); @@ -1503,7 +1086,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica }); } catch (_) { // User cancelled or closed the dialog - do not proceed with sync - console.log('[World Setup] Sync cancelled by user'); + console.debug('[World Setup] Sync cancelled by user'); return; } @@ -1542,7 +1125,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica const campaignId = this.setupData.selectedWorldId || settingsManager.getSelectedWorldId(); - console.log('Archivist Sync | Beginning sync with:', { + console.debug('Archivist Sync | Beginning sync with:', { apiKey: apiKey ? `***${apiKey.slice(-4)}` : 'none', campaignId, }); @@ -1567,11 +1150,11 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica await this.render(); return; } - console.log('[World Setup] ✓ Real-time sync successfully suppressed'); + console.debug('[World Setup] ✓ Real-time sync successfully suppressed'); // Ensure Journal folders exist for each sheet type BEFORE importing try { - console.log('[World Setup] Creating organized folders...'); + console.debug('[World Setup] Creating organized folders...'); const ensureFolder = async (name) => { try { return await Utils.ensureJournalFolder(name); @@ -1586,15 +1169,19 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica location: await ensureFolder('Archivist - Locations'), faction: await ensureFolder('Archivist - Factions'), recap: await ensureFolder('Recaps'), + journal: await ensureFolder('Archivist - Journals'), + quest: await ensureFolder('Archivist - Quests'), }; - console.log('[World Setup] Folders created:', { + console.debug('[World Setup] Folders created:', { pc: folders.pc || 'failed', npc: folders.npc || 'failed', item: folders.item || 'failed', location: folders.location || 'failed', faction: folders.faction || 'failed', recap: folders.recap || 'failed', + journal: folders.journal || 'failed', + quest: folders.quest || 'failed', }); this.setupData.destinations = { @@ -1603,14 +1190,18 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica item: folders.item, location: folders.location, faction: folders.faction, + journal: folders.journal, + quest: folders.quest, + recap: folders.recap, }; - console.log('[World Setup] Destinations set:', { + console.debug('[World Setup] Destinations set:', { pc: this.setupData.destinations.pc || 'none', npc: this.setupData.destinations.npc || 'none', item: this.setupData.destinations.item || 'none', location: this.setupData.destinations.location || 'none', faction: this.setupData.destinations.faction || 'none', + recap: this.setupData.destinations.recap || 'none', }); } catch (e) { console.error('[World Setup] Folder creation failed:', e); @@ -1621,7 +1212,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica ...(this.syncPlan.createInArchivist || []), ...(this.syncPlan.link || []), ]; - console.log(`Archivist Sync | Processing ${work.length} sync jobs:`, { + console.debug(`Archivist Sync | Processing ${work.length} sync jobs:`, { createInArchivist: this.syncPlan.createInArchivist?.length || 0, link: this.syncPlan.link?.length || 0, }); @@ -1971,7 +1562,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica type: job.kind, campaign_id: campaignId, }; - console.log(`Archivist Sync | Creating ${job.kind} character:`, { + console.debug(`Archivist Sync | Creating ${job.kind} character:`, { name: payload.character_name, campaignId, }); @@ -1981,7 +1572,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica ...getMappedFields('Item', doc), campaign_id: campaignId, }; - console.log(`Archivist Sync | Creating item:`, { + console.debug(`Archivist Sync | Creating item:`, { name: payload.name, campaignId, }); @@ -2000,7 +1591,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica ...(image ? { image } : {}), campaign_id: campaignId, }; - console.log(`Archivist Sync | Creating location from Scene:`, { + console.debug(`Archivist Sync | Creating location from Scene:`, { name: payload.name, campaignId, }); @@ -2047,7 +1638,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica html = Utils.toMarkdownIfHtml(String(desc || '')); imageUrl = doc?.img || undefined; - console.log( + console.debug( `[World Setup] Creating journal for exported ${job.kind}:`, { name: doc.name, @@ -2088,7 +1679,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica html = Utils.toMarkdownIfHtml(String(desc || '')); imageUrl = doc?.img || undefined; - console.log(`[World Setup] Creating journal for exported Item:`, { + console.debug(`[World Setup] Creating journal for exported Item:`, { name: doc.name, archivistId: newId, targetFolderId: targetFolderId || 'none', @@ -2123,7 +1714,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica html = ''; imageUrl = doc?.thumb || doc?.background?.src || undefined; - console.log( + console.debug( `[World Setup] Creating journal for exported Location:`, { name: doc.name, @@ -2177,7 +1768,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica await settingsManager.setJournalDestinations( this.setupData.destinations ); - console.log( + console.debug( '[World Setup] Journal destinations saved to settings:', this.setupData.destinations ); @@ -2194,7 +1785,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica // Resume realtime sync now that batch operations are complete try { settingsManager.resumeRealtimeSync?.(); - console.log( + console.debug( '[World Setup] ✓ Real-time sync resumed after successful setup' ); } catch (_) {} @@ -2214,16 +1805,12 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica : `Failed to process ${count} entities: ${errorNames}. Their descriptions exceed the maximum length of 10,000 characters. Please shorten the descriptions and try again.`; ui.notifications.error(message, { permanent: true }); } else { - ui.notifications.info('Sync completed'); + ui.notifications.info('Sync completed successfully. Click "Complete Setup" to finish.'); } - - try { - await this.close(); - } catch (_) {} } catch (e) { try { settingsManager.resumeRealtimeSync?.(); - console.log('[World Setup] Real-time sync resumed after error'); + console.debug('[World Setup] Real-time sync resumed after error'); } catch (_) {} console.error('[World Setup] ❌ Begin sync failed', e); ui.notifications.error('Sync failed'); @@ -2241,24 +1828,15 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica // Removed Archivist journal folder setup; journals are created only in user destinations - async _importArchivistMissing(apiKey, campaignId, jf) { + async _importArchivistMissing(apiKey, campaignId) { try { - console.log('Archivist Sync | Importing existing data from Archivist...'); - - // Always import existing Archivist data to Foundry during world setup - // This ensures that existing campaign data is available in the new Foundry world - - // Pull candidates from Archivist - const [chars, its, locs, facs] = await Promise.all([ - archivistApi.listCharacters(apiKey, campaignId), - archivistApi.listItems(apiKey, campaignId), - archivistApi.listLocations(apiKey, campaignId), - archivistApi.listFactions(apiKey, campaignId), - ]); - const allCharacters = chars.success ? chars.data || [] : []; - const allItems = its.success ? its.data || [] : []; - const allLocations = locs.success ? locs.data || [] : []; - const factions = facs.success ? facs.data || [] : []; + const allCharacters = this.archivistCandidates?.characters || []; + const allItems = this.archivistCandidates?.items || []; + const allLocations = this.archivistCandidates?.locations || []; + const factions = this.archivistCandidates?.factions || []; + const archivistJournals = this.archivistCandidates?.journals || []; + const archivistJournalFolders = this.archivistCandidates?.journalFolders || []; + const archivistQuests = this.archivistCandidates?.quests || []; // Build reconciliation lookup maps to avoid duplicating existing Foundry docs when user mapped them const reconcile = this.setupData?.reconcile || {}; @@ -2315,13 +1893,14 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica ); const total = - characters.length + items.length + locations.length + factions.length; - console.log( - `Archivist Sync | Found ${characters.length} characters, ${items.length} items, ${locations.length} locations, ${factions.length} factions in Archivist` + characters.length + items.length + locations.length + factions.length + + archivistJournals.length + archivistQuests.length; + console.debug( + `Archivist Sync | Found ${characters.length} characters, ${items.length} items, ${locations.length} locations, ${factions.length} factions, ${archivistJournals.length} journals, ${archivistQuests.length} quests in Archivist` ); if (!total) { - console.log( + console.debug( 'Archivist Sync | No existing data found in Archivist to import' ); return; @@ -2522,7 +2101,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica }; } - console.log(`Archivist Sync | Creating ${archivistType} actor:`, { + console.debug(`Archivist Sync | Creating ${archivistType} actor:`, { name: actorData.name, type: foundryType, folderId, @@ -2549,7 +2128,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica ? this.setupData.destinations.npc : this.setupData.destinations.pc; - console.log(`[World Setup] Creating journal for ${archivistType}:`, { + console.debug(`[World Setup] Creating journal for ${archivistType}:`, { name, archivistId: c.id, archivistType, @@ -2654,7 +2233,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica }; // Description no longer written into item system during setup - console.log(`Archivist Sync | Creating item:`, { + console.debug(`Archivist Sync | Creating item:`, { name: itemData.name, folderId, hasDescription: !!i.description, @@ -2674,7 +2253,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica const imageUrl = i.image || undefined; const targetFolderId = this.setupData.destinations.item; - console.log(`[World Setup] Creating journal for Item:`, { + console.debug(`[World Setup] Creating journal for Item:`, { name, archivistId: i.id, targetFolderId: targetFolderId || 'none', @@ -2736,7 +2315,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica const sheetType = kind.toLowerCase(); - console.log(`[World Setup] Creating journal for ${kind}:`, { + console.debug(`[World Setup] Creating journal for ${kind}:`, { name, archivistId: e.id, sheetType, @@ -2804,40 +2383,201 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica for (const c of characters) { this.syncStatus.current = `Import ${c.type || c.character_type || 'PC'}: ${c.character_name || c.name}`; - await this.render(); await createActor(c); this.syncStatus.processed++; await this.render(); } for (const it of items) { this.syncStatus.current = `Import Item: ${it.name}`; - await this.render(); await createItem(it); this.syncStatus.processed++; await this.render(); } - // Insert Locations alphabetically locations.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')) ); for (const l of locations) { this.syncStatus.current = `Import Location: ${l.name || l.title}`; - await this.render(); await upsertIntoContainer(l, 'Location'); this.syncStatus.processed++; await this.render(); } - // Insert Factions alphabetically factions.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')) ); for (const f of factions) { this.syncStatus.current = `Import Faction: ${f.name || f.title}`; - await this.render(); await upsertIntoContainer(f, 'Faction'); this.syncStatus.processed++; await this.render(); } + + // Import Journals with folder structure (GM-only, point-in-time snapshot) + if (archivistJournals.length) { + const journalRootFolderId = this.setupData.destinations.journal || null; + const folderIdMap = new Map(); + + // Build Foundry sub-folders mirroring Archivist journal folder paths + const sortedFolders = [...archivistJournalFolders].sort((a, b) => + (a.path || '').localeCompare(b.path || '') + ); + for (const af of sortedFolders) { + try { + const segments = (af.path || af.name || '').split('/').filter(Boolean); + let parentId = journalRootFolderId; + let currentPath = ''; + for (const seg of segments) { + currentPath = currentPath ? `${currentPath}/${seg}` : seg; + if (folderIdMap.has(currentPath)) { + parentId = folderIdMap.get(currentPath); + continue; + } + const existing = (game.folders?.contents || []).find( + (f) => + f.type === 'JournalEntry' && + f.name === seg && + (f.folder?.id || null) === (parentId || null) + ); + if (existing) { + folderIdMap.set(currentPath, existing.id); + parentId = existing.id; + } else { + const created = await Folder.create( + { name: seg, type: 'JournalEntry', folder: parentId || null }, + { render: false } + ); + folderIdMap.set(currentPath, created.id); + parentId = created.id; + } + } + folderIdMap.set(String(af.id), parentId); + } catch (e) { + console.warn('[World Setup] Failed to create journal folder', af, e); + } + } + + for (const j of archivistJournals) { + this.syncStatus.current = `Import Journal: ${j.title || 'Untitled'}`; + try { + const folderId = j.folder_id + ? folderIdMap.get(String(j.folder_id)) || journalRootFolderId + : journalRootFolderId; + const html = Utils.toMarkdownIfHtml(String(j.content || j.summary || '')); + const imageUrl = + typeof j.cover_image === 'string' && j.cover_image.trim() + ? j.cover_image.trim() + : null; + const journal = await Utils.createCustomJournalForImport({ + name: j.title || 'Untitled', + html, + imageUrl, + sheetType: 'journal', + archivistId: j.id, + worldId: campaignId, + folderId: folderId || null, + }); + // GM-only: set default ownership to NONE for all players + if (journal) { + try { + await journal.update( + { ownership: { default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE } }, + { render: false } + ); + } catch (_) {} + // Store folder_id in flags for reference (not kept in sync) + if (j.folder_id) { + try { + const flags = journal.getFlag(CONFIG.MODULE_ID, 'archivist') || {}; + flags.archivistFolderId = j.folder_id; + await journal.setFlag(CONFIG.MODULE_ID, 'archivist', flags); + } catch (_) {} + } + } + } catch (e) { + console.warn('[World Setup] Failed to import journal', j, e); + } + this.syncStatus.processed++; + await this.render(); + } + ui.notifications?.info?.( + 'Journals imported as GM-only. Folder structure reflects the import snapshot and will not auto-sync.' + ); + } + + // Import Quests + for (const q of archivistQuests) { + this.syncStatus.current = `Import Quest: ${q.questName || q.quest_name || 'Quest'}`; + try { + const questFolderId = this.setupData.destinations.quest || null; + + // Fetch full quest data if we only have summary + let fullQuest = q; + if (!q.objectives && q.id) { + try { + const resp = await archivistApi.getQuest(apiKey, q.id); + if (resp.success && resp.data) fullQuest = resp.data; + } catch (_) {} + } + + const html = Utils.toMarkdownIfHtml( + String(fullQuest.successDefinition || fullQuest.nextAction || '') + ); + const journal = await Utils.createCustomJournalForImport({ + name: fullQuest.questName || fullQuest.quest_name || 'Quest', + html, + sheetType: 'quest', + archivistId: fullQuest.id, + worldId: campaignId, + folderId: questFolderId || null, + }); + if (journal) { + const flags = journal.getFlag(CONFIG.MODULE_ID, 'archivist') || {}; + flags.questData = { + questName: fullQuest.questName || fullQuest.quest_name || '', + questGiver: fullQuest.questGiver || fullQuest.quest_giver || '', + questCategory: + fullQuest.questCategory || fullQuest.quest_category || 'n/a', + status: fullQuest.status || 'planned', + successDefinition: + fullQuest.successDefinition || fullQuest.success_definition || '', + failureConditions: + fullQuest.failureConditions || fullQuest.failure_conditions || '', + nextAction: fullQuest.nextAction || fullQuest.next_action || '', + resolution: fullQuest.resolution || '', + objectives: Array.isArray(fullQuest.objectives) ? fullQuest.objectives : [], + progressLog: Array.isArray(fullQuest.progressLog) + ? fullQuest.progressLog + : Array.isArray(fullQuest.progress_log) + ? fullQuest.progress_log + : Array.isArray(fullQuest.progressLogEntries) + ? fullQuest.progressLogEntries.map((e) => + typeof e === 'string' ? e : e.text || '' + ) + : Array.isArray(fullQuest.progress_log_entries) + ? fullQuest.progress_log_entries.map((e) => + typeof e === 'string' ? e : e.text || '' + ) + : [], + relatedCharacters: + fullQuest.relatedCharacters || fullQuest.related_characters || [], + relatedFactions: + fullQuest.relatedFactions || fullQuest.related_factions || [], + relatedLocations: + fullQuest.relatedLocations || fullQuest.related_locations || [], + relatedItems: + fullQuest.relatedItems || fullQuest.related_items || [], + firstSession: fullQuest.firstSession || fullQuest.first_session || null, + lastSession: fullQuest.lastSession || fullQuest.last_session || null, + }; + await journal.setFlag(CONFIG.MODULE_ID, 'archivist', flags); + } + } catch (e) { + console.warn('[World Setup] Failed to import quest', q, e); + } + this.syncStatus.processed++; + await this.render(); + } + // After creating journals and pages, hydrate link graph from Archivist Links try { // Ensure parent/child relationships for Locations using helper before indexing @@ -2962,66 +2702,6 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } } - /** - * Handle world selection - * @param {Event} event - Change event - * @private - */ - async _onSelectWorld(event) { - event.preventDefault(); - - const worldId = event.target.value; - if (!worldId) { - this.setupData.selectedWorldId = ''; - this.setupData.selectedWorldName = ''; - return; - } - - const selectedWorld = this.worlds.find((w) => w.id === worldId); - if (selectedWorld) { - const displayName = - selectedWorld?.name || selectedWorld?.title || 'World'; - this.setupData.selectedWorldId = worldId; - this.setupData.selectedWorldName = displayName; - - // Save to settings immediately - try { - await settingsManager.setSelectedWorld(worldId, displayName); - ui.notifications.info( - game.i18n.localize('ARCHIVIST_SYNC.messages.worldSaved') - ); - } catch (error) { - console.error('Error saving world selection during setup:', error); - ui.notifications.error( - game.i18n.localize('ARCHIVIST_SYNC.errors.saveFailed') - ); - } - } - - await this.render(); - } - - /** - * Convert markdown to HTML with proper paragraph and line break handling - */ - _mdToHtml(md) { - const s = String(md || '').trim(); - if (!s) return ''; - return s - .replace(/\r\n/g, '\n') - .split('\n\n') // Split on double newlines to create paragraphs - .map((para) => { - // Within each paragraph, convert single newlines to
- const content = para - .replace(/\n/g, '
') - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - .replace(/_(.*?)_/g, '$1'); - return `

${content}

`; - }) - .join(''); - } - /** * Create a Recaps folder and a journal per session, ordered by date */ @@ -3029,15 +2709,21 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica try { const sessionsResp = await archivistApi.listSessions(apiKey, campaignId); if (!sessionsResp.success) return; - const sessions = (sessionsResp.data || []).filter( - (s) => !!s.session_date - ); + const sessions = Array.isArray(sessionsResp.data) ? sessionsResp.data.slice() : []; - // Sort by ascending date (oldest first) + // Sort by ascending date (oldest first). Undated sessions go last. sessions.sort( - (a, b) => - new Date(a.session_date).getTime() - - new Date(b.session_date).getTime() + (a, b) => { + const aTime = a?.session_date + ? new Date(a.session_date).getTime() + : Number.POSITIVE_INFINITY; + const bTime = b?.session_date + ? new Date(b.session_date).getTime() + : Number.POSITIVE_INFINITY; + const safeATime = Number.isFinite(aTime) ? aTime : Number.POSITIVE_INFINITY; + const safeBTime = Number.isFinite(bTime) ? bTime : Number.POSITIVE_INFINITY; + return safeATime - safeBTime; + } ); // Create individual Recap journals, one per session, inside Recaps folder @@ -3064,8 +2750,12 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica for (const s of sessions) { const title = s.title || 'Session'; const html = String(s.summary || ''); - // Use session_date timestamp as sort value for proper ordering in Foundry sidebar - const sortValue = new Date(s.session_date).getTime(); + // Use session_date timestamp as sort value for proper ordering in Foundry + // sidebar; leave undated sessions unsorted and normalize them later. + const sessionDateMs = s.session_date + ? new Date(s.session_date).getTime() + : NaN; + const sortValue = Number.isFinite(sessionDateMs) ? sessionDateMs : undefined; const journal = await Utils.createCustomJournalForImport({ name: title, html, @@ -3077,13 +2767,15 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica sort: sortValue, }); if (journal) { - try { - await journal.setFlag( - CONFIG.MODULE_ID, - 'sessionDate', - String(s.session_date) - ); - } catch (_) {} + if (s.session_date) { + try { + await journal.setFlag( + CONFIG.MODULE_ID, + 'sessionDate', + String(s.session_date) + ); + } catch (_) {} + } } } @@ -3125,6 +2817,14 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } catch (_) { /* ignore ordering failures */ } + + // Recaps are created late in the wizard flow, so force the Journal + // directory to refresh here rather than waiting for a later global render. + try { + await ui?.journal?.render?.({ force: true }); + } catch (_) { + /* ignore refresh failures */ + } } catch (e) { console.warn('Recaps sync skipped/failed:', e); } @@ -3159,7 +2859,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica try { if (window.ARCHIVIST_SYNC?.installRealtimeSyncListeners) { window.ARCHIVIST_SYNC.installRealtimeSyncListeners(); - console.log( + console.debug( '[Archivist Sync] Real-Time Sync listeners installed after setup' ); } @@ -3170,7 +2870,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica setTimeout(async () => { try { await ui?.journal?.render?.({ force: true }); - console.log('[Archivist Sync] Journal Directory re-rendered after setup'); + console.debug('[Archivist Sync] Journal Directory re-rendered after setup'); } catch (e) { console.warn('[Archivist Sync] Failed to re-render Journal Directory after setup', e); } @@ -3183,89 +2883,6 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } } - /** - * Handle cancel button click - * @param {Event} event - Click event - * @private - */ - /** - * Generate and download a sample configuration file - */ - async _onDownloadSampleConfig(event) { - event?.preventDefault?.(); - - // Generate sample configuration with schema and examples - const sampleConfig = { - _schema_version: '1.0', - _description: - "Archivist Sync Configuration File - Edit the paths below to match your game system's data structure", - _instructions: { - actorMappings: - 'Configure how PC and NPC data is mapped from Foundry actors', - itemMappings: 'Configure how Item data is mapped from Foundry items', - destinations: - 'Configure where different entity types are synced to in Archivist', - }, - actorMappings: { - pc: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.details.biography.value', - _examples: { - namePath: 'name (actor name field)', - imagePath: 'img (actor image field)', - descriptionPath: - 'system.details.biography.value (D&D 5e), system.biography (PF2e), system.description (other systems)', - }, - }, - npc: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.details.biography.value', - _examples: { - namePath: 'name (actor name field)', - imagePath: 'img (actor image field)', - descriptionPath: - 'system.details.biography.value (D&D 5e), system.biography (PF2e), system.description (other systems)', - }, - }, - }, - itemMappings: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.description.value', - _examples: { - namePath: 'name (item name field)', - imagePath: 'img (item image field)', - descriptionPath: - 'system.description.value (D&D 5e), system.description (PF2e), system.description.value (other systems)', - }, - }, - destinations: { - pc: 'pc', - npc: 'npc', - item: 'item', - location: 'location', - faction: 'faction', - _options: { - pc: ['pc', 'npc'], - npc: ['npc', 'pc'], - item: ['item', 'note'], - location: ['location', 'note'], - faction: ['faction', 'note'], - }, - }, - }; - - // Open the sample config in a new tab - window.open( - 'https://raw.githubusercontent.com/camrun91/archivist-sync/main/archivist-sync-sample-config.json', - '_blank' - ); - - ui.notifications.info('Sample configuration opened in new tab.'); - } - async _onCancel(event) { event.preventDefault(); await this.close(); @@ -3285,20 +2902,19 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica progressBar.style.width = `${context.progressPercentage}%`; } - // Add keyboard shortcuts for Step 2 if (context.isStep2) { const apiKeyInput = this.element.querySelector('#api-key-input'); if (apiKeyInput) { - // Focus the input field apiKeyInput.focus(); - - // Add Enter key listener for validation - apiKeyInput.addEventListener('keypress', (event) => { - if (event.key === 'Enter') { - event.preventDefault(); - this._onValidateApiKey(event); - } - }); + if (!apiKeyInput.dataset.bound) { + apiKeyInput.addEventListener('keypress', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + this._onValidateApiKey(event); + } + }); + apiKeyInput.dataset.bound = 'true'; + } } } @@ -3435,43 +3051,73 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } }); - // Delegate checkbox toggle + // Delegate checkbox toggle with shift+click range-select if (!root.dataset.boundReconToggle) { - root.addEventListener('change', async (ev) => { - const cb = ev.target?.closest?.( - 'input[type="checkbox"][data-action="recon-toggle-select"]' - ); - if (!cb) return; - const tab = cb.dataset.tab; - const side = cb.dataset.side; - const id = cb.dataset.id; - const checked = cb.checked; + this._lastReconClick = {}; + + const syncRow = (tab, side, id, checked) => { const r = this.setupData.reconcile?.[tab]; if (!r) return; const list = r[side] || []; const row = list.find((x) => x.id === id); if (!row) return; row.selected = checked; - // Sync with matched counterpart const otherSide = side === 'archivist' ? 'foundry' : 'archivist'; const mid = row.match || null; if (mid) { const otherRow = (r[otherSide] || []).find((x) => x.id === mid); if (otherRow) { otherRow.selected = checked; - // Update the matched checkbox in the DOM const matchedCheckbox = root.querySelector( `input[data-action="recon-toggle-select"][data-tab="${tab}"][data-side="${otherSide}"][data-id="${mid}"]` ); if (matchedCheckbox) matchedCheckbox.checked = checked; } } - // Update header "select all" checkbox state + }; + + const updateHeaderCheckbox = (tab, side) => { + const r = this.setupData.reconcile?.[tab]; + if (!r) return; + const list = r[side] || []; const allSelected = list.length > 0 && list.every((x) => x.selected); - const headerCheckbox = root.querySelector( + const headerCb = root.querySelector( `input[data-action="recon-select-all-toggle"][data-tab="${tab}"][data-side="${side}"]` ); - if (headerCheckbox) headerCheckbox.checked = allSelected; + if (headerCb) headerCb.checked = allSelected; + }; + + root.addEventListener('click', (ev) => { + const cb = ev.target?.closest?.( + 'input[type="checkbox"][data-action="recon-toggle-select"]' + ); + if (!cb) return; + const tab = cb.dataset.tab; + const side = cb.dataset.side; + const key = `${tab}:${side}`; + const allCbs = Array.from( + root.querySelectorAll( + `input[data-action="recon-toggle-select"][data-tab="${tab}"][data-side="${side}"]` + ) + ); + const idx = allCbs.indexOf(cb); + + if (ev.shiftKey && this._lastReconClick[key] != null) { + const lastIdx = this._lastReconClick[key]; + const from = Math.min(lastIdx, idx); + const to = Math.max(lastIdx, idx); + const checked = cb.checked; + for (let i = from; i <= to; i++) { + const target = allCbs[i]; + target.checked = checked; + syncRow(tab, side, target.dataset.id, checked); + } + } else { + syncRow(tab, side, cb.dataset.id, cb.checked); + } + + this._lastReconClick[key] = idx; + updateHeaderCheckbox(tab, side); }); root.dataset.boundReconToggle = 'true'; } @@ -3641,553 +3287,201 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } } - // Legacy Step 4 (Mapping) removed – skip binding for legacy controls - if (false && context.isStep4) { - const val = (sel) => this.element.querySelector(sel)?.value?.trim() || ''; - const updateMappingData = () => { - this.setupData.mapping.pc.namePath = val('#map-pc-name'); - this.setupData.mapping.pc.imagePath = val('#map-pc-image'); - this.setupData.mapping.pc.descPath = val('#map-pc-desc'); - this.setupData.mapping.npc.namePath = val('#map-npc-name'); - this.setupData.mapping.npc.imagePath = val('#map-npc-image'); - this.setupData.mapping.npc.descPath = val('#map-npc-desc'); - this.setupData.mapping.item.namePath = val('#map-item-name'); - this.setupData.mapping.item.imagePath = val('#map-item-image'); - this.setupData.mapping.item.descPath = val('#map-item-desc'); - this.setupData.destinations.pc = val('#dest-pc'); - this.setupData.destinations.npc = val('#dest-npc'); - this.setupData.destinations.item = val('#dest-item'); - }; - - // Attach change listeners to all mapping and destination selects - const selectors = [ - '#map-pc-name', - '#map-pc-image', - '#map-pc-desc', - '#map-npc-name', - '#map-npc-image', - '#map-npc-desc', - '#map-item-name', - '#map-item-image', - '#map-item-desc', - '#dest-pc', - '#dest-npc', - '#dest-item', - ]; - - selectors.forEach((selector) => { - const element = this.element.querySelector(selector); - if (element && !element.dataset.boundSetup) { - element.addEventListener('change', updateMappingData); - element.dataset.boundSetup = 'true'; - } - }); - - // Add file input handler for config loading - const configFileInput = this.element.querySelector('#config-file-input'); - if (configFileInput && !configFileInput.dataset.boundSetup) { - configFileInput.addEventListener('change', async (event) => { - const file = event.target.files[0]; - if (!file) return; - - try { - const text = await file.text(); - const config = JSON.parse(text); - - // Apply configuration to form fields - if (config.actorMappings?.pc) { - if (config.actorMappings.pc.namePath) { - const pcNameSelect = this.element.querySelector('#map-pc-name'); - if (pcNameSelect) - pcNameSelect.value = config.actorMappings.pc.namePath; - } - if (config.actorMappings.pc.imagePath) { - const pcImageSelect = - this.element.querySelector('#map-pc-image'); - if (pcImageSelect) - pcImageSelect.value = config.actorMappings.pc.imagePath; - } - if (config.actorMappings.pc.descriptionPath) { - const pcDescSelect = this.element.querySelector('#map-pc-desc'); - if (pcDescSelect) - pcDescSelect.value = config.actorMappings.pc.descriptionPath; - } - } - - if (config.actorMappings?.npc) { - if (config.actorMappings.npc.namePath) { - const npcNameSelect = - this.element.querySelector('#map-npc-name'); - if (npcNameSelect) - npcNameSelect.value = config.actorMappings.npc.namePath; - } - if (config.actorMappings.npc.imagePath) { - const npcImageSelect = - this.element.querySelector('#map-npc-image'); - if (npcImageSelect) - npcImageSelect.value = config.actorMappings.npc.imagePath; - } - if (config.actorMappings.npc.descriptionPath) { - const npcDescSelect = - this.element.querySelector('#map-npc-desc'); - if (npcDescSelect) - npcDescSelect.value = - config.actorMappings.npc.descriptionPath; - } - } - - if (config.itemMappings) { - if (config.itemMappings.namePath) { - const itemNameSelect = - this.element.querySelector('#map-item-name'); - if (itemNameSelect) - itemNameSelect.value = config.itemMappings.namePath; - } - if (config.itemMappings.imagePath) { - const itemImageSelect = - this.element.querySelector('#map-item-image'); - if (itemImageSelect) - itemImageSelect.value = config.itemMappings.imagePath; - } - if (config.itemMappings.descriptionPath) { - const itemDescSelect = - this.element.querySelector('#map-item-desc'); - if (itemDescSelect) - itemDescSelect.value = config.itemMappings.descriptionPath; - } - } - - // Update internal data - updateMappingData(); - - ui.notifications.info('Configuration loaded successfully.'); - } catch (error) { - console.error('Failed to load configuration file:', error); - ui.notifications.error( - 'Failed to load configuration file. Please check the file format.' - ); - } - - // Clear the file input - event.target.value = ''; - }); - configFileInput.dataset.boundSetup = 'true'; - } - // Bind preset change validation in Step 4 (if the dropdown exists here) - const presetSelect = this.element.querySelector('#ws-system-preset'); - if (presetSelect && !presetSelect.dataset.boundSetup) { - presetSelect.addEventListener('change', async (e) => { - const key = e?.target?.value || ''; - if (!key) { - this.setupData.systemPreset = ''; - return; - } - try { - await this._validateOrRejectPreset(key); - this.setupData.systemPreset = key; - this._applyPresetToSetupData(key); - await this.render(); - ui.notifications.info( - `Applied ${presetSelect.options[presetSelect.selectedIndex].text} preset` - ); - } catch (err) { - ui.notifications.error( - String( - err?.message || err || 'Preset unavailable for this system.' - ) - ); - // revert selection - presetSelect.value = this.setupData.systemPreset || ''; - } - }); - presetSelect.dataset.boundSetup = 'true'; - } - } - } - - /** - * Get shared system presets (mirrors sync options dialog) - */ - _getSystemPresets() { - return { - dnd5e: { - name: 'D&D 5e', - actorMappings: { - pc: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.details.biography.value', - }, - npc: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.details.biography.public', - }, - }, - itemMappings: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.description.value', - }, - }, - pf2e: { - name: 'Pathfinder 2e', - actorMappings: { - pc: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.details.biography.value', - }, - npc: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.details.publicNotes', - }, - }, - itemMappings: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.description.value', - }, - }, - coc7: { - name: 'Call of Cthulhu 7e', - actorMappings: { - pc: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.biography.personal.description', - }, - npc: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.biography.personal.description', - }, - }, - itemMappings: { - namePath: 'name', - imagePath: 'img', - descriptionPath: 'system.description.value', - }, - }, - }; } - /** - * Try auto-detect preset based on whether all preset paths exist in system model - * Order: dnd5e -> pf2e -> coc7 - */ - async _autoDetectPreset() { - const order = ['dnd5e', 'pf2e', 'coc7']; - for (const key of order) { - try { - await this._validateOrRejectPreset(key); - return key; - } catch (_) { - /* try next */ + _buildReconciliationModel(input) { + const A = input.archivist || {}; + const F = input.foundry || {}; + const toName = (v) => String(v || '').trim(); + const normName = (v) => toName(v).toLowerCase(); + + const actors = Array.isArray(F.actors) ? F.actors : []; + const actorsBase = actors.map((a) => ({ + id: a.id, + name: toName(a.name), + img: a.img || '', + type: String(a.type || ''), + doc: a, + })); + let pcActors = actorsBase.filter( + (a) => a.type.toLowerCase() === 'character' + ); + let npcActors = actorsBase.filter( + (a) => + a.type.toLowerCase() === 'npc' || a.type.toLowerCase() === 'monster' + ); + if ( + pcActors.length === 0 && + npcActors.length === 0 && + actorsBase.length > 0 + ) { + const acChars = Array.isArray(A.characters) ? A.characters : []; + const acByName = new Map(); + for (const c of acChars) { + const n = normName(c.character_name || c.name); + if (n) + acByName.set(n, (c.type || c.character_type || '').toUpperCase()); + } + for (const a of actorsBase) { + const kind = acByName.get(normName(a.name)) || 'NPC'; + if (kind === 'PC') pcActors.push(a); + else npcActors.push(a); } } - return ''; - } - /** - * Validate preset against current system model; throws if invalid - */ - async _validateOrRejectPreset(key) { - const presets = this._getSystemPresets(); - const preset = presets[key]; - if (!preset) throw new Error('Unknown preset'); - - // Validate that each mapping path exists in the system model/property graph - const testPaths = [ - preset.actorMappings?.pc?.namePath, - preset.actorMappings?.pc?.imagePath, - preset.actorMappings?.pc?.descriptionPath, - preset.actorMappings?.npc?.namePath, - preset.actorMappings?.npc?.imagePath, - preset.actorMappings?.npc?.descriptionPath, - preset.itemMappings?.namePath, - preset.itemMappings?.imagePath, - preset.itemMappings?.descriptionPath, - ].filter(Boolean); - - // Use our discovered mapping options to validate existence - const availableActor = new Set( - (this.mappingOptions.actor || []).map((o) => o.path) + const fItems = (Array.isArray(F.items) ? F.items : []).map((i) => ({ + id: i.id, + name: toName(i.name), + img: i.img || '', + doc: i, + })); + const fScenes = (Array.isArray(F.scenes) ? F.scenes : []).map((s) => ({ + id: s.id, + name: toName(s.name), + img: s.thumb || s.background?.src || '', + doc: s, + })); + + const aChars = (Array.isArray(A.characters) ? A.characters : []).map( + (c) => ({ + id: String(c.id), + name: toName(c.character_name || c.name), + img: toName(c.image || ''), + type: String(c.type || c.character_type || 'PC').toUpperCase(), + }) ); - const availableItem = new Set( - (this.mappingOptions.item || []).map((o) => o.path) + const aItems = (Array.isArray(A.items) ? A.items : []).map((i) => ({ + id: String(i.id), + name: toName(i.name), + img: toName(i.image || ''), + })); + const aLocs = (Array.isArray(A.locations) ? A.locations : []).map((l) => ({ + id: String(l.id), + name: toName(l.name || l.title), + img: toName(l.image || ''), + })); + const aFactions = (Array.isArray(A.factions) ? A.factions : []).map( + (f) => ({ + id: String(f.id), + name: toName(f.name || f.title), + img: toName(f.image || ''), + }) ); - const exists = (p) => { - if (!p) return false; - if (p === 'name' || p === 'img') return true; // safe defaults always exist - if (p.startsWith('system.')) { - // Try actor first, then item - return availableActor.has(p) || availableItem.has(p); + const matchByName = ( + left, + right, + getLeftName, + getRightName, + constraint + ) => { + const usedRight = new Set(); + const leftOut = []; + for (const L of left) { + let hit = null; + for (const R of right) { + if (usedRight.has(R.id)) continue; + if (constraint && !constraint(L, R)) continue; + if (normName(getLeftName(L)) === normName(getRightName(R))) { + hit = R; + break; + } + } + if (hit) { + usedRight.add(hit.id); + leftOut.push({ ...L, match: hit.id, selected: true }); + } else { + leftOut.push({ ...L, match: null, selected: false }); + } } - return availableActor.has(p) || availableItem.has(p); + const rightOut = right.map((R) => { + const L = leftOut.find((x) => x.match === R.id) || null; + return { ...R, match: L ? L.id : null, selected: !!L }; + }); + return { leftOut, rightOut }; }; - const missing = testPaths.filter((p) => !exists(p)); - if (missing.length) { - throw new Error( - `Preset unavailable: missing properties in this system → ${missing.join(', ')}` - ); - } - return true; - } - - /** - * Apply preset mappings to setupData (does not touch destinations) - */ - _applyPresetToSetupData(key) { - const presets = this._getSystemPresets(); - const preset = presets[key]; - if (!preset) return; - const gp = (o, p, fb = '') => { - try { - return foundry.utils.getProperty(o, p) ?? fb; - } catch (_) { - return fb; - } + const allActors = [...pcActors, ...npcActors]; + const charConstraint = (ac, fa) => { + if (!fa.type || fa.type === '' || fa.type === 'base') return true; + return ac.type === 'PC' + ? fa.type.toLowerCase() === 'character' + : fa.type.toLowerCase() === 'npc' || + fa.type.toLowerCase() === 'monster'; }; - this.setupData.mapping.pc.namePath = gp( - preset, - 'actorMappings.pc.namePath' - ); - this.setupData.mapping.pc.imagePath = gp( - preset, - 'actorMappings.pc.imagePath' - ); - this.setupData.mapping.pc.descPath = gp( - preset, - 'actorMappings.pc.descriptionPath' + const { leftOut: aCharsOut, rightOut: fActorsOut } = matchByName( + aChars, + allActors, + (x) => x.name, + (x) => x.name, + charConstraint ); - this.setupData.mapping.npc.namePath = gp( - preset, - 'actorMappings.npc.namePath' - ); - this.setupData.mapping.npc.imagePath = gp( - preset, - 'actorMappings.npc.imagePath' - ); - this.setupData.mapping.npc.descPath = gp( - preset, - 'actorMappings.npc.descriptionPath' - ); - this.setupData.mapping.item.namePath = gp(preset, 'itemMappings.namePath'); - this.setupData.mapping.item.imagePath = gp( - preset, - 'itemMappings.imagePath' - ); - this.setupData.mapping.item.descPath = gp( - preset, - 'itemMappings.descriptionPath' - ); - } -} -// Build reconciliation model for Step 4 -WorldSetupDialog.prototype._buildReconciliationModel = function (input) { - console.log('[World Setup] _buildReconciliationModel called with:', input); - const A = input.archivist || {}; - const F = input.foundry || {}; - const toName = (v) => String(v || '').trim(); - const normName = (v) => toName(v).toLowerCase(); - - // Foundry actors classification with fallback - const actors = Array.isArray(F.actors) ? F.actors : []; - const actorsBase = actors.map((a) => ({ - id: a.id, - name: toName(a.name), - img: a.img || '', - type: String(a.type || ''), - doc: a, - })); - let pcActors = actorsBase.filter((a) => a.type.toLowerCase() === 'character'); - let npcActors = actorsBase.filter( - (a) => a.type.toLowerCase() === 'npc' || a.type.toLowerCase() === 'monster' - ); - if ( - pcActors.length === 0 && - npcActors.length === 0 && - actorsBase.length > 0 - ) { - const acChars = Array.isArray(A.characters) ? A.characters : []; - const acByName = new Map(); - for (const c of acChars) { - const n = normName(c.character_name || c.name); - if (n) acByName.set(n, (c.type || c.character_type || '').toUpperCase()); - } - for (const a of actorsBase) { - const kind = acByName.get(normName(a.name)) || 'NPC'; - if (kind === 'PC') pcActors.push(a); - else npcActors.push(a); - } - } + const { leftOut: aItemsOut, rightOut: fItemsOut } = matchByName( + aItems, + fItems, + (x) => x.name, + (x) => x.name + ); - // Foundry items and scenes - const fItems = (Array.isArray(F.items) ? F.items : []).map((i) => ({ - id: i.id, - name: toName(i.name), - img: i.img || '', - doc: i, - })); - const fScenes = (Array.isArray(F.scenes) ? F.scenes : []).map((s) => ({ - id: s.id, - name: toName(s.name), - img: s.thumb || s.background?.src || '', - doc: s, - })); - - // Archivist lists normalized - const aChars = (Array.isArray(A.characters) ? A.characters : []).map((c) => ({ - id: String(c.id), - name: toName(c.character_name || c.name), - img: toName(c.image || ''), - type: String(c.type || c.character_type || 'PC').toUpperCase(), - })); - const aItems = (Array.isArray(A.items) ? A.items : []).map((i) => ({ - id: String(i.id), - name: toName(i.name), - img: toName(i.image || ''), - })); - const aLocs = (Array.isArray(A.locations) ? A.locations : []).map((l) => ({ - id: String(l.id), - name: toName(l.name || l.title), - img: toName(l.image || ''), - })); - const aFactions = (Array.isArray(A.factions) ? A.factions : []).map((f) => ({ - id: String(f.id), - name: toName(f.name || f.title), - img: toName(f.image || ''), - })); - - // Matching helpers (one-to-one by name and type/bucket) - const matchByName = (left, right, getLeftName, getRightName, constraint) => { - const usedRight = new Set(); - const leftOut = []; - for (const L of left) { - let hit = null; - for (const R of right) { - if (usedRight.has(R.id)) continue; - if (constraint && !constraint(L, R)) continue; - if (normName(getLeftName(L)) === normName(getRightName(R))) { - hit = R; - break; - } - } - if (hit) { - usedRight.add(hit.id); - leftOut.push({ ...L, match: hit.id, selected: false }); - } else { - leftOut.push({ ...L, match: null, selected: false }); - } - } - // Right side mirror - const rightOut = right.map((R) => { - const L = leftOut.find((x) => x.match === R.id) || null; - return { ...R, match: L ? L.id : null, selected: false }; - }); - return { leftOut, rightOut }; - }; + const { leftOut: aLocsOut, rightOut: fScenesOut } = matchByName( + aLocs, + fScenes, + (x) => x.name, + (x) => x.name + ); - // Characters: allow PC->character and NPC->npc by constraint - const allActors = [...pcActors, ...npcActors]; - // More lenient constraint: if types exist, respect them; otherwise allow any match - const charConstraint = (ac, fa) => { - // If Foundry actor has no meaningful type, allow match - if (!fa.type || fa.type === '' || fa.type === 'base') return true; - // Otherwise respect type matching - return ac.type === 'PC' - ? fa.type.toLowerCase() === 'character' - : fa.type.toLowerCase() === 'npc' || fa.type.toLowerCase() === 'monster'; - }; - const { leftOut: aCharsOut, rightOut: fActorsOut } = matchByName( - aChars, - allActors, - (x) => x.name, - (x) => x.name, - charConstraint - ); - - // Items - const { leftOut: aItemsOut, rightOut: fItemsOut } = matchByName( - aItems, - fItems, - (x) => x.name, - (x) => x.name - ); - - // Locations vs Scenes - const { leftOut: aLocsOut, rightOut: fScenesOut } = matchByName( - aLocs, - fScenes, - (x) => x.name, - (x) => x.name - ); - - // Factions — Foundry side empty by default - const aFactionsOut = aFactions.map((f) => ({ - ...f, - match: null, - selected: false, - })); - const fFactionsOut = []; - - const result = { - characters: { archivist: aCharsOut, foundry: fActorsOut }, - items: { archivist: aItemsOut, foundry: fItemsOut }, - locations: { archivist: aLocsOut, foundry: fScenesOut }, - factions: { archivist: aFactionsOut, foundry: fFactionsOut }, - }; + const aFactionsOut = aFactions.map((f) => ({ + ...f, + match: null, + selected: false, + })); + const fFactionsOut = []; + + const result = { + characters: { archivist: aCharsOut, foundry: fActorsOut }, + items: { archivist: aItemsOut, foundry: fItemsOut }, + locations: { archivist: aLocsOut, foundry: fScenesOut }, + factions: { archivist: aFactionsOut, foundry: fFactionsOut }, + }; - // Post-pass: ensure exact-name matches are linked if still unmatched (helps systems without types) - try { - const ensureNameMatch = (leftArr, rightArr) => { - const rightByName = new Map(rightArr.map((r) => [normName(r.name), r])); - for (const l of leftArr) { - if (!l.match) { - const r = rightByName.get(normName(l.name)); - if (r && !r.match) { - l.match = r.id; - r.match = l.id; + try { + const ensureNameMatch = (leftArr, rightArr) => { + const rightByName = new Map( + rightArr.map((r) => [normName(r.name), r]) + ); + for (const l of leftArr) { + if (!l.match) { + const r = rightByName.get(normName(l.name)); + if (r && !r.match) { + l.match = r.id; + r.match = l.id; + } } } - } - }; + }; - ensureNameMatch(result.characters.archivist, result.characters.foundry); - ensureNameMatch(result.items.archivist, result.items.foundry); - ensureNameMatch(result.locations.archivist, result.locations.foundry); - } catch (_) { - /* ignore */ - } + ensureNameMatch(result.characters.archivist, result.characters.foundry); + ensureNameMatch(result.items.archivist, result.items.foundry); + ensureNameMatch(result.locations.archivist, result.locations.foundry); + } catch (_) { + /* ignore */ + } - // Sort all lists alphabetically by name for display in Steps 4 and 5 - const sortByName = (arr) => - arr.sort((a, b) => toName(a.name).localeCompare(toName(b.name))); - try { - sortByName(result.characters.archivist); - sortByName(result.characters.foundry); - sortByName(result.items.archivist); - sortByName(result.items.foundry); - sortByName(result.locations.archivist); - sortByName(result.locations.foundry); - sortByName(result.factions.archivist); - sortByName(result.factions.foundry); - } catch (_) { - /* ignore */ + const sortByName = (arr) => + arr.sort((a, b) => toName(a.name).localeCompare(toName(b.name))); + try { + sortByName(result.characters.archivist); + sortByName(result.characters.foundry); + sortByName(result.items.archivist); + sortByName(result.items.foundry); + sortByName(result.locations.archivist); + sortByName(result.locations.foundry); + sortByName(result.factions.archivist); + sortByName(result.factions.foundry); + } catch (_) { + /* ignore */ + } + return result; } - console.log('[World Setup] Reconciliation result:', { - charactersArchivist: aCharsOut.length, - charactersFoundry: fActorsOut.length, - itemsArchivist: aItemsOut.length, - itemsFoundry: fItemsOut.length, - locationsArchivist: aLocsOut.length, - locationsFoundry: fScenesOut.length, - factionsArchivist: aFactionsOut.length, - }); - return result; -}; +} diff --git a/scripts/modules/config.js b/scripts/modules/config.js index d2ec55e..6a1fdb1 100644 --- a/scripts/modules/config.js +++ b/scripts/modules/config.js @@ -85,7 +85,7 @@ export const SETTINGS = { scope: 'world', config: false, type: Object, - default: { pc: '', npc: '', item: '', location: '', faction: '' }, + default: { pc: '', npc: '', item: '', location: '', faction: '', journal: '', quest: '' }, }, CHAT_HISTORY: { diff --git a/scripts/modules/sheets/page-sheet-v2.js b/scripts/modules/sheets/page-sheet-v2.js index ff2df83..d51ce36 100644 --- a/scripts/modules/sheets/page-sheet-v2.js +++ b/scripts/modules/sheets/page-sheet-v2.js @@ -916,7 +916,7 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( console.log('[Archivist V2 Sheet] Syncing Recap/Session to API'); const payload = { title: nameNow }; // Only include summary if we actually read editor content this cycle - if (htmlRead) payload.summary = html; + if (htmlRead) payload.summary = Utils.toMarkdownIfHtml(String(html || '')); if (sessionDate) { // Send full ISO (with time) using flags value if available let fullIso = ''; @@ -930,6 +930,27 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( payload.session_date = fullIso || `${sessionDate}T00:00:00`; } await archivistApi.updateSession(apiKey, archivistId, payload); + } else if (sheetType === 'quest') { + console.log('[Archivist V2 Sheet] Syncing Quest to API'); + const payload = { questName: nameNow }; + result = await archivistApi.updateQuest(apiKey, archivistId, payload); + } else if (sheetType === 'journal') { + console.log('[Archivist V2 Sheet] Syncing Journal to API'); + const payload = { + id: archivistId, + title: nameNow, + }; + if (htmlRead) { + payload.content = Utils.toMarkdownIfHtml(String(html || '')); + } + result = await archivistApi.updateJournal(apiKey, payload); + if (result && !result.success && result.isDescriptionTooLong) { + ui.notifications?.error?.( + `Failed to save ${result.entityName || nameNow}: Content exceeds the maximum allowed length. Please shorten the content and try again.`, + { permanent: true } + ); + return; + } } } catch (e) { console.warn('[Archivist Sync][V2] commit: remote sync failed', e); @@ -1945,6 +1966,225 @@ export class RecapPageSheetV2 extends ArchivistBasePageSheetV2 { form: { template: 'modules/archivist-sync/templates/sheets/recap.hbs' }, }; } +export class JournalPageSheetV2 extends ArchivistBasePageSheetV2 { + static PARTS = { + form: { template: 'modules/archivist-sync/templates/sheets/journal.hbs' }, + }; +} +export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { + static PARTS = { + form: { template: 'modules/archivist-sync/templates/sheets/quest.hbs' }, + }; + + static TABS = { + archivist: { + navSelector: '.archivist-nav', + contentSelector: '.archivist-content', + initial: 'info', + }, + }; + + async _prepareContext(_options) { + const ctx = await super._prepareContext(_options); + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = flags.questData || {}; + const questName = qd.questName || qd.quest_name || ''; + if (!ctx.setup.name || ctx.setup.name === 'Quest') { + ctx.setup.name = questName || ctx.setup.name; + } + ctx.quest = { + questGiver: qd.questGiver || qd.quest_giver || '', + questCategory: qd.questCategory || qd.quest_category || 'n/a', + status: qd.status || 'planned', + successDefinition: qd.successDefinition || qd.success_definition || '', + failureConditions: qd.failureConditions || qd.failure_conditions || '', + nextAction: qd.nextAction || qd.next_action || '', + resolution: qd.resolution || '', + objectives: Array.isArray(qd.objectives) ? qd.objectives : [], + progressLog: Array.isArray(qd.progressLog) + ? qd.progressLog + : Array.isArray(qd.progress_log) + ? qd.progress_log + : [], + relatedCharacters: Array.isArray(qd.relatedCharacters) + ? qd.relatedCharacters + : Array.isArray(qd.related_characters) + ? qd.related_characters + : [], + relatedFactions: Array.isArray(qd.relatedFactions) + ? qd.relatedFactions + : Array.isArray(qd.related_factions) + ? qd.related_factions + : [], + relatedLocations: Array.isArray(qd.relatedLocations) + ? qd.relatedLocations + : Array.isArray(qd.related_locations) + ? qd.related_locations + : [], + relatedItems: Array.isArray(qd.relatedItems) + ? qd.relatedItems + : Array.isArray(qd.related_items) + ? qd.related_items + : [], + firstSession: qd.firstSession || qd.first_session || null, + lastSession: qd.lastSession || qd.last_session || null, + }; + ctx.quest.statusLabel = { + planned: 'Planned', + 'in-progress': 'In Progress', + blocked: 'Blocked', + failed: 'Failed', + done: 'Done', + 'n/a': 'N/A', + }[ctx.quest.status] || ctx.quest.status; + ctx.quest.statusIcon = { + planned: 'fa-compass', + 'in-progress': 'fa-hourglass-half', + blocked: 'fa-ban', + failed: 'fa-skull-crossbones', + done: 'fa-check-circle', + 'n/a': 'fa-question', + }[ctx.quest.status] || 'fa-question'; + ctx.quest.categoryLabel = { + main: 'Main', + side: 'Side', + faction: 'Faction', + personal: 'Personal', + 'n/a': '', + }[ctx.quest.questCategory] || ''; + return ctx; + } + + _onRender(context, options) { + super._onRender(context, options); + try { + const root = this.element; + this._renderQuestObjectives(root, context); + this._renderQuestProgress(root, context); + this._renderQuestLinks(root, context); + this._wireQuestEditActions(root, context); + } catch (e) { + console.warn('[Archivist Sync][V2] Quest _onRender failed', e); + } + } + + _renderQuestObjectives(root, context) { + const list = root.querySelector('.quest-objectives-list'); + if (!list) return; + const objectives = context.quest?.objectives || []; + list.innerHTML = ''; + if (!objectives.length) { + list.innerHTML = '
  • No objectives
  • '; + return; + } + const iconMap = { + pending: 'fa-circle', + 'in-progress': 'fa-hourglass-half', + completed: 'fa-check-circle', + failed: 'fa-times-circle', + blocked: 'fa-ban', + }; + for (const obj of objectives) { + const li = document.createElement('li'); + li.className = `quest-objective quest-objective-${obj.status || 'pending'}`; + const icon = iconMap[obj.status] || 'fa-circle'; + li.innerHTML = `${foundry.utils.escapeHTML(obj.text || '')}`; + list.appendChild(li); + } + } + + _renderQuestProgress(root, context) { + const list = root.querySelector('.quest-progress-log'); + if (!list) return; + const entries = context.quest?.progressLog || []; + list.innerHTML = ''; + if (!entries.length) { + list.innerHTML = + '
  • No progress entries
  • '; + return; + } + for (const entry of entries) { + const li = document.createElement('li'); + li.className = 'quest-progress-entry'; + const text = typeof entry === 'string' ? entry : entry.text || ''; + li.innerHTML = `${foundry.utils.escapeHTML(text)}`; + list.appendChild(li); + } + } + + _renderQuestLinks(root, context) { + const q = context.quest || {}; + const sections = [ + { sel: '.quest-related-characters', items: q.relatedCharacters }, + { sel: '.quest-related-factions', items: q.relatedFactions }, + { sel: '.quest-related-locations', items: q.relatedLocations }, + { sel: '.quest-related-items', items: q.relatedItems }, + ]; + for (const { sel, items } of sections) { + const el = root.querySelector(sel); + if (!el) continue; + el.innerHTML = ''; + if (!items?.length) { + el.innerHTML = 'None'; + continue; + } + for (const name of items) { + const chip = document.createElement('span'); + chip.className = 'quest-link-chip'; + chip.textContent = name; + el.appendChild(chip); + } + } + } + + _wireQuestEditActions(root, context) { + const addBtn = root.querySelector('[data-action="add-objective"]'); + if (addBtn) { + addBtn.addEventListener('click', () => this._addObjective()); + } + root.querySelectorAll('[data-action="remove-objective"]').forEach((btn) => { + btn.addEventListener('click', (ev) => { + const idx = Number( + ev.currentTarget.closest('[data-obj-index]')?.dataset.objIndex + ); + if (Number.isFinite(idx)) this._removeObjective(idx); + }); + }); + } + + async _addObjective() { + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = { ...(flags.questData || {}) }; + const objectives = Array.isArray(qd.objectives) + ? [...qd.objectives] + : []; + objectives.push({ text: 'New Objective', status: 'pending', order: objectives.length }); + qd.objectives = objectives; + await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', { + ...flags, + questData: qd, + }); + this.render(false); + } + + async _removeObjective(idx) { + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = { ...(flags.questData || {}) }; + const objectives = Array.isArray(qd.objectives) + ? [...qd.objectives] + : []; + objectives.splice(idx, 1); + qd.objectives = objectives; + await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', { + ...flags, + questData: qd, + }); + this.render(false); + } +} export function sheetClassId(Cls) { return `archivist-sync.${Cls.name}`; diff --git a/scripts/modules/utils.js b/scripts/modules/utils.js index 196e5bb..11bb73b 100644 --- a/scripts/modules/utils.js +++ b/scripts/modules/utils.js @@ -463,28 +463,52 @@ export class Utils { static markdownToStoredHtml(markdown) { const md = String(markdown ?? ''); try { + const trimmed = md.trim(); + const isProbablyHtml = + !!trimmed && + ((trimmed.startsWith('<') && trimmed.includes('>')) || + /<\/?[a-z][\s\S]*>/i.test(trimmed) || + /&(?:lt|gt|amp|quot|#39);/i.test(trimmed)); + + if (isProbablyHtml) { + return foundry?.utils?.TextEditor?.cleanHTML + ? foundry.utils.TextEditor.cleanHTML(md) + : md; + } + let rawHtml = ''; - if (window?.MarkdownIt) { - const mdIt = new window.MarkdownIt({ + const MarkdownItCtor = globalThis.MarkdownIt || window?.MarkdownIt; + if (MarkdownItCtor) { + const mdIt = new MarkdownItCtor({ + html: false, + linkify: true, + breaks: true, + }); + rawHtml = mdIt.render(md); + } else if (typeof globalThis.markdownit === 'function') { + const mdIt = globalThis.markdownit({ html: false, linkify: true, breaks: true, }); rawHtml = mdIt.render(md); } else { - // Minimal fallback: paragraphs + bold/italic with HTML escaping for security + // Minimal fallback: escape first, then apply lightweight markdown formatting + // so generated tags are not re-escaped into visible literal text. + const renderInline = (text) => + foundry.utils + .escapeHTML(String(text ?? '')) + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/\n/g, '
    '); + rawHtml = md .replace(/\r\n/g, '\n') - .replace( - /\*\*(.*?)\*\*/g, - (match, p1) => `${foundry.utils.escapeHTML(p1)}` - ) - .replace( - /_(.*?)_/g, - (match, p1) => `${foundry.utils.escapeHTML(p1)}` - ) .split(/\n{2,}/) - .map((p) => `

    ${foundry.utils.escapeHTML(p.trim())}

    `) + .map((p) => p.trim()) + .filter(Boolean) + .map((p) => `

    ${renderInline(p)}

    `) .join(''); } return foundry?.utils?.TextEditor?.cleanHTML @@ -1013,6 +1037,8 @@ export class Utils { item: 'Archivist - Items', location: 'Archivist - Locations', faction: 'Archivist - Factions', + journal: 'Archivist - Journals', + quest: 'Archivist - Quests', }; console.log( @@ -1040,6 +1066,8 @@ export class Utils { item: 'Archivist - Items', location: 'Archivist - Locations', faction: 'Archivist - Factions', + journal: 'Archivist - Journals', + quest: 'Archivist - Quests', }; const name = map[String(type || '').toLowerCase()]; @@ -1110,6 +1138,8 @@ export class Utils { location: 'archivist-sync.LocationPageSheetV2', faction: 'archivist-sync.FactionPageSheetV2', recap: 'archivist-sync.RecapPageSheetV2', + journal: 'archivist-sync.JournalPageSheetV2', + quest: 'archivist-sync.QuestPageSheetV2', }; const normalizedType = String(sheetType || '').toLowerCase(); const sheetClass = sheetClassMap[normalizedType] || ''; diff --git a/scripts/services/archivist-api.js b/scripts/services/archivist-api.js index 38bb05a..42bdfcb 100644 --- a/scripts/services/archivist-api.js +++ b/scripts/services/archivist-api.js @@ -67,6 +67,9 @@ export class ArchivistApiService { if ('summary' in p) { p.summary = this._sanitizeText(p.summary); } + if ('content' in p) { + p.content = this._sanitizeText(p.content); + } if ('title' in p && typeof p.title === 'string') { p.title = String(p.title); } @@ -79,6 +82,104 @@ export class ArchivistApiService { return p; } + /** + * Normalize Quest API payloads from snake_case to the camelCase shape used in the module. + * Preserve original keys as well so older call sites continue to work. + * @param {object} quest + * @returns {object} + */ + _normalizeQuestResponse(quest) { + const q = quest ? { ...quest } : {}; + q.questName = q.questName ?? q.quest_name ?? ''; + q.questGiver = q.questGiver ?? q.quest_giver ?? ''; + q.questGiverId = q.questGiverId ?? q.quest_giver_id ?? null; + q.questCategory = q.questCategory ?? q.quest_category ?? 'n/a'; + q.successDefinition = + q.successDefinition ?? q.success_definition ?? ''; + q.failureConditions = + q.failureConditions ?? q.failure_conditions ?? ''; + q.nextAction = q.nextAction ?? q.next_action ?? ''; + q.progressLog = q.progressLog ?? q.progress_log ?? []; + q.progressLogEntries = + q.progressLogEntries ?? q.progress_log_entries ?? []; + q.relatedCharacters = + q.relatedCharacters ?? q.related_characters ?? []; + q.relatedFactions = q.relatedFactions ?? q.related_factions ?? []; + q.relatedLocations = + q.relatedLocations ?? q.related_locations ?? []; + q.relatedItems = q.relatedItems ?? q.related_items ?? []; + q.relatedEntityRefs = + q.relatedEntityRefs ?? q.related_entity_refs ?? []; + q.firstSession = q.firstSession ?? q.first_session ?? null; + q.lastSession = q.lastSession ?? q.last_session ?? null; + return q; + } + + /** + * Normalize quest writes to the API's snake_case contract. + * @param {object} payload + * @returns {object} + */ + _normalizeQuestPayload(payload) { + const p = payload ? { ...payload } : {}; + + if (!('campaign_id' in p) && 'campaignId' in p) p.campaign_id = p.campaignId; + if (!('campaign_id' in p) && 'worldId' in p) p.campaign_id = p.worldId; + if (!('quest_name' in p) && 'questName' in p) p.quest_name = p.questName; + if (!('quest_giver' in p) && 'questGiver' in p) p.quest_giver = p.questGiver; + if (!('quest_giver_id' in p) && 'questGiverId' in p) p.quest_giver_id = p.questGiverId; + if (!('quest_category' in p) && 'questCategory' in p) p.quest_category = p.questCategory; + if (!('success_definition' in p) && 'successDefinition' in p) { + p.success_definition = p.successDefinition; + } + if (!('failure_conditions' in p) && 'failureConditions' in p) { + p.failure_conditions = p.failureConditions; + } + if (!('next_action' in p) && 'nextAction' in p) p.next_action = p.nextAction; + if (!('progress_log' in p) && 'progressLog' in p) p.progress_log = p.progressLog; + if (!('progress_log_entries' in p) && 'progressLogEntries' in p) { + p.progress_log_entries = p.progressLogEntries; + } + if (!('related_characters' in p) && 'relatedCharacters' in p) { + p.related_characters = p.relatedCharacters; + } + if (!('related_factions' in p) && 'relatedFactions' in p) { + p.related_factions = p.relatedFactions; + } + if (!('related_locations' in p) && 'relatedLocations' in p) { + p.related_locations = p.relatedLocations; + } + if (!('related_items' in p) && 'relatedItems' in p) { + p.related_items = p.relatedItems; + } + if (!('related_entity_refs' in p) && 'relatedEntityRefs' in p) { + p.related_entity_refs = p.relatedEntityRefs; + } + if (!('first_session' in p) && 'firstSession' in p) p.first_session = p.firstSession; + if (!('last_session' in p) && 'lastSession' in p) p.last_session = p.lastSession; + + delete p.campaignId; + delete p.worldId; + delete p.questName; + delete p.questGiver; + delete p.questGiverId; + delete p.questCategory; + delete p.successDefinition; + delete p.failureConditions; + delete p.nextAction; + delete p.progressLog; + delete p.progressLogEntries; + delete p.relatedCharacters; + delete p.relatedFactions; + delete p.relatedLocations; + delete p.relatedItems; + delete p.relatedEntityRefs; + delete p.firstSession; + delete p.lastSession; + + return p; + } + /** * Internal fetch helper * @param {string} apiKey @@ -467,7 +568,7 @@ export class ArchivistApiService { : Array.isArray(data.data) ? data.data : []; - all.push(...items); + all.push(...items.map((quest) => this._normalizeQuestResponse(quest))); const totalPages = typeof data.pages === 'number' ? data.pages @@ -504,7 +605,7 @@ export class ArchivistApiService { method: 'POST', body: JSON.stringify(norm), }); - return { success: true, data }; + return { success: true, data: this._normalizeQuestResponse(data) }; } catch (error) { const isRateLimited = error.message?.includes('429') || @@ -1060,6 +1161,351 @@ export class ArchivistApiService { } } + /** + * List all journals for a campaign (auto-paginate) + * @param {string} apiKey + * @param {string} campaignId + * @returns {Promise<{success:boolean,data:Array}>} + */ + async listJournals(apiKey, campaignId) { + try { + let page = 1; + const size = 100; + const all = []; + while (true) { + const data = await this._request( + apiKey, + `/journals?campaign_id=${encodeURIComponent(campaignId)}&page=${page}&size=${size}`, + { method: 'GET' } + ); + const items = Array.isArray(data) + ? data + : Array.isArray(data.data) + ? data.data + : []; + all.push(...items); + if (items.length < size) break; + page += 1; + } + return { success: true, data: all }; + } catch (error) { + console.error( + `${CONFIG.MODULE_TITLE} | Failed to list journals:`, + error + ); + return { + success: false, + message: error.message || 'Failed to list journals', + }; + } + } + + /** + * Get a single journal entry by ID + * @param {string} apiKey + * @param {string} journalId + * @returns {Promise<{success:boolean,data?:object}>} + */ + async getJournal(apiKey, journalId) { + try { + const data = await this._request( + apiKey, + `/journals/${encodeURIComponent(journalId)}`, + { method: 'GET' } + ); + return { success: true, data }; + } catch (error) { + console.error( + `${CONFIG.MODULE_TITLE} | Failed to get journal:`, + error + ); + return { + success: false, + message: error.message || 'Failed to get journal', + }; + } + } + + /** + * Create a journal entry + * @param {string} apiKey + * @param {object} payload - { world_id, title, content?, ... } + */ + async createJournal(apiKey, payload) { + const entityName = payload?.title || 'Unknown Journal'; + try { + const norm = this._normalizePayload(payload); + const data = await this._request(apiKey, `/journals`, { + method: 'POST', + body: JSON.stringify(norm), + }); + return { success: true, data }; + } catch (error) { + const isRateLimited = + error.message?.includes('429') || + error.message?.includes('rate limited'); + const isNetworkError = + error.message?.includes('Network error') || + error.message?.includes('Failed to fetch'); + console.error(`${CONFIG.MODULE_TITLE} | Failed to create journal:`, { + error: error.message, + payload: entityName, + isRateLimited, + isNetworkError, + }); + return { + success: false, + message: error.message || 'Failed to create journal', + retryable: isRateLimited || isNetworkError, + entityName, + entityType: 'Journal', + }; + } + } + + /** + * Update a journal entry (PUT — full replace) + * @param {string} apiKey + * @param {object} payload - { id, title?, content?, ... } + */ + async updateJournal(apiKey, payload) { + const entityName = payload?.title || 'Unknown Journal'; + try { + const norm = this._normalizePayload(payload); + const data = await this._request(apiKey, `/journals`, { + method: 'PUT', + body: JSON.stringify(norm), + }); + return { success: true, data }; + } catch (error) { + console.error(`${CONFIG.MODULE_TITLE} | Failed to update journal:`, { + error: error.message, + entityName, + }); + return { + success: false, + message: error.message || 'Failed to update journal', + entityName, + entityType: 'Journal', + }; + } + } + + /** + * Delete a journal entry + * @param {string} apiKey + * @param {string} journalId + */ + async deleteJournal(apiKey, journalId) { + try { + const data = await this._request( + apiKey, + `/journals?id=${encodeURIComponent(journalId)}`, + { method: 'DELETE' } + ); + return { success: true, data }; + } catch (error) { + console.error( + `${CONFIG.MODULE_TITLE} | Failed to delete journal:`, + error + ); + return { + success: false, + message: error.message || 'Failed to delete journal', + }; + } + } + + /** + * List all journal folders for a campaign (no pagination — returns all) + * @param {string} apiKey + * @param {string} campaignId + * @returns {Promise<{success:boolean,data:Array}>} + */ + async listJournalFolders(apiKey, campaignId) { + try { + const data = await this._request( + apiKey, + `/journal-folders?campaign_id=${encodeURIComponent(campaignId)}`, + { method: 'GET' } + ); + const items = Array.isArray(data) + ? data + : Array.isArray(data.data) + ? data.data + : []; + return { success: true, data: items }; + } catch (error) { + console.error( + `${CONFIG.MODULE_TITLE} | Failed to list journal folders:`, + error + ); + return { + success: false, + message: error.message || 'Failed to list journal folders', + }; + } + } + + /** + * List all quests for a campaign (auto-paginate) + * @param {string} apiKey + * @param {string} campaignId + * @returns {Promise<{success:boolean,data:Array}>} + */ + async listQuests(apiKey, campaignId) { + try { + let page = 1; + const size = 100; + const all = []; + while (true) { + const data = await this._request( + apiKey, + `/quests?campaign_id=${encodeURIComponent(campaignId)}&page=${page}&size=${size}`, + { method: 'GET' } + ); + const items = Array.isArray(data) + ? data + : Array.isArray(data.data) + ? data.data + : []; + all.push(...items); + const totalPages = + typeof data.pages === 'number' + ? data.pages + : items.length < size + ? page + : page + 1; + if (page >= totalPages || items.length < size) break; + page += 1; + } + return { success: true, data: all }; + } catch (error) { + console.error(`${CONFIG.MODULE_TITLE} | Failed to list quests:`, error); + return { + success: false, + message: error.message || 'Failed to list quests', + }; + } + } + + /** + * Get a single quest by ID (full QuestRead with objectives, progress, etc.) + * @param {string} apiKey + * @param {string} questId + * @returns {Promise<{success:boolean,data?:object}>} + */ + async getQuest(apiKey, questId) { + try { + const data = await this._request( + apiKey, + `/quests/${encodeURIComponent(questId)}`, + { method: 'GET' } + ); + return { success: true, data }; + } catch (error) { + console.error(`${CONFIG.MODULE_TITLE} | Failed to get quest:`, error); + return { + success: false, + message: error.message || 'Failed to get quest', + }; + } + } + + /** + * Create a quest + * @param {string} apiKey + * @param {object} payload - { worldId, questName, ... } + */ + async createQuest(apiKey, payload) { + const normalized = this._normalizeQuestPayload(payload); + const entityName = + normalized?.quest_name || payload?.questName || 'Unknown Quest'; + try { + const data = await this._request(apiKey, `/quests`, { + method: 'POST', + body: JSON.stringify(normalized), + }); + return { success: true, data: this._normalizeQuestResponse(data) }; + } catch (error) { + const isRateLimited = + error.message?.includes('429') || + error.message?.includes('rate limited'); + const isNetworkError = + error.message?.includes('Network error') || + error.message?.includes('Failed to fetch'); + console.error(`${CONFIG.MODULE_TITLE} | Failed to create quest:`, { + error: error.message, + payload: entityName, + isRateLimited, + isNetworkError, + }); + return { + success: false, + message: error.message || 'Failed to create quest', + retryable: isRateLimited || isNetworkError, + entityName, + entityType: 'Quest', + }; + } + } + + /** + * Update a quest (PATCH) + * @param {string} apiKey + * @param {string} questId + * @param {object} payload + */ + async updateQuest(apiKey, questId, payload) { + const normalized = this._normalizeQuestPayload(payload); + const entityName = + normalized?.quest_name || payload?.questName || 'Unknown Quest'; + try { + const data = await this._request( + apiKey, + `/quests/${encodeURIComponent(questId)}`, + { + method: 'PATCH', + body: JSON.stringify(normalized), + } + ); + return { success: true, data: this._normalizeQuestResponse(data) }; + } catch (error) { + console.error(`${CONFIG.MODULE_TITLE} | Failed to update quest:`, { + error: error.message, + entityName, + }); + return { + success: false, + message: error.message || 'Failed to update quest', + entityName, + entityType: 'Quest', + }; + } + } + + /** + * Delete a quest + * @param {string} apiKey + * @param {string} questId + */ + async deleteQuest(apiKey, questId) { + try { + const data = await this._request( + apiKey, + `/quests/${encodeURIComponent(questId)}`, + { method: 'DELETE' } + ); + return { success: true, data }; + } catch (error) { + console.error(`${CONFIG.MODULE_TITLE} | Failed to delete quest:`, error); + return { + success: false, + message: error.message || 'Failed to delete quest', + }; + } + } + /** * List Links for a campaign * @param {string} apiKey diff --git a/scripts/sidebar/ask-chat-tab.js b/scripts/sidebar/ask-chat-tab.js index 82731e4..b7d261a 100644 --- a/scripts/sidebar/ask-chat-tab.js +++ b/scripts/sidebar/ask-chat-tab.js @@ -110,7 +110,12 @@ export function registerArchivistSidebarTab(sidebarHtml) { li.appendChild(btn); const menu = tabsNav.querySelector('menu.flexcol') || tabsNav; - menu.appendChild(li); + const beforeNode = menu.lastElementChild; + if (beforeNode) { + menu.insertBefore(li, beforeNode); + } else { + menu.appendChild(li); + } } // Add content panel if missing diff --git a/styles/archivist-sync.css b/styles/archivist-sync.css index 0d6e0c8..8460460 100644 --- a/styles/archivist-sync.css +++ b/styles/archivist-sync.css @@ -2007,4 +2007,847 @@ img.archivist-icon { .recap-edit-btn i { font-size: 12px; +} + +/* ===== World Setup Wizard ===== */ + +.world-setup-dialog { + --setup-primary: #4a90e2; + --setup-success: #47d16a; + --setup-warning: #ffb020; + --setup-danger: #dc3545; + --setup-bg: rgba(35, 38, 44, 1); + --setup-bg-alt: rgba(42, 45, 51, 1); + --setup-border: rgba(255, 255, 255, 0.12); + --setup-text: #e8e8e8; + --setup-text-muted: #b9b9b9; + --setup-panel: rgba(255, 255, 255, 0.03); +} + +.world-setup-dialog .window-content { + padding: 0; + background: var(--as-main-bg); +} + +.setup-header { + background: var(--as-accent80); + color: var(--as-main-text); + padding: 20px; + text-align: center; +} + +.setup-header h2 { + margin: 0 0 10px 0; + font-size: 24px; + font-weight: 600; +} + +.setup-header p { + margin: 0; + opacity: 0.9; + font-size: 14px; +} + +.progress-container { + background: color-mix(in srgb, var(--as-border), transparent 70%); + height: 4px; + margin-top: 15px; + border-radius: 2px; + overflow: hidden; +} + +.progress-fill { + background: var(--as-accent); + height: 100%; + transition: width 0.3s ease; + border-radius: 2px; +} + +.world-setup-dialog #campaign-select { + min-height: 44px; + line-height: 1.4; + padding: 10px 12px; +} + +.world-setup-dialog #campaign-select option { + padding: 8px 10px; + line-height: 1.5; +} + +.world-setup-dialog .setup-form-group select { + min-height: 44px; + line-height: 1.4; + padding: 10px 12px; +} + +.world-setup-dialog .setup-form-group select option { + padding: 8px 10px; + line-height: 1.5; +} + +.setup-content { + padding: 16px; + min-height: 300px; + display: flex; + flex-direction: column; +} + +/* Step indicator with labels */ +.step-indicator { + display: flex; + justify-content: center; + align-items: flex-start; + gap: 0; + margin-bottom: 30px; +} + +.step-pip { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 60px; +} + +.step-pip .step-number { + width: 30px; + height: 30px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; + background: var(--setup-border); + color: var(--setup-text); +} + +.step-pip.active .step-number { + background: var(--setup-primary); + color: var(--setup-bg); +} + +.step-pip.completed .step-number { + background: var(--setup-success); + color: var(--setup-bg); +} + +.step-label { + font-size: 11px; + color: var(--setup-text-muted); + text-align: center; + white-space: nowrap; +} + +.step-pip.active .step-label { + color: var(--setup-primary); + font-weight: 600; +} + +.step-pip.completed .step-label { + color: var(--setup-success); +} + +.step-connector { + width: 20px; + height: 2px; + background: var(--setup-border); + margin-top: 14px; + flex-shrink: 0; +} + +.step-connector.completed { + background: var(--setup-success); +} + +.step-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 20px; +} + +.step-title { + font-size: 18px; + font-weight: 600; + color: var(--as-main-text); + margin: 0 0 10px 0; +} + +.step-description { + color: var(--as-text-muted); + line-height: 1.5; + margin-bottom: 20px; +} + +.setup-form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.setup-form-group label { + font-weight: 600; + color: var(--as-main-text); +} + +.setup-form-group select, +.setup-form-group input { + padding: 10px; + border: 1px solid var(--as-border); + border-radius: 4px; + font-size: 14px; +} + +.setup-actions { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-top: 1px solid var(--as-border); + background: var(--as-sidebar-bg); +} + +.setup-btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.setup-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.setup-btn.primary { + background: var(--as-accent); + color: white; +} + +.setup-btn.primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--as-accent), black 15%); +} + +.setup-btn.secondary { + background: var(--as-border-light); + color: var(--as-main-text); +} + +.setup-btn.secondary:hover:not(:disabled) { + background: color-mix(in srgb, var(--as-border), var(--as-main-bg) 60%); +} + +.setup-btn.success { + background: var(--as-success); + color: white; +} + +.setup-btn.success:hover:not(:disabled) { + background: color-mix(in srgb, var(--as-success), black 15%); +} + +.world-info-box { + background: var(--as-ui); + border: 1px solid var(--as-border); + border-radius: 8px; + padding: 15px; + margin: 10px 0; +} + +.world-info-box h4 { + margin: 0 0 10px 0; + color: var(--as-main-text); +} + +.world-info-box p { + margin: 5px 0; + color: #6c757d; + font-size: 14px; +} + +.status-indicator { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 4px; + font-size: 14px; + font-weight: 600; +} + +.status-indicator.success { + background: color-mix(in srgb, var(--as-success), transparent 90%); + color: var(--as-success); +} + +.status-indicator.warning { + background: rgba(255, 193, 7, 0.1); + color: var(--setup-warning); +} + +.loading-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--as-border); + border-top: 2px solid var(--as-accent); + border-radius: 50%; + animation: ws-spin 1s linear infinite; +} + +.ws-spinner-lg { + width: 32px; + height: 32px; + border-width: 3px; +} + +@keyframes ws-spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* Reconciliation rows */ +.world-setup-dialog .ws-select-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} + +.world-setup-dialog .ws-select-row input[type="checkbox"] { + flex-shrink: 0; +} + +.world-setup-dialog .ws-select-row .ws-name { + flex: 1 1 0; + min-width: 0; + background: var(--setup-panel); + color: var(--setup-text); + border: 1px solid var(--setup-border); + border-radius: 4px; + padding: 6px 10px; + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + height: 36px; +} + +.world-setup-dialog .ws-select-row .ws-name img { + display: inline-block; + width: 20px; + height: 20px; + border-radius: 50%; + flex-shrink: 0; +} + +.world-setup-dialog .ws-select-row select { + flex: 1 1 0; + min-width: 0; + background: var(--setup-panel); + color: var(--setup-text); + border: 1px solid var(--setup-border); + border-radius: 4px; + padding: 6px 10px; + height: 36px; + line-height: 1.2; +} + +.world-setup-dialog .ws-select-header { + font-weight: 600; + margin-bottom: 6px; +} + +.world-setup-dialog .ws-select-header span { + padding: 6px 10px; +} + +.world-setup-dialog .ws-select-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 6px; +} + +/* Matched row highlight */ +.world-setup-dialog .ws-row-matched { + background: color-mix(in srgb, var(--setup-success), transparent 90%); + border-radius: 4px; + padding: 2px; +} + +.world-setup-dialog .ws-row-matched .ws-name { + border-color: color-mix(in srgb, var(--setup-success), transparent 60%); +} + +/* Empty state placeholder */ +.world-setup-dialog .ws-empty-state { + padding: 12px; + text-align: center; + color: var(--as-text-muted); + font-style: italic; + background: var(--setup-panel); + border: 1px solid var(--setup-border); + border-radius: 4px; +} + +/* Loading state */ +.world-setup-dialog .ws-loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 40px 20px; + color: var(--setup-text); +} + +.world-setup-dialog .ws-loading-title { + font-size: 16px; +} + +.world-setup-dialog .ws-loading-subtitle { + font-size: 14px; + opacity: 0.75; +} + +/* Recon grid layout */ +.world-setup-dialog .ws-recon-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +/* Recon toolbar */ +.world-setup-dialog .ws-recon-toolbar { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +/* Helper note */ +.world-setup-dialog .ws-helper-note { + color: var(--as-text-muted); + font-size: 13px; + margin-top: 6px; +} + +.world-setup-dialog .ws-helper-note i { + margin-right: 4px; + color: var(--setup-primary); +} + +/* Progress logs */ +.world-setup-dialog .ws-progress-log { + max-height: 200px; + overflow: auto; + background: var(--as-ui); + color: var(--as-main-text); + border: 1px solid var(--as-border); + padding: 8px; + border-radius: 6px; +} + +.world-setup-dialog .ws-progress-log ul { + margin: 0; + padding-left: 16px; +} + +.world-setup-dialog .ws-progress-log li { + margin: 3px 0; + line-height: 1.35; +} + +/* Summary table */ +.world-setup-dialog .ws-summary-table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; +} + +.world-setup-dialog .ws-summary-table th { + text-align: center; + padding: 6px; + border-bottom: 1px solid var(--as-border); +} + +.world-setup-dialog .ws-summary-table .ws-th-left { + text-align: left; +} + +.world-setup-dialog .ws-summary-table td { + text-align: center; + padding: 6px; +} + +.world-setup-dialog .ws-summary-table .ws-td-left { + text-align: left; +} + +.world-setup-dialog .ws-summary-table .ws-danger { + color: var(--setup-danger); + font-weight: 600; +} + +/* Sync action buttons */ +.world-setup-dialog .ws-sync-actions { + text-align: right; + margin-top: 10px; +} + +/* Reconciliation tabs */ +.world-setup-dialog nav.ws-recon-nav { + display: flex; + flex-direction: row; + gap: 8px; + border-bottom: 1px solid var(--as-border); + margin-bottom: 10px; + padding-bottom: 0; +} + +.world-setup-dialog nav.ws-recon-nav .item { + padding: 8px 12px 5px; + border-radius: 4px 4px 0 0; + cursor: pointer; + background: transparent; + color: var(--as-text-muted); + text-decoration: none; + transition: color 0.2s ease; + font-weight: 600; + position: relative; +} + +.world-setup-dialog nav.ws-recon-nav .item:hover { + color: var(--as-main-text); +} + +.world-setup-dialog nav.ws-recon-nav .item.active { + color: var(--as-main-text); +} + +.world-setup-dialog nav.ws-recon-nav .item.active::after { + content: ""; + position: absolute; + left: 10px; + right: 10px; + bottom: 1px; + height: 3px; + border-radius: 999px; + background: var(--setup-primary); +} + +.world-setup-dialog nav.ws-recon-nav .item:not(.active) { + opacity: 1; +} + +.world-setup-dialog .ws-recon-content { + padding-top: 0; +} + +.world-setup-dialog .ws-recon-content .tab { + display: none; +} + +.world-setup-dialog .ws-recon-content .tab.active { + display: block; +} + +/* ── Journal Sheet ───────────────────────────────── */ + +.archivist-sheet-journal .journal-subtitle { + display: block; + text-align: center; + font-style: italic; + opacity: 0.7; + margin-bottom: 0.5rem; +} + +.archivist-sheet-journal .journal-import-notice { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + margin: 0 0.5rem 0.75rem; + border-radius: 6px; + background: rgba(100, 140, 200, 0.12); + border: 1px solid rgba(100, 140, 200, 0.3); + font-size: 0.8rem; + color: var(--as-main-text, #ddd); + line-height: 1.35; +} + +.archivist-sheet-journal .journal-import-notice i { + flex-shrink: 0; + font-size: 0.9rem; + opacity: 0.8; +} + +/* ── Quest Sheet ─────────────────────────────────── */ + +.archivist-sheet-quest .quest-badges { + display: flex; + gap: 0.4rem; + justify-content: center; + flex-wrap: wrap; + margin: 0.35rem 0 0.25rem; +} + +.quest-status-badge, +.quest-category-badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.15rem 0.55rem; + border-radius: 10px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.quest-status-badge { + color: #fff; +} + +.quest-status-badge.quest-status-planned { + background: #5a7ec2; +} + +.quest-status-badge.quest-status-in-progress { + background: #c49a30; +} + +.quest-status-badge.quest-status-blocked { + background: #a55; +} + +.quest-status-badge.quest-status-failed { + background: #8b3a3a; +} + +.quest-status-badge.quest-status-done { + background: #3a8b4a; +} + +.quest-status-badge.quest-status-n\/a { + background: #666; +} + +.quest-category-badge { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + color: var(--as-main-text, #ccc); +} + +.archivist-avatar-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 120px; + height: 120px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 2.5rem; + color: rgba(255, 255, 255, 0.3); + margin: 0 auto; +} + +/* Quest metadata fields */ + +.quest-metadata { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.quest-field-group { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.quest-field-group label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.65; +} + +.quest-field-group span { + font-size: 0.85rem; + line-height: 1.4; +} + +/* Quest section headings */ + +.quest-section-heading { + font-size: 0.9rem; + font-weight: 600; + margin: 0 0 0.5rem; + padding-bottom: 0.3rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Objectives */ + +.quest-objectives-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.quest-objective { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.35rem 0.5rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); +} + +.quest-objective-icon { + flex-shrink: 0; + margin-top: 0.15rem; + font-size: 0.8rem; +} + +.quest-objective-completed .quest-objective-icon { + color: #4caf50; +} + +.quest-objective-failed .quest-objective-icon { + color: #e53935; +} + +.quest-objective-blocked .quest-objective-icon { + color: #a55; +} + +.quest-objective-in-progress .quest-objective-icon { + color: #c49a30; +} + +.quest-objective-pending .quest-objective-icon { + opacity: 0.4; +} + +.quest-objective-text { + font-size: 0.85rem; + line-height: 1.4; +} + +.quest-objective-completed .quest-objective-text { + text-decoration: line-through; + opacity: 0.6; +} + +/* Progress log */ + +.quest-progress-log { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.quest-progress-entry { + display: flex; + align-items: flex-start; + gap: 0.45rem; + padding: 0.3rem 0.5rem; + font-size: 0.85rem; + line-height: 1.4; + border-left: 2px solid rgba(255, 255, 255, 0.1); + padding-left: 0.75rem; +} + +.quest-progress-icon { + flex-shrink: 0; + margin-top: 0.2rem; + font-size: 0.7rem; + opacity: 0.4; +} + +/* Related entity links */ + +.quest-related-section { + margin-bottom: 0.75rem; +} + +.quest-related-section h4 { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + opacity: 0.6; + margin: 0 0 0.3rem; +} + +.quest-link-chip { + display: inline-block; + padding: 0.15rem 0.5rem; + margin: 0.1rem 0.2rem; + border-radius: 10px; + font-size: 0.78rem; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.quest-empty-state { + font-size: 0.8rem; + font-style: italic; + opacity: 0.45; + padding: 0.25rem 0; +} + +/* Quest edit mode buttons */ + +.quest-add-btn, +.quest-remove-btn { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.06); + color: var(--as-main-text, #ccc); + cursor: pointer; + margin-bottom: 0.5rem; +} + +.quest-add-btn:hover, +.quest-remove-btn:hover { + background: rgba(255, 255, 255, 0.12); +} + +.quest-remove-btn { + padding: 0.15rem 0.35rem; + margin-left: auto; + opacity: 0.6; +} + +.quest-remove-btn:hover { + opacity: 1; + color: #e53935; } \ No newline at end of file diff --git a/styles/sync-dialog.css b/styles/sync-dialog.css index a9f4d69..b892176 100644 --- a/styles/sync-dialog.css +++ b/styles/sync-dialog.css @@ -9,6 +9,8 @@ max-height: 100%; } +/* Toolbar */ + .sync-toolbar { display: flex; align-items: center; @@ -45,15 +47,12 @@ } @keyframes spin { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } +/* Loading */ + .loading-panel { display: flex; flex-direction: column; @@ -73,6 +72,18 @@ animation: spin 1s linear infinite; } +.sync-progress-text { + font-weight: 600; + font-size: 15px; +} + +.sync-progress-detail { + font-size: 13px; + opacity: 0.7; +} + +/* Panels */ + .panel { display: flex; flex-direction: column; @@ -83,22 +94,38 @@ .panel-header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; gap: 12px; flex-wrap: wrap; } +.panel-title-group { + display: flex; + flex-direction: column; + gap: 2px; +} + .panel-header h3 { margin: 0; font-size: 16px; font-weight: 600; } +.panel-subtitle { + margin: 0; + font-size: 12px; + color: var(--arch-text-muted, #999); + line-height: 1.4; +} + .panel-header .bulk { display: flex; gap: 8px; + flex-shrink: 0; } +/* Tables */ + .table-wrap { overflow: visible; border: 1px solid var(--arch-border); @@ -153,11 +180,44 @@ background: rgba(255, 176, 32, 0.1); } -.diff-table tbody tr.deleted-row { - opacity: 0.6; - text-decoration: line-through; +/* Column sizing */ + +.col-checkbox { width: 40px; } +.col-type { width: 120px; } +.col-indicator { width: 80px; text-align: center; } +.col-create-new { width: 140px; text-align: center; } +.cell-center { text-align: center; } + +.sync-check-icon { + color: var(--arch-success); +} + +/* Delete rows */ + +.diff-table tbody tr.sync-deleted-row { + background: rgba(197, 48, 48, 0.08); + opacity: 1; +} + +.diff-table tbody tr.sync-deleted-row:hover { + background: rgba(197, 48, 48, 0.15); } +.diff-table tbody tr.sync-deleted-row td { + color: var(--arch-danger, #c53030); +} + +.diff-table tbody tr.sync-deleted-row .badge.danger { + background: var(--arch-danger, #c53030); + color: white; +} + +.diff-table tbody tr.sync-deleted-row .badge.danger i { + margin-right: 3px; +} + +/* Badges */ + .diff-table .badge { display: inline-block; padding: 2px 6px; @@ -169,6 +229,8 @@ color: white; } +/* Checkboxes */ + .diff-table input[type="checkbox"], .import-table input[type="checkbox"] { cursor: pointer; @@ -179,6 +241,37 @@ opacity: 0.3; } +.core-toggle-label { + display: inline-flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; +} + +.core-toggle-label span { + text-transform: capitalize; +} + +/* Empty states */ + +.sync-empty-state { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 16px; + border: 1px dashed var(--arch-border); + border-radius: 4px; + color: var(--arch-text-muted, #999); + font-size: 13px; +} + +.sync-empty-state i { + font-size: 18px; + color: var(--arch-success, #48bb78); +} + +/* Footer */ + .sync-footer { display: flex; gap: 8px; @@ -203,10 +296,20 @@ border-color: var(--arch-accent, #ffb020); } -.sync-footer button.primary:hover { +.sync-footer button.primary:hover:not(:disabled) { filter: brightness(1.1); } +.sync-footer button.primary:disabled { + opacity: 0.5; + cursor: not-allowed; + filter: none; +} + +.sync-footer button.primary i { + margin-right: 6px; +} + .sync-footer button.secondary { background: var(--arch-panel); color: var(--arch-text); @@ -215,4 +318,4 @@ .sync-footer button.secondary:hover { background: var(--arch-bg-alt); border-color: var(--arch-accent); -} \ No newline at end of file +} diff --git a/templates/sheets/journal.hbs b/templates/sheets/journal.hbs new file mode 100644 index 0000000..c63edbe --- /dev/null +++ b/templates/sheets/journal.hbs @@ -0,0 +1,39 @@ +
    +
    +
    +
    + {{#if setup.editingInfo}} + + {{else}} +

    {{setup.name}}

    + {{/if}} +
    + Journal + {{#if setup.isGM}} + + {{/if}} +
    + {{#if setup.isGM}} +
    + + Edits sync to Archivist. Folder structure is a point-in-time snapshot and not kept in sync. +
    + {{/if}} +
    +
    + {{#if setup.editingInfo}} + + {{{htmlContent}}} + + {{else}} + {{{htmlContent}}} + {{/if}} +
    +
    +
    +
    diff --git a/templates/sheets/quest.hbs b/templates/sheets/quest.hbs new file mode 100644 index 0000000..ce41007 --- /dev/null +++ b/templates/sheets/quest.hbs @@ -0,0 +1,159 @@ +
    + +
    +
    +
    + {{#if setup.editingInfo}} + + {{{htmlContent}}} + + {{else}} + {{{htmlContent}}} + {{/if}} +
    + +
    +
    +

    Objectives

    + {{#if setup.editingInfo}} + + {{/if}} +
      + {{#each quest.objectives}} +
    1. + {{this.text}} + {{#if ../setup.editingInfo}} + + {{/if}} +
    2. + {{/each}} +
    +
    +
    +

    Progress Log

    +
      + {{#each quest.progressLog}} +
    • {{this}}
    • + {{/each}} +
    +
    +
    +

    Related Entities

    + + + + +
    +
    + {{#if setup.isGM}} +
    + + {{{setup.gmNotes}}} + +
    + {{else}} +
    +

    GM-only notes are not visible to players.

    +
    + {{/if}} +
    +
    +
    diff --git a/templates/sync-dialog.hbs b/templates/sync-dialog.hbs index c70f7a1..3cb9f88 100644 --- a/templates/sync-dialog.hbs +++ b/templates/sync-dialog.hbs @@ -1,91 +1,113 @@
    - {{#if isLoading}} + {{#if syncProgress}}
    - Loading... + Processing {{syncProgress.processed}} / {{syncProgress.total}}… + {{#if syncProgress.current}}{{syncProgress.current}}{{/if}} +
    + {{else if isLoading}} +
    + + Comparing Archivist and Foundry data…
    {{else}}
    -
    -

    Review Diffs ({{stats.diffs}})

    +
    +

    Review Changes ({{stats.diffs}})

    +

    These Foundry journals have changes in Archivist. Check the ones you want to update.

    +
    + {{#if stats.diffs}}
    + {{/if}}
    + {{#if stats.diffs}}
    - - + + - - - - - + + + + + {{#each diffs as |d|}} + class="{{#if d.selected}}selected{{/if}} {{#if d.deleted}}sync-deleted-row{{/if}}" + {{#if d.deleted}}title="This entity was deleted in Archivist. Syncing will permanently remove the Foundry journal."{{/if}}> - - - - - {{/each}}
    TypeType NameTitleDescriptionImageDateLinksNameDescriptionImageDateLinks
    {{d.type}} {{d.name}} - {{#if d.deleted}}Deleted{{/if}} + {{#if d.deleted}} + Deleted + {{/if}} - {{#if d.changes.name}}{{/if}} + + {{#if d.changes.name}}{{/if}} - {{#if d.changes.description}}{{/if}} + + {{#if d.changes.description}}{{/if}} - {{#if d.changes.image}}{{/if}} + + {{#if d.changes.image}}{{/if}} - {{#if d.changes.sessionDate}}{{/if}} + + {{#if d.changes.sessionDate}}{{/if}} - {{#if d.changes.links}}{{/if}} + + {{#if d.changes.links}}{{/if}}
    + {{else}} +
    + + No changes detected — your Foundry journals are up to date with Archivist. +
    + {{/if}}
    -

    Import New ({{stats.imports}})

    +
    +

    Import New ({{stats.imports}})

    +

    These Archivist entities don't have a Foundry journal yet. Check the ones you want to import.

    +
    + {{#if stats.imports}}
    + {{/if}}
    + {{#if stats.imports}}
    - - + + - + @@ -94,11 +116,11 @@ - @@ -107,11 +129,19 @@
    TypeType NameCreate NewCreate New
    {{r.type}} {{r.name}} + {{#if r.coreType}} -
    + {{else}} +
    + + No new Archivist entities to import. +
    + {{/if}}
    {{/if}} -
    \ No newline at end of file + diff --git a/templates/world-setup-dialog.hbs b/templates/world-setup-dialog.hbs index eb3dab6..6a93bbf 100644 --- a/templates/world-setup-dialog.hbs +++ b/templates/world-setup-dialog.hbs @@ -1,389 +1,5 @@
    -

    {{localize "ARCHIVIST_SYNC.worldSetup.title"}}

    @@ -397,17 +13,17 @@
    {{#each steps}} -
    - {{#if (lt this ../currentStep)}} - - {{else}} - {{this}} - {{/if}} +
    +
    + {{#if (lt this.num ../currentStep)}} + + {{else}} + {{this.num}} + {{/if}} +
    + {{this.label}}
    - {{#unless @last}}
    -
    {{/unless}} + {{#unless @last}}
    {{/unless}} {{/each}}
    @@ -441,12 +57,6 @@
    -
    - -
    {{/if}} @@ -454,35 +64,36 @@ {{#if isStep4}} -

    Reconciliation

    +

    Reconcile Foundry & Archivist

    -

    Match and select which documents you wish to sync between Foundry and Archivist. If these lists are - empty, you can proceed to the next step.

    +

    Check items you want to sync. Use the dropdown to match Archivist entities to existing Foundry + documents. Matched items will be linked; unmatched checked items will be imported or exported.

    +

    Factions and session recaps are always + imported in full from Archivist and are not shown here.

    -
    -
    {{#if isLoading}} -
    - - Loading reconciliation data… - Fetching Archivist and Foundry documents +
    + + Loading reconciliation data… + Fetching Archivist and Foundry documents
    {{else}} {{#unless setupData.hasAnyCandidates}} -
    +
    No Foundry or Archivist candidates detected. You can proceed to the next step.
    {{/unless}} @@ -497,29 +108,28 @@
    {{!-- Characters Tab --}}
    -
    +
    -
    +
    - Archivist Characters - Match + Archivist Characters + Match to Foundry
    {{#if setupData.reconcile.characters.archivist.length}} -
      +
        {{#each setupData.reconcile.characters.archivist}} -
      • +
      • - {{#if this.img}}{{/if}} + {{#if this.img}}{{/if}} {{this.name}} - Foundry Actors - Match + Foundry Actors + Match
    {{#if setupData.reconcile.characters.foundry.length}} -
      +
        {{#each setupData.reconcile.characters.foundry}} -
      • +
      • - {{#if this.img}}{{/if}} + {{#if this.img}}{{/if}} {{this.name}} - Archivist Items - Match + Archivist Items + Match to Foundry
    {{#if setupData.reconcile.items.archivist.length}} -
      +
        {{#each setupData.reconcile.items.archivist}} -
      • +
      • - {{#if this.img}}{{/if}}{{this.name}} + {{#if this.img}}{{/if}}{{this.name}} - Foundry Items - Match + Foundry Items + Match to Archivist
    {{#if setupData.reconcile.items.foundry.length}} -
      +
        {{#each setupData.reconcile.items.foundry}} -
      • +
      • - {{#if this.img}}{{/if}}{{this.name}} + {{#if this.img}}{{/if}}{{this.name}} - Archivist Locations - Match + Archivist Locations + Match to Foundry
    {{#if setupData.reconcile.locations.archivist.length}} -
      +
        {{#each setupData.reconcile.locations.archivist}} -
      • +
      • - {{#if this.img}}{{/if}}{{this.name}} + {{#if this.img}}{{/if}}{{this.name}} - Foundry Scenes - Match + Foundry Scenes + Match to Archivist
    {{#if setupData.reconcile.locations.foundry.length}} -
      +
        {{#each setupData.reconcile.locations.foundry}} -
      • +
      • - {{#if this.img}}{{/if}}{{this.name}} + {{#if this.img}}{{/if}}{{this.name}}