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/lang/en.json b/lang/en.json index b742d46..b757611 100644 --- a/lang/en.json +++ b/lang/en.json @@ -171,7 +171,9 @@ "items": "Items", "recaps": "Recaps", "mappingOverride": "Mapping Override (JSON)", - "importProgress": "Import Progress" + "importProgress": "Import Progress", + "journals": "Journals", + "quests": "Quests" }, "status": { "connected": "Connected", @@ -229,7 +231,9 @@ "locationsPulled": "Locations pulled successfully", "charactersPulled": "Characters pulled successfully", "worldInitialized": "Foundry world has been initialized with Archivist for the first time", - "worldInitializedReset": "World initialization reset. Reloading to run setup again..." + "worldInitializedReset": "World initialization reset. Reloading to run setup again...", + "journalImportNotice": "Journal content syncs with Archivist. Folder structure is a point-in-time snapshot and not kept in sync.", + "questSynced": "Quest synced with Archivist." }, "errors": { "fetchFailed": "Failed to fetch worlds from Archivist API", @@ -319,6 +323,35 @@ "name": "Real‑Time Sync (auto‑update Archivist)", "hint": "When enabled, changes you make in Foundry (create, edit, or delete Actors, Items, and designated Faction/Location journal pages) are immediately mirrored to Archivist. Recaps are read‑only: creating a Recaps page will not create a Game Session, and deleting a Recaps page will not delete a Game Session in Archivist." } + }, + "quest": { + "status": { + "planned": "Planned", + "inProgress": "In Progress", + "blocked": "Blocked", + "failed": "Failed", + "done": "Done" + }, + "category": { + "main": "Main", + "side": "Side", + "faction": "Faction", + "personal": "Personal" + }, + "objectiveStatus": { + "pending": "Pending", + "inProgress": "In Progress", + "completed": "Completed", + "failed": "Failed", + "blocked": "Blocked" + }, + "fields": { + "questGiver": "Quest Giver", + "successDefinition": "Success Definition", + "failureConditions": "Failure Conditions", + "nextAction": "Next Action", + "resolution": "Resolution" + } } } } diff --git a/scripts/archivist-sync.js b/scripts/archivist-sync.js index e801cae..b75da9e 100644 --- a/scripts/archivist-sync.js +++ b/scripts/archivist-sync.js @@ -60,6 +60,8 @@ Hooks.once('init', async function () { LocationPageSheetV2, FactionPageSheetV2, RecapPageSheetV2, + JournalPageSheetV2, + QuestPageSheetV2, } = await import('./modules/sheets/page-sheet-v2.js'); const DSC = @@ -99,6 +101,16 @@ Hooks.once('init', async function () { types: ['base'], makeDefault: false, }); + DSC.registerSheet(JournalEntry, 'archivist-sync', JournalPageSheetV2, { + label: 'Archivist: Journal', + types: ['base'], + makeDefault: false, + }); + DSC.registerSheet(JournalEntry, 'archivist-sync', QuestPageSheetV2, { + label: 'Archivist: Quest', + types: ['base'], + makeDefault: false, + }); } catch (e) { console.error( '[Archivist Sync] Failed to register V2 DocumentSheet sheets', @@ -108,6 +120,11 @@ Hooks.once('init', async function () { // Register the Archivist Chat tab with the core Sidebar early so it renders its // nav button and panel using the Application V2 TabGroup. Availability will be // handled at runtime by showing/hiding the button and panel. + // NOTE: this Sidebar.TABS registration mechanism works on v13 but was found + // to not support dynamic tab addition on v14 — do not port this removal + // back here; v14 instead injects the chat button at runtime via + // updateArchivistChatAvailability() in the ready hook, which this branch + // also has as a fallback (see below). try { const Sidebar = foundry.applications.sidebar?.Sidebar; if (Sidebar) { @@ -266,9 +283,7 @@ Hooks.once('ready', async function () { try { const sidebar = document.getElementById('sidebar'); const tabsNav = sidebar?.querySelector?.('#sidebar-tabs, nav.tabs'); - if (!tabsNav) { - return; - } + if (tabsNav) { const onClick = (ev) => { // CRITICAL: Ignore clicks on dice roll cards or any elements inside them @@ -335,6 +350,8 @@ Hooks.once('ready', async function () { }, 0); }; tabsNav.addEventListener('click', onClick); + + } // end if (tabsNav found for delegated renderer) } catch (e) { console.error('[Archivist Sync] Failed to install delegated renderer', e); } @@ -343,9 +360,7 @@ Hooks.once('ready', async function () { try { const sidebar = document.getElementById('sidebar'); const tabsNav = sidebar?.querySelector?.('#sidebar-tabs, nav.tabs'); - if (!tabsNav) { - return; - } + if (tabsNav) { const onOtherTabClick = (ev) => { // CRITICAL: Ignore clicks on dice roll cards or any elements inside them @@ -410,6 +425,8 @@ Hooks.once('ready', async function () { }, 0); }; tabsNav.addEventListener('click', onOtherTabClick); + + } // end if (tabsNav found for delegated cleanup) } catch (e) { console.error('[Archivist Sync] Failed to install delegated cleanup', e); } @@ -885,7 +902,12 @@ function updateArchivistChatAvailability() { }); li.appendChild(btn); const menu = tabsNav.querySelector('menu.flexcol') || tabsNav; - menu.appendChild(li); + const beforeNode = menu.lastElementChild; + if (beforeNode) { + menu.insertBefore(li, beforeNode); + } else { + menu.appendChild(li); + } } catch (e) { console.warn( '[Archivist Sync] Failed to inject fallback Sidebar tab button', @@ -1100,6 +1122,17 @@ function installRealtimeSyncListeners() { ...(image ? { image } : {}), campaign_id: worldId, }); + } else if (sheetType === 'quest') { + res = await archivistApi.createQuest(apiKey, { + worldId, + questName: entry.name || 'Quest', + }); + } else if (sheetType === 'journal') { + res = await archivistApi.createJournal(apiKey, { + world_id: worldId, + title: entry.name || 'Journal', + content: description, + }); } if (res.success && res.data?.id) { @@ -1219,6 +1252,7 @@ function installRealtimeSyncListeners() { const flags = parent?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; const sheetType = String(flags?.sheetType || '').toLowerCase(); if (!sheetType || !flags.archivistId) return; + const html = Utils.extractPageHtml(page); const payload = { description: Utils.toMarkdownIfHtml?.(html) || html }; @@ -1252,6 +1286,19 @@ function installRealtimeSyncListeners() { summary: payload.description, }); return; + } else if (sheetType === 'quest') { + // 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, { + id: flags.archivistId, + title: parent?.name || page.name, + content: Utils.toMarkdownIfHtml?.(html) || html, + }); + return; } if (res && !res?.success && res?.isDescriptionTooLong) { @@ -1294,6 +1341,10 @@ function installRealtimeSyncListeners() { await archivistApi.updateLocation(apiKey, id, { name }); } else if (st === 'faction') { await archivistApi.updateFaction(apiKey, id, { name }); + } else if (st === 'quest') { + await archivistApi.updateQuest(apiKey, id, { questName: name }); + } else if (st === 'journal') { + await archivistApi.updateJournal(apiKey, { id, title: name }); } } catch (e) { console.warn('[RTS] updateJournalEntry (title sync) failed', e); @@ -1374,6 +1425,10 @@ function installRealtimeSyncListeners() { await archivistApi.deleteLocation(apiKey, id); } else if (st === 'faction' && archivistApi.deleteFaction) { await archivistApi.deleteFaction(apiKey, id); + } else if (st === 'quest') { + await archivistApi.deleteQuest(apiKey, id); + } else if (st === 'journal') { + await archivistApi.deleteJournal(apiKey, id); } } catch (e) { console.warn('[RTS] preDeleteJournalEntry failed', e); diff --git a/scripts/dialogs/sync-dialog.js b/scripts/dialogs/sync-dialog.js index 81fb955..0fcb207 100644 --- a/scripts/dialogs/sync-dialog.js +++ b/scripts/dialogs/sync-dialog.js @@ -14,9 +14,10 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi constructor(options = {}) { super(options); this.isLoading = false; + this.syncProgress = null; this.model = { - diffs: [], // { type, id, name, journalId, changes: { name?, description?, image?, links? }, deleted?:boolean, selected:boolean } - imports: [], // { type, id, name, image, description, selected:boolean, createCore:boolean, coreType:'actor'|'item'|'scene'|null } + diffs: [], + imports: [], stats: { diffs: 0, imports: 0 }, }; this._scrollPosition = 0; @@ -48,11 +49,88 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi async _onRender(context, options) { await super._onRender?.(context, options); - // Restore scroll position after render const content = this.element?.querySelector?.('.sync-dialog-content'); if (content && this._scrollPosition !== undefined) { content.scrollTop = this._scrollPosition; } + this._updateSyncButtonState(); + + // Shift+click multi-select for checkboxes + this._lastToggleClick = null; + const rows = this.element?.querySelectorAll?.('tr[data-id]') || []; + for (const row of rows) { + const cb = row.querySelector('input[data-action="toggleRow"]'); + if (!cb) continue; + row.addEventListener('click', (e) => { + if (e.target.closest('[data-action="toggleCore"]')) return; + if (!e.shiftKey || !this._lastToggleClick) { + this._lastToggleClick = row; + return; + } + // 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); + if (startIdx < 0 || endIdx < 0) return; + const [lo, hi] = startIdx < endIdx ? [startIdx, endIdx] : [endIdx, startIdx]; + const targetState = cb.checked; + for (let i = lo; i <= hi; i++) { + const r = allRows[i]; + const kind = r.dataset.kind; + const id = String(r.dataset.id || ''); + let modelRow; + if (kind === 'diff') modelRow = this.model.diffs.find((x) => String(x.id) === id); + else if (kind === 'import') modelRow = this.model.imports.find((x) => String(x.id) === id); + if (modelRow) { + modelRow.selected = targetState; + const rCb = r.querySelector('input[data-action="toggleRow"]'); + if (rCb) rCb.checked = targetState; + r.classList.toggle('selected', targetState); + } + } + this._lastToggleClick = row; + this._updateSyncButtonState(); + }); + } + } + + _updateSyncButtonState() { + const btn = this.element?.querySelector?.('[data-action="sync"]'); + if (!btn) return; + const hasSelected = + this.model.diffs.some((d) => d.selected) || + this.model.imports.some((i) => i.selected); + btn.disabled = !hasSelected; + } + + /** + * 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() { @@ -63,30 +141,30 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi } async _prepareContext() { - // If not initialized, show loading and trigger background fetch if (!this._initialized) { - // Trigger data load in background (don't await here) this._loadModel().then(() => { this._initialized = true; this.render({ force: true }); }); - // Return loading state immediately return { isLoading: true, diffs: [], imports: [], stats: { diffs: 0, imports: 0 }, - isGM: game.user?.isGM, + syncProgress: null, }; } - const ctx = { + const hasSelected = + this.model.diffs.some((d) => d.selected) || + this.model.imports.some((i) => i.selected); + return { isLoading: this.isLoading, diffs: this.model.diffs, imports: this.model.imports, stats: this.model.stats, - isGM: game.user?.isGM, + syncProgress: this.syncProgress || null, + hasSelected, }; - return ctx; } async _onSelectAll(event) { @@ -117,15 +195,18 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi if (!row) return; const kind = row?.dataset?.kind; const id = String(row?.dataset?.id || ''); + let modelRow; if (kind === 'diff') { - const diffRow = this.model.diffs.find((x) => String(x.id) === id); - if (diffRow) diffRow.selected = !diffRow.selected; + modelRow = this.model.diffs.find((x) => String(x.id) === id); } else if (kind === 'import') { - const importRow = this.model.imports.find((x) => String(x.id) === id); - if (importRow) importRow.selected = !importRow.selected; + modelRow = this.model.imports.find((x) => String(x.id) === id); } - this._captureScrollPosition(); - await this.render(); + if (!modelRow) return; + modelRow.selected = !modelRow.selected; + const cb = row.querySelector('input[type="checkbox"]'); + if (cb) cb.checked = modelRow.selected; + row.classList.toggle('selected', modelRow.selected); + this._updateSyncButtonState(); } async _onToggleCreateCore(event) { @@ -135,19 +216,14 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi const row = this.model.imports.find((x) => String(x.id) === id); if (!row || !row.coreType) return; row.createCore = !row.createCore; - this._captureScrollPosition(); - await this.render(); } async _onRefresh(event) { event?.preventDefault?.(); - // Set loading state to show the spinner this.isLoading = true; - await this.render(); try { await this._loadModel(true); } finally { - // Loading state will be set to false by _loadModel await this.render(); } } @@ -193,28 +269,65 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi await this.render(); return; } - console.log('[SyncDialog] ✓ Real-time sync successfully suppressed'); + console.debug('[SyncDialog] Real-time sync successfully suppressed'); try { - // Apply diffs + // Confirm deletions before proceeding + const deleteDiffs = selectedDiffs.filter((d) => d.deleted); + if (deleteDiffs.length > 0) { + const names = deleteDiffs + .map((d) => 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?

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

${content}

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

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

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

    ${renderInline(p)}

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

    {{setup.name}}

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

    Objectives

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

    No objectives

    + {{/unless}} + {{/unless}} +
    +
    +

    Progress Log

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

    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}} + + + + +
    +
    + {{#if setup.isGM}} +
    + + {{{setup.gmNotes}}} + +
    + {{else}} +
    +

    GM-only notes are not visible to players.

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

    Review Diffs ({{stats.diffs}})

    +
    +

    Review Changes ({{stats.diffs}})

    +

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

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

    Import New ({{stats.imports}})

    +
    +

    Import New ({{stats.imports}})

    +

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

    +
    + {{#if stats.imports}}
    + {{/if}}
    + {{#if stats.imports}}
    - - + + - + @@ -94,11 +116,11 @@ - @@ -107,11 +129,19 @@
    TypeType NameCreate NewCreate New
    {{r.type}} {{r.name}} + {{#if r.coreType}} -
    + {{else}} +
    + + No new Archivist entities to import. +
    + {{/if}}
    - +
    {{/if}} -
    \ No newline at end of file +
    diff --git a/templates/world-setup-dialog.hbs b/templates/world-setup-dialog.hbs index eb3dab6..65fa651 100644 --- a/templates/world-setup-dialog.hbs +++ b/templates/world-setup-dialog.hbs @@ -898,11 +898,11 @@
    -
    - {{syncStatus.processed}} / {{syncStatus.total}} — {{syncStatus.current}} + {{syncStatus.processed}} / {{syncStatus.total}} — {{syncStatus.current}}
    {{/if}} {{#if syncStatus.logs}}