diff --git a/.cursor/rules/40-archivist-api.mdc b/.cursor/rules/40-archivist-api.mdc index 6435748..6ecd2ba 100644 --- a/.cursor/rules/40-archivist-api.mdc +++ b/.cursor/rules/40-archivist-api.mdc @@ -170,6 +170,52 @@ DELETE /v1/items/{item_id} → 204 ⸻ +Quests + +GET /v1/quests?campaign_id=&page=&size=&search=&status=&quest_category= → paginated list +GET /v1/quests/{quest_id} +POST /v1/quests (worldId*/campaign_id*, questName*, questGiver?, questCategory?, status?, successDefinition?, failureConditions?, nextAction?, resolution?, objectives?, progressLog?, relatedCharacters?, relatedFactions?, relatedLocations?, relatedItems?, relatedEntityRefs?) → 201 +PATCH /v1/quests/{quest_id} — same fields as POST, all optional (partial update) +DELETE /v1/quests/{quest_id} → 204 + +IMPORTANT — casing differs from every other entity above: Quest's Pydantic schemas +declare fields in **camelCase** (`questName`, `successDefinition`, `relatedEntityRefs`, +…) as the primary name, with snake_case accepted only as an alias +(`populate_by_name=True` + an alias_generator). Every other entity in this doc uses +snake_case as canonical. Sending camelCase quest field names is correct and is what +`archivist-sync`'s `_normalizeQuestPayload`/`_normalizeQuestResponse` already assume. + +`objectives`: `[{ text, status }]` where status is one of +`pending | in-progress | completed | failed | blocked`. +`relatedEntityRefs`: `[{ entityType, entityId, entityNameSnapshot?, label? }]` where +entityType is one of `character | faction | location | item` — **Quest has no generic +`description` field**, and **Quest cannot appear in `/v1/campaigns/{id}/links`** (no +`ENTITY_CONFIG` entry on the backend for it, in either direction). Quest relationships +only ever persist via `relatedEntityRefs` on the quest itself — treat Quest as the +single source of truth for its own relationships, not the generic links system. + +⸻ + +Journals + +GET /v1/journals?campaign_id=&page=&size= +GET /v1/journals/{journal_id} +POST /v1/journals (id?, title*, content?, campaign_id*) → 201 +PATCH /v1/journals (id*, title?, content?) → 200 — note: PATCH takes `id` in the body, +not as a path param, unlike every other entity in this doc. +DELETE /v1/journals/{journal_id} → 204 + +⸻ + +Links — PATCH exists, use it instead of delete+recreate + +PATCH /v1/campaigns/{campaign_id}/links/{link_id} (alias?) → 200 +Prefer this over DELETE-then-POST when only a link's label/alias is changing and the +from/to endpoints stay the same — cheaper and avoids a window where the link doesn't +exist. Quest is not a valid `from_type`/`to_type` here (see above). + +⸻ + Client Helpers (use these in this project) Create a tiny wrapper so all calls are consistent and type-safe: diff --git a/module.json b/module.json index 54a1177..d1ea7d6 100644 --- a/module.json +++ b/module.json @@ -12,7 +12,7 @@ "version": "2.0.0", "compatibility": { "minimum": "14.359", - "verified": "13.351" + "verified": "14.359" }, "relationships": { "systems": [], diff --git a/scripts/archivist-sync.js b/scripts/archivist-sync.js index 4d535b3..1b0fe8c 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, { diff --git a/scripts/dialogs/sync-dialog.js b/scripts/dialogs/sync-dialog.js index 7984d62..0fcb207 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'); @@ -968,6 +1010,8 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi fullQuest.relatedLocations || fullQuest.related_locations || [], relatedItems: fullQuest.relatedItems || fullQuest.related_items || [], + relatedEntityRefs: + fullQuest.relatedEntityRefs || fullQuest.related_entity_refs || [], firstSession: fullQuest.firstSession || fullQuest.first_session || null, lastSession: fullQuest.lastSession || fullQuest.last_session || null, }; @@ -1160,7 +1204,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..e1f7d4c 100644 --- a/scripts/dialogs/world-setup-dialog.js +++ b/scripts/dialogs/world-setup-dialog.js @@ -371,6 +371,14 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica linked: 0, noteNoExport: true, }, + { + key: 'Quests', + fromArchivist: Number(imp.quests || 0), + createFoundry: 0, + toArchivist: 0, + linked: 0, + noteNoExport: true, + }, ]; // Set warning flag if any of Characters, Items, or Locations have > 100 exports @@ -1828,6 +1836,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 +2413,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 +2428,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 +2437,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 +2525,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.' @@ -2519,12 +2547,11 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } catch (_) {} } - const html = Utils.toMarkdownIfHtml( - String(fullQuest.successDefinition || fullQuest.nextAction || '') - ); + // Quest has no generic description field in the API; its Info tab is a + // structured metadata form (see quest.hbs), not free-text page content. const journal = await Utils.createCustomJournalForImport({ name: fullQuest.questName || fullQuest.quest_name || 'Quest', - html, + html: '', sheetType: 'quest', archivistId: fullQuest.id, worldId: campaignId, @@ -2566,6 +2593,8 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica fullQuest.relatedLocations || fullQuest.related_locations || [], relatedItems: fullQuest.relatedItems || fullQuest.related_items || [], + relatedEntityRefs: + fullQuest.relatedEntityRefs || fullQuest.related_entity_refs || [], firstSession: fullQuest.firstSession || fullQuest.first_session || null, lastSession: fullQuest.lastSession || fullQuest.last_session || null, }; @@ -2575,7 +2604,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/journal-manager.js b/scripts/modules/journal-manager.js index 819ff83..30609f2 100644 --- a/scripts/modules/journal-manager.js +++ b/scripts/modules/journal-manager.js @@ -14,6 +14,8 @@ export class JournalManager { location: 'Archivist - Locations', faction: 'Archivist - Factions', recap: 'Recaps', + quest: 'Archivist - Quests', + journal: 'Archivist - Journals', entry: null, }; } diff --git a/scripts/modules/projection/merge.js b/scripts/modules/projection/merge.js index b693b27..7726f46 100644 --- a/scripts/modules/projection/merge.js +++ b/scripts/modules/projection/merge.js @@ -1,7 +1,9 @@ -// Merge helper: inject Archivist HTML into an existing HTML field non-destructively +// Merge helper: inject Archivist content into an existing field non-destructively const WRAP_START = '
'; const WRAP_END = '
'; +const TEXT_WRAP_START = '[Archivist]'; +const TEXT_WRAP_END = '[/Archivist]'; /** * Create a wrapped block containing Archivist HTML content. @@ -28,14 +30,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(); @@ -43,3 +45,30 @@ export function stripHtml(html) { return String(html || ''); } } + +/** + * Create a wrapped block containing Archivist plain text content, marked so a + * later sync can find and replace just this block (mirrors toArchivistBlock's + * behavior for the HTML case). + * @param {string} text + */ +export function toArchivistPlainBlock(text) { + const safe = String(text ?? ''); + return `${TEXT_WRAP_START}\n${safe}\n${TEXT_WRAP_END}`; +} + +/** + * Non-destructively merge Archivist content into a plain-text (non-HTML) + * field: appends a marked block (or replaces its own previously-injected + * block on re-sync) instead of overwriting the field's existing content. + * @param {string} existingText + * @param {string} archivistHtml - raw Archivist HTML/markdown; stripped to plain text before merging + */ +export function mergeArchivistPlainSection(existingText, archivistHtml) { + const current = String(existingText ?? ''); + const block = toArchivistPlainBlock(stripHtml(archivistHtml)); + const re = /\[Archivist\][\s\S]*?\[\/Archivist\]/; + if (!current) return block; + if (re.test(current)) return current.replace(re, block); + return `${current}\n\n${block}`; +} diff --git a/scripts/modules/projection/slot-resolver.js b/scripts/modules/projection/slot-resolver.js index 5f1896a..b70b82b 100644 --- a/scripts/modules/projection/slot-resolver.js +++ b/scripts/modules/projection/slot-resolver.js @@ -1,5 +1,5 @@ import { AdapterRegistry } from './adapter-registry.js'; -import { mergeArchivistSection, stripHtml } from './merge.js'; +import { mergeArchivistSection, mergeArchivistPlainSection } from './merge.js'; import { CONFIG } from '../config.js'; function defaultHeuristics(doc) { @@ -100,7 +100,7 @@ export async function projectDescription(doc, archivistHtml) { const current = String(foundry.utils.getProperty(doc, slot.path) ?? ''); const next = slot.html ? mergeArchivistSection(current, String(archivistHtml ?? '')) - : stripHtml(archivistHtml); + : mergeArchivistPlainSection(current, archivistHtml); const update = { [slot.path]: next, [`flags.${CONFIG.MODULE_ID}.op`]: op, diff --git a/scripts/modules/reconcile/reconcile-service.js b/scripts/modules/reconcile/reconcile-service.js index 8d37d2a..db1ad77 100644 --- a/scripts/modules/reconcile/reconcile-service.js +++ b/scripts/modules/reconcile/reconcile-service.js @@ -18,14 +18,17 @@ export class ReconcileService { if (!apiKey || !campaignId) throw new Error('Archivist not configured'); // Fetch entities in parallel - const [chars, items, locs, facs, sessions, links] = 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), - ]); + const [chars, items, locs, facs, sessions, links, quests, journalsList] = + 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.listQuests(apiKey, campaignId), + archivistApi.listJournals(apiKey, campaignId), + ]); const characters = chars.success ? chars.data || [] : []; const itemsData = items.success ? items.data || [] : []; @@ -33,6 +36,12 @@ export class ReconcileService { const factions = facs.success ? facs.data || [] : []; const sessionsData = sessions.success ? sessions.data || [] : []; const linksData = links.success ? links.data || [] : []; + // listQuests() rows aren't normalized server-side consistently; route through + // the same camelCase/snake_case-tolerant normalizer used elsewhere for quests. + const questsData = (quests.success ? quests.data || [] : []).map((q) => + archivistApi._normalizeQuestResponse(q) + ); + const journalsData = journalsList.success ? journalsList.data || [] : []; // Ensure default folders, index existing journals by archivistId try { @@ -96,6 +105,45 @@ export class ReconcileService { for (const it of itemsData) await ensureSheet(it, 'item'); for (const l of locations) await ensureSheet(l, 'location'); for (const f of factions) await ensureSheet(f, 'faction'); + for (const j of journalsData) await ensureSheet(j, 'journal'); + + // Quests: ensure a sheet exists, then refresh its full questData from Archivist + // (title/image handled generically by ensureSheet; the rest is quest-specific). + for (const q of questsData) { + const entity = { ...q, name: q.questName || 'Quest' }; + const j = await ensureSheet(entity, 'quest'); + try { + const flags = j.getFlag(CONFIG.MODULE_ID, 'archivist') || {}; + flags.questData = { + questName: q.questName || '', + questGiver: q.questGiver || '', + questCategory: q.questCategory || 'n/a', + status: q.status || 'planned', + successDefinition: q.successDefinition || '', + failureConditions: q.failureConditions || '', + nextAction: q.nextAction || '', + resolution: q.resolution || '', + objectives: Array.isArray(q.objectives) ? q.objectives : [], + progressLog: Array.isArray(q.progressLog) ? q.progressLog : [], + relatedCharacters: Array.isArray(q.relatedCharacters) + ? q.relatedCharacters + : [], + relatedFactions: Array.isArray(q.relatedFactions) + ? q.relatedFactions + : [], + relatedLocations: Array.isArray(q.relatedLocations) + ? q.relatedLocations + : [], + relatedItems: Array.isArray(q.relatedItems) ? q.relatedItems : [], + relatedEntityRefs: Array.isArray(q.relatedEntityRefs) + ? q.relatedEntityRefs + : [], + firstSession: q.firstSession || null, + lastSession: q.lastSession || null, + }; + await j.setFlag(CONFIG.MODULE_ID, 'archivist', flags); + } catch (_) {} + } // Recaps: ensure a single Recaps container exists with pages ordered by session_date try { diff --git a/scripts/modules/sheets/page-sheet-v2.js b/scripts/modules/sheets/page-sheet-v2.js index d51ce36..9eedeed 100644 --- a/scripts/modules/sheets/page-sheet-v2.js +++ b/scripts/modules/sheets/page-sheet-v2.js @@ -171,8 +171,8 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( return result; } - _onRender(context, options) { - super._onRender(context, options); + async _onRender(context, options) { + await super._onRender(context, options); try { const root = this.element; const content = root.querySelector('.archivist-content') || root; @@ -269,6 +269,17 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( visBtn.style.height = '16px'; } catch (_) {} } + const uploadBtn = root.querySelector( + '[data-action="upload-local-image"]' + ); + if (uploadBtn && !uploadBtn.dataset.bound) { + uploadBtn.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + this._onUploadLocalImage(); + }); + uploadBtn.dataset.bound = 'true'; + } } catch (_) {} } @@ -660,13 +671,108 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( console.warn('[Archivist Sync][PageV2] _onRender failed', e); } try { + // Awaited so Foundry's render pipeline (which awaits _onRender) doesn't + // consider the sheet "done" until tab counts/cards are actually set. + // Previously these were fire-and-forget: opening a sheet by clicking a + // card in another custom sheet (vs. opening it fresh from the Journal + // Directory) could resolve .render()'s promise — and any .then() chained + // on it, e.g. bringToFront() — before this finished, leaving some tabs + // populated with cards but never getting their (N) count set. if (typeof this._renderLinkedGrids === 'function') - this._renderLinkedGrids(); + await this._renderLinkedGrids(); if (typeof this._renderActorItemCards === 'function') - this._renderActorItemCards(); + await this._renderActorItemCards(); + if (typeof this._renderRelatedQuests === 'function') + await this._renderRelatedQuests(); } catch (_) {} } + /** + * Populate a read-only "Quests" tab with quests that reference this entity. + * The Archivist API has no reverse-lookup endpoint for "quests referencing X", + * so this fetches the quest list and filters client-side by relatedEntityRefs. + * Quest is the source of truth for the relationship; edit/unlink from the + * Quest sheet itself, not from here. + */ + async _renderRelatedQuests() { + const grid = this.element?.querySelector?.( + '.tab[data-tab="quests"] .archivist-grid' + ); + if (!grid) return; + const flags = this._getArchivistFlags(); + const sheetType = String(flags.sheetType || '').toLowerCase(); + const entityType = { pc: 'character', npc: 'character', character: 'character', faction: 'faction', location: 'location', item: 'item' }[sheetType]; + const archivistId = String(flags.archivistId || ''); + if (!entityType || !archivistId) { + grid.innerHTML = 'None'; + return; + } + const seq = (this._rqSeq = (this._rqSeq || 0) + 1); + let quests = []; + try { + quests = await ArchivistBasePageSheetV2._loadQuestsCached(); + } catch (_) { + quests = []; + } + if (this._rqSeq !== seq) return; // superseded by a later render + const matches = quests.filter((q) => + (Array.isArray(q.relatedEntityRefs) ? q.relatedEntityRefs : []).some( + (r) => + String(r.entityType).toLowerCase() === entityType && + String(r.entityId) === archivistId + ) + ); + grid.innerHTML = ''; + if (!matches.length) { + grid.innerHTML = 'None'; + return; + } + for (const q of matches) { + const questJournal = this._findPageOrEntryByArchivistId(String(q.id)); + const name = q.questName || 'Quest'; + const card = document.createElement('div'); + card.className = 'archivist-card'; + card.dataset.archivistId = String(q.id); + card.innerHTML = `${foundry.utils.escapeHTML(name)}`; + if (questJournal) { + card.addEventListener('click', () => { + const doc = + questJournal.documentName === 'JournalEntryPage' + ? questJournal.parent + : questJournal; + doc?.sheet?.render?.(true); + }); + } + grid.appendChild(card); + } + const nav = this.element?.querySelector?.( + '.archivist-nav [data-tab="quests"]' + ); + if (nav) { + const base = nav.dataset._label || nav.textContent || 'Quests'; + if (!nav.dataset._label) nav.dataset._label = base; + nav.textContent = matches.length > 0 ? `${base} (${matches.length})` : base; + } + } + + /** Short-lived cache so opening several sheets at once doesn't re-fetch listQuests repeatedly. */ + static async _loadQuestsCached() { + const now = Date.now(); + const cache = ArchivistBasePageSheetV2._questsCache; + if (cache && now - cache.at < 5000) return cache.data; + const apiKey = settingsManager.getApiKey?.(); + const campaignId = settingsManager.getSelectedWorldId?.(); + if (!apiKey || !campaignId) return []; + const result = await archivistApi.listQuests(apiKey, campaignId); + const raw = result?.success ? result.data || [] : []; + // listQuests() doesn't normalize each row; the API's casing for quest + // fields is inconsistent between endpoints, so route through the same + // camelCase/snake_case-tolerant normalizer used by get/create/updateQuest. + const data = raw.map((q) => archivistApi._normalizeQuestResponse(q)); + ArchivistBasePageSheetV2._questsCache = { at: now, data }; + return data; + } + async _toggleEditMode() { console.log( '[Archivist V2 Sheet] _toggleEditMode called, current editMode:', @@ -932,7 +1038,19 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( await archivistApi.updateSession(apiKey, archivistId, payload); } else if (sheetType === 'quest') { console.log('[Archivist V2 Sheet] Syncing Quest to API'); - const payload = { questName: nameNow }; + const root = this.element; + const readVal = (sel) => + String(root?.querySelector?.(sel)?.value ?? '').trim(); + const payload = { + questName: nameNow, + questGiver: readVal('.quest-giver-input'), + questCategory: readVal('.quest-category-select') || 'n/a', + status: readVal('.quest-status-select') || 'planned', + successDefinition: readVal('.quest-success-input'), + failureConditions: readVal('.quest-failure-input'), + nextAction: readVal('.quest-next-action-input'), + resolution: readVal('.quest-resolution-input'), + }; result = await archivistApi.updateQuest(apiKey, archivistId, payload); } else if (sheetType === 'journal') { console.log('[Archivist V2 Sheet] Syncing Journal to API'); @@ -981,6 +1099,51 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( const targetType = String(targetFlags.sheetType || '').toLowerCase(); if (targetType === 'recap' || targetType === 'session') return; + // Quests can't participate in the generic /links API (the backend has no + // ENTITY_CONFIG entry for Quest), so their relationships persist via + // updateQuest's relatedEntityRefs instead. Quest is the single source of + // truth for its own relationships; dropping a Quest onto another sheet + // would create an orphaned/inconsistent write, so we block that instead. + if (droppedType === 'quest' && targetType !== 'quest') { + ui.notifications?.warn?.( + 'Link quests from the Quest sheet itself (drag the Character/Faction/Location/Item onto the Quest).' + ); + return; + } + if (targetType === 'quest') { + const bucket = this._bucketForDrop(dropped); + if (!bucket || typeof this._linkQuestEntity !== 'function') return; + if (!QuestPageSheetV2.BUCKET_TO_ENTITY_TYPE[bucket]) { + ui.notifications?.warn?.( + 'Quests can only link to Characters, Factions, Locations, and Items.' + ); + return; + } + if (dropped.documentName === 'Actor' || dropped.documentName === 'Item') { + const aid = dropped.getFlag(CONFIG.MODULE_ID, 'archivistId'); + if (!aid) { + ui.notifications?.warn?.( + 'Only Archivist-synced Actors/Items can be linked to a Quest.' + ); + return; + } + await this._linkQuestEntity(bucket, String(aid), dropped.name); + return; + } + const toDoc = + dropped?.documentName === 'JournalEntryPage' + ? dropped.parent + : dropped?.documentName === 'JournalEntry' + ? dropped + : null; + if (!toDoc) return; + const toFlags = toDoc?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const toId = String(toFlags.archivistId || ''); + if (!toId) return; + await this._linkQuestEntity(bucket, toId, toDoc.name); + return; + } + const bucket = this._bucketForDrop(dropped); if (!bucket) return; @@ -1662,6 +1825,100 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( ) || null ); } + + static SHEET_TYPE_TO_IMAGE_ENTITY_TYPE = { + pc: 'character', + npc: 'character', + character: 'character', + item: 'item', + location: 'location', + faction: 'faction', + }; + + /** + * Resolve a genuinely local Foundry image path worth uploading to Archivist, + * mirroring _resolveSheetImage's fallback chain but stopping before the + * default-icon fallback (nothing useful to upload in that case). + */ + _resolveLocalImageForUpload() { + const entry = this.document; + if (!entry) return null; + const f = entry?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const st = String(f.sheetType || '').toLowerCase(); + const explicit = String(entry?.img || '').trim(); + if (explicit) return explicit; + if (st === 'pc' || st === 'npc' || st === 'character') { + const actorId = (f.foundryRefs?.actors || [])[0]; + const actor = actorId ? game.actors?.get?.(actorId) : null; + if (actor?.img) return String(actor.img).trim(); + } else if (st === 'item') { + const itemId = (f.foundryRefs?.items || [])[0]; + const itm = itemId ? game.items?.get?.(itemId) : null; + if (itm?.img) return String(itm.img).trim(); + } else if (st === 'location') { + const sceneId = (f.foundryRefs?.scenes || [])[0]; + const scene = sceneId ? game.scenes?.get?.(sceneId) : null; + const sc = scene?.thumbnail || scene?.img || ''; + if (sc) return String(sc).trim(); + } + return null; + } + + /** + * Upload the sheet's local Foundry image (Actor/Item/Scene/journal img) to + * Archivist via the presigned-URL flow, so it's visible outside Foundry. + * Not available for Quests — the API has no image support for that type. + */ + async _onUploadLocalImage() { + const flags = this._getArchivistFlags(); + const sheetType = String(flags.sheetType || '').toLowerCase(); + const entityType = + ArchivistBasePageSheetV2.SHEET_TYPE_TO_IMAGE_ENTITY_TYPE[sheetType]; + if (!entityType) return; + const archivistId = String(flags.archivistId || ''); + const apiKey = settingsManager.getApiKey?.(); + const campaignId = settingsManager.getSelectedWorldId?.(); + if (!apiKey || !campaignId || !archivistId) { + ui.notifications?.warn?.('Archivist world not configured.'); + return; + } + const src = this._resolveLocalImageForUpload(); + if (!src) { + ui.notifications?.warn?.('No local image found to upload for this sheet.'); + return; + } + if (/^https?:\/\//i.test(src)) { + ui.notifications?.info?.( + 'This image is already hosted remotely; nothing to upload.' + ); + return; + } + ui.notifications?.info?.('Uploading image to Archivist…'); + try { + const res = await fetch(src); + if (!res.ok) throw new Error(`Could not read local image (HTTP ${res.status})`); + const blob = await res.blob(); + const contentType = blob.type || 'image/png'; + const fileName = src.split('/').pop() || 'image'; + const result = await archivistApi.uploadEntityImage(apiKey, campaignId, { + entityType, + entityId: archivistId, + fileName, + contentType, + bytes: blob, + }); + if (result.success) { + ui.notifications?.info?.('Image uploaded to Archivist.'); + } else { + ui.notifications?.error?.( + `Image upload failed: ${result.message || 'unknown error'}` + ); + } + } catch (e) { + console.warn('[Archivist Sync][V2] Local image upload failed', e); + ui.notifications?.error?.('Image upload failed. See console for details.'); + } + } } export class EntryPageSheetV2 extends ArchivistBasePageSheetV2 { @@ -1691,8 +1948,8 @@ export class LocationPageSheetV2 extends ArchivistBasePageSheetV2 { form: { template: 'modules/archivist-sync/templates/sheets/location.hbs' }, }; - _onRender(context, options) { - super._onRender(context, options); + async _onRender(context, options) { + await super._onRender(context, options); try { const root = this.element; // Tab toggling @@ -1984,6 +2241,25 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { }, }; + /** + * 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); const flags = @@ -2027,17 +2303,47 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { : Array.isArray(qd.related_items) ? qd.related_items : [], + relatedEntityRefs: Array.isArray(qd.relatedEntityRefs) + ? qd.relatedEntityRefs + : Array.isArray(qd.related_entity_refs) + ? qd.related_entity_refs + : [], 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,18 +2352,19 @@ 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; } - _onRender(context, options) { - super._onRender(context, options); + async _onRender(context, options) { + await super._onRender(context, options); try { const root = this.element; this._renderQuestObjectives(root, context); @@ -2113,23 +2420,80 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { } } + /** Map an entityType (as stored in relatedEntityRefs) to its Links-tab grid selector. */ + static ENTITY_TYPE_TO_SELECTOR = { + character: '.quest-related-characters', + faction: '.quest-related-factions', + location: '.quest-related-locations', + item: '.quest-related-items', + }; + + /** Map a generic drop bucket (from _bucketForDrop) to the entityType Archivist expects. */ + static BUCKET_TO_ENTITY_TYPE = { + characters: 'character', + factions: 'faction', + locationsAssociative: 'location', + items: 'item', + }; + _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 refs = Array.isArray(q.relatedEntityRefs) ? q.relatedEntityRefs : []; + const byType = { character: [], faction: [], location: [], item: [] }; + for (const ref of refs) { + const t = String(ref?.entityType || '').toLowerCase(); + if (byType[t]) byType[t].push(ref); + } + // Legacy name-string fallbacks for quests that predate relatedEntityRefs + const legacyByType = { + character: q.relatedCharacters || [], + faction: q.relatedFactions || [], + location: q.relatedLocations || [], + item: q.relatedItems || [], + }; + + for (const [type, sel] of Object.entries( + QuestPageSheetV2.ENTITY_TYPE_TO_SELECTOR + )) { const el = root.querySelector(sel); if (!el) continue; el.innerHTML = ''; - if (!items?.length) { + const refsForType = byType[type]; + if (!refsForType.length && !legacyByType[type]?.length) { el.innerHTML = 'None'; continue; } - for (const name of items) { + for (const ref of refsForType) { + const entityId = String(ref.entityId || ''); + const name = ref.label || ref.entityNameSnapshot || 'Unknown'; + const targetDoc = entityId + ? this._findPageOrEntryByArchivistId(entityId) + : null; + const img = targetDoc + ? this._resolveSheetImage(targetDoc) + : 'icons/svg/mystery-man.svg'; + const card = document.createElement('div'); + card.className = 'archivist-card'; + card.dataset.archivistId = entityId; + card.innerHTML = `${foundry.utils.escapeHTML(name)}
`; + if (targetDoc) { + card.addEventListener('click', (ev) => { + if (ev.target?.closest?.('.actions')) return; + const doc = + targetDoc.documentName === 'JournalEntryPage' + ? targetDoc.parent + : targetDoc; + doc?.sheet?.render?.(true); + }); + } + el.appendChild(card); + } + // Names without a resolvable entityId (legacy data) render as plain, unlinkable chips + const linkedNames = new Set( + refsForType.map((r) => r.label || r.entityNameSnapshot) + ); + for (const name of legacyByType[type] || []) { + if (linkedNames.has(name)) continue; const chip = document.createElement('span'); chip.className = 'quest-link-chip'; chip.textContent = name; @@ -2151,37 +2515,179 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { if (Number.isFinite(idx)) this._removeObjective(idx); }); }); + root + .querySelectorAll('[data-action="unlink-quest-entity"]') + .forEach((btn) => { + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const { entityType, entityId } = ev.currentTarget.dataset; + if (entityType && entityId) + this._unlinkQuestEntity(entityType, entityId); + }); + }); + + // Seed select values (Handlebars has no per-option "selected" helper here) + if (context.setup?.editingInfo) { + const categorySelect = root.querySelector('.quest-category-select'); + if (categorySelect) categorySelect.value = context.quest?.questCategory || 'n/a'; + const statusSelect = root.querySelector('.quest-status-select'); + if (statusSelect) statusSelect.value = context.quest?.status || 'planned'; + + root.querySelectorAll('[data-obj-index]').forEach((li) => { + const idx = Number(li.dataset.objIndex); + const obj = context.quest?.objectives?.[idx]; + if (!obj) return; + const statusSel = li.querySelector('.quest-objective-status-select'); + if (statusSel) { + statusSel.value = obj.status || 'pending'; + statusSel.addEventListener('change', () => + this._updateObjective(idx, { status: statusSel.value }) + ); + } + const textInput = li.querySelector('.quest-objective-text-input'); + if (textInput) { + textInput.addEventListener('change', () => + this._updateObjective(idx, { text: textInput.value }) + ); + } + }); + } } - async _addObjective() { + /** Persist the current objectives array (local flags + remote PATCH). */ + async _syncObjectives(objectives) { 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; + const qd = { ...(flags.questData || {}), objectives }; await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', { ...flags, questData: qd, }); + try { + const apiKey = settingsManager.getApiKey?.(); + const archivistId = String(flags.archivistId || ''); + if (apiKey && archivistId) { + await archivistApi.updateQuest(apiKey, archivistId, { objectives }); + } + } catch (e) { + console.warn('[Archivist Sync][V2] Failed to sync quest objectives', e); + } this.render(false); } + 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' }); + await this._syncObjectives(objectives); + } + async _removeObjective(idx) { const flags = this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; - const qd = { ...(flags.questData || {}) }; + const qd = flags.questData || {}; const objectives = Array.isArray(qd.objectives) ? [...qd.objectives] : []; objectives.splice(idx, 1); - qd.objectives = objectives; + await this._syncObjectives(objectives); + } + + async _updateObjective(idx, patch) { + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = flags.questData || {}; + const objectives = Array.isArray(qd.objectives) + ? [...qd.objectives] + : []; + if (!objectives[idx]) return; + objectives[idx] = { ...objectives[idx], ...patch }; + // Skip the full re-render here (would drop focus mid-edit); commit silently. + const nextFlags = { + ...flags, + questData: { ...qd, objectives }, + }; + await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', nextFlags); + try { + const apiKey = settingsManager.getApiKey?.(); + const archivistId = String(flags.archivistId || ''); + if (apiKey && archivistId) { + await archivistApi.updateQuest(apiKey, archivistId, { objectives }); + } + } catch (e) { + console.warn('[Archivist Sync][V2] Failed to sync quest objective', e); + } + } + + /** Link an entity (Character/Item/Faction/Location) to this Quest via relatedEntityRefs. */ + async _linkQuestEntity(bucket, entityId, entityName) { + const entityType = QuestPageSheetV2.BUCKET_TO_ENTITY_TYPE[bucket]; + if (!entityType) return; + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = flags.questData || {}; + const refs = Array.isArray(qd.relatedEntityRefs) + ? [...qd.relatedEntityRefs] + : []; + if ( + refs.some( + (r) => + String(r.entityId) === String(entityId) && + String(r.entityType).toLowerCase() === entityType + ) + ) { + return; // already linked + } + refs.push({ + entityType, + entityId: String(entityId), + entityNameSnapshot: entityName, + label: entityName, + }); + await this._commitQuestLinks(refs); + } + + /** Unlink an entity from this Quest's relatedEntityRefs. */ + async _unlinkQuestEntity(entityType, entityId) { + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = flags.questData || {}; + const refs = ( + Array.isArray(qd.relatedEntityRefs) ? qd.relatedEntityRefs : [] + ).filter( + (r) => + !( + String(r.entityId) === String(entityId) && + String(r.entityType).toLowerCase() === String(entityType).toLowerCase() + ) + ); + await this._commitQuestLinks(refs); + } + + async _commitQuestLinks(relatedEntityRefs) { + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = { ...(flags.questData || {}), relatedEntityRefs }; await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', { ...flags, questData: qd, }); + try { + const apiKey = settingsManager.getApiKey?.(); + const archivistId = String(flags.archivistId || ''); + if (apiKey && archivistId) { + await archivistApi.updateQuest(apiKey, archivistId, { + relatedEntityRefs, + }); + } + } catch (e) { + console.warn('[Archivist Sync][V2] Failed to sync quest links', e); + } this.render(false); } } 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(/(?!\n)/gi, '\n') - .replace(/<\/p\s*>/gi, '\n\n') - .replace(/<\/(div|section|article|header|footer|aside)\s*>/gi, '\n\n') - .replace(/]*>/gi, '\n• ') - .replace(/<\/(h1|h2|h3|h4|h5|h6)\s*>/gi, '\n\n'); - const tmp = document.createElement('div'); - tmp.innerHTML = pre; - let text = tmp.textContent || tmp.innerText || ''; - // Normalize multiple blank lines to at most two to create paragraphs - text = text.replace(/\n{3,}/g, '\n\n'); - return text.trim(); - } catch (_) { - return s; - } - } - /** - * Log messages with module prefix - * @param {string} message - The message to log - * @param {string} level - Log level (log, warn, error) - */ - static log(message) { - console.log(`${CONFIG.MODULE_TITLE} | ${message}`); - } - - /** - * Show notification to user - * @param {string} message - The message to show - * @param {string} type - Notification type (info, warn, error) - */ - static notify(message, type = 'info') { - ui.notifications[type](message); - } - - /** - * Get localized string - * @param {string} key - The localization key - * @param {object} data - Data for string interpolation - * @returns {string} Localized string - */ - static localize(key, data = {}) { - return game.i18n.format(key, data); - } - - /** - * 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 - * @param {string} markdown - * @returns {string} sanitized HTML - */ - static markdownToStoredHtml(markdown) { - const md = String(markdown ?? ''); - try { - let rawHtml = ''; - if (window?.MarkdownIt) { - const mdIt = new window.MarkdownIt({ html: false, linkify: true, breaks: true }); - rawHtml = mdIt.render(md); - } else { - // Minimal fallback: paragraphs + bold/italic with HTML escaping for security - 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())}

`) - .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} - */ - static getFactionJournals() { - const entries = game.journal?.contents || []; - const factionFolder = this._findFolderByNameInsensitive('Archivist - Factions'); - return entries.filter(j => { - const flagged = j.getFlag(CONFIG.MODULE_ID, 'archivistType') === 'faction'; - const inFolder = factionFolder && j.folder?.id === factionFolder.id; - return flagged || inFolder; - }); - } - - /** - * Get journal entries that likely represent Locations (by folder name or flag) - * @returns {Array} - */ - static getLocationJournals() { - const entries = game.journal?.contents || []; - const locationFolder = this._findFolderByNameInsensitive('Archivist - Locations'); - return entries.filter(j => { - const flagged = j.getFlag(CONFIG.MODULE_ID, 'archivistType') === 'location'; - const inFolder = locationFolder && j.folder?.id === locationFolder.id; - return flagged || inFolder; - }); - } - - /** - * Find a folder by name (case-insensitive) - * @param {string} name - * @returns {Folder | undefined} - */ - static _findFolderByNameInsensitive(name) { - const folders = game.folders?.contents || []; - return folders.find( - f => f.type === 'JournalEntry' && f.name.toLowerCase() === String(name).toLowerCase() - ); - } - - /** - * Transform actor data for API synchronization - * @param {Array} actors - Array of Foundry actor objects - * @returns {Array} Array of transformed character data - */ - static transformActorsForSync(actors) { - return actors.map(actor => ({ - foundryId: actor.id, - name: actor.name, - type: actor.type, - description: actor.system?.details?.biography?.value || '', - level: actor.system?.details?.level || 1, - race: actor.system?.details?.race || '', - class: actor.system?.details?.class || '', - })); - } - - /** - * Build Archivist Character payload from a Foundry Actor - * @param {Actor} actor - * @param {string} worldId - */ - static toApiCharacterPayload(actor, worldId) { - const isPC = actor.type === 'character'; - const pick = (...paths) => { - for (const p of paths) { - try { - const v = foundry.utils.getProperty(actor, p); - if (typeof v === 'string' && v.trim()) return v; - } catch (_) { } - } - return ''; - }; - const bioHtml = pick('system.details.biography.value', 'system.details.biography.public', 'system.description.value'); - return { - character_name: actor.name, - player_name: actor?.system?.details?.player || '', - description: this.toMarkdownIfHtml(bioHtml || ''), - type: isPC ? 'PC' : 'NPC', - campaign_id: worldId, - }; - } - - /** - * Build Archivist Faction payload from a JournalEntry - * @param {JournalEntry} journal - * @param {string} worldId - */ - static toApiFactionPayload(journal, worldId) { - const raw = String(journal?.img || '').trim(); - const image = raw.startsWith('https://') ? raw : undefined; - const text = this._extractJournalText(journal); - // Strip leading image since we set it as a separate property - const cleanedText = this.stripLeadingImage(text); - return { - name: journal.name, - // Journal pages store HTML — convert to Markdown for API - description: this.toMarkdownIfHtml(cleanedText), - ...(image ? { image } : {}), - campaign_id: worldId, - }; - } - - /** - * Build Archivist Location payload from a JournalEntry - * @param {JournalEntry} journal - * @param {string} worldId - */ - static toApiLocationPayload(journal, worldId) { - const raw = String(journal?.img || '').trim(); - const image = raw.startsWith('https://') ? raw : undefined; - const text = this._extractJournalText(journal); - // Strip leading image since we set it as a separate property - const cleanedText = this.stripLeadingImage(text); - return { - name: journal.name, - description: this.toMarkdownIfHtml(cleanedText), - ...(image ? { image } : {}), - campaign_id: worldId, - }; - } - - /** - * Extract text content from a JournalEntry (first text page) - * @param {JournalEntry} journal - * @returns {string} - */ - static _extractJournalText(journal) { - const pages = journal.pages?.contents || journal.pages || []; - const textPage = pages.find(p => p.type === 'text'); - // Foundry v10+ stores text in page.text.content - return textPage?.text?.content || journal.content || ''; - } - - /** - * Remove a single leading image from Markdown or HTML at the top of text. - * Handles patterns like: \n![alt](url)\n, , or wrapped in

. - * @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*]*>\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} - */ - static async ensureRootJournalContainer(name) { - const journals = game.journal?.contents || []; - let j = journals.find(x => x.name === name && !x.folder); - if (j) return j; - j = await JournalEntry.create({ name, folder: null, pages: [] }, { render: false }); - return j; - } - - /** - * Create or update a text page within a container journal - * Returns the page document. If creating multiple, call with items pre-sorted, as creation order defines index. - * @param {JournalEntry} container - * @param {object} opts { name, html, imageUrl, flags } - */ - static async upsertContainerTextPage(container, { name, html, imageUrl, flags } = {}) { - const pages = container.pages?.contents || []; - // Prefer matching by Archivist ID if provided via flags - let page = null; - if (flags?.archivistId) { - page = pages.find(p => this.getPageArchivistMeta(p).id === flags.archivistId); - } - if (!page) page = pages.find(p => p.name === name && p.type === 'text'); - const baseMd = String(html || ''); - if (page) { - await page.update({ - name, - type: 'text', - text: { content: baseMd, markdown: baseMd, format: 2 }, - }); - } else { - const created = await container.createEmbeddedDocuments('JournalEntryPage', [ - { name, type: 'text', text: { content: baseMd, markdown: baseMd, format: 2 } }, - ]); - page = created?.[0] || null; - } - if (page && flags) { - await this.setPageArchivistMeta( - page, - flags.archivistId, - flags.archivistType, - flags.archivistWorldId - ); - } - return page; - } - - /** - * Sort pages within a container using comparator over page docs - * Applies increasing sort values to match comparator order. - * @param {JournalEntry} container - * @param {(a: JournalEntryPage, b: JournalEntryPage) => number} comparator - */ - static async sortContainerPages(container, comparator) { - const pages = (container.pages?.contents || []).slice().sort(comparator); - let sort = 0; - const updates = pages.map(p => ({ _id: p.id, sort: (sort += 100) })); - if (updates.length) await container.updateEmbeddedDocuments('JournalEntryPage', updates); - } - - /** - * Extract HTML text content from a JournalEntryPage - * @param {JournalEntryPage} page - */ - static extractPageHtml(page) { - if (!page) return ''; - if (page.type === 'text') { - const fmt = Number(page?.text?.format ?? 0); - const md = page?.text?.markdown; - if (fmt === 2 && typeof md === 'string') return String(md); - return String(page?.text?.content || md || ''); - } - return ''; - } - - /** - * Validate API key format - * @param {string} apiKey - The API key to validate - * @returns {boolean} True if API key appears valid - */ - static validateApiKey(apiKey) { - return apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0; - } - - /** - * Safely parse JSON response - * @param {string} jsonString - JSON string to parse - * @returns {object|null} Parsed object or null if parsing fails - */ - static safeJsonParse(jsonString) { - try { - return JSON.parse(jsonString); - } catch (error) { - this.log(`Failed to parse JSON: ${error.message}`, 'warn'); - return null; - } - } - - /** - * Debounce function to limit rapid successive calls - * @param {Function} func - Function to debounce - * @param {number} wait - Wait time in milliseconds - * @param {boolean} immediate - Whether to trigger on leading edge - * @returns {Function} Debounced function - */ - static debounce(func, wait, immediate = false) { - let timeout; - return function executedFunction(...args) { - const later = () => { - timeout = null; - if (!immediate) func.apply(this, args); - }; - const callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - if (callNow) func.apply(this, args); - }; - } - - /** - * Check if user is a GM - * @returns {boolean} True if current user is a GM - */ - static isGM() { - return game.user.isGM; - } - - /** - * Ensure a folder exists for JournalEntries by name; returns folder id or null - * @param {string} name - */ - static async ensureJournalFolder(name) { - const existing = this._findFolderByNameInsensitive(name); - if (existing) return existing.id; - const created = await Folder.create({ name, type: 'JournalEntry' }); - return created?.id || null; - } - - /** Ensure top-level organized folders exist for Archivist types */ - static async ensureArchivistFolders() { - try { - const folders = { - pc: 'Archivist - PCs', - npc: 'Archivist - NPCs', - item: 'Archivist - Items', - location: 'Archivist - Locations', - faction: 'Archivist - Factions', - }; - - console.log('[Archivist Sync] Ensuring organized folders:', Object.values(folders)); - for (const name of Object.values(folders)) { - await this.ensureJournalFolder(name); - } - } catch (e) { - console.warn('[Archivist Sync] ensureArchivistFolders failed:', e); - } - } - - /** Get the organized folder for a given Archivist sheet type */ - static getArchivistFolder(type) { - try { - console.log('[Archivist Sync] getArchivistFolder called:', { - type, - }); - - const map = { - pc: 'Archivist - PCs', - npc: 'Archivist - NPCs', - item: 'Archivist - Items', - location: 'Archivist - Locations', - faction: 'Archivist - Factions', - }; - const name = map[String(type || '').toLowerCase()]; - - if (!name) { - console.log('[Archivist Sync] No folder name mapped for type:', type); - return null; - } - - const folders = game.folders?.contents || []; - const found = folders.find(f => f.type === 'JournalEntry' && f.name === name) || null; - - console.log('[Archivist Sync] Folder lookup result:', { - searchingFor: name, - found: found?.name || 'none', - foundId: found?.id || 'none', - }); - - return found; - } catch (e) { - console.warn('[Archivist Sync] getArchivistFolder failed:', e); - return null; - } - } - - /** Move a JournalEntry into its organized folder based on flags.archivist.sheetType */ - static async moveJournalToTypeFolder(journal) { - try { - const flags = journal.getFlag(CONFIG.MODULE_ID, 'archivist') || {}; - const type = String(flags.sheetType || '').toLowerCase(); - const folder = this.getArchivistFolder(type); - if (!folder) return false; - if (journal.folder?.id === folder.id) return false; - await journal.update({ folder: folder.id }); - return true; - } catch (_) { - return false; - } - } - - /** Create a custom sheet JournalEntry for an imported Archivist entity */ - static async createCustomJournalForImport({ name, html = '', imageUrl, sheetType, archivistId, worldId, folderId, sort }) { - try { - console.log(`[Archivist Sync] createCustomJournalForImport called:`, { - name, - sheetType, - archivistId, - providedFolderId: folderId, - sort, - }); - - await this.ensureArchivistFolders(); - const folder = this.getArchivistFolder(sheetType); - const sheetClassMap = { - pc: 'archivist-sync.PCPageSheetV2', - npc: 'archivist-sync.NPCPageSheetV2', - item: 'archivist-sync.ItemPageSheetV2', - location: 'archivist-sync.LocationPageSheetV2', - faction: 'archivist-sync.FactionPageSheetV2', - recap: 'archivist-sync.RecapPageSheetV2', - }; - const normalizedType = String(sheetType || '').toLowerCase(); - const sheetClass = sheetClassMap[normalizedType] || ''; - - console.log(`[Archivist Sync] Folder lookup results:`, { - sheetType, - foundFolder: folder?.name || 'none', - foundFolderId: folder?.id || 'none', - providedFolderId: folderId || 'none', - willUseFolderId: folderId || folder?.id || 'none (root)', - }); - - const targetFolderId = folderId || folder?.id || null; - const createData = { - name, - folder: targetFolderId, - ...(imageUrl ? { img: imageUrl } : {}), - ...(typeof sort === 'number' ? { sort } : {}), - flags: { - core: { sheetClass, sheet: sheetClass }, - }, - }; - - const journal = await JournalEntry.create(createData, { render: false }); - - console.log(`[Archivist Sync] Journal created:`, { - journalId: journal.id, - journalName: journal.name, - assignedFolderId: targetFolderId, - actualFolderId: journal.folder?.id || 'none (root)', - actualFolderName: journal.folder?.name || 'none (root)', - }); - - await this.ensureJournalTextPage(journal, html); - // Hub image flag removed - await journal.setFlag(CONFIG.MODULE_ID, 'archivist', { - sheetType: normalizedType, - archivistId: archivistId || null, - archivistWorldId: worldId || null, - image: imageUrl || null, - archivistRefs: { characters: [], items: [], entries: [], factions: [], locationsAssociative: [] }, - foundryRefs: { actors: [], items: [], scenes: [], journals: [] }, - }); - - console.log(`[Archivist Sync] Journal finalized with flags, final location:`, { - journalId: journal.id, - folderId: journal.folder?.id || 'root', - folderName: journal.folder?.name || 'root', - }); - - return journal; - } catch (e) { - console.warn('[Archivist Sync] createCustomJournalForImport failed', e); - return null; - } - } - - /** Create a new Archivist journal with flags and initial text page */ - static async createArchivistJournal({ name, sheetType, archivistId, worldId, folderName, text = '', sort }) { - const folder = folderName ? await this.ensureJournalFolder(folderName) : null; - // Map Archivist sheet types to our registered V2 sheet classes - const sheetClassMap = { - pc: 'archivist-sync.PCPageSheetV2', - npc: 'archivist-sync.NPCPageSheetV2', - item: 'archivist-sync.ItemPageSheetV2', - location: 'archivist-sync.LocationPageSheetV2', - faction: 'archivist-sync.FactionPageSheetV2', - recap: 'archivist-sync.RecapPageSheetV2', - }; - const normalizedType = String(sheetType || '').toLowerCase(); - const sheetClass = sheetClassMap[normalizedType] || ''; - // Provide our archivist flags at creation so createJournalEntry hook can POST immediately - const initialArchivistFlags = { - sheetType: normalizedType, - archivistId: archivistId || null, - archivistWorldId: worldId || null, - archivistRefs: { characters: [], items: [], entries: [], factions: [], locationsAssociative: [] }, - foundryRefs: { actors: [], items: [], scenes: [], journals: [] }, - }; - const createData = { - name, - folder, - ...(typeof sort === 'number' ? { sort } : {}), - flags: { - core: { sheetClass, sheet: sheetClass }, - [CONFIG.MODULE_ID]: { archivist: initialArchivistFlags }, - } - }; - const journal = await JournalEntry.create(createData, { render: false }); - await this.ensureJournalTextPage(journal, text); - // Flags were provided at creation; no need to set again here - return journal; - } - - static createPcJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'pc', folderName: 'Archivist - PCs' }); } - static createNpcJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'npc', folderName: 'Archivist - NPCs' }); } - static createItemJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'item', folderName: 'Archivist - Items' }); } - static createLocationJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'location', folderName: 'Archivist - Locations' }); } - static createFactionJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'faction', folderName: 'Archivist - Factions' }); } - static createRecapJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'recap', folderName: 'Recaps' }); } - - /** - * Deep clone an object - * @param {object} obj - Object to clone - * @returns {object} Cloned object - */ - static deepClone(obj) { - return foundry.utils.deepClone(obj); - } - - /** - * Merge objects using Foundry's utility - * @param {object} original - Original object - * @param {object} other - Object to merge - * @returns {object} Merged object - */ - static mergeObject(original, other) { - return foundry.utils.mergeObject(original, other); - } - - /** - * Generate a random ID - * @param {number} length - Length of the ID - * @returns {string} Random ID string - */ - static generateId(length = 8) { - return foundry.utils.randomID(length); - } - - /** - * Format error message for display - * @param {Error|string} error - Error object or message - * @returns {string} Formatted error message - */ - static formatError(error) { - if (error instanceof Error) { - return error.message; - } - return String(error); - } - - /** - * Check if a string is empty or whitespace only - * @param {string} str - String to check - * @returns {boolean} True if string is empty or whitespace - */ - static isEmpty(str) { - return !str || str.trim().length === 0; - } - - /** - * Capitalize first letter of a string - * @param {string} str - String to capitalize - * @returns {string} Capitalized string - */ - static capitalize(str) { - if (!str) return ''; - return str.charAt(0).toUpperCase() + str.slice(1); - } -} diff --git a/scripts/services/archivist-api.js b/scripts/services/archivist-api.js index 42bdfcb..2090c47 100644 --- a/scripts/services/archivist-api.js +++ b/scripts/services/archivist-api.js @@ -122,62 +122,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 +250,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) @@ -1658,6 +1674,177 @@ export class ArchivistApiService { } } + /** + * Update a Link's alias in a campaign (PATCH). + * Prefer this over delete+recreate when only the alias/label changes and the + * from/to endpoints stay the same. + * @param {string} apiKey + * @param {string} campaignId + * @param {string} linkId + * @param {{alias?: string}} payload + */ + async updateLink(apiKey, campaignId, linkId, payload) { + try { + const data = await this._request( + apiKey, + `/campaigns/${encodeURIComponent(campaignId)}/links/${encodeURIComponent(linkId)}`, + { + method: 'PATCH', + body: JSON.stringify(payload), + } + ); + return { success: true, data }; + } catch (error) { + console.error(`${CONFIG.MODULE_TITLE} | Failed to update link:`, error); + return { + success: false, + message: error.message || 'Failed to update link', + }; + } + } + + /** + * Step 1 of the local-image-upload flow: request a presigned upload URL. + * Supported entity types: character, faction, location, item (NOT quest — + * the API has no image support for Quest). + * @param {string} apiKey + * @param {string} campaignId + * @param {{entityType:string, entityId:string, fileName:string, contentType:string}} params + * @returns {Promise<{success:boolean, data?:{object_key:string, upload_url:string, public_url:string, expires_in_seconds:number}}>} + */ + async initImageUpload( + apiKey, + campaignId, + { entityType, entityId, fileName, contentType } + ) { + try { + const data = await this._request( + apiKey, + `/campaigns/${encodeURIComponent(campaignId)}/images/init`, + { + method: 'POST', + body: JSON.stringify({ + entity_type: entityType, + entity_id: entityId, + file_name: fileName, + content_type: contentType, + }), + } + ); + return { success: true, data }; + } catch (error) { + console.error( + `${CONFIG.MODULE_TITLE} | Failed to init image upload:`, + error + ); + return { + success: false, + message: error.message || 'Failed to init image upload', + }; + } + } + + /** + * Step 2: PUT the raw image bytes directly to the presigned R2 URL from initImageUpload. + * This does NOT go through _request (no api key / json headers — the presigned + * URL carries its own auth). + * @param {string} uploadUrl + * @param {Blob|ArrayBuffer} bytes + * @param {string} contentType + */ + async uploadImageBytes(uploadUrl, bytes, contentType) { + const res = await fetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': contentType }, + body: bytes, + }); + if (!res.ok) { + throw new Error(`Image upload PUT failed: HTTP ${res.status}`); + } + return true; + } + + /** + * Step 3: tell the API the upload finished so it can validate + attach the image. + * @param {string} apiKey + * @param {string} campaignId + * @param {{objectKey:string, entityType:string, entityId:string, attach?:boolean}} params + * @returns {Promise<{success:boolean, data?:{url:string, attached:boolean}}>} + */ + async completeImageUpload( + apiKey, + campaignId, + { objectKey, entityType, entityId, attach = true } + ) { + try { + const data = await this._request( + apiKey, + `/campaigns/${encodeURIComponent(campaignId)}/images/complete`, + { + method: 'POST', + body: JSON.stringify({ + object_key: objectKey, + entity_type: entityType, + entity_id: entityId, + attach, + }), + } + ); + return { success: true, data }; + } catch (error) { + console.error( + `${CONFIG.MODULE_TITLE} | Failed to complete image upload:`, + error + ); + return { + success: false, + message: error.message || 'Failed to complete image upload', + }; + } + } + + /** + * Convenience wrapper: run the full init -> PUT -> complete flow for a local + * Foundry image (read via Utils.fetchLocalImageBytes or similar). + * @param {string} apiKey + * @param {string} campaignId + * @param {{entityType:string, entityId:string, fileName:string, contentType:string, bytes:Blob|ArrayBuffer}} params + * @returns {Promise<{success:boolean, url?:string, message?:string}>} + */ + async uploadEntityImage( + apiKey, + campaignId, + { entityType, entityId, fileName, contentType, bytes } + ) { + const init = await this.initImageUpload(apiKey, campaignId, { + entityType, + entityId, + fileName, + contentType, + }); + if (!init.success) return init; + try { + await this.uploadImageBytes( + init.data.upload_url, + bytes, + contentType + ); + } catch (error) { + return { + success: false, + message: error.message || 'Failed to upload image bytes', + }; + } + const complete = await this.completeImageUpload(apiKey, campaignId, { + objectKey: init.data.object_key, + entityType, + entityId, + attach: true, + }); + if (!complete.success) return complete; + return { success: true, url: complete.data?.url }; + } + /** * Ask (RAG chat) — non-streaming * @param {string} apiKey diff --git a/styles/archivist-sync.css b/styles/archivist-sync.css index 8460460..fb5363b 100644 --- a/styles/archivist-sync.css +++ b/styles/archivist-sync.css @@ -73,6 +73,11 @@ border-color: var(--color-border-highlight-hover, #ff8020); } +/* Upload-image button sits to the left of the edit toggle, not on top of it */ +.archivist-edit-toggle.archivist-upload-toggle { + right: 48px; +} + /* When editing, move button down to not overlap with ProseMirror controls */ .archivist-sheet prose-mirror[toggled]~.archivist-edit-toggle, .archivist-info prose-mirror[toggled]~.archivist-edit-toggle, @@ -2692,6 +2697,44 @@ img.archivist-icon { line-height: 1.4; } +.quest-field-group input[type='text'], +.quest-field-group select, +.quest-field-group textarea { + font-size: 0.85rem; + line-height: 1.4; + padding: 0.3rem 0.5rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + color: inherit; + font-family: inherit; + resize: vertical; +} + +.quest-objective { + flex-wrap: wrap; +} + +.quest-objective-text-input { + flex: 1 1 auto; + min-width: 8rem; + font-size: 0.85rem; + padding: 0.25rem 0.4rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + color: inherit; +} + +.quest-objective-status-select { + font-size: 0.78rem; + padding: 0.2rem 0.35rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + color: inherit; +} + /* Quest section headings */ .quest-section-heading { diff --git a/templates/sheets/character.hbs b/templates/sheets/character.hbs index 21114b4..e193f5d 100644 --- a/templates/sheets/character.hbs +++ b/templates/sheets/character.hbs @@ -23,6 +23,10 @@ {{#if setup.editingInfo}}{{else}}{{/if}} + {{/if}}

@@ -78,6 +83,10 @@ +
+

Quests are linked from the Quest sheet itself; this list is read-only here.

+
+
{{#if setup.isGM}}
diff --git a/templates/sheets/faction.hbs b/templates/sheets/faction.hbs index 906665e..2d94eed 100644 --- a/templates/sheets/faction.hbs +++ b/templates/sheets/faction.hbs @@ -18,6 +18,10 @@ {{#if setup.editingInfo}}{{else}}{{/if}} + {{/if}} @@ -62,6 +67,10 @@
+
+

Quests are linked from the Quest sheet itself; this list is read-only here.

+
+
{{#if setup.isGM}}
diff --git a/templates/sheets/item.hbs b/templates/sheets/item.hbs index a1b29ff..c10a7ee 100644 --- a/templates/sheets/item.hbs +++ b/templates/sheets/item.hbs @@ -20,6 +20,10 @@ {{#if setup.editingInfo}}{{else}}{{/if}} + {{/if}} @@ -75,6 +80,10 @@
+
+

Quests are linked from the Quest sheet itself; this list is read-only here.

+
+
{{#if setup.isGM}}
diff --git a/templates/sheets/location.hbs b/templates/sheets/location.hbs index 13bb7bd..72f4399 100644 --- a/templates/sheets/location.hbs +++ b/templates/sheets/location.hbs @@ -18,6 +18,10 @@ {{#if setup.editingInfo}}{{else}}{{/if}} + {{/if}} @@ -96,6 +101,10 @@
+
+

Quests are linked from the Quest sheet itself; this list is read-only here.

+
+
{{#if setup.isGM}}
diff --git a/templates/sheets/quest.hbs b/templates/sheets/quest.hbs index ce41007..c2994ea 100644 --- a/templates/sheets/quest.hbs +++ b/templates/sheets/quest.hbs @@ -40,47 +40,82 @@
-
- {{#if setup.editingInfo}} - - {{{htmlContent}}} - - {{else}} - {{{htmlContent}}} - {{/if}} -

Progress Log

@@ -125,21 +174,25 @@

Related Entities

+ {{#if setup.isGM}} +

Drag Characters, Factions, Locations, or Items onto this sheet to link them. Unlink from + here — related entities show this quest under their own "Quests" tab.

+ {{/if}}
diff --git a/templates/world-setup-dialog.hbs b/templates/world-setup-dialog.hbs index 6a93bbf..6ce4c2c 100644 --- a/templates/world-setup-dialog.hbs +++ b/templates/world-setup-dialog.hbs @@ -68,8 +68,8 @@

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.

+

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

@@ -481,11 +481,11 @@
-
- {{syncStatus.processed}} / {{syncStatus.total}} — {{syncStatus.current}} + {{syncStatus.processed}} / {{syncStatus.total}} — {{syncStatus.current}}
{{/if}} {{#if syncStatus.logs}}