diff --git a/module.json b/module.json index 54a1177..ed65b8d 100644 --- a/module.json +++ b/module.json @@ -11,8 +11,8 @@ "type": "module", "version": "2.0.0", "compatibility": { - "minimum": "14.359", - "verified": "13.351" + "minimum": "13.351", + "verified": "14.359" }, "relationships": { "systems": [], diff --git a/scripts/archivist-sync.js b/scripts/archivist-sync.js index 4d535b3..656efe5 100644 --- a/scripts/archivist-sync.js +++ b/scripts/archivist-sync.js @@ -1238,9 +1238,10 @@ function installRealtimeSyncListeners() { }); return; } else if (sheetType === 'quest') { - res = await archivistApi.updateQuest(apiKey, flags.archivistId, { - questName: parent?.name || page.name, - }); + // Quest page content is not a synced API field (quests use structured + // fields like successDefinition, not a free description), and name + // changes are handled by the updateJournalEntry title-sync hook. So a + // page-content edit has nothing to push — skip the redundant PATCH. return; } else if (sheetType === 'journal') { res = await archivistApi.updateJournal(apiKey, { @@ -1248,7 +1249,6 @@ function installRealtimeSyncListeners() { title: parent?.name || page.name, content: Utils.toMarkdownIfHtml?.(html) || html, }); - return; } if (res && !res?.success && res?.isDescriptionTooLong) { diff --git a/scripts/dialogs/sync-dialog.js b/scripts/dialogs/sync-dialog.js index 7984d62..7959070 100644 --- a/scripts/dialogs/sync-dialog.js +++ b/scripts/dialogs/sync-dialog.js @@ -67,6 +67,9 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi this._lastToggleClick = row; return; } + // Range-select handles the clicked row itself; stop the event here so + // the root-level toggleRow action doesn't flip it back afterwards. + e.stopPropagation(); const allRows = [...this.element.querySelectorAll('tr[data-id]')]; const startIdx = allRows.indexOf(this._lastToggleClick); const endIdx = allRows.indexOf(row); @@ -102,6 +105,34 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi btn.disabled = !hasSelected; } + /** + * Update the in-progress panel's text directly rather than re-rendering the + * whole dialog for every synced item. The panel is rendered once before the + * sync loop begins; a skipped update (panel absent) is corrected by the + * final render when syncProgress is cleared. + * @private + */ + _updateProgressUI() { + const panel = this.element?.querySelector?.('.loading-panel'); + if (!panel || !this.syncProgress) return; + const { processed, total, current } = this.syncProgress; + const textEl = panel.querySelector('.sync-progress-text'); + if (textEl) { + textEl.textContent = `Processing ${processed} / ${total}…`; + } + let detailEl = panel.querySelector('.sync-progress-detail'); + if (current) { + if (!detailEl) { + detailEl = document.createElement('span'); + detailEl.className = 'sync-progress-detail'; + panel.appendChild(detailEl); + } + detailEl.textContent = current; + } else if (detailEl) { + detailEl.remove(); + } + } + _captureScrollPosition() { const content = this.element?.querySelector?.('.sync-dialog-content'); if (content) { @@ -244,7 +275,9 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi // Confirm deletions before proceeding const deleteDiffs = selectedDiffs.filter((d) => d.deleted); if (deleteDiffs.length > 0) { - const names = deleteDiffs.map((d) => d.name).join(', '); + const names = deleteDiffs + .map((d) => foundry.utils.escapeHTML(String(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?
`, @@ -267,7 +300,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi for (const d of selectedDiffs) { this.syncProgress.current = `${d.type}: ${d.name}`; this.syncProgress.processed = processed; - await this.render(); + this._updateProgressUI(); try { await this._applyDiff(d); } catch (e) { @@ -280,7 +313,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi for (const i of selectedImports) { this.syncProgress.current = `${i.type}: ${i.name}`; this.syncProgress.processed = processed; - await this.render(); + this._updateProgressUI(); try { await this._applyImport(i, campaignId, apiKey); } catch (e) { @@ -289,6 +322,9 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi } processed++; } + // Reflect the final processed count before the panel is torn down. + this.syncProgress.processed = processed; + this._updateProgressUI(); this.syncProgress = null; @@ -677,7 +713,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi type, id, name: row.character_name || row.name || row.title || 'Untitled', - description: row.description || row.summary || '', + description: row.description || row.summary || row.content || '', image: row.image || '', selected: false, createCore: false, @@ -862,7 +898,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi : null; if (!sheetType) return; // Convert markdown from Archivist to HTML for Foundry storage (sessions use summary) - const markdownContent = String(row.description || row.summary || ''); + const markdownContent = String(row.description || row.summary || row.content || ''); const htmlContent = Utils.markdownToStoredHtml(markdownContent); // Determine folder ID based on sheet type using saved destinations @@ -882,10 +918,16 @@ 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 === 'journal') { + // Worlds set up before journals existed have no saved destination — + // fall back to ensuring the default folder like recaps do. + folderId = + destinations.journal || + (await Utils.ensureJournalFolder('Archivist - Journals')); + } else if (sheetType === 'quest') { + folderId = + destinations.quest || + (await Utils.ensureJournalFolder('Archivist - Quests')); } else if (sheetType === 'recap') { // For sessions/recaps, use Recaps folder and preserve session_date ordering folderId = await Utils.ensureJournalFolder('Recaps'); @@ -1160,7 +1202,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi else if (itemId) targetDoc = game.items?.get?.(itemId) || null; else if (sceneId) targetDoc = game.scenes?.get?.(sceneId) || null; - const md = String(row.description || row.summary || ''); + const md = String(row.description || row.summary || row.content || ''); const html = Utils.markdownToStoredHtml(md); if (targetDoc) { diff --git a/scripts/dialogs/world-setup-dialog.js b/scripts/dialogs/world-setup-dialog.js index 4878497..582b850 100644 --- a/scripts/dialogs/world-setup-dialog.js +++ b/scripts/dialogs/world-setup-dialog.js @@ -1828,6 +1828,26 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica // Removed Archivist journal folder setup; journals are created only in user destinations + /** + * Update the Step 6 progress bar/text in place instead of re-rendering the + * whole wizard step for every imported entity. The panel is rendered once + * before the import loops begin; the final render after the loops reflects + * the completed state. + * @private + */ + _updateSyncStatusUI() { + const root = this.element; + if (!root || !this.syncStatus?.total) return; + const { processed, total, current } = this.syncStatus; + const pct = total > 0 ? Math.round((processed / total) * 100) : 0; + const fill = root.querySelector('.ws-sync-progress-fill'); + if (fill) fill.style.width = `${pct}%`; + const text = root.querySelector('.ws-sync-progress-text'); + if (text) { + text.textContent = `${processed} / ${total} — ${current || ''}`; + } + } + async _importArchivistMissing(apiKey, campaignId) { try { const allCharacters = this.archivistCandidates?.characters || []; @@ -2385,13 +2405,13 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica this.syncStatus.current = `Import ${c.type || c.character_type || 'PC'}: ${c.character_name || c.name}`; await createActor(c); this.syncStatus.processed++; - await this.render(); + this._updateSyncStatusUI(); } for (const it of items) { this.syncStatus.current = `Import Item: ${it.name}`; await createItem(it); this.syncStatus.processed++; - await this.render(); + this._updateSyncStatusUI(); } locations.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')) @@ -2400,7 +2420,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica this.syncStatus.current = `Import Location: ${l.name || l.title}`; await upsertIntoContainer(l, 'Location'); this.syncStatus.processed++; - await this.render(); + this._updateSyncStatusUI(); } factions.sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')) @@ -2409,7 +2429,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica this.syncStatus.current = `Import Faction: ${f.name || f.title}`; await upsertIntoContainer(f, 'Faction'); this.syncStatus.processed++; - await this.render(); + this._updateSyncStatusUI(); } // Import Journals with folder structure (GM-only, point-in-time snapshot) @@ -2497,7 +2517,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica console.warn('[World Setup] Failed to import journal', j, e); } this.syncStatus.processed++; - await this.render(); + this._updateSyncStatusUI(); } ui.notifications?.info?.( 'Journals imported as GM-only. Folder structure reflects the import snapshot and will not auto-sync.' @@ -2575,7 +2595,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica console.warn('[World Setup] Failed to import quest', q, e); } this.syncStatus.processed++; - await this.render(); + this._updateSyncStatusUI(); } // After creating journals and pages, hydrate link graph from Archivist Links diff --git a/scripts/modules/projection/merge.js b/scripts/modules/projection/merge.js index b693b27..61d2529 100644 --- a/scripts/modules/projection/merge.js +++ b/scripts/modules/projection/merge.js @@ -28,14 +28,14 @@ export function mergeArchivistSection(existingHtml, archivistHtml) { } /** - * Strip HTML down to plain text via Foundry's TextEditor if available. + * Strip HTML down to plain text. Foundry exposes no core strip-to-text + * helper (in v13 or v14), so parse into a detached element and read its + * text content. * @param {string} html */ export function stripHtml(html) { try { const s = String(html ?? ''); - const te = foundry?.utils?.TextEditor; - if (te?.stripHTML) return te.stripHTML(s); const tmp = document.createElement('div'); tmp.innerHTML = s; return (tmp.textContent || '').trim(); diff --git a/scripts/modules/sheets/page-sheet-v2.js b/scripts/modules/sheets/page-sheet-v2.js index d51ce36..272d40b 100644 --- a/scripts/modules/sheets/page-sheet-v2.js +++ b/scripts/modules/sheets/page-sheet-v2.js @@ -944,13 +944,8 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( 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; - } + // The API imposes no length limit on journal content, so there is no + // too-long case to surface here (unlike Character/Item/etc.). } } catch (e) { console.warn('[Archivist Sync][V2] commit: remote sync failed', e); @@ -1976,13 +1971,24 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { form: { template: 'modules/archivist-sync/templates/sheets/quest.hbs' }, }; - static TABS = { - archivist: { - navSelector: '.archivist-nav', - contentSelector: '.archivist-content', - initial: 'info', - }, - }; + /** + * Localize a key, returning null when i18n is unavailable or the key is + * missing (Foundry returns the key unchanged on a miss) so callers can fall + * back with `||`. + * @param {string|null} key + * @returns {string|null} + * @private + */ + static _localize(key) { + if (!key) return null; + try { + const localized = game?.i18n?.localize?.(key); + if (localized && localized !== key) return localized; + } catch (_) { + /* i18n not ready */ + } + return null; + } async _prepareContext(_options) { const ctx = await super._prepareContext(_options); @@ -2030,14 +2036,39 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { firstSession: qd.firstSession || qd.first_session || null, lastSession: qd.lastSession || qd.last_session || null, }; - ctx.quest.statusLabel = { + // Status/category labels come from lang/en.json when available, falling + // back to English defaults if i18n isn't ready or a key is missing. Status + // values use hyphens (e.g. 'in-progress') while lang keys are camelCase. + const statusKeyMap = { + planned: 'planned', + 'in-progress': 'inProgress', + blocked: 'blocked', + failed: 'failed', + done: 'done', + }; + const statusFallback = { planned: 'Planned', 'in-progress': 'In Progress', blocked: 'Blocked', failed: 'Failed', done: 'Done', 'n/a': 'N/A', - }[ctx.quest.status] || ctx.quest.status; + }; + const categoryFallback = { + main: 'Main', + side: 'Side', + faction: 'Faction', + personal: 'Personal', + 'n/a': '', + }; + ctx.quest.statusLabel = + QuestPageSheetV2._localize( + statusKeyMap[ctx.quest.status] + ? `ARCHIVIST_SYNC.quest.status.${statusKeyMap[ctx.quest.status]}` + : null + ) || + statusFallback[ctx.quest.status] || + ctx.quest.status; ctx.quest.statusIcon = { planned: 'fa-compass', 'in-progress': 'fa-hourglass-half', @@ -2046,13 +2077,14 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { 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] || ''; + ctx.quest.categoryLabel = + QuestPageSheetV2._localize( + ctx.quest.questCategory && ctx.quest.questCategory !== 'n/a' + ? `ARCHIVIST_SYNC.quest.category.${ctx.quest.questCategory}` + : null + ) || + categoryFallback[ctx.quest.questCategory] || + ''; return ctx; } @@ -2161,12 +2193,7 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { ? [...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); + await this._persistObjectives(flags, qd, objectives); } async _removeObjective(idx) { @@ -2177,11 +2204,45 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { ? [...qd.objectives] : []; objectives.splice(idx, 1); + await this._persistObjectives(flags, qd, objectives); + } + + /** + * Store the objective list on the journal flags and push it to Archivist. + * A PATCH with `objectives` replaces the server-side list, so the full array + * is sent. Local flags are updated regardless of remote success so the sheet + * stays responsive when offline or unconfigured. + * @param {object} flags - current archivist flag bag + * @param {object} qd - current questData snapshot + * @param {Array} objectives - next objective list + * @private + */ + async _persistObjectives(flags, qd, objectives) { qd.objectives = objectives; await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', { ...flags, questData: qd, }); + try { + const apiKey = settingsManager.getApiKey?.(); + const questId = flags.archivistId; + if (apiKey && questId) { + const result = await archivistApi.updateQuest(apiKey, questId, { + objectives, + }); + if (result && !result.success) { + console.warn( + '[Archivist Sync][V2] Quest objective sync failed', + result.message + ); + ui.notifications?.warn?.( + 'Objective saved locally but could not sync to Archivist.' + ); + } + } + } catch (e) { + console.warn('[Archivist Sync][V2] Quest objective sync error', e); + } this.render(false); } } diff --git a/scripts/modules/utils.js b/scripts/modules/utils.js index 11bb73b..2fa372b 100644 --- a/scripts/modules/utils.js +++ b/scripts/modules/utils.js @@ -456,13 +456,19 @@ export class Utils { * Convert Markdown to sanitized HTML suitable for storage in Actor/Item fields. * - Prefer a global MarkdownIt instance if available * - Fall back to a minimal converter for basic syntax - * - Always sanitize with Foundry's TextEditor.cleanHTML + * - Always sanitize with Foundry's cleanHTML helper * @param {string} markdown * @returns {string} sanitized HTML */ static markdownToStoredHtml(markdown) { const md = String(markdown ?? ''); try { + // v14+: foundry.utils.cleanHTML. Older builds exposed it via + // foundry.utils.TextEditor.cleanHTML or the global TextEditor. + const cleanHTML = + foundry?.utils?.cleanHTML ?? + foundry?.utils?.TextEditor?.cleanHTML ?? + globalThis.TextEditor?.cleanHTML; const trimmed = md.trim(); const isProbablyHtml = !!trimmed && @@ -471,9 +477,7 @@ export class Utils { /&(?:lt|gt|amp|quot|#39);/i.test(trimmed)); if (isProbablyHtml) { - return foundry?.utils?.TextEditor?.cleanHTML - ? foundry.utils.TextEditor.cleanHTML(md) - : md; + return cleanHTML ? cleanHTML(md) : md; } let rawHtml = ''; @@ -511,9 +515,7 @@ export class Utils { .map((p) => `${renderInline(p)}
`) .join(''); } - return foundry?.utils?.TextEditor?.cleanHTML - ? foundry.utils.TextEditor.cleanHTML(rawHtml) - : rawHtml; + return cleanHTML ? cleanHTML(rawHtml) : rawHtml; } catch (_) { return String(markdown || ''); } diff --git a/scripts/modules/utils.js.bak b/scripts/modules/utils.js.bak deleted file mode 100644 index 69fa90f..0000000 --- a/scripts/modules/utils.js.bak +++ /dev/null @@ -1,1033 +0,0 @@ - /** - * Return the current system id in lowercase (e.g., "dnd5e", "pf2e"). - */ - static getSystemId() { - return String(game?.system?.id || '').toLowerCase(); -} - - /** - * Safely get the first non-empty string value from an object using a list of dot-paths. - * @param {object} obj - * @param {string[]} paths - * @returns {string} - */ - static pickFirstProperty(obj, paths = []) { - const get = (o, path) => { - try { - if (foundry?.utils?.getProperty) return foundry.utils.getProperty(o, path); - } catch (_) { } - return String(path).split('.').reduce((acc, k) => (acc && k in acc ? acc[k] : undefined), o); - }; - for (const p of paths) { - const v = get(obj, p); - if (typeof v === 'string' && v.trim()) return v; - } - return ''; -} - - /** - * Compute the preferred READ paths for an Actor description based on system and actor type. - * @param {Actor} actor - * @returns {string[]} - */ - static getActorDescriptionReadPaths(actor) { - const sysId = this.getSystemId(); - const isPC = String(actor?.type || '').toLowerCase() === 'character'; - const isNPC = String(actor?.type || '').toLowerCase() === 'npc'; - - if (sysId === 'dnd5e') return ['system.details.biography.value', 'system.details.biography.public', 'system.description.value']; - - if (sysId === 'pf2e') { - if (isPC) return ['system.details.biography.backstory', 'system.details.publicNotes', 'system.description.value']; - if (isNPC) return ['system.details.notes.description', 'system.details.publicNotes', 'system.description.value']; - return ['system.details.biography.backstory', 'system.details.notes.description', 'system.details.publicNotes', 'system.description.value']; - } - - // Generic fallbacks - return [ - 'system.details.biography.value', - 'system.details.biography.public', - 'system.description.value', - 'system.details.description', - 'system.details.publicNotes', - 'system.description', - ]; -} - - /** - * Compute the preferred WRITE path for projecting an Actor description back into the system. - * @param {Actor} actor - * @returns {string} A dot-path suitable for Actor.update({ [path]: html }) - */ - static getActorDescriptionWritePath(actor) { - const sysId = this.getSystemId(); - const isPC = String(actor?.type || '').toLowerCase() === 'character'; - const isNPC = String(actor?.type || '').toLowerCase() === 'npc'; - - if (sysId === 'dnd5e') return 'system.details.biography.value'; - - if (sysId === 'pf2e') { - if (isPC) return 'system.details.biography.backstory'; - if (isNPC) return 'system.details.notes.description'; - return 'system.details.publicNotes'; - } - - // Generic destination - return 'system.description.value'; -} - - /** - * Read an Actor description as plain text (Markdown-like) for ingest. - * @param {Actor} actor - * @returns {string} - */ - static readActorDescription(actor) { - const paths = this.getActorDescriptionReadPaths(actor); - // Strip to text/markdown for ingest using our existing HTML->MD helper - const htmlOrText = this.pickFirstProperty(actor, paths); - return this.toMarkdownIfHtml(htmlOrText); -} - - /** - * Project a description onto an Actor at the proper system path. - * Accepts Markdown and converts to sanitized HTML for storage. - * @param {Actor} actor - * @param {string} markdown - */ - static async projectActorDescription(actor, markdown) { - const destPath = this.getActorDescriptionWritePath(actor); - const html = this.markdownToStoredHtml(String(markdown ?? '')); - await actor.update({ [destPath]: html }); - return destPath; -} -import { CONFIG } from './config.js'; - -/** - * Utility functions for Archivist Sync Module - */ -export class Utils { - /** - * Resolve a valid Item type for the current system. - * - Prefer a cached value after first resolution - * - Fallback to 'loot' if available; otherwise first defined item type - * @returns {string} valid item type id for Item.create - */ - static getDefaultItemType() { - try { - if (this.__defaultItemType && typeof this.__defaultItemType === 'string') { - console.log('[Utils.getDefaultItemType] Using cached default type:', this.__defaultItemType); - return this.__defaultItemType; - } - console.log('[Utils.getDefaultItemType] Discovering system Item types...'); - const types = (() => { - try { - const meta = CONFIG?.Item?.documentClass?.metadata?.types; - console.log('[Utils.getDefaultItemType] CONFIG.Item.documentClass.metadata.types:', meta); - if (Array.isArray(meta) && meta.length) return meta.map(t => String(t).toLowerCase()); - } catch (e) { - console.warn('[Utils.getDefaultItemType] Failed to read CONFIG metadata:', e); - } - try { - const model = game?.system?.model?.Item; - const keys = model ? Object.keys(model) : []; - console.log('[Utils.getDefaultItemType] game.system.model.Item keys:', keys); - if (keys.length) return keys.map(t => String(t).toLowerCase()); - } catch (e) { - console.warn('[Utils.getDefaultItemType] Failed to read system.model.Item:', e); - } - try { - const docTypes = game?.system?.documentTypes?.Item; - console.log('[Utils.getDefaultItemType] game.system.documentTypes.Item:', docTypes); - if (Array.isArray(docTypes) && docTypes.length) return docTypes.map(t => String(t).toLowerCase()); - if (docTypes && typeof docTypes === 'object') { - const keys = Object.keys(docTypes); - if (keys.length) return keys.map(t => String(t).toLowerCase()); - } - } catch (e) { - console.warn('[Utils.getDefaultItemType] Failed to read system.documentTypes.Item:', e); - } - console.warn('[Utils.getDefaultItemType] No system types found, returning empty array'); - return []; - })(); - console.log('[Utils.getDefaultItemType] Resolved types array:', types); - const picked = types[0] || 'loot'; - console.log('[Utils.getDefaultItemType] Picked default type:', picked, '(will fallback to "loot" if types empty)'); - this.__defaultItemType = picked; - return picked; - } catch (e) { - console.error('[Utils.getDefaultItemType] Outer catch, returning "loot":', e); - return 'loot'; - } - } - - /** - * Resolve a safe Item type from a source descriptor with fallback to system default. - * @param {any} source - object that may include type/item_type/category - * @returns {string} valid item type id - */ - static resolveItemType(source) { - try { - console.log('[Utils.resolveItemType] Called with source:', { type: source?.type, item_type: source?.item_type, category: source?.category }); - const types = (() => { - try { - const meta = CONFIG?.Item?.documentClass?.metadata?.types; - console.log('[Utils.resolveItemType] CONFIG.Item.documentClass.metadata.types:', meta); - if (Array.isArray(meta) && meta.length) return meta.map(t => String(t).toLowerCase()); - } catch (_) { } - try { - const model = game?.system?.model?.Item; - const keys = model ? Object.keys(model) : []; - console.log('[Utils.resolveItemType] game.system.model.Item keys:', keys); - if (keys.length) return keys.map(t => String(t).toLowerCase()); - } catch (_) { } - try { - const docTypes = game?.system?.documentTypes?.Item; - console.log('[Utils.resolveItemType] game.system.documentTypes.Item:', docTypes); - if (Array.isArray(docTypes) && docTypes.length) return docTypes.map(t => String(t).toLowerCase()); - if (docTypes && typeof docTypes === 'object') { - const keys = Object.keys(docTypes); - if (keys.length) return keys.map(t => String(t).toLowerCase()); - } - } catch (_) { } - return []; - })(); - console.log('[Utils.resolveItemType] Available system types:', types); - const typeSet = new Set(types); - const raw = String(source?.type ?? source?.item_type ?? source?.category ?? '') - .trim() - .toLowerCase(); - console.log('[Utils.resolveItemType] Raw type from source:', raw); - if (raw && typeSet.has(raw)) { - console.log('[Utils.resolveItemType] Raw type is valid, returning:', raw); - return raw; - } - - // Helper: pick the first that exists in current system types - const pick = (candidates) => { - for (const c of candidates) { - const t = String(c).toLowerCase(); - if (typeSet.has(t)) return t; - } - return null; - }; - - // Common normalizations and aliases across systems - // Prefer system-specific types when available (e.g., PF2e uses 'treasure' instead of 'loot') - const alias = ( - pick([ - // Loot-like - raw.match(/loot|treasure|generic/) ? 'treasure' : null, - // Equipment/armor/weapons - raw.match(/^armor|^equipment/) ? 'equipment' : null, - raw.match(/weapon/) ? 'weapon' : null, - // Consumables - raw.match(/consum/) ? 'consumable' : null, - // Containers - raw.match(/pack|bag|backpack/) ? 'backpack' : null, - // Others - raw.match(/feat|ability/) ? 'feat' : null, - raw.match(/tool/) ? 'tool' : null, - raw.match(/spell/) ? 'spell' : null, - ].filter(Boolean)) - ); - if (alias) { - console.log('[Utils.resolveItemType] Found alias match:', alias); - return alias; - } - - // As a final attempt, map generic buckets to something safe - console.log('[Utils.resolveItemType] No alias match, trying generic fallbacks...'); - const generic = pick(['equipment', 'treasure', 'weapon', 'consumable']); - if (generic) { - console.log('[Utils.resolveItemType] Found generic fallback:', generic); - return generic; - } - - console.log('[Utils.resolveItemType] No generic fallback, getting default type...'); - const deflt = this.getDefaultItemType(); - console.log('[Utils.resolveItemType] Default type:', deflt, 'typeSet size:', typeSet.size); - if (!typeSet.size || typeSet.has(deflt)) { - console.log('[Utils.resolveItemType] Returning default:', deflt); - return deflt; - } - const final = types[0] || deflt || 'equipment'; - console.log('[Utils.resolveItemType] Final fallback:', final); - return final; - } catch (e) { - console.error('[Utils.resolveItemType] Exception, returning default:', e); - return this.getDefaultItemType(); - } - } - /** - * Convert HTML to Markdown (naive conversion: strip HTML tags, keep text) - * @param {any} value - The value to convert - * @returns {string} Plain text - */ - static toMarkdownIfHtml(value) { - const s = String(value ?? ''); - if (!s) return ''; - try { - // Insert explicit newlines for common block elements before stripping tags - let pre = s - .replace(/\r\n/g, '\n') - .replace(/${foundry.utils.escapeHTML(p.trim())}
`) - .join(''); - } - return foundry?.utils?.TextEditor?.cleanHTML - ? foundry.utils.TextEditor.cleanHTML(rawHtml) - : rawHtml; - } catch (_) { - return String(markdown || ''); - } - } - - /** - * Get current Foundry world information - * @returns {object} World information object - */ - static getFoundryWorldInfo() { - return { - id: game.world.id, - title: game.world.title, - description: game.world.description || 'No description', - }; - } - - /** - * Get character actors from the current world - * @returns {Array} Array of character and NPC actors - */ - static getCharacterActors() { - return game.actors.contents.filter(actor => actor.type === 'character' || actor.type === 'npc'); - } - - /** - * Get journal entries that likely represent Factions (by folder name or flag) - * @returns {Array.
- * @param {string} text
- * @returns {string}
- */
- static stripLeadingImage(text) {
- const s = String(text || '');
- if (!s) return '';
- // Common patterns: Markdown image at start, possibly followed by blank line
- const mdImg = /^(?:\s*)!\[[^\]]*\]\([^\)]+\)\s*(?:\n+)?/;
- if (mdImg.test(s)) return s.replace(mdImg, '').trimStart();
- // HTML possibly wrapped in
at the very start - const htmlImgP = /^(?:\s*)
]*>\s* .
- * @param {string} text
- * @returns {string}
- */
- _stripLeadingImage(text) {
- const s = String(text || '');
- if (!s) return '';
- // Common patterns: Markdown image at start, possibly followed by blank line
- const mdImg = /^(?:\s*)!\[[^\]]*\]\([^\)]+\)\s*(?:\n+)?/;
- if (mdImg.test(s)) return s.replace(mdImg, '').trimStart();
- // HTML at the very start
- const htmlImgP = /^(?:\s*) ]*>\s*]*>\s*<\/p>\s*/i;
- if (htmlImgP.test(s)) return s.replace(htmlImgP, '').trimStart();
- const htmlImg = /^(?:\s*)
]*>\s*/i;
- if (htmlImg.test(s)) return s.replace(htmlImg, '').trimStart();
- return s;
- }
-
- /**
- * Ensure a journal has a single primary text page with provided content.
- * Works across Foundry versions (v10+ with pages collection).
- * @param {JournalEntry} journal
- * @param {string} content
- */
- static async ensureJournalTextPage(journal, content) {
- // v10+ API: JournalEntryPage documents under journal.pages
- const pagesCollection = journal.pages;
- const safeContent = String(content ?? '');
-
- console.log(`[Utils] ensureJournalTextPage:`, {
- journalId: journal?.id,
- journalName: journal?.name,
- contentLength: safeContent.length,
- contentPreview: safeContent.substring(0, 100),
- });
-
- if (pagesCollection) {
- const pages =
- pagesCollection.contents ?? (Array.isArray(pagesCollection) ? pagesCollection : []);
- const textPage = pages.find(p => p.type === 'text');
- if (textPage) {
- await textPage.update({ text: { content: safeContent, markdown: safeContent, format: 2 } });
- } else {
- await journal.createEmbeddedDocuments('JournalEntryPage', [
- {
- name: 'Description',
- type: 'text',
- text: { content: safeContent, markdown: safeContent, format: 2 },
- },
- ]);
- }
- return;
- }
- // Fallback (older Foundry versions) — use JournalEntry content
- await journal.update({ content: safeContent });
- }
-
- /**
- * Set a journal's thumbnail image (img property) to the provided URL.
- * Does not modify journal content or pages.
- * @param {JournalEntry} journal
- * @param {string} imageUrl
- */
- static async ensureJournalLeadImage(journal, imageUrl) {
- try {
- const url = String(imageUrl || '').trim();
- if (!url) return;
- console.debug('[Archivist Sync] ensureJournalLeadImage()', { journalId: journal?.id, url });
- // Set the journal thumbnail so it shows in lists
- try {
- await journal.update({ img: url });
- } catch (e) {
- console.debug('[Archivist Sync] journal img update failed', e);
- }
- } catch (e) {
- console.warn('[Archivist Sync] Failed to set journal lead image:', e);
- }
- }
-
- /**
- * Flags helpers for mapping Archivist IDs
- */
- static getActorArchivistId(actor) {
- return actor.getFlag(CONFIG.MODULE_ID, 'archivistId');
- }
-
- static async setActorArchivistId(actor, id, worldId) {
- await actor.setFlag(CONFIG.MODULE_ID, 'archivistId', id);
- if (worldId) await actor.setFlag(CONFIG.MODULE_ID, 'archivistWorldId', worldId);
- return true;
- }
-
- static getJournalArchivistMeta(journal) {
- return {
- id: journal.getFlag(CONFIG.MODULE_ID, 'archivistId') || null,
- type: journal.getFlag(CONFIG.MODULE_ID, 'archivistType') || null,
- worldId: journal.getFlag(CONFIG.MODULE_ID, 'archivistWorldId') || null,
- };
- }
-
- static async setJournalArchivistMeta(journal, id, type, worldId) {
- await journal.setFlag(CONFIG.MODULE_ID, 'archivistId', id);
- if (type) await journal.setFlag(CONFIG.MODULE_ID, 'archivistType', type);
- if (worldId) await journal.setFlag(CONFIG.MODULE_ID, 'archivistWorldId', worldId);
- }
-
- /**
- * Get Archivist metadata from a JournalEntryPage
- * @param {JournalEntryPage} page
- */
- static getPageArchivistMeta(page) {
- return {
- id: page?.getFlag?.(CONFIG.MODULE_ID, 'archivistId') || null,
- type: page?.getFlag?.(CONFIG.MODULE_ID, 'archivistType') || null,
- worldId: page?.getFlag?.(CONFIG.MODULE_ID, 'archivistWorldId') || null,
- };
- }
-
- /**
- * Set Archivist metadata on a JournalEntryPage
- * @param {JournalEntryPage} page
- * @param {string} id
- * @param {string} type
- * @param {string} worldId
- */
- static async setPageArchivistMeta(page, id, type, worldId) {
- if (!page) return;
- if (id) await page.setFlag(CONFIG.MODULE_ID, 'archivistId', id);
- if (type) await page.setFlag(CONFIG.MODULE_ID, 'archivistType', type);
- if (worldId) await page.setFlag(CONFIG.MODULE_ID, 'archivistWorldId', worldId);
- }
-
- /**
- * Ensure a single root-level JournalEntry exists as a container
- * @param {string} name
- * @returns {Promise
, or wrapped in
possibly wrapped in
]*>\s*<\/p>\s*/i;
- if (htmlImgP.test(s)) return s.replace(htmlImgP, '').trimStart();
- const htmlImg = /^(?:\s*)
]*>\s*/i;
- if (htmlImg.test(s)) return s.replace(htmlImg, '').trimStart();
- return s;
- }
-
/**
* Normalize payload text fields to Markdown and optionally strip leading image.
* - Converts known text fields that can contain rich text (description, summary)
@@ -61,7 +41,7 @@ export class ArchivistApiService {
if ('description' in p) {
p.description = this._sanitizeText(p.description);
if (opts.stripImageFromDescription) {
- p.description = this._stripLeadingImage(p.description);
+ p.description = Utils.stripLeadingImage(p.description);
}
}
if ('summary' in p) {
@@ -122,62 +102,75 @@ export class ArchivistApiService {
*/
_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;
+ const out = {};
+
+ // The API's QuestCreate/QuestUpdate schemas declare `extra="forbid"`, so
+ // any key they don't recognize rejects the whole request with a 422. Build
+ // a strict whitelist of writable fields only — read-only response fields
+ // (questGiverId, progressLogEntries, firstSession, lastSession, counts,
+ // orderIndex, timestamps) must never leak into a write payload.
+
+ // campaign/world id is accepted on create only (QuestUpdate has no such
+ // field); only forward it when a caller actually supplied one.
+ const campaignId = p.campaign_id ?? p.campaignId ?? p.worldId;
+ if (campaignId !== undefined) out.campaign_id = campaignId;
+
+ // Scalar text / enum fields.
+ const scalars = {
+ quest_name: p.quest_name ?? p.questName,
+ quest_giver: p.quest_giver ?? p.questGiver,
+ quest_category: p.quest_category ?? p.questCategory,
+ status: p.status,
+ success_definition: p.success_definition ?? p.successDefinition,
+ failure_conditions: p.failure_conditions ?? p.failureConditions,
+ next_action: p.next_action ?? p.nextAction,
+ resolution: p.resolution,
+ };
+ for (const [key, value] of Object.entries(scalars)) {
+ if (value !== undefined) out[key] = value;
}
- if (!('related_locations' in p) && 'relatedLocations' in p) {
- p.related_locations = p.relatedLocations;
+
+ // Objectives → [{ text, status }] (drop server-managed id/order). The
+ // API requires text with min_length=1 and rejects the whole request
+ // otherwise, so drop entries with empty text.
+ const objectives = p.objectives;
+ if (Array.isArray(objectives)) {
+ out.objectives = objectives
+ .map((o) =>
+ typeof o === 'string'
+ ? { text: o.trim(), status: 'pending' }
+ : {
+ text: String(o?.text ?? '').trim(),
+ status: o?.status ?? 'pending',
+ }
+ )
+ .filter((o) => o.text.length > 0);
}
- if (!('related_items' in p) && 'relatedItems' in p) {
- p.related_items = p.relatedItems;
+
+ // Progress log → [str].
+ const progressLog = p.progress_log ?? p.progressLog;
+ if (Array.isArray(progressLog)) {
+ out.progress_log = progressLog.map((e) =>
+ typeof e === 'string' ? e : e?.text ?? ''
+ );
}
- if (!('related_entity_refs' in p) && 'relatedEntityRefs' in p) {
- p.related_entity_refs = p.relatedEntityRefs;
+
+ // Related entity name lists → [str].
+ const relatedLists = {
+ related_characters: p.related_characters ?? p.relatedCharacters,
+ related_factions: p.related_factions ?? p.relatedFactions,
+ related_locations: p.related_locations ?? p.relatedLocations,
+ related_items: p.related_items ?? p.relatedItems,
+ };
+ for (const [key, value] of Object.entries(relatedLists)) {
+ if (Array.isArray(value)) out[key] = value;
}
- 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;
+ // Structured related refs pass through untouched.
+ const refs = p.related_entity_refs ?? p.relatedEntityRefs;
+ if (Array.isArray(refs)) out.related_entity_refs = refs;
+
+ return out;
}
/**
@@ -237,21 +230,24 @@ export class ArchivistApiService {
}
}
+ // keepalive lets in-flight writes survive page unload, but Chromium
+ // rejects keepalive requests whose body exceeds 64 KiB — skip it for
+ // large payloads (e.g. long journal content) so they still send.
+ const bodySize =
+ typeof options?.body === 'string'
+ ? new TextEncoder().encode(options.body).length
+ : 0;
+ const useKeepalive = isWrite && bodySize < 60000;
+
while (attempt <= maxRetries) {
try {
- const method = String(options?.method || 'GET').toUpperCase();
- const isWrite =
- method === 'POST' ||
- method === 'PUT' ||
- method === 'PATCH' ||
- method === 'DELETE';
const fetchOptions = {
...options,
headers,
mode: 'cors',
cache: 'no-store',
};
- if (isWrite) fetchOptions.keepalive = true;
+ if (useKeepalive) fetchOptions.keepalive = true;
const response = await fetch(url, fetchOptions);
// Handle successful responses (non-429)
@@ -568,7 +564,7 @@ export class ArchivistApiService {
: Array.isArray(data.data)
? data.data
: [];
- all.push(...items.map((quest) => this._normalizeQuestResponse(quest)));
+ all.push(...items);
const totalPages =
typeof data.pages === 'number'
? data.pages
@@ -1369,7 +1365,9 @@ export class ArchivistApiService {
: Array.isArray(data.data)
? data.data
: [];
- all.push(...items);
+ // Normalize to camelCase so quest reads are consistent with the shape
+ // returned by createQuest/updateQuest/getQuest.
+ all.push(...items.map((quest) => this._normalizeQuestResponse(quest)));
const totalPages =
typeof data.pages === 'number'
? data.pages
diff --git a/scripts/sidebar/ask-chat-sidebar-tab.js b/scripts/sidebar/ask-chat-sidebar-tab.js
deleted file mode 100644
index 405f8b6..0000000
--- a/scripts/sidebar/ask-chat-sidebar-tab.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import { AskChatWindow } from '../dialogs/ask-chat-window.js';
-
-export class AskChatSidebarTab extends foundry.applications.sidebar
- .AbstractSidebarTab {
- static DEFAULT_OPTIONS = foundry.utils.mergeObject(
- foundry.utils.deepClone(super.DEFAULT_OPTIONS),
- {
- id: 'archivist-chat',
- title:
- game.i18n?.localize?.('ARCHIVIST_SYNC.Menu.AskChat.Label') ??
- 'Archivist Chat',
- icon: 'fa-solid fa-sparkles',
- tooltip:
- game.i18n?.localize?.('ARCHIVIST_SYNC.Menu.AskChat.Label') ??
- 'Archivist Chat',
- group: 'primary',
- contentTemplate: 'modules/archivist-sync/templates/ask-chat-window.hbs',
- popOut: false,
- }
- );
-
- async getData(options) {
- try {
- console.log('[Archivist Sync][SidebarTab] getData()', { options });
- } catch (_) {}
- if (!this.chatApp) this.chatApp = new AskChatWindow({ popOut: false });
- return this.chatApp.getData();
- }
-
- async activateListeners(html) {
- try {
- console.log('[Archivist Sync][SidebarTab] activateListeners()', { html });
- } catch (_) {}
- super.activateListeners(html);
- if (!this.chatApp) this.chatApp = new AskChatWindow({ popOut: false });
- const el = this.element;
- if (el) {
- el.style.height = '100%';
- el.style.overflow = 'hidden auto';
- // Mount and render the chat UI into the sidebar panel
- this.chatApp._mountEl = el;
- await this.chatApp.render(false);
- const msgList = el.querySelector?.('.messages');
- if (msgList) msgList.scrollTop = msgList.scrollHeight;
- console.log('[Archivist Sync][SidebarTab] content mounted');
- }
- }
-}
-
-export default AskChatSidebarTab;
diff --git a/templates/world-setup-dialog.hbs b/templates/world-setup-dialog.hbs
index 6a93bbf..0585f80 100644
--- a/templates/world-setup-dialog.hbs
+++ b/templates/world-setup-dialog.hbs
@@ -481,11 +481,11 @@