seitime-bridge/scripts/macros.js
Vassili Minaev ed853de6a2 Push module credits at end-of-session
endSession() now pushes the world's active module list to Frappe after
the snapshot push. Each module is projected to {id, title, url,
authors[].name + url} — versions and compatibility info are intentionally
omitted so the eventual public credits page doesn't leak dependency
versions. The Frappe endpoint also strips these fields server-side.

Module inventory push is treated as non-critical: a failure logs a
warning and adds a line to the chat summary, but doesn't abort the
session-completion flow. The chat summary now reports the count of
modules tracked.

Public API gains seitimeBridge.pushModuleInventory() for manual pushes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 03:17:30 -06:00

184 lines
5.4 KiB
JavaScript

/**
* GM-facing orchestration: the End Session flow and the bulk
* snapshot push it depends on. Imported by main.js and exposed on
* `globalThis.seitimeBridge` for use from Foundry macros.
*
* Recommended macro body (Script type, no arguments):
* seitimeBridge.endSession();
*/
import { MODULE_ID } from "./constants.js";
import {
completeSession,
listScheduledSessions,
pushModuleInventory,
pushSnapshot,
} from "./api.js";
function escapeHtml(s) {
return String(s ?? "").replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
}
/**
* Push snapshots for every PC in the world, in parallel. Returns a summary
* with successful/failed counts and the names of any failures so the caller
* can surface them.
*/
export async function pushAllSnapshots() {
const pcs = game.actors.filter((a) => a.type === "character");
const results = await Promise.allSettled(pcs.map((a) => pushSnapshot(a)));
const failed = results
.map((r, i) => (r.status === "rejected" ? { name: pcs[i].name, error: r.reason?.message ?? String(r.reason) } : null))
.filter(Boolean);
return {
total: pcs.length,
successful: pcs.length - failed.length,
failed,
};
}
/**
* Show the appropriate dialog for the number of candidates and resolve to
* the chosen session_id, or null if the user cancels.
*/
async function pickSession(sessions) {
if (sessions.length === 1) {
const s = sessions[0];
const confirmed = await Dialog.confirm({
title: "End Session",
content: `
<p>End <b>${escapeHtml(s.session_title)}</b>?</p>
<p>This will mark it Completed, schedule the next session, and push snapshots for all PCs.</p>
`,
yes: () => true,
no: () => false,
defaultYes: true,
});
return confirmed ? s.session_id : null;
}
const options = sessions
.map(
(s) =>
`<option value="${escapeHtml(s.session_id)}">${escapeHtml(s.session_title)}${s.session_start ? `${escapeHtml(s.session_start)}` : ""}</option>`,
)
.join("");
return new Promise((resolve) => {
new Dialog({
title: "End Session",
content: `
<p>Multiple scheduled sessions match this Foundry world. Pick one to end:</p>
<select id="seitime-session-pick" style="width:100%; margin-bottom:0.5em;">
${options}
</select>
`,
buttons: {
cancel: { label: "Cancel", callback: () => resolve(null) },
end: {
label: "End Session",
callback: (html) => {
const value = html.find("#seitime-session-pick").val();
resolve(value || null);
},
},
},
default: "end",
close: () => resolve(null),
}).render(true);
});
}
function buildSummaryHtml(completion, snapshotResult, moduleResult) {
const parts = [
`<h3>Session Complete</h3>`,
`<p><b>${escapeHtml(completion.completed_session)}</b> marked Completed.</p>`,
];
if (completion.next_session) {
parts.push(
`<p>Next session scheduled: <b>${escapeHtml(completion.next_session)}</b> (#${escapeHtml(completion.next_session_number)}).</p>`,
);
}
parts.push(
`<p>Snapshots pushed: ${snapshotResult.successful} of ${snapshotResult.total}.</p>`,
);
if (snapshotResult.failed.length) {
const items = snapshotResult.failed
.map((f) => `<li>${escapeHtml(f.name)}: ${escapeHtml(f.error)}</li>`)
.join("");
parts.push(`<p style="color:#a00"><b>Failed pushes:</b></p><ul>${items}</ul>`);
}
if (moduleResult?.upserted) {
parts.push(
`<p>Tracked ${moduleResult.upserted} active module(s) for credits.</p>`,
);
} else if (moduleResult?.error) {
parts.push(
`<p style="color:#a00">Module inventory push failed: ${escapeHtml(moduleResult.error)}</p>`,
);
}
return parts.join("");
}
/**
* Full end-of-session flow. Invoked from a Foundry macro by the GM.
*/
export async function endSession() {
if (!game.user.isGM) {
ui.notifications.warn("End Session is GM-only.");
return;
}
let sessions;
try {
sessions = await listScheduledSessions();
} catch (err) {
console.error(`[${MODULE_ID}]`, err);
ui.notifications.error(`Could not fetch scheduled sessions: ${err.message}`);
return;
}
if (!sessions.length) {
ui.notifications.warn(
"No scheduled sessions found for this Foundry world. Check that the world ID matches a Game in Frappe.",
);
return;
}
const sessionId = await pickSession(sessions);
if (!sessionId) return;
let completion;
try {
completion = await completeSession(sessionId);
} catch (err) {
console.error(`[${MODULE_ID}]`, err);
ui.notifications.error(`Could not complete session: ${err.message}`);
return;
}
const snapshotResult = await pushAllSnapshots();
// Module inventory is non-critical — log and continue if the push fails
// so the session still gets marked Completed with a clean summary.
let moduleResult = null;
try {
const raw = await pushModuleInventory();
moduleResult = raw?.message ?? null;
} catch (err) {
console.warn(`[${MODULE_ID}] module inventory push failed:`, err);
moduleResult = { error: err.message };
}
await ChatMessage.create({
content: buildSummaryHtml(completion, snapshotResult, moduleResult),
speaker: { alias: "Seitime Bridge" },
whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id),
});
const failureNote = snapshotResult.failed.length ? ` (${snapshotResult.failed.length} snapshot push(es) failed — see chat)` : "";
ui.notifications.info(`Session ended. ${snapshotResult.successful}/${snapshotResult.total} snapshots pushed${failureNote}.`);
}