diff --git a/.cursor/rules/40-archivist-api.mdc b/.cursor/rules/40-archivist-api.mdc
index 6435748..6ecd2ba 100644
--- a/.cursor/rules/40-archivist-api.mdc
+++ b/.cursor/rules/40-archivist-api.mdc
@@ -170,6 +170,52 @@ DELETE /v1/items/{item_id} → 204
⸻
+Quests
+
+GET /v1/quests?campaign_id=&page=&size=&search=&status=&quest_category= → paginated list
+GET /v1/quests/{quest_id}
+POST /v1/quests (worldId*/campaign_id*, questName*, questGiver?, questCategory?, status?, successDefinition?, failureConditions?, nextAction?, resolution?, objectives?, progressLog?, relatedCharacters?, relatedFactions?, relatedLocations?, relatedItems?, relatedEntityRefs?) → 201
+PATCH /v1/quests/{quest_id} — same fields as POST, all optional (partial update)
+DELETE /v1/quests/{quest_id} → 204
+
+IMPORTANT — casing differs from every other entity above: Quest's Pydantic schemas
+declare fields in **camelCase** (`questName`, `successDefinition`, `relatedEntityRefs`,
+…) as the primary name, with snake_case accepted only as an alias
+(`populate_by_name=True` + an alias_generator). Every other entity in this doc uses
+snake_case as canonical. Sending camelCase quest field names is correct and is what
+`archivist-sync`'s `_normalizeQuestPayload`/`_normalizeQuestResponse` already assume.
+
+`objectives`: `[{ text, status }]` where status is one of
+`pending | in-progress | completed | failed | blocked`.
+`relatedEntityRefs`: `[{ entityType, entityId, entityNameSnapshot?, label? }]` where
+entityType is one of `character | faction | location | item` — **Quest has no generic
+`description` field**, and **Quest cannot appear in `/v1/campaigns/{id}/links`** (no
+`ENTITY_CONFIG` entry on the backend for it, in either direction). Quest relationships
+only ever persist via `relatedEntityRefs` on the quest itself — treat Quest as the
+single source of truth for its own relationships, not the generic links system.
+
+⸻
+
+Journals
+
+GET /v1/journals?campaign_id=&page=&size=
+GET /v1/journals/{journal_id}
+POST /v1/journals (id?, title*, content?, campaign_id*) → 201
+PATCH /v1/journals (id*, title?, content?) → 200 — note: PATCH takes `id` in the body,
+not as a path param, unlike every other entity in this doc.
+DELETE /v1/journals/{journal_id} → 204
+
+⸻
+
+Links — PATCH exists, use it instead of delete+recreate
+
+PATCH /v1/campaigns/{campaign_id}/links/{link_id} (alias?) → 200
+Prefer this over DELETE-then-POST when only a link's label/alias is changing and the
+from/to endpoints stay the same — cheaper and avoids a window where the link doesn't
+exist. Quest is not a valid `from_type`/`to_type` here (see above).
+
+⸻
+
Client Helpers (use these in this project)
Create a tiny wrapper so all calls are consistent and type-safe:
diff --git a/module.json b/module.json
index 54a1177..d1ea7d6 100644
--- a/module.json
+++ b/module.json
@@ -12,7 +12,7 @@
"version": "2.0.0",
"compatibility": {
"minimum": "14.359",
- "verified": "13.351"
+ "verified": "14.359"
},
"relationships": {
"systems": [],
diff --git a/scripts/archivist-sync.js b/scripts/archivist-sync.js
index 4d535b3..1b0fe8c 100644
--- a/scripts/archivist-sync.js
+++ b/scripts/archivist-sync.js
@@ -1238,9 +1238,10 @@ function installRealtimeSyncListeners() {
});
return;
} else if (sheetType === 'quest') {
- res = await archivistApi.updateQuest(apiKey, flags.archivistId, {
- questName: parent?.name || page.name,
- });
+ // Quest page content is not a synced API field (quests use structured
+ // fields like successDefinition, not a free description), and name
+ // changes are handled by the updateJournalEntry title-sync hook. So a
+ // page-content edit has nothing to push — skip the redundant PATCH.
return;
} else if (sheetType === 'journal') {
res = await archivistApi.updateJournal(apiKey, {
diff --git a/scripts/dialogs/sync-dialog.js b/scripts/dialogs/sync-dialog.js
index 7984d62..0fcb207 100644
--- a/scripts/dialogs/sync-dialog.js
+++ b/scripts/dialogs/sync-dialog.js
@@ -67,6 +67,9 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
this._lastToggleClick = row;
return;
}
+ // Range-select handles the clicked row itself; stop the event here so
+ // the root-level toggleRow action doesn't flip it back afterwards.
+ e.stopPropagation();
const allRows = [...this.element.querySelectorAll('tr[data-id]')];
const startIdx = allRows.indexOf(this._lastToggleClick);
const endIdx = allRows.indexOf(row);
@@ -102,6 +105,34 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
btn.disabled = !hasSelected;
}
+ /**
+ * Update the in-progress panel's text directly rather than re-rendering the
+ * whole dialog for every synced item. The panel is rendered once before the
+ * sync loop begins; a skipped update (panel absent) is corrected by the
+ * final render when syncProgress is cleared.
+ * @private
+ */
+ _updateProgressUI() {
+ const panel = this.element?.querySelector?.('.loading-panel');
+ if (!panel || !this.syncProgress) return;
+ const { processed, total, current } = this.syncProgress;
+ const textEl = panel.querySelector('.sync-progress-text');
+ if (textEl) {
+ textEl.textContent = `Processing ${processed} / ${total}…`;
+ }
+ let detailEl = panel.querySelector('.sync-progress-detail');
+ if (current) {
+ if (!detailEl) {
+ detailEl = document.createElement('span');
+ detailEl.className = 'sync-progress-detail';
+ panel.appendChild(detailEl);
+ }
+ detailEl.textContent = current;
+ } else if (detailEl) {
+ detailEl.remove();
+ }
+ }
+
_captureScrollPosition() {
const content = this.element?.querySelector?.('.sync-dialog-content');
if (content) {
@@ -244,7 +275,9 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
// Confirm deletions before proceeding
const deleteDiffs = selectedDiffs.filter((d) => d.deleted);
if (deleteDiffs.length > 0) {
- const names = deleteDiffs.map((d) => d.name).join(', ');
+ const names = deleteDiffs
+ .map((d) => foundry.utils.escapeHTML(String(d.name ?? '')))
+ .join(', ');
const confirmed = await foundry.applications.api.DialogV2.confirm({
window: { title: 'Confirm Deletion' },
content: `
${deleteDiffs.length} journal${deleteDiffs.length > 1 ? 's' : ''} will be permanently deleted:
${names}
This cannot be undone. Continue?
`,
@@ -267,7 +300,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
for (const d of selectedDiffs) {
this.syncProgress.current = `${d.type}: ${d.name}`;
this.syncProgress.processed = processed;
- await this.render();
+ this._updateProgressUI();
try {
await this._applyDiff(d);
} catch (e) {
@@ -280,7 +313,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
for (const i of selectedImports) {
this.syncProgress.current = `${i.type}: ${i.name}`;
this.syncProgress.processed = processed;
- await this.render();
+ this._updateProgressUI();
try {
await this._applyImport(i, campaignId, apiKey);
} catch (e) {
@@ -289,6 +322,9 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
}
processed++;
}
+ // Reflect the final processed count before the panel is torn down.
+ this.syncProgress.processed = processed;
+ this._updateProgressUI();
this.syncProgress = null;
@@ -677,7 +713,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
type,
id,
name: row.character_name || row.name || row.title || 'Untitled',
- description: row.description || row.summary || '',
+ description: row.description || row.summary || row.content || '',
image: row.image || '',
selected: false,
createCore: false,
@@ -862,7 +898,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
: null;
if (!sheetType) return;
// Convert markdown from Archivist to HTML for Foundry storage (sessions use summary)
- const markdownContent = String(row.description || row.summary || '');
+ const markdownContent = String(row.description || row.summary || row.content || '');
const htmlContent = Utils.markdownToStoredHtml(markdownContent);
// Determine folder ID based on sheet type using saved destinations
@@ -882,10 +918,16 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
folderId = destinations.location;
} else if (sheetType === 'faction' && destinations.faction) {
folderId = destinations.faction;
- } else if (sheetType === 'journal' && destinations.journal) {
- folderId = destinations.journal;
- } else if (sheetType === 'quest' && destinations.quest) {
- folderId = destinations.quest;
+ } else if (sheetType === 'journal') {
+ // Worlds set up before journals existed have no saved destination —
+ // fall back to ensuring the default folder like recaps do.
+ folderId =
+ destinations.journal ||
+ (await Utils.ensureJournalFolder('Archivist - Journals'));
+ } else if (sheetType === 'quest') {
+ folderId =
+ destinations.quest ||
+ (await Utils.ensureJournalFolder('Archivist - Quests'));
} else if (sheetType === 'recap') {
// For sessions/recaps, use Recaps folder and preserve session_date ordering
folderId = await Utils.ensureJournalFolder('Recaps');
@@ -968,6 +1010,8 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
fullQuest.relatedLocations || fullQuest.related_locations || [],
relatedItems:
fullQuest.relatedItems || fullQuest.related_items || [],
+ relatedEntityRefs:
+ fullQuest.relatedEntityRefs || fullQuest.related_entity_refs || [],
firstSession: fullQuest.firstSession || fullQuest.first_session || null,
lastSession: fullQuest.lastSession || fullQuest.last_session || null,
};
@@ -1160,7 +1204,7 @@ export class SyncDialog extends foundry.applications.api.HandlebarsApplicationMi
else if (itemId) targetDoc = game.items?.get?.(itemId) || null;
else if (sceneId) targetDoc = game.scenes?.get?.(sceneId) || null;
- const md = String(row.description || row.summary || '');
+ const md = String(row.description || row.summary || row.content || '');
const html = Utils.markdownToStoredHtml(md);
if (targetDoc) {
diff --git a/scripts/dialogs/world-setup-dialog.js b/scripts/dialogs/world-setup-dialog.js
index 4878497..e1f7d4c 100644
--- a/scripts/dialogs/world-setup-dialog.js
+++ b/scripts/dialogs/world-setup-dialog.js
@@ -371,6 +371,14 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica
linked: 0,
noteNoExport: true,
},
+ {
+ key: 'Quests',
+ fromArchivist: Number(imp.quests || 0),
+ createFoundry: 0,
+ toArchivist: 0,
+ linked: 0,
+ noteNoExport: true,
+ },
];
// Set warning flag if any of Characters, Items, or Locations have > 100 exports
@@ -1828,6 +1836,26 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica
// Removed Archivist journal folder setup; journals are created only in user destinations
+ /**
+ * Update the Step 6 progress bar/text in place instead of re-rendering the
+ * whole wizard step for every imported entity. The panel is rendered once
+ * before the import loops begin; the final render after the loops reflects
+ * the completed state.
+ * @private
+ */
+ _updateSyncStatusUI() {
+ const root = this.element;
+ if (!root || !this.syncStatus?.total) return;
+ const { processed, total, current } = this.syncStatus;
+ const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
+ const fill = root.querySelector('.ws-sync-progress-fill');
+ if (fill) fill.style.width = `${pct}%`;
+ const text = root.querySelector('.ws-sync-progress-text');
+ if (text) {
+ text.textContent = `${processed} / ${total} — ${current || ''}`;
+ }
+ }
+
async _importArchivistMissing(apiKey, campaignId) {
try {
const allCharacters = this.archivistCandidates?.characters || [];
@@ -2385,13 +2413,13 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica
this.syncStatus.current = `Import ${c.type || c.character_type || 'PC'}: ${c.character_name || c.name}`;
await createActor(c);
this.syncStatus.processed++;
- await this.render();
+ this._updateSyncStatusUI();
}
for (const it of items) {
this.syncStatus.current = `Import Item: ${it.name}`;
await createItem(it);
this.syncStatus.processed++;
- await this.render();
+ this._updateSyncStatusUI();
}
locations.sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''))
@@ -2400,7 +2428,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica
this.syncStatus.current = `Import Location: ${l.name || l.title}`;
await upsertIntoContainer(l, 'Location');
this.syncStatus.processed++;
- await this.render();
+ this._updateSyncStatusUI();
}
factions.sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''))
@@ -2409,7 +2437,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica
this.syncStatus.current = `Import Faction: ${f.name || f.title}`;
await upsertIntoContainer(f, 'Faction');
this.syncStatus.processed++;
- await this.render();
+ this._updateSyncStatusUI();
}
// Import Journals with folder structure (GM-only, point-in-time snapshot)
@@ -2497,7 +2525,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica
console.warn('[World Setup] Failed to import journal', j, e);
}
this.syncStatus.processed++;
- await this.render();
+ this._updateSyncStatusUI();
}
ui.notifications?.info?.(
'Journals imported as GM-only. Folder structure reflects the import snapshot and will not auto-sync.'
@@ -2519,12 +2547,11 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica
} catch (_) {}
}
- const html = Utils.toMarkdownIfHtml(
- String(fullQuest.successDefinition || fullQuest.nextAction || '')
- );
+ // Quest has no generic description field in the API; its Info tab is a
+ // structured metadata form (see quest.hbs), not free-text page content.
const journal = await Utils.createCustomJournalForImport({
name: fullQuest.questName || fullQuest.quest_name || 'Quest',
- html,
+ html: '',
sheetType: 'quest',
archivistId: fullQuest.id,
worldId: campaignId,
@@ -2566,6 +2593,8 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica
fullQuest.relatedLocations || fullQuest.related_locations || [],
relatedItems:
fullQuest.relatedItems || fullQuest.related_items || [],
+ relatedEntityRefs:
+ fullQuest.relatedEntityRefs || fullQuest.related_entity_refs || [],
firstSession: fullQuest.firstSession || fullQuest.first_session || null,
lastSession: fullQuest.lastSession || fullQuest.last_session || null,
};
@@ -2575,7 +2604,7 @@ export class WorldSetupDialog extends foundry.applications.api.HandlebarsApplica
console.warn('[World Setup] Failed to import quest', q, e);
}
this.syncStatus.processed++;
- await this.render();
+ this._updateSyncStatusUI();
}
// After creating journals and pages, hydrate link graph from Archivist Links
diff --git a/scripts/modules/journal-manager.js b/scripts/modules/journal-manager.js
index 819ff83..30609f2 100644
--- a/scripts/modules/journal-manager.js
+++ b/scripts/modules/journal-manager.js
@@ -14,6 +14,8 @@ export class JournalManager {
location: 'Archivist - Locations',
faction: 'Archivist - Factions',
recap: 'Recaps',
+ quest: 'Archivist - Quests',
+ journal: 'Archivist - Journals',
entry: null,
};
}
diff --git a/scripts/modules/projection/merge.js b/scripts/modules/projection/merge.js
index b693b27..7726f46 100644
--- a/scripts/modules/projection/merge.js
+++ b/scripts/modules/projection/merge.js
@@ -1,7 +1,9 @@
-// Merge helper: inject Archivist HTML into an existing HTML field non-destructively
+// Merge helper: inject Archivist content into an existing field non-destructively
const WRAP_START = '';
const WRAP_END = '';
+const TEXT_WRAP_START = '[Archivist]';
+const TEXT_WRAP_END = '[/Archivist]';
/**
* Create a wrapped block containing Archivist HTML content.
@@ -28,14 +30,14 @@ export function mergeArchivistSection(existingHtml, archivistHtml) {
}
/**
- * Strip HTML down to plain text via Foundry's TextEditor if available.
+ * Strip HTML down to plain text. Foundry exposes no core strip-to-text
+ * helper (in v13 or v14), so parse into a detached element and read its
+ * text content.
* @param {string} html
*/
export function stripHtml(html) {
try {
const s = String(html ?? '');
- const te = foundry?.utils?.TextEditor;
- if (te?.stripHTML) return te.stripHTML(s);
const tmp = document.createElement('div');
tmp.innerHTML = s;
return (tmp.textContent || '').trim();
@@ -43,3 +45,30 @@ export function stripHtml(html) {
return String(html || '');
}
}
+
+/**
+ * Create a wrapped block containing Archivist plain text content, marked so a
+ * later sync can find and replace just this block (mirrors toArchivistBlock's
+ * behavior for the HTML case).
+ * @param {string} text
+ */
+export function toArchivistPlainBlock(text) {
+ const safe = String(text ?? '');
+ return `${TEXT_WRAP_START}\n${safe}\n${TEXT_WRAP_END}`;
+}
+
+/**
+ * Non-destructively merge Archivist content into a plain-text (non-HTML)
+ * field: appends a marked block (or replaces its own previously-injected
+ * block on re-sync) instead of overwriting the field's existing content.
+ * @param {string} existingText
+ * @param {string} archivistHtml - raw Archivist HTML/markdown; stripped to plain text before merging
+ */
+export function mergeArchivistPlainSection(existingText, archivistHtml) {
+ const current = String(existingText ?? '');
+ const block = toArchivistPlainBlock(stripHtml(archivistHtml));
+ const re = /\[Archivist\][\s\S]*?\[\/Archivist\]/;
+ if (!current) return block;
+ if (re.test(current)) return current.replace(re, block);
+ return `${current}\n\n${block}`;
+}
diff --git a/scripts/modules/projection/slot-resolver.js b/scripts/modules/projection/slot-resolver.js
index 5f1896a..b70b82b 100644
--- a/scripts/modules/projection/slot-resolver.js
+++ b/scripts/modules/projection/slot-resolver.js
@@ -1,5 +1,5 @@
import { AdapterRegistry } from './adapter-registry.js';
-import { mergeArchivistSection, stripHtml } from './merge.js';
+import { mergeArchivistSection, mergeArchivistPlainSection } from './merge.js';
import { CONFIG } from '../config.js';
function defaultHeuristics(doc) {
@@ -100,7 +100,7 @@ export async function projectDescription(doc, archivistHtml) {
const current = String(foundry.utils.getProperty(doc, slot.path) ?? '');
const next = slot.html
? mergeArchivistSection(current, String(archivistHtml ?? ''))
- : stripHtml(archivistHtml);
+ : mergeArchivistPlainSection(current, archivistHtml);
const update = {
[slot.path]: next,
[`flags.${CONFIG.MODULE_ID}.op`]: op,
diff --git a/scripts/modules/reconcile/reconcile-service.js b/scripts/modules/reconcile/reconcile-service.js
index 8d37d2a..db1ad77 100644
--- a/scripts/modules/reconcile/reconcile-service.js
+++ b/scripts/modules/reconcile/reconcile-service.js
@@ -18,14 +18,17 @@ export class ReconcileService {
if (!apiKey || !campaignId) throw new Error('Archivist not configured');
// Fetch entities in parallel
- const [chars, items, locs, facs, sessions, links] = await Promise.all([
- archivistApi.listCharacters(apiKey, campaignId),
- archivistApi.listItems(apiKey, campaignId),
- archivistApi.listLocations(apiKey, campaignId),
- archivistApi.listFactions(apiKey, campaignId),
- archivistApi.listSessions(apiKey, campaignId),
- archivistApi.listLinks(apiKey, campaignId),
- ]);
+ const [chars, items, locs, facs, sessions, links, quests, journalsList] =
+ await Promise.all([
+ archivistApi.listCharacters(apiKey, campaignId),
+ archivistApi.listItems(apiKey, campaignId),
+ archivistApi.listLocations(apiKey, campaignId),
+ archivistApi.listFactions(apiKey, campaignId),
+ archivistApi.listSessions(apiKey, campaignId),
+ archivistApi.listLinks(apiKey, campaignId),
+ archivistApi.listQuests(apiKey, campaignId),
+ archivistApi.listJournals(apiKey, campaignId),
+ ]);
const characters = chars.success ? chars.data || [] : [];
const itemsData = items.success ? items.data || [] : [];
@@ -33,6 +36,12 @@ export class ReconcileService {
const factions = facs.success ? facs.data || [] : [];
const sessionsData = sessions.success ? sessions.data || [] : [];
const linksData = links.success ? links.data || [] : [];
+ // listQuests() rows aren't normalized server-side consistently; route through
+ // the same camelCase/snake_case-tolerant normalizer used elsewhere for quests.
+ const questsData = (quests.success ? quests.data || [] : []).map((q) =>
+ archivistApi._normalizeQuestResponse(q)
+ );
+ const journalsData = journalsList.success ? journalsList.data || [] : [];
// Ensure default folders, index existing journals by archivistId
try {
@@ -96,6 +105,45 @@ export class ReconcileService {
for (const it of itemsData) await ensureSheet(it, 'item');
for (const l of locations) await ensureSheet(l, 'location');
for (const f of factions) await ensureSheet(f, 'faction');
+ for (const j of journalsData) await ensureSheet(j, 'journal');
+
+ // Quests: ensure a sheet exists, then refresh its full questData from Archivist
+ // (title/image handled generically by ensureSheet; the rest is quest-specific).
+ for (const q of questsData) {
+ const entity = { ...q, name: q.questName || 'Quest' };
+ const j = await ensureSheet(entity, 'quest');
+ try {
+ const flags = j.getFlag(CONFIG.MODULE_ID, 'archivist') || {};
+ flags.questData = {
+ questName: q.questName || '',
+ questGiver: q.questGiver || '',
+ questCategory: q.questCategory || 'n/a',
+ status: q.status || 'planned',
+ successDefinition: q.successDefinition || '',
+ failureConditions: q.failureConditions || '',
+ nextAction: q.nextAction || '',
+ resolution: q.resolution || '',
+ objectives: Array.isArray(q.objectives) ? q.objectives : [],
+ progressLog: Array.isArray(q.progressLog) ? q.progressLog : [],
+ relatedCharacters: Array.isArray(q.relatedCharacters)
+ ? q.relatedCharacters
+ : [],
+ relatedFactions: Array.isArray(q.relatedFactions)
+ ? q.relatedFactions
+ : [],
+ relatedLocations: Array.isArray(q.relatedLocations)
+ ? q.relatedLocations
+ : [],
+ relatedItems: Array.isArray(q.relatedItems) ? q.relatedItems : [],
+ relatedEntityRefs: Array.isArray(q.relatedEntityRefs)
+ ? q.relatedEntityRefs
+ : [],
+ firstSession: q.firstSession || null,
+ lastSession: q.lastSession || null,
+ };
+ await j.setFlag(CONFIG.MODULE_ID, 'archivist', flags);
+ } catch (_) {}
+ }
// Recaps: ensure a single Recaps container exists with pages ordered by session_date
try {
diff --git a/scripts/modules/sheets/page-sheet-v2.js b/scripts/modules/sheets/page-sheet-v2.js
index d51ce36..9eedeed 100644
--- a/scripts/modules/sheets/page-sheet-v2.js
+++ b/scripts/modules/sheets/page-sheet-v2.js
@@ -171,8 +171,8 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin(
return result;
}
- _onRender(context, options) {
- super._onRender(context, options);
+ async _onRender(context, options) {
+ await super._onRender(context, options);
try {
const root = this.element;
const content = root.querySelector('.archivist-content') || root;
@@ -269,6 +269,17 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin(
visBtn.style.height = '16px';
} catch (_) {}
}
+ const uploadBtn = root.querySelector(
+ '[data-action="upload-local-image"]'
+ );
+ if (uploadBtn && !uploadBtn.dataset.bound) {
+ uploadBtn.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._onUploadLocalImage();
+ });
+ uploadBtn.dataset.bound = 'true';
+ }
} catch (_) {}
}
@@ -660,13 +671,108 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin(
console.warn('[Archivist Sync][PageV2] _onRender failed', e);
}
try {
+ // Awaited so Foundry's render pipeline (which awaits _onRender) doesn't
+ // consider the sheet "done" until tab counts/cards are actually set.
+ // Previously these were fire-and-forget: opening a sheet by clicking a
+ // card in another custom sheet (vs. opening it fresh from the Journal
+ // Directory) could resolve .render()'s promise — and any .then() chained
+ // on it, e.g. bringToFront() — before this finished, leaving some tabs
+ // populated with cards but never getting their (N) count set.
if (typeof this._renderLinkedGrids === 'function')
- this._renderLinkedGrids();
+ await this._renderLinkedGrids();
if (typeof this._renderActorItemCards === 'function')
- this._renderActorItemCards();
+ await this._renderActorItemCards();
+ if (typeof this._renderRelatedQuests === 'function')
+ await this._renderRelatedQuests();
} catch (_) {}
}
+ /**
+ * Populate a read-only "Quests" tab with quests that reference this entity.
+ * The Archivist API has no reverse-lookup endpoint for "quests referencing X",
+ * so this fetches the quest list and filters client-side by relatedEntityRefs.
+ * Quest is the source of truth for the relationship; edit/unlink from the
+ * Quest sheet itself, not from here.
+ */
+ async _renderRelatedQuests() {
+ const grid = this.element?.querySelector?.(
+ '.tab[data-tab="quests"] .archivist-grid'
+ );
+ if (!grid) return;
+ const flags = this._getArchivistFlags();
+ const sheetType = String(flags.sheetType || '').toLowerCase();
+ const entityType = { pc: 'character', npc: 'character', character: 'character', faction: 'faction', location: 'location', item: 'item' }[sheetType];
+ const archivistId = String(flags.archivistId || '');
+ if (!entityType || !archivistId) {
+ grid.innerHTML = 'None';
+ return;
+ }
+ const seq = (this._rqSeq = (this._rqSeq || 0) + 1);
+ let quests = [];
+ try {
+ quests = await ArchivistBasePageSheetV2._loadQuestsCached();
+ } catch (_) {
+ quests = [];
+ }
+ if (this._rqSeq !== seq) return; // superseded by a later render
+ const matches = quests.filter((q) =>
+ (Array.isArray(q.relatedEntityRefs) ? q.relatedEntityRefs : []).some(
+ (r) =>
+ String(r.entityType).toLowerCase() === entityType &&
+ String(r.entityId) === archivistId
+ )
+ );
+ grid.innerHTML = '';
+ if (!matches.length) {
+ grid.innerHTML = 'None';
+ return;
+ }
+ for (const q of matches) {
+ const questJournal = this._findPageOrEntryByArchivistId(String(q.id));
+ const name = q.questName || 'Quest';
+ const card = document.createElement('div');
+ card.className = 'archivist-card';
+ card.dataset.archivistId = String(q.id);
+ card.innerHTML = `${foundry.utils.escapeHTML(name)}`;
+ if (questJournal) {
+ card.addEventListener('click', () => {
+ const doc =
+ questJournal.documentName === 'JournalEntryPage'
+ ? questJournal.parent
+ : questJournal;
+ doc?.sheet?.render?.(true);
+ });
+ }
+ grid.appendChild(card);
+ }
+ const nav = this.element?.querySelector?.(
+ '.archivist-nav [data-tab="quests"]'
+ );
+ if (nav) {
+ const base = nav.dataset._label || nav.textContent || 'Quests';
+ if (!nav.dataset._label) nav.dataset._label = base;
+ nav.textContent = matches.length > 0 ? `${base} (${matches.length})` : base;
+ }
+ }
+
+ /** Short-lived cache so opening several sheets at once doesn't re-fetch listQuests repeatedly. */
+ static async _loadQuestsCached() {
+ const now = Date.now();
+ const cache = ArchivistBasePageSheetV2._questsCache;
+ if (cache && now - cache.at < 5000) return cache.data;
+ const apiKey = settingsManager.getApiKey?.();
+ const campaignId = settingsManager.getSelectedWorldId?.();
+ if (!apiKey || !campaignId) return [];
+ const result = await archivistApi.listQuests(apiKey, campaignId);
+ const raw = result?.success ? result.data || [] : [];
+ // listQuests() doesn't normalize each row; the API's casing for quest
+ // fields is inconsistent between endpoints, so route through the same
+ // camelCase/snake_case-tolerant normalizer used by get/create/updateQuest.
+ const data = raw.map((q) => archivistApi._normalizeQuestResponse(q));
+ ArchivistBasePageSheetV2._questsCache = { at: now, data };
+ return data;
+ }
+
async _toggleEditMode() {
console.log(
'[Archivist V2 Sheet] _toggleEditMode called, current editMode:',
@@ -932,7 +1038,19 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin(
await archivistApi.updateSession(apiKey, archivistId, payload);
} else if (sheetType === 'quest') {
console.log('[Archivist V2 Sheet] Syncing Quest to API');
- const payload = { questName: nameNow };
+ const root = this.element;
+ const readVal = (sel) =>
+ String(root?.querySelector?.(sel)?.value ?? '').trim();
+ const payload = {
+ questName: nameNow,
+ questGiver: readVal('.quest-giver-input'),
+ questCategory: readVal('.quest-category-select') || 'n/a',
+ status: readVal('.quest-status-select') || 'planned',
+ successDefinition: readVal('.quest-success-input'),
+ failureConditions: readVal('.quest-failure-input'),
+ nextAction: readVal('.quest-next-action-input'),
+ resolution: readVal('.quest-resolution-input'),
+ };
result = await archivistApi.updateQuest(apiKey, archivistId, payload);
} else if (sheetType === 'journal') {
console.log('[Archivist V2 Sheet] Syncing Journal to API');
@@ -981,6 +1099,51 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin(
const targetType = String(targetFlags.sheetType || '').toLowerCase();
if (targetType === 'recap' || targetType === 'session') return;
+ // Quests can't participate in the generic /links API (the backend has no
+ // ENTITY_CONFIG entry for Quest), so their relationships persist via
+ // updateQuest's relatedEntityRefs instead. Quest is the single source of
+ // truth for its own relationships; dropping a Quest onto another sheet
+ // would create an orphaned/inconsistent write, so we block that instead.
+ if (droppedType === 'quest' && targetType !== 'quest') {
+ ui.notifications?.warn?.(
+ 'Link quests from the Quest sheet itself (drag the Character/Faction/Location/Item onto the Quest).'
+ );
+ return;
+ }
+ if (targetType === 'quest') {
+ const bucket = this._bucketForDrop(dropped);
+ if (!bucket || typeof this._linkQuestEntity !== 'function') return;
+ if (!QuestPageSheetV2.BUCKET_TO_ENTITY_TYPE[bucket]) {
+ ui.notifications?.warn?.(
+ 'Quests can only link to Characters, Factions, Locations, and Items.'
+ );
+ return;
+ }
+ if (dropped.documentName === 'Actor' || dropped.documentName === 'Item') {
+ const aid = dropped.getFlag(CONFIG.MODULE_ID, 'archivistId');
+ if (!aid) {
+ ui.notifications?.warn?.(
+ 'Only Archivist-synced Actors/Items can be linked to a Quest.'
+ );
+ return;
+ }
+ await this._linkQuestEntity(bucket, String(aid), dropped.name);
+ return;
+ }
+ const toDoc =
+ dropped?.documentName === 'JournalEntryPage'
+ ? dropped.parent
+ : dropped?.documentName === 'JournalEntry'
+ ? dropped
+ : null;
+ if (!toDoc) return;
+ const toFlags = toDoc?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {};
+ const toId = String(toFlags.archivistId || '');
+ if (!toId) return;
+ await this._linkQuestEntity(bucket, toId, toDoc.name);
+ return;
+ }
+
const bucket = this._bucketForDrop(dropped);
if (!bucket) return;
@@ -1662,6 +1825,100 @@ class ArchivistBasePageSheetV2 extends V2.HandlebarsApplicationMixin(
) || null
);
}
+
+ static SHEET_TYPE_TO_IMAGE_ENTITY_TYPE = {
+ pc: 'character',
+ npc: 'character',
+ character: 'character',
+ item: 'item',
+ location: 'location',
+ faction: 'faction',
+ };
+
+ /**
+ * Resolve a genuinely local Foundry image path worth uploading to Archivist,
+ * mirroring _resolveSheetImage's fallback chain but stopping before the
+ * default-icon fallback (nothing useful to upload in that case).
+ */
+ _resolveLocalImageForUpload() {
+ const entry = this.document;
+ if (!entry) return null;
+ const f = entry?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {};
+ const st = String(f.sheetType || '').toLowerCase();
+ const explicit = String(entry?.img || '').trim();
+ if (explicit) return explicit;
+ if (st === 'pc' || st === 'npc' || st === 'character') {
+ const actorId = (f.foundryRefs?.actors || [])[0];
+ const actor = actorId ? game.actors?.get?.(actorId) : null;
+ if (actor?.img) return String(actor.img).trim();
+ } else if (st === 'item') {
+ const itemId = (f.foundryRefs?.items || [])[0];
+ const itm = itemId ? game.items?.get?.(itemId) : null;
+ if (itm?.img) return String(itm.img).trim();
+ } else if (st === 'location') {
+ const sceneId = (f.foundryRefs?.scenes || [])[0];
+ const scene = sceneId ? game.scenes?.get?.(sceneId) : null;
+ const sc = scene?.thumbnail || scene?.img || '';
+ if (sc) return String(sc).trim();
+ }
+ return null;
+ }
+
+ /**
+ * Upload the sheet's local Foundry image (Actor/Item/Scene/journal img) to
+ * Archivist via the presigned-URL flow, so it's visible outside Foundry.
+ * Not available for Quests — the API has no image support for that type.
+ */
+ async _onUploadLocalImage() {
+ const flags = this._getArchivistFlags();
+ const sheetType = String(flags.sheetType || '').toLowerCase();
+ const entityType =
+ ArchivistBasePageSheetV2.SHEET_TYPE_TO_IMAGE_ENTITY_TYPE[sheetType];
+ if (!entityType) return;
+ const archivistId = String(flags.archivistId || '');
+ const apiKey = settingsManager.getApiKey?.();
+ const campaignId = settingsManager.getSelectedWorldId?.();
+ if (!apiKey || !campaignId || !archivistId) {
+ ui.notifications?.warn?.('Archivist world not configured.');
+ return;
+ }
+ const src = this._resolveLocalImageForUpload();
+ if (!src) {
+ ui.notifications?.warn?.('No local image found to upload for this sheet.');
+ return;
+ }
+ if (/^https?:\/\//i.test(src)) {
+ ui.notifications?.info?.(
+ 'This image is already hosted remotely; nothing to upload.'
+ );
+ return;
+ }
+ ui.notifications?.info?.('Uploading image to Archivist…');
+ try {
+ const res = await fetch(src);
+ if (!res.ok) throw new Error(`Could not read local image (HTTP ${res.status})`);
+ const blob = await res.blob();
+ const contentType = blob.type || 'image/png';
+ const fileName = src.split('/').pop() || 'image';
+ const result = await archivistApi.uploadEntityImage(apiKey, campaignId, {
+ entityType,
+ entityId: archivistId,
+ fileName,
+ contentType,
+ bytes: blob,
+ });
+ if (result.success) {
+ ui.notifications?.info?.('Image uploaded to Archivist.');
+ } else {
+ ui.notifications?.error?.(
+ `Image upload failed: ${result.message || 'unknown error'}`
+ );
+ }
+ } catch (e) {
+ console.warn('[Archivist Sync][V2] Local image upload failed', e);
+ ui.notifications?.error?.('Image upload failed. See console for details.');
+ }
+ }
}
export class EntryPageSheetV2 extends ArchivistBasePageSheetV2 {
@@ -1691,8 +1948,8 @@ export class LocationPageSheetV2 extends ArchivistBasePageSheetV2 {
form: { template: 'modules/archivist-sync/templates/sheets/location.hbs' },
};
- _onRender(context, options) {
- super._onRender(context, options);
+ async _onRender(context, options) {
+ await super._onRender(context, options);
try {
const root = this.element;
// Tab toggling
@@ -1984,6 +2241,25 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 {
},
};
+ /**
+ * Localize a key, returning null when i18n is unavailable or the key is
+ * missing (Foundry returns the key unchanged on a miss) so callers can fall
+ * back with `||`.
+ * @param {string|null} key
+ * @returns {string|null}
+ * @private
+ */
+ static _localize(key) {
+ if (!key) return null;
+ try {
+ const localized = game?.i18n?.localize?.(key);
+ if (localized && localized !== key) return localized;
+ } catch (_) {
+ /* i18n not ready */
+ }
+ return null;
+ }
+
async _prepareContext(_options) {
const ctx = await super._prepareContext(_options);
const flags =
@@ -2027,17 +2303,47 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 {
: Array.isArray(qd.related_items)
? qd.related_items
: [],
+ relatedEntityRefs: Array.isArray(qd.relatedEntityRefs)
+ ? qd.relatedEntityRefs
+ : Array.isArray(qd.related_entity_refs)
+ ? qd.related_entity_refs
+ : [],
firstSession: qd.firstSession || qd.first_session || null,
lastSession: qd.lastSession || qd.last_session || null,
};
- ctx.quest.statusLabel = {
+ // Status/category labels come from lang/en.json when available, falling
+ // back to English defaults if i18n isn't ready or a key is missing. Status
+ // values use hyphens (e.g. 'in-progress') while lang keys are camelCase.
+ const statusKeyMap = {
+ planned: 'planned',
+ 'in-progress': 'inProgress',
+ blocked: 'blocked',
+ failed: 'failed',
+ done: 'done',
+ };
+ const statusFallback = {
planned: 'Planned',
'in-progress': 'In Progress',
blocked: 'Blocked',
failed: 'Failed',
done: 'Done',
'n/a': 'N/A',
- }[ctx.quest.status] || ctx.quest.status;
+ };
+ const categoryFallback = {
+ main: 'Main',
+ side: 'Side',
+ faction: 'Faction',
+ personal: 'Personal',
+ 'n/a': '',
+ };
+ ctx.quest.statusLabel =
+ QuestPageSheetV2._localize(
+ statusKeyMap[ctx.quest.status]
+ ? `ARCHIVIST_SYNC.quest.status.${statusKeyMap[ctx.quest.status]}`
+ : null
+ ) ||
+ statusFallback[ctx.quest.status] ||
+ ctx.quest.status;
ctx.quest.statusIcon = {
planned: 'fa-compass',
'in-progress': 'fa-hourglass-half',
@@ -2046,18 +2352,19 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 {
done: 'fa-check-circle',
'n/a': 'fa-question',
}[ctx.quest.status] || 'fa-question';
- ctx.quest.categoryLabel = {
- main: 'Main',
- side: 'Side',
- faction: 'Faction',
- personal: 'Personal',
- 'n/a': '',
- }[ctx.quest.questCategory] || '';
+ ctx.quest.categoryLabel =
+ QuestPageSheetV2._localize(
+ ctx.quest.questCategory && ctx.quest.questCategory !== 'n/a'
+ ? `ARCHIVIST_SYNC.quest.category.${ctx.quest.questCategory}`
+ : null
+ ) ||
+ categoryFallback[ctx.quest.questCategory] ||
+ '';
return ctx;
}
- _onRender(context, options) {
- super._onRender(context, options);
+ async _onRender(context, options) {
+ await super._onRender(context, options);
try {
const root = this.element;
this._renderQuestObjectives(root, context);
@@ -2113,23 +2420,80 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 {
}
}
+ /** Map an entityType (as stored in relatedEntityRefs) to its Links-tab grid selector. */
+ static ENTITY_TYPE_TO_SELECTOR = {
+ character: '.quest-related-characters',
+ faction: '.quest-related-factions',
+ location: '.quest-related-locations',
+ item: '.quest-related-items',
+ };
+
+ /** Map a generic drop bucket (from _bucketForDrop) to the entityType Archivist expects. */
+ static BUCKET_TO_ENTITY_TYPE = {
+ characters: 'character',
+ factions: 'faction',
+ locationsAssociative: 'location',
+ items: 'item',
+ };
+
_renderQuestLinks(root, context) {
const q = context.quest || {};
- const sections = [
- { sel: '.quest-related-characters', items: q.relatedCharacters },
- { sel: '.quest-related-factions', items: q.relatedFactions },
- { sel: '.quest-related-locations', items: q.relatedLocations },
- { sel: '.quest-related-items', items: q.relatedItems },
- ];
- for (const { sel, items } of sections) {
+ const refs = Array.isArray(q.relatedEntityRefs) ? q.relatedEntityRefs : [];
+ const byType = { character: [], faction: [], location: [], item: [] };
+ for (const ref of refs) {
+ const t = String(ref?.entityType || '').toLowerCase();
+ if (byType[t]) byType[t].push(ref);
+ }
+ // Legacy name-string fallbacks for quests that predate relatedEntityRefs
+ const legacyByType = {
+ character: q.relatedCharacters || [],
+ faction: q.relatedFactions || [],
+ location: q.relatedLocations || [],
+ item: q.relatedItems || [],
+ };
+
+ for (const [type, sel] of Object.entries(
+ QuestPageSheetV2.ENTITY_TYPE_TO_SELECTOR
+ )) {
const el = root.querySelector(sel);
if (!el) continue;
el.innerHTML = '';
- if (!items?.length) {
+ const refsForType = byType[type];
+ if (!refsForType.length && !legacyByType[type]?.length) {
el.innerHTML = 'None';
continue;
}
- for (const name of items) {
+ for (const ref of refsForType) {
+ const entityId = String(ref.entityId || '');
+ const name = ref.label || ref.entityNameSnapshot || 'Unknown';
+ const targetDoc = entityId
+ ? this._findPageOrEntryByArchivistId(entityId)
+ : null;
+ const img = targetDoc
+ ? this._resolveSheetImage(targetDoc)
+ : 'icons/svg/mystery-man.svg';
+ const card = document.createElement('div');
+ card.className = 'archivist-card';
+ card.dataset.archivistId = entityId;
+ card.innerHTML = `${foundry.utils.escapeHTML(name)}`;
+ if (targetDoc) {
+ card.addEventListener('click', (ev) => {
+ if (ev.target?.closest?.('.actions')) return;
+ const doc =
+ targetDoc.documentName === 'JournalEntryPage'
+ ? targetDoc.parent
+ : targetDoc;
+ doc?.sheet?.render?.(true);
+ });
+ }
+ el.appendChild(card);
+ }
+ // Names without a resolvable entityId (legacy data) render as plain, unlinkable chips
+ const linkedNames = new Set(
+ refsForType.map((r) => r.label || r.entityNameSnapshot)
+ );
+ for (const name of legacyByType[type] || []) {
+ if (linkedNames.has(name)) continue;
const chip = document.createElement('span');
chip.className = 'quest-link-chip';
chip.textContent = name;
@@ -2151,37 +2515,179 @@ export class QuestPageSheetV2 extends ArchivistBasePageSheetV2 {
if (Number.isFinite(idx)) this._removeObjective(idx);
});
});
+ root
+ .querySelectorAll('[data-action="unlink-quest-entity"]')
+ .forEach((btn) => {
+ btn.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const { entityType, entityId } = ev.currentTarget.dataset;
+ if (entityType && entityId)
+ this._unlinkQuestEntity(entityType, entityId);
+ });
+ });
+
+ // Seed select values (Handlebars has no per-option "selected" helper here)
+ if (context.setup?.editingInfo) {
+ const categorySelect = root.querySelector('.quest-category-select');
+ if (categorySelect) categorySelect.value = context.quest?.questCategory || 'n/a';
+ const statusSelect = root.querySelector('.quest-status-select');
+ if (statusSelect) statusSelect.value = context.quest?.status || 'planned';
+
+ root.querySelectorAll('[data-obj-index]').forEach((li) => {
+ const idx = Number(li.dataset.objIndex);
+ const obj = context.quest?.objectives?.[idx];
+ if (!obj) return;
+ const statusSel = li.querySelector('.quest-objective-status-select');
+ if (statusSel) {
+ statusSel.value = obj.status || 'pending';
+ statusSel.addEventListener('change', () =>
+ this._updateObjective(idx, { status: statusSel.value })
+ );
+ }
+ const textInput = li.querySelector('.quest-objective-text-input');
+ if (textInput) {
+ textInput.addEventListener('change', () =>
+ this._updateObjective(idx, { text: textInput.value })
+ );
+ }
+ });
+ }
}
- async _addObjective() {
+ /** Persist the current objectives array (local flags + remote PATCH). */
+ async _syncObjectives(objectives) {
const flags =
this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {};
- const qd = { ...(flags.questData || {}) };
- const objectives = Array.isArray(qd.objectives)
- ? [...qd.objectives]
- : [];
- objectives.push({ text: 'New Objective', status: 'pending', order: objectives.length });
- qd.objectives = objectives;
+ const qd = { ...(flags.questData || {}), objectives };
await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', {
...flags,
questData: qd,
});
+ try {
+ const apiKey = settingsManager.getApiKey?.();
+ const archivistId = String(flags.archivistId || '');
+ if (apiKey && archivistId) {
+ await archivistApi.updateQuest(apiKey, archivistId, { objectives });
+ }
+ } catch (e) {
+ console.warn('[Archivist Sync][V2] Failed to sync quest objectives', e);
+ }
this.render(false);
}
+ async _addObjective() {
+ const flags =
+ this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {};
+ const qd = flags.questData || {};
+ const objectives = Array.isArray(qd.objectives)
+ ? [...qd.objectives]
+ : [];
+ objectives.push({ text: 'New Objective', status: 'pending' });
+ await this._syncObjectives(objectives);
+ }
+
async _removeObjective(idx) {
const flags =
this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {};
- const qd = { ...(flags.questData || {}) };
+ const qd = flags.questData || {};
const objectives = Array.isArray(qd.objectives)
? [...qd.objectives]
: [];
objectives.splice(idx, 1);
- qd.objectives = objectives;
+ await this._syncObjectives(objectives);
+ }
+
+ async _updateObjective(idx, patch) {
+ const flags =
+ this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {};
+ const qd = flags.questData || {};
+ const objectives = Array.isArray(qd.objectives)
+ ? [...qd.objectives]
+ : [];
+ if (!objectives[idx]) return;
+ objectives[idx] = { ...objectives[idx], ...patch };
+ // Skip the full re-render here (would drop focus mid-edit); commit silently.
+ const nextFlags = {
+ ...flags,
+ questData: { ...qd, objectives },
+ };
+ await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', nextFlags);
+ try {
+ const apiKey = settingsManager.getApiKey?.();
+ const archivistId = String(flags.archivistId || '');
+ if (apiKey && archivistId) {
+ await archivistApi.updateQuest(apiKey, archivistId, { objectives });
+ }
+ } catch (e) {
+ console.warn('[Archivist Sync][V2] Failed to sync quest objective', e);
+ }
+ }
+
+ /** Link an entity (Character/Item/Faction/Location) to this Quest via relatedEntityRefs. */
+ async _linkQuestEntity(bucket, entityId, entityName) {
+ const entityType = QuestPageSheetV2.BUCKET_TO_ENTITY_TYPE[bucket];
+ if (!entityType) return;
+ const flags =
+ this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {};
+ const qd = flags.questData || {};
+ const refs = Array.isArray(qd.relatedEntityRefs)
+ ? [...qd.relatedEntityRefs]
+ : [];
+ if (
+ refs.some(
+ (r) =>
+ String(r.entityId) === String(entityId) &&
+ String(r.entityType).toLowerCase() === entityType
+ )
+ ) {
+ return; // already linked
+ }
+ refs.push({
+ entityType,
+ entityId: String(entityId),
+ entityNameSnapshot: entityName,
+ label: entityName,
+ });
+ await this._commitQuestLinks(refs);
+ }
+
+ /** Unlink an entity from this Quest's relatedEntityRefs. */
+ async _unlinkQuestEntity(entityType, entityId) {
+ const flags =
+ this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {};
+ const qd = flags.questData || {};
+ const refs = (
+ Array.isArray(qd.relatedEntityRefs) ? qd.relatedEntityRefs : []
+ ).filter(
+ (r) =>
+ !(
+ String(r.entityId) === String(entityId) &&
+ String(r.entityType).toLowerCase() === String(entityType).toLowerCase()
+ )
+ );
+ await this._commitQuestLinks(refs);
+ }
+
+ async _commitQuestLinks(relatedEntityRefs) {
+ const flags =
+ this.document?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {};
+ const qd = { ...(flags.questData || {}), relatedEntityRefs };
await this.document.setFlag(CONFIG.MODULE_ID, 'archivist', {
...flags,
questData: qd,
});
+ try {
+ const apiKey = settingsManager.getApiKey?.();
+ const archivistId = String(flags.archivistId || '');
+ if (apiKey && archivistId) {
+ await archivistApi.updateQuest(apiKey, archivistId, {
+ relatedEntityRefs,
+ });
+ }
+ } catch (e) {
+ console.warn('[Archivist Sync][V2] Failed to sync quest links', e);
+ }
this.render(false);
}
}
diff --git a/scripts/modules/utils.js.bak b/scripts/modules/utils.js.bak
deleted file mode 100644
index 69fa90f..0000000
--- a/scripts/modules/utils.js.bak
+++ /dev/null
@@ -1,1033 +0,0 @@
- /**
- * Return the current system id in lowercase (e.g., "dnd5e", "pf2e").
- */
- static getSystemId() {
- return String(game?.system?.id || '').toLowerCase();
-}
-
- /**
- * Safely get the first non-empty string value from an object using a list of dot-paths.
- * @param {object} obj
- * @param {string[]} paths
- * @returns {string}
- */
- static pickFirstProperty(obj, paths = []) {
- const get = (o, path) => {
- try {
- if (foundry?.utils?.getProperty) return foundry.utils.getProperty(o, path);
- } catch (_) { }
- return String(path).split('.').reduce((acc, k) => (acc && k in acc ? acc[k] : undefined), o);
- };
- for (const p of paths) {
- const v = get(obj, p);
- if (typeof v === 'string' && v.trim()) return v;
- }
- return '';
-}
-
- /**
- * Compute the preferred READ paths for an Actor description based on system and actor type.
- * @param {Actor} actor
- * @returns {string[]}
- */
- static getActorDescriptionReadPaths(actor) {
- const sysId = this.getSystemId();
- const isPC = String(actor?.type || '').toLowerCase() === 'character';
- const isNPC = String(actor?.type || '').toLowerCase() === 'npc';
-
- if (sysId === 'dnd5e') return ['system.details.biography.value', 'system.details.biography.public', 'system.description.value'];
-
- if (sysId === 'pf2e') {
- if (isPC) return ['system.details.biography.backstory', 'system.details.publicNotes', 'system.description.value'];
- if (isNPC) return ['system.details.notes.description', 'system.details.publicNotes', 'system.description.value'];
- return ['system.details.biography.backstory', 'system.details.notes.description', 'system.details.publicNotes', 'system.description.value'];
- }
-
- // Generic fallbacks
- return [
- 'system.details.biography.value',
- 'system.details.biography.public',
- 'system.description.value',
- 'system.details.description',
- 'system.details.publicNotes',
- 'system.description',
- ];
-}
-
- /**
- * Compute the preferred WRITE path for projecting an Actor description back into the system.
- * @param {Actor} actor
- * @returns {string} A dot-path suitable for Actor.update({ [path]: html })
- */
- static getActorDescriptionWritePath(actor) {
- const sysId = this.getSystemId();
- const isPC = String(actor?.type || '').toLowerCase() === 'character';
- const isNPC = String(actor?.type || '').toLowerCase() === 'npc';
-
- if (sysId === 'dnd5e') return 'system.details.biography.value';
-
- if (sysId === 'pf2e') {
- if (isPC) return 'system.details.biography.backstory';
- if (isNPC) return 'system.details.notes.description';
- return 'system.details.publicNotes';
- }
-
- // Generic destination
- return 'system.description.value';
-}
-
- /**
- * Read an Actor description as plain text (Markdown-like) for ingest.
- * @param {Actor} actor
- * @returns {string}
- */
- static readActorDescription(actor) {
- const paths = this.getActorDescriptionReadPaths(actor);
- // Strip to text/markdown for ingest using our existing HTML->MD helper
- const htmlOrText = this.pickFirstProperty(actor, paths);
- return this.toMarkdownIfHtml(htmlOrText);
-}
-
- /**
- * Project a description onto an Actor at the proper system path.
- * Accepts Markdown and converts to sanitized HTML for storage.
- * @param {Actor} actor
- * @param {string} markdown
- */
- static async projectActorDescription(actor, markdown) {
- const destPath = this.getActorDescriptionWritePath(actor);
- const html = this.markdownToStoredHtml(String(markdown ?? ''));
- await actor.update({ [destPath]: html });
- return destPath;
-}
-import { CONFIG } from './config.js';
-
-/**
- * Utility functions for Archivist Sync Module
- */
-export class Utils {
- /**
- * Resolve a valid Item type for the current system.
- * - Prefer a cached value after first resolution
- * - Fallback to 'loot' if available; otherwise first defined item type
- * @returns {string} valid item type id for Item.create
- */
- static getDefaultItemType() {
- try {
- if (this.__defaultItemType && typeof this.__defaultItemType === 'string') {
- console.log('[Utils.getDefaultItemType] Using cached default type:', this.__defaultItemType);
- return this.__defaultItemType;
- }
- console.log('[Utils.getDefaultItemType] Discovering system Item types...');
- const types = (() => {
- try {
- const meta = CONFIG?.Item?.documentClass?.metadata?.types;
- console.log('[Utils.getDefaultItemType] CONFIG.Item.documentClass.metadata.types:', meta);
- if (Array.isArray(meta) && meta.length) return meta.map(t => String(t).toLowerCase());
- } catch (e) {
- console.warn('[Utils.getDefaultItemType] Failed to read CONFIG metadata:', e);
- }
- try {
- const model = game?.system?.model?.Item;
- const keys = model ? Object.keys(model) : [];
- console.log('[Utils.getDefaultItemType] game.system.model.Item keys:', keys);
- if (keys.length) return keys.map(t => String(t).toLowerCase());
- } catch (e) {
- console.warn('[Utils.getDefaultItemType] Failed to read system.model.Item:', e);
- }
- try {
- const docTypes = game?.system?.documentTypes?.Item;
- console.log('[Utils.getDefaultItemType] game.system.documentTypes.Item:', docTypes);
- if (Array.isArray(docTypes) && docTypes.length) return docTypes.map(t => String(t).toLowerCase());
- if (docTypes && typeof docTypes === 'object') {
- const keys = Object.keys(docTypes);
- if (keys.length) return keys.map(t => String(t).toLowerCase());
- }
- } catch (e) {
- console.warn('[Utils.getDefaultItemType] Failed to read system.documentTypes.Item:', e);
- }
- console.warn('[Utils.getDefaultItemType] No system types found, returning empty array');
- return [];
- })();
- console.log('[Utils.getDefaultItemType] Resolved types array:', types);
- const picked = types[0] || 'loot';
- console.log('[Utils.getDefaultItemType] Picked default type:', picked, '(will fallback to "loot" if types empty)');
- this.__defaultItemType = picked;
- return picked;
- } catch (e) {
- console.error('[Utils.getDefaultItemType] Outer catch, returning "loot":', e);
- return 'loot';
- }
- }
-
- /**
- * Resolve a safe Item type from a source descriptor with fallback to system default.
- * @param {any} source - object that may include type/item_type/category
- * @returns {string} valid item type id
- */
- static resolveItemType(source) {
- try {
- console.log('[Utils.resolveItemType] Called with source:', { type: source?.type, item_type: source?.item_type, category: source?.category });
- const types = (() => {
- try {
- const meta = CONFIG?.Item?.documentClass?.metadata?.types;
- console.log('[Utils.resolveItemType] CONFIG.Item.documentClass.metadata.types:', meta);
- if (Array.isArray(meta) && meta.length) return meta.map(t => String(t).toLowerCase());
- } catch (_) { }
- try {
- const model = game?.system?.model?.Item;
- const keys = model ? Object.keys(model) : [];
- console.log('[Utils.resolveItemType] game.system.model.Item keys:', keys);
- if (keys.length) return keys.map(t => String(t).toLowerCase());
- } catch (_) { }
- try {
- const docTypes = game?.system?.documentTypes?.Item;
- console.log('[Utils.resolveItemType] game.system.documentTypes.Item:', docTypes);
- if (Array.isArray(docTypes) && docTypes.length) return docTypes.map(t => String(t).toLowerCase());
- if (docTypes && typeof docTypes === 'object') {
- const keys = Object.keys(docTypes);
- if (keys.length) return keys.map(t => String(t).toLowerCase());
- }
- } catch (_) { }
- return [];
- })();
- console.log('[Utils.resolveItemType] Available system types:', types);
- const typeSet = new Set(types);
- const raw = String(source?.type ?? source?.item_type ?? source?.category ?? '')
- .trim()
- .toLowerCase();
- console.log('[Utils.resolveItemType] Raw type from source:', raw);
- if (raw && typeSet.has(raw)) {
- console.log('[Utils.resolveItemType] Raw type is valid, returning:', raw);
- return raw;
- }
-
- // Helper: pick the first that exists in current system types
- const pick = (candidates) => {
- for (const c of candidates) {
- const t = String(c).toLowerCase();
- if (typeSet.has(t)) return t;
- }
- return null;
- };
-
- // Common normalizations and aliases across systems
- // Prefer system-specific types when available (e.g., PF2e uses 'treasure' instead of 'loot')
- const alias = (
- pick([
- // Loot-like
- raw.match(/loot|treasure|generic/) ? 'treasure' : null,
- // Equipment/armor/weapons
- raw.match(/^armor|^equipment/) ? 'equipment' : null,
- raw.match(/weapon/) ? 'weapon' : null,
- // Consumables
- raw.match(/consum/) ? 'consumable' : null,
- // Containers
- raw.match(/pack|bag|backpack/) ? 'backpack' : null,
- // Others
- raw.match(/feat|ability/) ? 'feat' : null,
- raw.match(/tool/) ? 'tool' : null,
- raw.match(/spell/) ? 'spell' : null,
- ].filter(Boolean))
- );
- if (alias) {
- console.log('[Utils.resolveItemType] Found alias match:', alias);
- return alias;
- }
-
- // As a final attempt, map generic buckets to something safe
- console.log('[Utils.resolveItemType] No alias match, trying generic fallbacks...');
- const generic = pick(['equipment', 'treasure', 'weapon', 'consumable']);
- if (generic) {
- console.log('[Utils.resolveItemType] Found generic fallback:', generic);
- return generic;
- }
-
- console.log('[Utils.resolveItemType] No generic fallback, getting default type...');
- const deflt = this.getDefaultItemType();
- console.log('[Utils.resolveItemType] Default type:', deflt, 'typeSet size:', typeSet.size);
- if (!typeSet.size || typeSet.has(deflt)) {
- console.log('[Utils.resolveItemType] Returning default:', deflt);
- return deflt;
- }
- const final = types[0] || deflt || 'equipment';
- console.log('[Utils.resolveItemType] Final fallback:', final);
- return final;
- } catch (e) {
- console.error('[Utils.resolveItemType] Exception, returning default:', e);
- return this.getDefaultItemType();
- }
- }
- /**
- * Convert HTML to Markdown (naive conversion: strip HTML tags, keep text)
- * @param {any} value - The value to convert
- * @returns {string} Plain text
- */
- static toMarkdownIfHtml(value) {
- const s = String(value ?? '');
- if (!s) return '';
- try {
- // Insert explicit newlines for common block elements before stripping tags
- let pre = s
- .replace(/\r\n/g, '\n')
- .replace(/ (?!\n)/gi, '\n')
- .replace(/<\/p\s*>/gi, '\n\n')
- .replace(/<\/(div|section|article|header|footer|aside)\s*>/gi, '\n\n')
- .replace(/
]*>/gi, '\n• ')
- .replace(/<\/(h1|h2|h3|h4|h5|h6)\s*>/gi, '\n\n');
- const tmp = document.createElement('div');
- tmp.innerHTML = pre;
- let text = tmp.textContent || tmp.innerText || '';
- // Normalize multiple blank lines to at most two to create paragraphs
- text = text.replace(/\n{3,}/g, '\n\n');
- return text.trim();
- } catch (_) {
- return s;
- }
- }
- /**
- * Log messages with module prefix
- * @param {string} message - The message to log
- * @param {string} level - Log level (log, warn, error)
- */
- static log(message) {
- console.log(`${CONFIG.MODULE_TITLE} | ${message}`);
- }
-
- /**
- * Show notification to user
- * @param {string} message - The message to show
- * @param {string} type - Notification type (info, warn, error)
- */
- static notify(message, type = 'info') {
- ui.notifications[type](message);
- }
-
- /**
- * Get localized string
- * @param {string} key - The localization key
- * @param {object} data - Data for string interpolation
- * @returns {string} Localized string
- */
- static localize(key, data = {}) {
- return game.i18n.format(key, data);
- }
-
- /**
- * Convert Markdown to sanitized HTML suitable for storage in Actor/Item fields.
- * - Prefer a global MarkdownIt instance if available
- * - Fall back to a minimal converter for basic syntax
- * - Always sanitize with Foundry's TextEditor.cleanHTML
- * @param {string} markdown
- * @returns {string} sanitized HTML
- */
- static markdownToStoredHtml(markdown) {
- const md = String(markdown ?? '');
- try {
- let rawHtml = '';
- if (window?.MarkdownIt) {
- const mdIt = new window.MarkdownIt({ html: false, linkify: true, breaks: true });
- rawHtml = mdIt.render(md);
- } else {
- // Minimal fallback: paragraphs + bold/italic with HTML escaping for security
- rawHtml = md
- .replace(/\r\n/g, '\n')
- .replace(
- /\*\*(.*?)\*\*/g,
- (match, p1) => `${foundry.utils.escapeHTML(p1)}`
- )
- .replace(/_(.*?)_/g, (match, p1) => `${foundry.utils.escapeHTML(p1)}`)
- .split(/\n{2,}/)
- .map(p => `
${foundry.utils.escapeHTML(p.trim())}
`)
- .join('');
- }
- return foundry?.utils?.TextEditor?.cleanHTML
- ? foundry.utils.TextEditor.cleanHTML(rawHtml)
- : rawHtml;
- } catch (_) {
- return String(markdown || '');
- }
- }
-
- /**
- * Get current Foundry world information
- * @returns {object} World information object
- */
- static getFoundryWorldInfo() {
- return {
- id: game.world.id,
- title: game.world.title,
- description: game.world.description || 'No description',
- };
- }
-
- /**
- * Get character actors from the current world
- * @returns {Array} Array of character and NPC actors
- */
- static getCharacterActors() {
- return game.actors.contents.filter(actor => actor.type === 'character' || actor.type === 'npc');
- }
-
- /**
- * Get journal entries that likely represent Factions (by folder name or flag)
- * @returns {Array}
- */
- static getFactionJournals() {
- const entries = game.journal?.contents || [];
- const factionFolder = this._findFolderByNameInsensitive('Archivist - Factions');
- return entries.filter(j => {
- const flagged = j.getFlag(CONFIG.MODULE_ID, 'archivistType') === 'faction';
- const inFolder = factionFolder && j.folder?.id === factionFolder.id;
- return flagged || inFolder;
- });
- }
-
- /**
- * Get journal entries that likely represent Locations (by folder name or flag)
- * @returns {Array}
- */
- static getLocationJournals() {
- const entries = game.journal?.contents || [];
- const locationFolder = this._findFolderByNameInsensitive('Archivist - Locations');
- return entries.filter(j => {
- const flagged = j.getFlag(CONFIG.MODULE_ID, 'archivistType') === 'location';
- const inFolder = locationFolder && j.folder?.id === locationFolder.id;
- return flagged || inFolder;
- });
- }
-
- /**
- * Find a folder by name (case-insensitive)
- * @param {string} name
- * @returns {Folder | undefined}
- */
- static _findFolderByNameInsensitive(name) {
- const folders = game.folders?.contents || [];
- return folders.find(
- f => f.type === 'JournalEntry' && f.name.toLowerCase() === String(name).toLowerCase()
- );
- }
-
- /**
- * Transform actor data for API synchronization
- * @param {Array} actors - Array of Foundry actor objects
- * @returns {Array} Array of transformed character data
- */
- static transformActorsForSync(actors) {
- return actors.map(actor => ({
- foundryId: actor.id,
- name: actor.name,
- type: actor.type,
- description: actor.system?.details?.biography?.value || '',
- level: actor.system?.details?.level || 1,
- race: actor.system?.details?.race || '',
- class: actor.system?.details?.class || '',
- }));
- }
-
- /**
- * Build Archivist Character payload from a Foundry Actor
- * @param {Actor} actor
- * @param {string} worldId
- */
- static toApiCharacterPayload(actor, worldId) {
- const isPC = actor.type === 'character';
- const pick = (...paths) => {
- for (const p of paths) {
- try {
- const v = foundry.utils.getProperty(actor, p);
- if (typeof v === 'string' && v.trim()) return v;
- } catch (_) { }
- }
- return '';
- };
- const bioHtml = pick('system.details.biography.value', 'system.details.biography.public', 'system.description.value');
- return {
- character_name: actor.name,
- player_name: actor?.system?.details?.player || '',
- description: this.toMarkdownIfHtml(bioHtml || ''),
- type: isPC ? 'PC' : 'NPC',
- campaign_id: worldId,
- };
- }
-
- /**
- * Build Archivist Faction payload from a JournalEntry
- * @param {JournalEntry} journal
- * @param {string} worldId
- */
- static toApiFactionPayload(journal, worldId) {
- const raw = String(journal?.img || '').trim();
- const image = raw.startsWith('https://') ? raw : undefined;
- const text = this._extractJournalText(journal);
- // Strip leading image since we set it as a separate property
- const cleanedText = this.stripLeadingImage(text);
- return {
- name: journal.name,
- // Journal pages store HTML — convert to Markdown for API
- description: this.toMarkdownIfHtml(cleanedText),
- ...(image ? { image } : {}),
- campaign_id: worldId,
- };
- }
-
- /**
- * Build Archivist Location payload from a JournalEntry
- * @param {JournalEntry} journal
- * @param {string} worldId
- */
- static toApiLocationPayload(journal, worldId) {
- const raw = String(journal?.img || '').trim();
- const image = raw.startsWith('https://') ? raw : undefined;
- const text = this._extractJournalText(journal);
- // Strip leading image since we set it as a separate property
- const cleanedText = this.stripLeadingImage(text);
- return {
- name: journal.name,
- description: this.toMarkdownIfHtml(cleanedText),
- ...(image ? { image } : {}),
- campaign_id: worldId,
- };
- }
-
- /**
- * Extract text content from a JournalEntry (first text page)
- * @param {JournalEntry} journal
- * @returns {string}
- */
- static _extractJournalText(journal) {
- const pages = journal.pages?.contents || journal.pages || [];
- const textPage = pages.find(p => p.type === 'text');
- // Foundry v10+ stores text in page.text.content
- return textPage?.text?.content || journal.content || '';
- }
-
- /**
- * Remove a single leading image from Markdown or HTML at the top of text.
- * Handles patterns like: \n\n, , or wrapped in
.
- * @param {string} text
- * @returns {string}
- */
- static stripLeadingImage(text) {
- const s = String(text || '');
- if (!s) return '';
- // Common patterns: Markdown image at start, possibly followed by blank line
- const mdImg = /^(?:\s*)!\[[^\]]*\]\([^\)]+\)\s*(?:\n+)?/;
- if (mdImg.test(s)) return s.replace(mdImg, '').trimStart();
- // HTML possibly wrapped in
at the very start
- const htmlImgP = /^(?:\s*)
]*>\s*]*>\s*<\/p>\s*/i;
- if (htmlImgP.test(s)) return s.replace(htmlImgP, '').trimStart();
- const htmlImg = /^(?:\s*)]*>\s*/i;
- if (htmlImg.test(s)) return s.replace(htmlImg, '').trimStart();
- return s;
- }
-
- /**
- * Ensure a journal has a single primary text page with provided content.
- * Works across Foundry versions (v10+ with pages collection).
- * @param {JournalEntry} journal
- * @param {string} content
- */
- static async ensureJournalTextPage(journal, content) {
- // v10+ API: JournalEntryPage documents under journal.pages
- const pagesCollection = journal.pages;
- const safeContent = String(content ?? '');
-
- console.log(`[Utils] ensureJournalTextPage:`, {
- journalId: journal?.id,
- journalName: journal?.name,
- contentLength: safeContent.length,
- contentPreview: safeContent.substring(0, 100),
- });
-
- if (pagesCollection) {
- const pages =
- pagesCollection.contents ?? (Array.isArray(pagesCollection) ? pagesCollection : []);
- const textPage = pages.find(p => p.type === 'text');
- if (textPage) {
- await textPage.update({ text: { content: safeContent, markdown: safeContent, format: 2 } });
- } else {
- await journal.createEmbeddedDocuments('JournalEntryPage', [
- {
- name: 'Description',
- type: 'text',
- text: { content: safeContent, markdown: safeContent, format: 2 },
- },
- ]);
- }
- return;
- }
- // Fallback (older Foundry versions) — use JournalEntry content
- await journal.update({ content: safeContent });
- }
-
- /**
- * Set a journal's thumbnail image (img property) to the provided URL.
- * Does not modify journal content or pages.
- * @param {JournalEntry} journal
- * @param {string} imageUrl
- */
- static async ensureJournalLeadImage(journal, imageUrl) {
- try {
- const url = String(imageUrl || '').trim();
- if (!url) return;
- console.debug('[Archivist Sync] ensureJournalLeadImage()', { journalId: journal?.id, url });
- // Set the journal thumbnail so it shows in lists
- try {
- await journal.update({ img: url });
- } catch (e) {
- console.debug('[Archivist Sync] journal img update failed', e);
- }
- } catch (e) {
- console.warn('[Archivist Sync] Failed to set journal lead image:', e);
- }
- }
-
- /**
- * Flags helpers for mapping Archivist IDs
- */
- static getActorArchivistId(actor) {
- return actor.getFlag(CONFIG.MODULE_ID, 'archivistId');
- }
-
- static async setActorArchivistId(actor, id, worldId) {
- await actor.setFlag(CONFIG.MODULE_ID, 'archivistId', id);
- if (worldId) await actor.setFlag(CONFIG.MODULE_ID, 'archivistWorldId', worldId);
- return true;
- }
-
- static getJournalArchivistMeta(journal) {
- return {
- id: journal.getFlag(CONFIG.MODULE_ID, 'archivistId') || null,
- type: journal.getFlag(CONFIG.MODULE_ID, 'archivistType') || null,
- worldId: journal.getFlag(CONFIG.MODULE_ID, 'archivistWorldId') || null,
- };
- }
-
- static async setJournalArchivistMeta(journal, id, type, worldId) {
- await journal.setFlag(CONFIG.MODULE_ID, 'archivistId', id);
- if (type) await journal.setFlag(CONFIG.MODULE_ID, 'archivistType', type);
- if (worldId) await journal.setFlag(CONFIG.MODULE_ID, 'archivistWorldId', worldId);
- }
-
- /**
- * Get Archivist metadata from a JournalEntryPage
- * @param {JournalEntryPage} page
- */
- static getPageArchivistMeta(page) {
- return {
- id: page?.getFlag?.(CONFIG.MODULE_ID, 'archivistId') || null,
- type: page?.getFlag?.(CONFIG.MODULE_ID, 'archivistType') || null,
- worldId: page?.getFlag?.(CONFIG.MODULE_ID, 'archivistWorldId') || null,
- };
- }
-
- /**
- * Set Archivist metadata on a JournalEntryPage
- * @param {JournalEntryPage} page
- * @param {string} id
- * @param {string} type
- * @param {string} worldId
- */
- static async setPageArchivistMeta(page, id, type, worldId) {
- if (!page) return;
- if (id) await page.setFlag(CONFIG.MODULE_ID, 'archivistId', id);
- if (type) await page.setFlag(CONFIG.MODULE_ID, 'archivistType', type);
- if (worldId) await page.setFlag(CONFIG.MODULE_ID, 'archivistWorldId', worldId);
- }
-
- /**
- * Ensure a single root-level JournalEntry exists as a container
- * @param {string} name
- * @returns {Promise}
- */
- static async ensureRootJournalContainer(name) {
- const journals = game.journal?.contents || [];
- let j = journals.find(x => x.name === name && !x.folder);
- if (j) return j;
- j = await JournalEntry.create({ name, folder: null, pages: [] }, { render: false });
- return j;
- }
-
- /**
- * Create or update a text page within a container journal
- * Returns the page document. If creating multiple, call with items pre-sorted, as creation order defines index.
- * @param {JournalEntry} container
- * @param {object} opts { name, html, imageUrl, flags }
- */
- static async upsertContainerTextPage(container, { name, html, imageUrl, flags } = {}) {
- const pages = container.pages?.contents || [];
- // Prefer matching by Archivist ID if provided via flags
- let page = null;
- if (flags?.archivistId) {
- page = pages.find(p => this.getPageArchivistMeta(p).id === flags.archivistId);
- }
- if (!page) page = pages.find(p => p.name === name && p.type === 'text');
- const baseMd = String(html || '');
- if (page) {
- await page.update({
- name,
- type: 'text',
- text: { content: baseMd, markdown: baseMd, format: 2 },
- });
- } else {
- const created = await container.createEmbeddedDocuments('JournalEntryPage', [
- { name, type: 'text', text: { content: baseMd, markdown: baseMd, format: 2 } },
- ]);
- page = created?.[0] || null;
- }
- if (page && flags) {
- await this.setPageArchivistMeta(
- page,
- flags.archivistId,
- flags.archivistType,
- flags.archivistWorldId
- );
- }
- return page;
- }
-
- /**
- * Sort pages within a container using comparator over page docs
- * Applies increasing sort values to match comparator order.
- * @param {JournalEntry} container
- * @param {(a: JournalEntryPage, b: JournalEntryPage) => number} comparator
- */
- static async sortContainerPages(container, comparator) {
- const pages = (container.pages?.contents || []).slice().sort(comparator);
- let sort = 0;
- const updates = pages.map(p => ({ _id: p.id, sort: (sort += 100) }));
- if (updates.length) await container.updateEmbeddedDocuments('JournalEntryPage', updates);
- }
-
- /**
- * Extract HTML text content from a JournalEntryPage
- * @param {JournalEntryPage} page
- */
- static extractPageHtml(page) {
- if (!page) return '';
- if (page.type === 'text') {
- const fmt = Number(page?.text?.format ?? 0);
- const md = page?.text?.markdown;
- if (fmt === 2 && typeof md === 'string') return String(md);
- return String(page?.text?.content || md || '');
- }
- return '';
- }
-
- /**
- * Validate API key format
- * @param {string} apiKey - The API key to validate
- * @returns {boolean} True if API key appears valid
- */
- static validateApiKey(apiKey) {
- return apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0;
- }
-
- /**
- * Safely parse JSON response
- * @param {string} jsonString - JSON string to parse
- * @returns {object|null} Parsed object or null if parsing fails
- */
- static safeJsonParse(jsonString) {
- try {
- return JSON.parse(jsonString);
- } catch (error) {
- this.log(`Failed to parse JSON: ${error.message}`, 'warn');
- return null;
- }
- }
-
- /**
- * Debounce function to limit rapid successive calls
- * @param {Function} func - Function to debounce
- * @param {number} wait - Wait time in milliseconds
- * @param {boolean} immediate - Whether to trigger on leading edge
- * @returns {Function} Debounced function
- */
- static debounce(func, wait, immediate = false) {
- let timeout;
- return function executedFunction(...args) {
- const later = () => {
- timeout = null;
- if (!immediate) func.apply(this, args);
- };
- const callNow = immediate && !timeout;
- clearTimeout(timeout);
- timeout = setTimeout(later, wait);
- if (callNow) func.apply(this, args);
- };
- }
-
- /**
- * Check if user is a GM
- * @returns {boolean} True if current user is a GM
- */
- static isGM() {
- return game.user.isGM;
- }
-
- /**
- * Ensure a folder exists for JournalEntries by name; returns folder id or null
- * @param {string} name
- */
- static async ensureJournalFolder(name) {
- const existing = this._findFolderByNameInsensitive(name);
- if (existing) return existing.id;
- const created = await Folder.create({ name, type: 'JournalEntry' });
- return created?.id || null;
- }
-
- /** Ensure top-level organized folders exist for Archivist types */
- static async ensureArchivistFolders() {
- try {
- const folders = {
- pc: 'Archivist - PCs',
- npc: 'Archivist - NPCs',
- item: 'Archivist - Items',
- location: 'Archivist - Locations',
- faction: 'Archivist - Factions',
- };
-
- console.log('[Archivist Sync] Ensuring organized folders:', Object.values(folders));
- for (const name of Object.values(folders)) {
- await this.ensureJournalFolder(name);
- }
- } catch (e) {
- console.warn('[Archivist Sync] ensureArchivistFolders failed:', e);
- }
- }
-
- /** Get the organized folder for a given Archivist sheet type */
- static getArchivistFolder(type) {
- try {
- console.log('[Archivist Sync] getArchivistFolder called:', {
- type,
- });
-
- const map = {
- pc: 'Archivist - PCs',
- npc: 'Archivist - NPCs',
- item: 'Archivist - Items',
- location: 'Archivist - Locations',
- faction: 'Archivist - Factions',
- };
- const name = map[String(type || '').toLowerCase()];
-
- if (!name) {
- console.log('[Archivist Sync] No folder name mapped for type:', type);
- return null;
- }
-
- const folders = game.folders?.contents || [];
- const found = folders.find(f => f.type === 'JournalEntry' && f.name === name) || null;
-
- console.log('[Archivist Sync] Folder lookup result:', {
- searchingFor: name,
- found: found?.name || 'none',
- foundId: found?.id || 'none',
- });
-
- return found;
- } catch (e) {
- console.warn('[Archivist Sync] getArchivistFolder failed:', e);
- return null;
- }
- }
-
- /** Move a JournalEntry into its organized folder based on flags.archivist.sheetType */
- static async moveJournalToTypeFolder(journal) {
- try {
- const flags = journal.getFlag(CONFIG.MODULE_ID, 'archivist') || {};
- const type = String(flags.sheetType || '').toLowerCase();
- const folder = this.getArchivistFolder(type);
- if (!folder) return false;
- if (journal.folder?.id === folder.id) return false;
- await journal.update({ folder: folder.id });
- return true;
- } catch (_) {
- return false;
- }
- }
-
- /** Create a custom sheet JournalEntry for an imported Archivist entity */
- static async createCustomJournalForImport({ name, html = '', imageUrl, sheetType, archivistId, worldId, folderId, sort }) {
- try {
- console.log(`[Archivist Sync] createCustomJournalForImport called:`, {
- name,
- sheetType,
- archivistId,
- providedFolderId: folderId,
- sort,
- });
-
- await this.ensureArchivistFolders();
- const folder = this.getArchivistFolder(sheetType);
- const sheetClassMap = {
- pc: 'archivist-sync.PCPageSheetV2',
- npc: 'archivist-sync.NPCPageSheetV2',
- item: 'archivist-sync.ItemPageSheetV2',
- location: 'archivist-sync.LocationPageSheetV2',
- faction: 'archivist-sync.FactionPageSheetV2',
- recap: 'archivist-sync.RecapPageSheetV2',
- };
- const normalizedType = String(sheetType || '').toLowerCase();
- const sheetClass = sheetClassMap[normalizedType] || '';
-
- console.log(`[Archivist Sync] Folder lookup results:`, {
- sheetType,
- foundFolder: folder?.name || 'none',
- foundFolderId: folder?.id || 'none',
- providedFolderId: folderId || 'none',
- willUseFolderId: folderId || folder?.id || 'none (root)',
- });
-
- const targetFolderId = folderId || folder?.id || null;
- const createData = {
- name,
- folder: targetFolderId,
- ...(imageUrl ? { img: imageUrl } : {}),
- ...(typeof sort === 'number' ? { sort } : {}),
- flags: {
- core: { sheetClass, sheet: sheetClass },
- },
- };
-
- const journal = await JournalEntry.create(createData, { render: false });
-
- console.log(`[Archivist Sync] Journal created:`, {
- journalId: journal.id,
- journalName: journal.name,
- assignedFolderId: targetFolderId,
- actualFolderId: journal.folder?.id || 'none (root)',
- actualFolderName: journal.folder?.name || 'none (root)',
- });
-
- await this.ensureJournalTextPage(journal, html);
- // Hub image flag removed
- await journal.setFlag(CONFIG.MODULE_ID, 'archivist', {
- sheetType: normalizedType,
- archivistId: archivistId || null,
- archivistWorldId: worldId || null,
- image: imageUrl || null,
- archivistRefs: { characters: [], items: [], entries: [], factions: [], locationsAssociative: [] },
- foundryRefs: { actors: [], items: [], scenes: [], journals: [] },
- });
-
- console.log(`[Archivist Sync] Journal finalized with flags, final location:`, {
- journalId: journal.id,
- folderId: journal.folder?.id || 'root',
- folderName: journal.folder?.name || 'root',
- });
-
- return journal;
- } catch (e) {
- console.warn('[Archivist Sync] createCustomJournalForImport failed', e);
- return null;
- }
- }
-
- /** Create a new Archivist journal with flags and initial text page */
- static async createArchivistJournal({ name, sheetType, archivistId, worldId, folderName, text = '', sort }) {
- const folder = folderName ? await this.ensureJournalFolder(folderName) : null;
- // Map Archivist sheet types to our registered V2 sheet classes
- const sheetClassMap = {
- pc: 'archivist-sync.PCPageSheetV2',
- npc: 'archivist-sync.NPCPageSheetV2',
- item: 'archivist-sync.ItemPageSheetV2',
- location: 'archivist-sync.LocationPageSheetV2',
- faction: 'archivist-sync.FactionPageSheetV2',
- recap: 'archivist-sync.RecapPageSheetV2',
- };
- const normalizedType = String(sheetType || '').toLowerCase();
- const sheetClass = sheetClassMap[normalizedType] || '';
- // Provide our archivist flags at creation so createJournalEntry hook can POST immediately
- const initialArchivistFlags = {
- sheetType: normalizedType,
- archivistId: archivistId || null,
- archivistWorldId: worldId || null,
- archivistRefs: { characters: [], items: [], entries: [], factions: [], locationsAssociative: [] },
- foundryRefs: { actors: [], items: [], scenes: [], journals: [] },
- };
- const createData = {
- name,
- folder,
- ...(typeof sort === 'number' ? { sort } : {}),
- flags: {
- core: { sheetClass, sheet: sheetClass },
- [CONFIG.MODULE_ID]: { archivist: initialArchivistFlags },
- }
- };
- const journal = await JournalEntry.create(createData, { render: false });
- await this.ensureJournalTextPage(journal, text);
- // Flags were provided at creation; no need to set again here
- return journal;
- }
-
- static createPcJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'pc', folderName: 'Archivist - PCs' }); }
- static createNpcJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'npc', folderName: 'Archivist - NPCs' }); }
- static createItemJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'item', folderName: 'Archivist - Items' }); }
- static createLocationJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'location', folderName: 'Archivist - Locations' }); }
- static createFactionJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'faction', folderName: 'Archivist - Factions' }); }
- static createRecapJournal(opts) { return this.createArchivistJournal({ ...opts, sheetType: 'recap', folderName: 'Recaps' }); }
-
- /**
- * Deep clone an object
- * @param {object} obj - Object to clone
- * @returns {object} Cloned object
- */
- static deepClone(obj) {
- return foundry.utils.deepClone(obj);
- }
-
- /**
- * Merge objects using Foundry's utility
- * @param {object} original - Original object
- * @param {object} other - Object to merge
- * @returns {object} Merged object
- */
- static mergeObject(original, other) {
- return foundry.utils.mergeObject(original, other);
- }
-
- /**
- * Generate a random ID
- * @param {number} length - Length of the ID
- * @returns {string} Random ID string
- */
- static generateId(length = 8) {
- return foundry.utils.randomID(length);
- }
-
- /**
- * Format error message for display
- * @param {Error|string} error - Error object or message
- * @returns {string} Formatted error message
- */
- static formatError(error) {
- if (error instanceof Error) {
- return error.message;
- }
- return String(error);
- }
-
- /**
- * Check if a string is empty or whitespace only
- * @param {string} str - String to check
- * @returns {boolean} True if string is empty or whitespace
- */
- static isEmpty(str) {
- return !str || str.trim().length === 0;
- }
-
- /**
- * Capitalize first letter of a string
- * @param {string} str - String to capitalize
- * @returns {string} Capitalized string
- */
- static capitalize(str) {
- if (!str) return '';
- return str.charAt(0).toUpperCase() + str.slice(1);
- }
-}
diff --git a/scripts/services/archivist-api.js b/scripts/services/archivist-api.js
index 42bdfcb..2090c47 100644
--- a/scripts/services/archivist-api.js
+++ b/scripts/services/archivist-api.js
@@ -122,62 +122,75 @@ export class ArchivistApiService {
*/
_normalizeQuestPayload(payload) {
const p = payload ? { ...payload } : {};
-
- if (!('campaign_id' in p) && 'campaignId' in p) p.campaign_id = p.campaignId;
- if (!('campaign_id' in p) && 'worldId' in p) p.campaign_id = p.worldId;
- if (!('quest_name' in p) && 'questName' in p) p.quest_name = p.questName;
- if (!('quest_giver' in p) && 'questGiver' in p) p.quest_giver = p.questGiver;
- if (!('quest_giver_id' in p) && 'questGiverId' in p) p.quest_giver_id = p.questGiverId;
- if (!('quest_category' in p) && 'questCategory' in p) p.quest_category = p.questCategory;
- if (!('success_definition' in p) && 'successDefinition' in p) {
- p.success_definition = p.successDefinition;
- }
- if (!('failure_conditions' in p) && 'failureConditions' in p) {
- p.failure_conditions = p.failureConditions;
- }
- if (!('next_action' in p) && 'nextAction' in p) p.next_action = p.nextAction;
- if (!('progress_log' in p) && 'progressLog' in p) p.progress_log = p.progressLog;
- if (!('progress_log_entries' in p) && 'progressLogEntries' in p) {
- p.progress_log_entries = p.progressLogEntries;
- }
- if (!('related_characters' in p) && 'relatedCharacters' in p) {
- p.related_characters = p.relatedCharacters;
- }
- if (!('related_factions' in p) && 'relatedFactions' in p) {
- p.related_factions = p.relatedFactions;
+ const out = {};
+
+ // The API's QuestCreate/QuestUpdate schemas declare `extra="forbid"`, so
+ // any key they don't recognize rejects the whole request with a 422. Build
+ // a strict whitelist of writable fields only — read-only response fields
+ // (questGiverId, progressLogEntries, firstSession, lastSession, counts,
+ // orderIndex, timestamps) must never leak into a write payload.
+
+ // campaign/world id is accepted on create only (QuestUpdate has no such
+ // field); only forward it when a caller actually supplied one.
+ const campaignId = p.campaign_id ?? p.campaignId ?? p.worldId;
+ if (campaignId !== undefined) out.campaign_id = campaignId;
+
+ // Scalar text / enum fields.
+ const scalars = {
+ quest_name: p.quest_name ?? p.questName,
+ quest_giver: p.quest_giver ?? p.questGiver,
+ quest_category: p.quest_category ?? p.questCategory,
+ status: p.status,
+ success_definition: p.success_definition ?? p.successDefinition,
+ failure_conditions: p.failure_conditions ?? p.failureConditions,
+ next_action: p.next_action ?? p.nextAction,
+ resolution: p.resolution,
+ };
+ for (const [key, value] of Object.entries(scalars)) {
+ if (value !== undefined) out[key] = value;
}
- if (!('related_locations' in p) && 'relatedLocations' in p) {
- p.related_locations = p.relatedLocations;
+
+ // Objectives → [{ text, status }] (drop server-managed id/order). The
+ // API requires text with min_length=1 and rejects the whole request
+ // otherwise, so drop entries with empty text.
+ const objectives = p.objectives;
+ if (Array.isArray(objectives)) {
+ out.objectives = objectives
+ .map((o) =>
+ typeof o === 'string'
+ ? { text: o.trim(), status: 'pending' }
+ : {
+ text: String(o?.text ?? '').trim(),
+ status: o?.status ?? 'pending',
+ }
+ )
+ .filter((o) => o.text.length > 0);
}
- if (!('related_items' in p) && 'relatedItems' in p) {
- p.related_items = p.relatedItems;
+
+ // Progress log → [str].
+ const progressLog = p.progress_log ?? p.progressLog;
+ if (Array.isArray(progressLog)) {
+ out.progress_log = progressLog.map((e) =>
+ typeof e === 'string' ? e : e?.text ?? ''
+ );
}
- if (!('related_entity_refs' in p) && 'relatedEntityRefs' in p) {
- p.related_entity_refs = p.relatedEntityRefs;
+
+ // Related entity name lists → [str].
+ const relatedLists = {
+ related_characters: p.related_characters ?? p.relatedCharacters,
+ related_factions: p.related_factions ?? p.relatedFactions,
+ related_locations: p.related_locations ?? p.relatedLocations,
+ related_items: p.related_items ?? p.relatedItems,
+ };
+ for (const [key, value] of Object.entries(relatedLists)) {
+ if (Array.isArray(value)) out[key] = value;
}
- if (!('first_session' in p) && 'firstSession' in p) p.first_session = p.firstSession;
- if (!('last_session' in p) && 'lastSession' in p) p.last_session = p.lastSession;
-
- delete p.campaignId;
- delete p.worldId;
- delete p.questName;
- delete p.questGiver;
- delete p.questGiverId;
- delete p.questCategory;
- delete p.successDefinition;
- delete p.failureConditions;
- delete p.nextAction;
- delete p.progressLog;
- delete p.progressLogEntries;
- delete p.relatedCharacters;
- delete p.relatedFactions;
- delete p.relatedLocations;
- delete p.relatedItems;
- delete p.relatedEntityRefs;
- delete p.firstSession;
- delete p.lastSession;
- return p;
+ // Structured related refs pass through untouched.
+ const refs = p.related_entity_refs ?? p.relatedEntityRefs;
+ if (Array.isArray(refs)) out.related_entity_refs = refs;
+
+ return out;
}
/**
@@ -237,21 +250,24 @@ export class ArchivistApiService {
}
}
+ // keepalive lets in-flight writes survive page unload, but Chromium
+ // rejects keepalive requests whose body exceeds 64 KiB — skip it for
+ // large payloads (e.g. long journal content) so they still send.
+ const bodySize =
+ typeof options?.body === 'string'
+ ? new TextEncoder().encode(options.body).length
+ : 0;
+ const useKeepalive = isWrite && bodySize < 60000;
+
while (attempt <= maxRetries) {
try {
- const method = String(options?.method || 'GET').toUpperCase();
- const isWrite =
- method === 'POST' ||
- method === 'PUT' ||
- method === 'PATCH' ||
- method === 'DELETE';
const fetchOptions = {
...options,
headers,
mode: 'cors',
cache: 'no-store',
};
- if (isWrite) fetchOptions.keepalive = true;
+ if (useKeepalive) fetchOptions.keepalive = true;
const response = await fetch(url, fetchOptions);
// Handle successful responses (non-429)
@@ -1658,6 +1674,177 @@ export class ArchivistApiService {
}
}
+ /**
+ * Update a Link's alias in a campaign (PATCH).
+ * Prefer this over delete+recreate when only the alias/label changes and the
+ * from/to endpoints stay the same.
+ * @param {string} apiKey
+ * @param {string} campaignId
+ * @param {string} linkId
+ * @param {{alias?: string}} payload
+ */
+ async updateLink(apiKey, campaignId, linkId, payload) {
+ try {
+ const data = await this._request(
+ apiKey,
+ `/campaigns/${encodeURIComponent(campaignId)}/links/${encodeURIComponent(linkId)}`,
+ {
+ method: 'PATCH',
+ body: JSON.stringify(payload),
+ }
+ );
+ return { success: true, data };
+ } catch (error) {
+ console.error(`${CONFIG.MODULE_TITLE} | Failed to update link:`, error);
+ return {
+ success: false,
+ message: error.message || 'Failed to update link',
+ };
+ }
+ }
+
+ /**
+ * Step 1 of the local-image-upload flow: request a presigned upload URL.
+ * Supported entity types: character, faction, location, item (NOT quest —
+ * the API has no image support for Quest).
+ * @param {string} apiKey
+ * @param {string} campaignId
+ * @param {{entityType:string, entityId:string, fileName:string, contentType:string}} params
+ * @returns {Promise<{success:boolean, data?:{object_key:string, upload_url:string, public_url:string, expires_in_seconds:number}}>}
+ */
+ async initImageUpload(
+ apiKey,
+ campaignId,
+ { entityType, entityId, fileName, contentType }
+ ) {
+ try {
+ const data = await this._request(
+ apiKey,
+ `/campaigns/${encodeURIComponent(campaignId)}/images/init`,
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ entity_type: entityType,
+ entity_id: entityId,
+ file_name: fileName,
+ content_type: contentType,
+ }),
+ }
+ );
+ return { success: true, data };
+ } catch (error) {
+ console.error(
+ `${CONFIG.MODULE_TITLE} | Failed to init image upload:`,
+ error
+ );
+ return {
+ success: false,
+ message: error.message || 'Failed to init image upload',
+ };
+ }
+ }
+
+ /**
+ * Step 2: PUT the raw image bytes directly to the presigned R2 URL from initImageUpload.
+ * This does NOT go through _request (no api key / json headers — the presigned
+ * URL carries its own auth).
+ * @param {string} uploadUrl
+ * @param {Blob|ArrayBuffer} bytes
+ * @param {string} contentType
+ */
+ async uploadImageBytes(uploadUrl, bytes, contentType) {
+ const res = await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: { 'Content-Type': contentType },
+ body: bytes,
+ });
+ if (!res.ok) {
+ throw new Error(`Image upload PUT failed: HTTP ${res.status}`);
+ }
+ return true;
+ }
+
+ /**
+ * Step 3: tell the API the upload finished so it can validate + attach the image.
+ * @param {string} apiKey
+ * @param {string} campaignId
+ * @param {{objectKey:string, entityType:string, entityId:string, attach?:boolean}} params
+ * @returns {Promise<{success:boolean, data?:{url:string, attached:boolean}}>}
+ */
+ async completeImageUpload(
+ apiKey,
+ campaignId,
+ { objectKey, entityType, entityId, attach = true }
+ ) {
+ try {
+ const data = await this._request(
+ apiKey,
+ `/campaigns/${encodeURIComponent(campaignId)}/images/complete`,
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ object_key: objectKey,
+ entity_type: entityType,
+ entity_id: entityId,
+ attach,
+ }),
+ }
+ );
+ return { success: true, data };
+ } catch (error) {
+ console.error(
+ `${CONFIG.MODULE_TITLE} | Failed to complete image upload:`,
+ error
+ );
+ return {
+ success: false,
+ message: error.message || 'Failed to complete image upload',
+ };
+ }
+ }
+
+ /**
+ * Convenience wrapper: run the full init -> PUT -> complete flow for a local
+ * Foundry image (read via Utils.fetchLocalImageBytes or similar).
+ * @param {string} apiKey
+ * @param {string} campaignId
+ * @param {{entityType:string, entityId:string, fileName:string, contentType:string, bytes:Blob|ArrayBuffer}} params
+ * @returns {Promise<{success:boolean, url?:string, message?:string}>}
+ */
+ async uploadEntityImage(
+ apiKey,
+ campaignId,
+ { entityType, entityId, fileName, contentType, bytes }
+ ) {
+ const init = await this.initImageUpload(apiKey, campaignId, {
+ entityType,
+ entityId,
+ fileName,
+ contentType,
+ });
+ if (!init.success) return init;
+ try {
+ await this.uploadImageBytes(
+ init.data.upload_url,
+ bytes,
+ contentType
+ );
+ } catch (error) {
+ return {
+ success: false,
+ message: error.message || 'Failed to upload image bytes',
+ };
+ }
+ const complete = await this.completeImageUpload(apiKey, campaignId, {
+ objectKey: init.data.object_key,
+ entityType,
+ entityId,
+ attach: true,
+ });
+ if (!complete.success) return complete;
+ return { success: true, url: complete.data?.url };
+ }
+
/**
* Ask (RAG chat) — non-streaming
* @param {string} apiKey
diff --git a/styles/archivist-sync.css b/styles/archivist-sync.css
index 8460460..fb5363b 100644
--- a/styles/archivist-sync.css
+++ b/styles/archivist-sync.css
@@ -73,6 +73,11 @@
border-color: var(--color-border-highlight-hover, #ff8020);
}
+/* Upload-image button sits to the left of the edit toggle, not on top of it */
+.archivist-edit-toggle.archivist-upload-toggle {
+ right: 48px;
+}
+
/* When editing, move button down to not overlap with ProseMirror controls */
.archivist-sheet prose-mirror[toggled]~.archivist-edit-toggle,
.archivist-info prose-mirror[toggled]~.archivist-edit-toggle,
@@ -2692,6 +2697,44 @@ img.archivist-icon {
line-height: 1.4;
}
+.quest-field-group input[type='text'],
+.quest-field-group select,
+.quest-field-group textarea {
+ font-size: 0.85rem;
+ line-height: 1.4;
+ padding: 0.3rem 0.5rem;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ color: inherit;
+ font-family: inherit;
+ resize: vertical;
+}
+
+.quest-objective {
+ flex-wrap: wrap;
+}
+
+.quest-objective-text-input {
+ flex: 1 1 auto;
+ min-width: 8rem;
+ font-size: 0.85rem;
+ padding: 0.25rem 0.4rem;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ color: inherit;
+}
+
+.quest-objective-status-select {
+ font-size: 0.78rem;
+ padding: 0.2rem 0.35rem;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ color: inherit;
+}
+
/* Quest section headings */
.quest-section-heading {
diff --git a/templates/sheets/character.hbs b/templates/sheets/character.hbs
index 21114b4..e193f5d 100644
--- a/templates/sheets/character.hbs
+++ b/templates/sheets/character.hbs
@@ -23,6 +23,10 @@
{{#if setup.editingInfo}}{{else}}{{/if}}
+
{{/if}}
@@ -78,6 +83,10 @@
+
+
Quests are linked from the Quest sheet itself; this list is read-only here.
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.
Check items you want to sync. Use the dropdown to match Archivist entities to existing Foundry
documents. Matched items will be linked; unmatched checked items will be imported or exported.
-
Factions and session recaps are always
- imported in full from Archivist and are not shown here.
+
Factions, session recaps, and quests are
+ always imported in full from Archivist and are not shown here.