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
37 changes: 35 additions & 2 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,9 @@
"items": "Items",
"recaps": "Recaps",
"mappingOverride": "Mapping Override (JSON)",
"importProgress": "Import Progress"
"importProgress": "Import Progress",
"journals": "Journals",
"quests": "Quests"
},
"status": {
"connected": "Connected",
Expand Down Expand Up @@ -229,7 +231,9 @@
"locationsPulled": "Locations pulled successfully",
"charactersPulled": "Characters pulled successfully",
"worldInitialized": "Foundry world has been initialized with Archivist for the first time",
"worldInitializedReset": "World initialization reset. Reloading to run setup again..."
"worldInitializedReset": "World initialization reset. Reloading to run setup again...",
"journalImportNotice": "Journal content syncs with Archivist. Folder structure is a point-in-time snapshot and not kept in sync.",
"questSynced": "Quest synced with Archivist."
},
"errors": {
"fetchFailed": "Failed to fetch worlds from Archivist API",
Expand Down Expand Up @@ -319,6 +323,35 @@
"name": "Real‑Time Sync (auto‑update Archivist)",
"hint": "When enabled, changes you make in Foundry (create, edit, or delete Actors, Items, and designated Faction/Location journal pages) are immediately mirrored to Archivist. Recaps are read‑only: creating a Recaps page will not create a Game Session, and deleting a Recaps page will not delete a Game Session in Archivist."
}
},
"quest": {
"status": {
"planned": "Planned",
"inProgress": "In Progress",
"blocked": "Blocked",
"failed": "Failed",
"done": "Done"
},
"category": {
"main": "Main",
"side": "Side",
"faction": "Faction",
"personal": "Personal"
},
"objectiveStatus": {
"pending": "Pending",
"inProgress": "In Progress",
"completed": "Completed",
"failed": "Failed",
"blocked": "Blocked"
},
"fields": {
"questGiver": "Quest Giver",
"successDefinition": "Success Definition",
"failureConditions": "Failure Conditions",
"nextAction": "Next Action",
"resolution": "Resolution"
}
}
}
}
69 changes: 62 additions & 7 deletions scripts/archivist-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ Hooks.once('init', async function () {
LocationPageSheetV2,
FactionPageSheetV2,
RecapPageSheetV2,
JournalPageSheetV2,
QuestPageSheetV2,
} = await import('./modules/sheets/page-sheet-v2.js');

const DSC =
Expand Down Expand Up @@ -99,6 +101,16 @@ Hooks.once('init', async function () {
types: ['base'],
makeDefault: false,
});
DSC.registerSheet(JournalEntry, 'archivist-sync', JournalPageSheetV2, {
label: 'Archivist: Journal',
types: ['base'],
makeDefault: false,
});
DSC.registerSheet(JournalEntry, 'archivist-sync', QuestPageSheetV2, {
label: 'Archivist: Quest',
types: ['base'],
makeDefault: false,
});
} catch (e) {
console.error(
'[Archivist Sync] Failed to register V2 DocumentSheet sheets',
Expand All @@ -108,6 +120,11 @@ Hooks.once('init', async function () {
// Register the Archivist Chat tab with the core Sidebar early so it renders its
// nav button and panel using the Application V2 TabGroup. Availability will be
// handled at runtime by showing/hiding the button and panel.
// NOTE: this Sidebar.TABS registration mechanism works on v13 but was found
// to not support dynamic tab addition on v14 — do not port this removal
// back here; v14 instead injects the chat button at runtime via
// updateArchivistChatAvailability() in the ready hook, which this branch
// also has as a fallback (see below).
try {
const Sidebar = foundry.applications.sidebar?.Sidebar;
if (Sidebar) {
Expand Down Expand Up @@ -266,9 +283,7 @@ Hooks.once('ready', async function () {
try {
const sidebar = document.getElementById('sidebar');
const tabsNav = sidebar?.querySelector?.('#sidebar-tabs, nav.tabs');
if (!tabsNav) {
return;
}
if (tabsNav) {

const onClick = (ev) => {
// CRITICAL: Ignore clicks on dice roll cards or any elements inside them
Expand Down Expand Up @@ -335,6 +350,8 @@ Hooks.once('ready', async function () {
}, 0);
};
tabsNav.addEventListener('click', onClick);

} // end if (tabsNav found for delegated renderer)
} catch (e) {
console.error('[Archivist Sync] Failed to install delegated renderer', e);
}
Expand All @@ -343,9 +360,7 @@ Hooks.once('ready', async function () {
try {
const sidebar = document.getElementById('sidebar');
const tabsNav = sidebar?.querySelector?.('#sidebar-tabs, nav.tabs');
if (!tabsNav) {
return;
}
if (tabsNav) {

const onOtherTabClick = (ev) => {
// CRITICAL: Ignore clicks on dice roll cards or any elements inside them
Expand Down Expand Up @@ -410,6 +425,8 @@ Hooks.once('ready', async function () {
}, 0);
};
tabsNav.addEventListener('click', onOtherTabClick);

} // end if (tabsNav found for delegated cleanup)
} catch (e) {
console.error('[Archivist Sync] Failed to install delegated cleanup', e);
}
Expand Down Expand Up @@ -885,7 +902,12 @@ function updateArchivistChatAvailability() {
});
li.appendChild(btn);
const menu = tabsNav.querySelector('menu.flexcol') || tabsNav;
menu.appendChild(li);
const beforeNode = menu.lastElementChild;
if (beforeNode) {
menu.insertBefore(li, beforeNode);
} else {
menu.appendChild(li);
}
} catch (e) {
console.warn(
'[Archivist Sync] Failed to inject fallback Sidebar tab button',
Expand Down Expand Up @@ -1100,6 +1122,17 @@ function installRealtimeSyncListeners() {
...(image ? { image } : {}),
campaign_id: worldId,
});
} else if (sheetType === 'quest') {
res = await archivistApi.createQuest(apiKey, {
worldId,
questName: entry.name || 'Quest',
});
} else if (sheetType === 'journal') {
res = await archivistApi.createJournal(apiKey, {
world_id: worldId,
title: entry.name || 'Journal',
Comment on lines +1131 to +1133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass campaign_id when creating journals

Journal creation requires campaign_id per .cursor/rules/40-archivist-api.mdc, but the new realtime Journal path passes world_id and createJournal() only sanitizes fields without translating it. Creating a new Archivist Journal sheet with realtime sync enabled will therefore POST a payload missing the required campaign id and fail validation.

Useful? React with 👍 / 👎.

content: description,
});
}

if (res.success && res.data?.id) {
Expand Down Expand Up @@ -1219,6 +1252,7 @@ function installRealtimeSyncListeners() {
const flags = parent?.getFlag?.(CONFIG.MODULE_ID, 'archivist') || {};
const sheetType = String(flags?.sheetType || '').toLowerCase();
if (!sheetType || !flags.archivistId) return;

const html = Utils.extractPageHtml(page);
const payload = { description: Utils.toMarkdownIfHtml?.(html) || html };

Expand Down Expand Up @@ -1252,6 +1286,19 @@ function installRealtimeSyncListeners() {
summary: payload.description,
});
return;
} else if (sheetType === 'quest') {
// Quest page content is not a synced API field (quests use structured
// fields like successDefinition, not a free description), and name
// changes are handled by the updateJournalEntry title-sync hook. So a
// page-content edit has nothing to push — skip the redundant PATCH.
return;
} else if (sheetType === 'journal') {
res = await archivistApi.updateJournal(apiKey, {
id: flags.archivistId,
title: parent?.name || page.name,
content: Utils.toMarkdownIfHtml?.(html) || html,
});
return;
}

if (res && !res?.success && res?.isDescriptionTooLong) {
Expand Down Expand Up @@ -1294,6 +1341,10 @@ function installRealtimeSyncListeners() {
await archivistApi.updateLocation(apiKey, id, { name });
} else if (st === 'faction') {
await archivistApi.updateFaction(apiKey, id, { name });
} else if (st === 'quest') {
await archivistApi.updateQuest(apiKey, id, { questName: name });
} else if (st === 'journal') {
await archivistApi.updateJournal(apiKey, { id, title: name });
}
} catch (e) {
console.warn('[RTS] updateJournalEntry (title sync) failed', e);
Expand Down Expand Up @@ -1374,6 +1425,10 @@ function installRealtimeSyncListeners() {
await archivistApi.deleteLocation(apiKey, id);
} else if (st === 'faction' && archivistApi.deleteFaction) {
await archivistApi.deleteFaction(apiKey, id);
} else if (st === 'quest') {
await archivistApi.deleteQuest(apiKey, id);
} else if (st === 'journal') {
await archivistApi.deleteJournal(apiKey, id);
}
} catch (e) {
console.warn('[RTS] preDeleteJournalEntry failed', e);
Expand Down
Loading