Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .cursor/rules/40-archivist-api.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion module.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"version": "2.0.0",
"compatibility": {
"minimum": "14.359",
"verified": "13.351"
"verified": "14.359"
},
"relationships": {
"systems": [],
Expand Down
7 changes: 4 additions & 3 deletions scripts/archivist-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
64 changes: 54 additions & 10 deletions scripts/dialogs/sync-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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: `<p><strong>${deleteDiffs.length}</strong> journal${deleteDiffs.length > 1 ? 's' : ''} will be permanently deleted:</p><p>${names}</p><p>This cannot be undone. Continue?</p>`,
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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) {
Expand Down
49 changes: 39 additions & 10 deletions scripts/dialogs/world-setup-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 || [];
Expand Down Expand Up @@ -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 || ''))
Expand All @@ -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 || ''))
Expand All @@ -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)
Expand Down Expand Up @@ -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.'
Expand All @@ -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,
Expand Down Expand Up @@ -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,
};
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions scripts/modules/journal-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export class JournalManager {
location: 'Archivist - Locations',
faction: 'Archivist - Factions',
recap: 'Recaps',
quest: 'Archivist - Quests',
journal: 'Archivist - Journals',
entry: null,
};
}
Expand Down
Loading