From 5b7cdec3adf9f29a2014e9ce0615b6e792c5107d Mon Sep 17 00:00:00 2001 From: Greg Harezlak Date: Sun, 12 Jul 2026 16:15:07 -0700 Subject: [PATCH 1/9] fix: correct verified compatibility on v14 line compatibility.verified was "13.351" while minimum was "14.359" - verified must never be lower than minimum. Looks like a v13/v14 field cross-contamination similar to the incident that caused the original v14 rollback. Reset verified to match minimum until tested against a newer 14.x build. --- module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module.json b/module.json index 54a1177..d1ea7d6 100644 --- a/module.json +++ b/module.json @@ -12,7 +12,7 @@ "version": "2.0.0", "compatibility": { "minimum": "14.359", - "verified": "13.351" + "verified": "14.359" }, "relationships": { "systems": [], From 081273c9ad8b44377ac97ab866645fe695ec6eb8 Mon Sep 17 00:00:00 2001 From: Greg Harezlak Date: Sun, 12 Jul 2026 16:27:06 -0700 Subject: [PATCH 2/9] feat(quests): complete the Quests feature on the v14 line Previously only the quest title round-tripped to the API; every other field (giver, category, status, success/failure/next-action/resolution) was display-only after first import, objectives were local-only, and Quests sat outside the shared linking system entirely. - Info tab is now a structured edit form instead of a stubbed ProseMirror body (Quest has no generic `description` field in the API); drop the hack that stuffed successDefinition/nextAction into page content on import. - _commitEdits sends the full updateQuest payload, not just questName. - Objectives (add/remove/edit text+status) sync to the API instead of being local-flag-only. - Quest relationships persist via relatedEntityRefs on the quest itself (updateQuest), since the API's link system has no ENTITY_CONFIG entry for Quest in either direction. Links tab now renders real, clickable, unlinkable cards backed by entity IDs instead of static name chips. - Character/Item/Location/Faction sheets get a read-only "Quests" tab (client-side filtered from listQuests(), since there's no reverse lookup endpoint) so relationships are visible from both sides. - reconcile-service now includes quests/journals in the ongoing sync pass, not just first import. - Documented Quest/Journal/Links shapes in the Cursor API rules doc, which previously covered every other entity but not these. Co-Authored-By: Claude Sonnet 5 --- .cursor/rules/40-archivist-api.mdc | 46 +++ scripts/dialogs/sync-dialog.js | 2 + scripts/dialogs/world-setup-dialog.js | 9 +- scripts/modules/journal-manager.js | 2 + .../modules/reconcile/reconcile-service.js | 64 ++- scripts/modules/sheets/page-sheet-v2.js | 387 +++++++++++++++++- styles/archivist-sync.css | 38 ++ templates/sheets/character.hbs | 5 + templates/sheets/faction.hbs | 5 + templates/sheets/item.hbs | 5 + templates/sheets/location.hbs | 5 + templates/sheets/quest.hbs | 113 +++-- 12 files changed, 620 insertions(+), 61 deletions(-) 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/scripts/dialogs/sync-dialog.js b/scripts/dialogs/sync-dialog.js index 7984d62..02373d6 100644 --- a/scripts/dialogs/sync-dialog.js +++ b/scripts/dialogs/sync-dialog.js @@ -968,6 +968,8 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi fullQuest.relatedLocations || fullQuest.related_locations || [], relatedItems: fullQuest.relatedItems || fullQuest.related_items || [], + relatedEntityRefs: + fullQuest.relatedEntityRefs || fullQuest.related_entity_refs || [], firstSession: fullQuest.firstSession || fullQuest.first_session || null, lastSession: fullQuest.lastSession || fullQuest.last_session || null, }; diff --git a/scripts/dialogs/world-setup-dialog.js b/scripts/dialogs/world-setup-dialog.js index 4878497..9a6f655 100644 --- a/scripts/dialogs/world-setup-dialog.js +++ b/scripts/dialogs/world-setup-dialog.js @@ -2519,12 +2519,11 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica } catch (_) {} } - const html = Utils.toMarkdownIfHtml( - String(fullQuest.successDefinition || fullQuest.nextAction || '') - ); + // Quest has no generic description field in the API; its Info tab is a + // structured metadata form (see quest.hbs), not free-text page content. const journal = await Utils.createCustomJournalForImport({ name: fullQuest.questName || fullQuest.quest_name || 'Quest', - html, + html: '', sheetType: 'quest', archivistId: fullQuest.id, worldId: campaignId, @@ -2566,6 +2565,8 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica fullQuest.relatedLocations || fullQuest.related_locations || [], relatedItems: fullQuest.relatedItems || fullQuest.related_items || [], + relatedEntityRefs: + fullQuest.relatedEntityRefs || fullQuest.related_entity_refs || [], firstSession: fullQuest.firstSession || fullQuest.first_session || null, lastSession: fullQuest.lastSession || fullQuest.last_session || null, }; 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/reconcile/reconcile-service.js b/scripts/modules/reconcile/reconcile-service.js index 8d37d2a..db1ad77 100644 --- a/scripts/modules/reconcile/reconcile-service.js +++ b/scripts/modules/reconcile/reconcile-service.js @@ -18,14 +18,17 @@ export class ReconcileService { if (!apiKey || !campaignId) throw new Error('Archivist not configured'); // Fetch entities in parallel - const [chars, items, locs, facs, sessions, links] = await Promise.all([ - archivistApi.listCharacters(apiKey, campaignId), - archivistApi.listItems(apiKey, campaignId), - archivistApi.listLocations(apiKey, campaignId), - archivistApi.listFactions(apiKey, campaignId), - archivistApi.listSessions(apiKey, campaignId), - archivistApi.listLinks(apiKey, campaignId), - ]); + const [chars, items, locs, facs, sessions, links, quests, journalsList] = + await Promise.all([ + archivistApi.listCharacters(apiKey, campaignId), + archivistApi.listItems(apiKey, campaignId), + archivistApi.listLocations(apiKey, campaignId), + archivistApi.listFactions(apiKey, campaignId), + archivistApi.listSessions(apiKey, campaignId), + archivistApi.listLinks(apiKey, campaignId), + archivistApi.listQuests(apiKey, campaignId), + archivistApi.listJournals(apiKey, campaignId), + ]); const characters = chars.success ? chars.data || [] : []; const itemsData = items.success ? items.data || [] : []; @@ -33,6 +36,12 @@ export class ReconcileService { const factions = facs.success ? facs.data || [] : []; const sessionsData = sessions.success ? sessions.data || [] : []; const linksData = links.success ? links.data || [] : []; + // listQuests() rows aren't normalized server-side consistently; route through + // the same camelCase/snake_case-tolerant normalizer used elsewhere for quests. + const questsData = (quests.success ? quests.data || [] : []).map((q) => + archivistApi._normalizeQuestResponse(q) + ); + const journalsData = journalsList.success ? journalsList.data || [] : []; // Ensure default folders, index existing journals by archivistId try { @@ -96,6 +105,45 @@ export class ReconcileService { for (const it of itemsData) await ensureSheet(it, 'item'); for (const l of locations) await ensureSheet(l, 'location'); for (const f of factions) await ensureSheet(f, 'faction'); + for (const j of journalsData) await ensureSheet(j, 'journal'); + + // Quests: ensure a sheet exists, then refresh its full questData from Archivist + // (title/image handled generically by ensureSheet; the rest is quest-specific). + for (const q of questsData) { + const entity = { ...q, name: q.questName || 'Quest' }; + const j = await ensureSheet(entity, 'quest'); + try { + const flags = j.getFlag(CONFIG.MODULE_ID, 'archivist') || {}; + flags.questData = { + questName: q.questName || '', + questGiver: q.questGiver || '', + questCategory: q.questCategory || 'n/a', + status: q.status || 'planned', + successDefinition: q.successDefinition || '', + failureConditions: q.failureConditions || '', + nextAction: q.nextAction || '', + resolution: q.resolution || '', + objectives: Array.isArray(q.objectives) ? q.objectives : [], + progressLog: Array.isArray(q.progressLog) ? q.progressLog : [], + relatedCharacters: Array.isArray(q.relatedCharacters) + ? q.relatedCharacters + : [], + relatedFactions: Array.isArray(q.relatedFactions) + ? q.relatedFactions + : [], + relatedLocations: Array.isArray(q.relatedLocations) + ? q.relatedLocations + : [], + relatedItems: Array.isArray(q.relatedItems) ? q.relatedItems : [], + relatedEntityRefs: Array.isArray(q.relatedEntityRefs) + ? q.relatedEntityRefs + : [], + firstSession: q.firstSession || null, + lastSession: q.lastSession || null, + }; + await j.setFlag(CONFIG.MODULE_ID, 'archivist', flags); + } catch (_) {} + } // Recaps: ensure a single Recaps container exists with pages ordered by session_date try { diff --git a/scripts/modules/sheets/page-sheet-v2.js b/scripts/modules/sheets/page-sheet-v2.js index d51ce36..9f792e6 100644 --- a/scripts/modules/sheets/page-sheet-v2.js +++ b/scripts/modules/sheets/page-sheet-v2.js @@ -664,9 +664,97 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( this._renderLinkedGrids(); if (typeof this._renderActorItemCards === 'function') this._renderActorItemCards(); + if (typeof this._renderRelatedQuests === 'function') + this._renderRelatedQuests(); } catch (_) {} } + /** + * Populate a read-only "Quests" tab with quests that reference this entity. + * The Archivist API has no reverse-lookup endpoint for "quests referencing X", + * so this fetches the quest list and filters client-side by relatedEntityRefs. + * Quest is the source of truth for the relationship; edit/unlink from the + * Quest sheet itself, not from here. + */ + async _renderRelatedQuests() { + const grid = this.element?.querySelector?.( + '.tab[data-tab="quests"] .archivist-grid' + ); + if (!grid) return; + const flags = this._getArchivistFlags(); + const sheetType = String(flags.sheetType || '').toLowerCase(); + const entityType = { pc: 'character', npc: 'character', character: 'character', faction: 'faction', location: 'location', item: 'item' }[sheetType]; + const archivistId = String(flags.archivistId || ''); + if (!entityType || !archivistId) { + grid.innerHTML = 'None'; + return; + } + const seq = (this._rqSeq = (this._rqSeq || 0) + 1); + let quests = []; + try { + quests = await ArchivistBasePageSheetV2._loadQuestsCached(); + } catch (_) { + quests = []; + } + if (this._rqSeq !== seq) return; // superseded by a later render + const matches = quests.filter((q) => + (Array.isArray(q.relatedEntityRefs) ? q.relatedEntityRefs : []).some( + (r) => + String(r.entityType).toLowerCase() === entityType && + String(r.entityId) === archivistId + ) + ); + grid.innerHTML = ''; + if (!matches.length) { + grid.innerHTML = 'None'; + return; + } + for (const q of matches) { + const questJournal = this._findPageOrEntryByArchivistId(String(q.id)); + const name = q.questName || 'Quest'; + const card = document.createElement('div'); + card.className = 'archivist-card'; + card.dataset.archivistId = String(q.id); + card.innerHTML = `${foundry.utils.escapeHTML(name)}`; + if (questJournal) { + card.addEventListener('click', () => { + const doc = + questJournal.documentName === 'JournalEntryPage' + ? questJournal.parent + : questJournal; + doc?.sheet?.render?.(true); + }); + } + grid.appendChild(card); + } + const nav = this.element?.querySelector?.( + '.archivist-nav [data-tab="quests"]' + ); + if (nav) { + const base = nav.dataset._label || nav.textContent || 'Quests'; + if (!nav.dataset._label) nav.dataset._label = base; + nav.textContent = matches.length > 0 ? `${base} (${matches.length})` : base; + } + } + + /** Short-lived cache so opening several sheets at once doesn't re-fetch listQuests repeatedly. */ + static async _loadQuestsCached() { + const now = Date.now(); + const cache = ArchivistBasePageSheetV2._questsCache; + if (cache && now - cache.at < 5000) return cache.data; + const apiKey = settingsManager.getApiKey?.(); + const campaignId = settingsManager.getSelectedWorldId?.(); + if (!apiKey || !campaignId) return []; + const result = await archivistApi.listQuests(apiKey, campaignId); + const raw = result?.success ? result.data || [] : []; + // listQuests() doesn't normalize each row; the API's casing for quest + // fields is inconsistent between endpoints, so route through the same + // camelCase/snake_case-tolerant normalizer used by get/create/updateQuest. + const data = raw.map((q) => archivistApi._normalizeQuestResponse(q)); + ArchivistBasePageSheetV2._questsCache = { at: now, data }; + return data; + } + async _toggleEditMode() { console.log( '[Archivist V2 Sheet] _toggleEditMode called, current editMode:', @@ -932,7 +1020,19 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin( await archivistApi.updateSession(apiKey, archivistId, payload); } else if (sheetType === 'quest') { console.log('[Archivist V2 Sheet] Syncing Quest to API'); - const payload = { questName: nameNow }; + const root = this.element; + const readVal = (sel) => + String(root?.querySelector?.(sel)?.value ?? '').trim(); + const payload = { + questName: nameNow, + questGiver: readVal('.quest-giver-input'), + questCategory: readVal('.quest-category-select') || 'n/a', + status: readVal('.quest-status-select') || 'planned', + successDefinition: readVal('.quest-success-input'), + failureConditions: readVal('.quest-failure-input'), + nextAction: readVal('.quest-next-action-input'), + resolution: readVal('.quest-resolution-input'), + }; result = await archivistApi.updateQuest(apiKey, archivistId, payload); } else if (sheetType === 'journal') { console.log('[Archivist V2 Sheet] Syncing Journal to API'); @@ -981,6 +1081,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; @@ -2027,6 +2172,11 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { : Array.isArray(qd.related_items) ? qd.related_items : [], + relatedEntityRefs: Array.isArray(qd.relatedEntityRefs) + ? qd.relatedEntityRefs + : Array.isArray(qd.related_entity_refs) + ? qd.related_entity_refs + : [], firstSession: qd.firstSession || qd.first_session || null, lastSession: qd.lastSession || qd.last_session || null, }; @@ -2113,23 +2263,80 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { } } + /** Map an entityType (as stored in relatedEntityRefs) to its Links-tab grid selector. */ + static ENTITY_TYPE_TO_SELECTOR = { + character: '.quest-related-characters', + faction: '.quest-related-factions', + location: '.quest-related-locations', + item: '.quest-related-items', + }; + + /** Map a generic drop bucket (from _bucketForDrop) to the entityType Archivist expects. */ + static BUCKET_TO_ENTITY_TYPE = { + characters: 'character', + factions: 'faction', + locationsAssociative: 'location', + items: 'item', + }; + _renderQuestLinks(root, context) { const q = context.quest || {}; - const sections = [ - { sel: '.quest-related-characters', items: q.relatedCharacters }, - { sel: '.quest-related-factions', items: q.relatedFactions }, - { sel: '.quest-related-locations', items: q.relatedLocations }, - { sel: '.quest-related-items', items: q.relatedItems }, - ]; - for (const { sel, items } of sections) { + const refs = Array.isArray(q.relatedEntityRefs) ? q.relatedEntityRefs : []; + const byType = { character: [], faction: [], location: [], item: [] }; + for (const ref of refs) { + const t = String(ref?.entityType || '').toLowerCase(); + if (byType[t]) byType[t].push(ref); + } + // Legacy name-string fallbacks for quests that predate relatedEntityRefs + const legacyByType = { + character: q.relatedCharacters || [], + faction: q.relatedFactions || [], + location: q.relatedLocations || [], + item: q.relatedItems || [], + }; + + for (const [type, sel] of Object.entries( + QuestPageSheetV2.ENTITY_TYPE_TO_SELECTOR + )) { const el = root.querySelector(sel); if (!el) continue; el.innerHTML = ''; - if (!items?.length) { + const refsForType = byType[type]; + if (!refsForType.length && !legacyByType[type]?.length) { el.innerHTML = 'None'; continue; } - for (const name of items) { + for (const ref of refsForType) { + const entityId = String(ref.entityId || ''); + const name = ref.label || ref.entityNameSnapshot || 'Unknown'; + const targetDoc = entityId + ? this._findPageOrEntryByArchivistId(entityId) + : null; + const img = targetDoc + ? this._resolveSheetImage(targetDoc) + : 'icons/svg/mystery-man.svg'; + const card = document.createElement('div'); + card.className = 'archivist-card'; + card.dataset.archivistId = entityId; + card.innerHTML = `${foundry.utils.escapeHTML(name)}
`; + if (targetDoc) { + card.addEventListener('click', (ev) => { + if (ev.target?.closest?.('.actions')) return; + const doc = + targetDoc.documentName === 'JournalEntryPage' + ? targetDoc.parent + : targetDoc; + doc?.sheet?.render?.(true); + }); + } + el.appendChild(card); + } + // Names without a resolvable entityId (legacy data) render as plain, unlinkable chips + const linkedNames = new Set( + refsForType.map((r) => r.label || r.entityNameSnapshot) + ); + for (const name of legacyByType[type] || []) { + if (linkedNames.has(name)) continue; const chip = document.createElement('span'); chip.className = 'quest-link-chip'; chip.textContent = name; @@ -2151,37 +2358,179 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 { if (Number.isFinite(idx)) this._removeObjective(idx); }); }); + root + .querySelectorAll('[data-action="unlink-quest-entity"]') + .forEach((btn) => { + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const { entityType, entityId } = ev.currentTarget.dataset; + if (entityType && entityId) + this._unlinkQuestEntity(entityType, entityId); + }); + }); + + // Seed select values (Handlebars has no per-option "selected" helper here) + if (context.setup?.editingInfo) { + const categorySelect = root.querySelector('.quest-category-select'); + if (categorySelect) categorySelect.value = context.quest?.questCategory || 'n/a'; + const statusSelect = root.querySelector('.quest-status-select'); + if (statusSelect) statusSelect.value = context.quest?.status || 'planned'; + + root.querySelectorAll('[data-obj-index]').forEach((li) => { + const idx = Number(li.dataset.objIndex); + const obj = context.quest?.objectives?.[idx]; + if (!obj) return; + const statusSel = li.querySelector('.quest-objective-status-select'); + if (statusSel) { + statusSel.value = obj.status || 'pending'; + statusSel.addEventListener('change', () => + this._updateObjective(idx, { status: statusSel.value }) + ); + } + const textInput = li.querySelector('.quest-objective-text-input'); + if (textInput) { + textInput.addEventListener('change', () => + this._updateObjective(idx, { text: textInput.value }) + ); + } + }); + } } - async _addObjective() { + /** Persist the current objectives array (local flags + remote PATCH). */ + async _syncObjectives(objectives) { const flags = this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; - const qd = { ...(flags.questData || {}) }; - const objectives = Array.isArray(qd.objectives) - ? [...qd.objectives] - : []; - objectives.push({ text: 'New Objective', status: 'pending', order: objectives.length }); - qd.objectives = objectives; + const qd = { ...(flags.questData || {}), objectives }; await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', { ...flags, questData: qd, }); + try { + const apiKey = settingsManager.getApiKey?.(); + const archivistId = String(flags.archivistId || ''); + if (apiKey && archivistId) { + await archivistApi.updateQuest(apiKey, archivistId, { objectives }); + } + } catch (e) { + console.warn('[Archivist Sync][V2] Failed to sync quest objectives', e); + } this.render(false); } + async _addObjective() { + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = flags.questData || {}; + const objectives = Array.isArray(qd.objectives) + ? [...qd.objectives] + : []; + objectives.push({ text: 'New Objective', status: 'pending' }); + await this._syncObjectives(objectives); + } + async _removeObjective(idx) { const flags = this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; - const qd = { ...(flags.questData || {}) }; + const qd = flags.questData || {}; const objectives = Array.isArray(qd.objectives) ? [...qd.objectives] : []; objectives.splice(idx, 1); - qd.objectives = objectives; + await this._syncObjectives(objectives); + } + + async _updateObjective(idx, patch) { + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = flags.questData || {}; + const objectives = Array.isArray(qd.objectives) + ? [...qd.objectives] + : []; + if (!objectives[idx]) return; + objectives[idx] = { ...objectives[idx], ...patch }; + // Skip the full re-render here (would drop focus mid-edit); commit silently. + const nextFlags = { + ...flags, + questData: { ...qd, objectives }, + }; + await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', nextFlags); + try { + const apiKey = settingsManager.getApiKey?.(); + const archivistId = String(flags.archivistId || ''); + if (apiKey && archivistId) { + await archivistApi.updateQuest(apiKey, archivistId, { objectives }); + } + } catch (e) { + console.warn('[Archivist Sync][V2] Failed to sync quest objective', e); + } + } + + /** Link an entity (Character/Item/Faction/Location) to this Quest via relatedEntityRefs. */ + async _linkQuestEntity(bucket, entityId, entityName) { + const entityType = QuestPageSheetV2.BUCKET_TO_ENTITY_TYPE[bucket]; + if (!entityType) return; + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = flags.questData || {}; + const refs = Array.isArray(qd.relatedEntityRefs) + ? [...qd.relatedEntityRefs] + : []; + if ( + refs.some( + (r) => + String(r.entityId) === String(entityId) && + String(r.entityType).toLowerCase() === entityType + ) + ) { + return; // already linked + } + refs.push({ + entityType, + entityId: String(entityId), + entityNameSnapshot: entityName, + label: entityName, + }); + await this._commitQuestLinks(refs); + } + + /** Unlink an entity from this Quest's relatedEntityRefs. */ + async _unlinkQuestEntity(entityType, entityId) { + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = flags.questData || {}; + const refs = ( + Array.isArray(qd.relatedEntityRefs) ? qd.relatedEntityRefs : [] + ).filter( + (r) => + !( + String(r.entityId) === String(entityId) && + String(r.entityType).toLowerCase() === String(entityType).toLowerCase() + ) + ); + await this._commitQuestLinks(refs); + } + + async _commitQuestLinks(relatedEntityRefs) { + const flags = + this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {}; + const qd = { ...(flags.questData || {}), relatedEntityRefs }; await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', { ...flags, questData: qd, }); + try { + const apiKey = settingsManager.getApiKey?.(); + const archivistId = String(flags.archivistId || ''); + if (apiKey && archivistId) { + await archivistApi.updateQuest(apiKey, archivistId, { + relatedEntityRefs, + }); + } + } catch (e) { + console.warn('[Archivist Sync][V2] Failed to sync quest links', e); + } this.render(false); } } diff --git a/styles/archivist-sync.css b/styles/archivist-sync.css index 8460460..34a5853 100644 --- a/styles/archivist-sync.css +++ b/styles/archivist-sync.css @@ -2692,6 +2692,44 @@ img.archivist-icon { line-height: 1.4; } +.quest-field-group input[type='text'], +.quest-field-group select, +.quest-field-group textarea { + font-size: 0.85rem; + line-height: 1.4; + padding: 0.3rem 0.5rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + color: inherit; + font-family: inherit; + resize: vertical; +} + +.quest-objective { + flex-wrap: wrap; +} + +.quest-objective-text-input { + flex: 1 1 auto; + min-width: 8rem; + font-size: 0.85rem; + padding: 0.25rem 0.4rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + color: inherit; +} + +.quest-objective-status-select { + font-size: 0.78rem; + padding: 0.2rem 0.35rem; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.15); + color: inherit; +} + /* Quest section headings */ .quest-section-heading { diff --git a/templates/sheets/character.hbs b/templates/sheets/character.hbs index 21114b4..19421bd 100644 --- a/templates/sheets/character.hbs +++ b/templates/sheets/character.hbs @@ -33,6 +33,7 @@ Locations Factions Journals + Quests GM Notes @@ -78,6 +79,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..2712e42 100644 --- a/templates/sheets/faction.hbs +++ b/templates/sheets/faction.hbs @@ -28,6 +28,7 @@ Locations Factions Journals + Quests GM Notes @@ -62,6 +63,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..bd0dad8 100644 --- a/templates/sheets/item.hbs +++ b/templates/sheets/item.hbs @@ -30,6 +30,7 @@ Locations Factions Journals + Quests GM Notes @@ -75,6 +76,10 @@
+
+

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

+
+
{{#if setup.isGM}}
diff --git a/templates/sheets/location.hbs b/templates/sheets/location.hbs index 13bb7bd..fb9b7b4 100644 --- a/templates/sheets/location.hbs +++ b/templates/sheets/location.hbs @@ -28,6 +28,7 @@ Locations Factions Journals + Quests GM Notes @@ -96,6 +97,10 @@
+
+

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

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

Progress Log

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

Related Entities

+ {{#if setup.isGM}} +

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

+ {{/if}}
From 74a370cab104d64f84f2f6ba1effd608fcf5ddee Mon Sep 17 00:00:00 2001 From: Greg Harezlak Date: Sun, 12 Jul 2026 16:31:10 -0700 Subject: [PATCH 3/9] feat(api): local image upload to Archivist + link PATCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Historically users couldn't import existing Foundry Actor/Item images because they're stored locally and the module had no way to send image bytes to the API. Confirmed the API's actual upload shape: a 3-step presigned-URL flow (init -> PUT bytes directly to R2 -> complete), not a raw multipart POST. 4.5MB cap and content-sniffing are server-enforced. - archivist-api.js: initImageUpload/uploadImageBytes/completeImageUpload plus a uploadEntityImage convenience wrapper running all three steps. - Character/Item/Location/Faction sheets get an "Upload local image" header button (GM-only) that reads the linked Actor/Item/Scene's (or the journal's own) local img via fetch() and pushes it to Archivist. Not available for Quest — the API has no image support for that type. - archivist-api.js: added updateLink (PATCH) alongside the existing createLink/deleteLink so a link's alias can be corrected in place instead of delete+recreate, once a UI flow needs it. Other endpoints surveyed but intentionally left for a future pass to keep this change bounded: unified /campaigns/{id}/search, the lightweight /entities picker endpoint, session transcript/handout -> Journal auto-generation, the recordings/transcription pipeline, and journal-folder CRUD (create/rename/delete). Co-Authored-By: Claude Sonnet 5 --- scripts/modules/sheets/page-sheet-v2.js | 105 +++++++++++++++ scripts/services/archivist-api.js | 171 ++++++++++++++++++++++++ templates/sheets/character.hbs | 4 + templates/sheets/faction.hbs | 4 + templates/sheets/item.hbs | 4 + templates/sheets/location.hbs | 4 + 6 files changed, 292 insertions(+) diff --git a/scripts/modules/sheets/page-sheet-v2.js b/scripts/modules/sheets/page-sheet-v2.js index 9f792e6..c10dd20 100644 --- a/scripts/modules/sheets/page-sheet-v2.js +++ b/scripts/modules/sheets/page-sheet-v2.js @@ -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 (_) {} } @@ -1807,6 +1818,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 { diff --git a/scripts/services/archivist-api.js b/scripts/services/archivist-api.js index 42bdfcb..09ea42f 100644 --- a/scripts/services/archivist-api.js +++ b/scripts/services/archivist-api.js @@ -1658,6 +1658,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/templates/sheets/character.hbs b/templates/sheets/character.hbs index 19421bd..c6e2a2a 100644 --- a/templates/sheets/character.hbs +++ b/templates/sheets/character.hbs @@ -23,6 +23,10 @@ {{#if setup.editingInfo}}{{else}}{{/if}} + {{/if}}