export class StorageAuditReportApp extends foundry.applications.api.ApplicationV2 {
static DEFAULT_OPTIONS = {
id: "kosmos-storage-audit-report",
classes: ["kosmos-storage-audit"],
tag: "div",
actions: {
runAnalysis: StorageAuditReportApp.#onRunAnalysis,
toggleShowAll: StorageAuditReportApp.#onToggleShowAll,
toggleRaw: StorageAuditReportApp.#onToggleRaw,
exportReport: StorageAuditReportApp.#onExportReport
},
window: {
title: "Kosmos Storage Audit",
icon: "fa-solid fa-broom-ball",
resizable: true
},
position: {
width: 980,
height: 680
}
};
#analysis = null;
#loading = false;
#showAll = false;
#progress = null;
#showRaw = false;
constructor(options = {}) {
super(options);
this.options.window.title = localize("KSA.AppTitle");
}
async _prepareContext() {
const findings = this.#analysis?.findings ?? [];
const visibleFindings = this.#showAll ? findings : findings.filter(f => f.severity !== "info");
const groupedFindings = groupFindings(visibleFindings);
return {
loading: this.#loading,
hasAnalysis: !!this.#analysis,
showAll: this.#showAll,
showRaw: this.#showRaw,
progress: this.#progress,
summary: this.#summarize(this.#analysis),
groupedFindings,
findings: visibleFindings
};
}
async _renderHTML(context) {
const container = document.createElement("div");
container.className = "storage-audit";
container.innerHTML = `
${localize("KSA.Hero.Title")}
${renderLocalizedCodeText("KSA.Hero.Intro1", { dataRoot: "data", publicRoot: "public" }, ["data", "public"])}
${renderLocalizedCodeText("KSA.Hero.Intro2", { dataRoot: "data" }, ["data"])}
${escapeHtml(localize("KSA.Hero.Intro3"))}
${renderProgress(context.progress, context.loading)}
${renderSummary(context.summary, context.loading)}
${renderGroupedFindingList(context.groupedFindings, context.hasAnalysis, context.loading, context.showAll)}
${renderFindingList(context.findings, context.hasAnalysis, context.loading, context.showAll, context.showRaw)}
`;
return container;
}
_replaceHTML(result, content) {
content.replaceChildren(result);
for (const link of content.querySelectorAll(".content-link[data-uuid]")) {
link.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
void openSourceUuid(link.dataset.uuid);
});
}
}
async runAnalysis() {
this.#loading = true;
this.#progress = {
phase: "start",
label: localize("KSA.Progress.Initialize"),
files: 0,
sources: 0,
references: 0,
findings: 0,
currentSource: null
};
await this.render({ force: true });
try {
const api = game.modules.get("kosmos-storage-audit")?.api;
this.#analysis = await api.runRuntimeAnalysis({
onProgress: update => this.#updateProgress(update)
});
const count = this.#analysis.findings.length;
ui.notifications.info(format("KSA.Notify.Completed", { count }));
} catch (error) {
console.error("kosmos-storage-audit | analysis failed", error);
ui.notifications.error(format("KSA.Notify.Failed", { message: error.message }));
} finally {
this.#loading = false;
await this.render({ force: true });
}
}
toggleShowAll() {
this.#showAll = !this.#showAll;
return this.render({ force: true });
}
toggleRaw() {
this.#showRaw = !this.#showRaw;
return this.render({ force: true });
}
exportReport() {
if (!this.#analysis) return;
const payload = {
exportedAt: new Date().toISOString(),
summary: this.#summarize(this.#analysis),
groupedFindings: groupFindings(this.#analysis.findings),
findings: this.#analysis.findings.map(serializeFinding)
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = format("KSA.Export.Filename", { timestamp: Date.now() });
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
#summarize(analysis) {
if (!analysis) return null;
const bySeverity = { high: 0, warning: 0, info: 0 };
const byKind = {};
const grouped = {
brokenReferences: 0,
packageReferences: 0,
orphans: 0
};
for (const finding of analysis.findings) {
bySeverity[finding.severity] = (bySeverity[finding.severity] ?? 0) + 1;
byKind[finding.kind] = (byKind[finding.kind] ?? 0) + 1;
}
grouped.brokenReferences = new Set(
analysis.findings.filter(f => f.kind === "broken-reference").map(f => f.target.locator ?? formatTarget(f.target))
).size;
grouped.packageReferences = new Set(
analysis.findings.filter(f => f.kind === "non-package-to-package-reference").map(f => f.target.locator ?? formatTarget(f.target))
).size;
grouped.orphans = analysis.findings.filter(f => f.kind === "orphan-file").length;
return {
files: analysis.files.length,
references: analysis.references.length,
findings: analysis.findings.length,
bySeverity,
byKind,
grouped
};
}
#updateProgress(update) {
this.#progress = {
...(this.#progress ?? {}),
...update
};
this.render({ force: true });
}
static #onRunAnalysis(_event, _button) {
return this.runAnalysis();
}
static #onToggleShowAll(_event, _button) {
return this.toggleShowAll();
}
static #onToggleRaw(_event, _button) {
return this.toggleRaw();
}
static #onExportReport(_event, _button) {
return this.exportReport();
}
}
function renderProgress(progress, loading) {
if (!loading || !progress) return "";
return `
${escapeHtml(progress.label ?? localize("KSA.Progress.Analyzing"))}
${escapeHtml(format("KSA.Progress.Files", { count: progress.files ?? 0 }))}
${escapeHtml(format("KSA.Progress.Sources", { count: progress.sources ?? 0 }))}
${escapeHtml(format("KSA.Progress.References", { count: progress.references ?? 0 }))}
${progress.findings != null ? `${escapeHtml(format("KSA.Progress.Findings", { count: progress.findings }))}` : ""}
${progress.currentSource ? `${escapeHtml(format("KSA.Progress.Current", { source: progress.currentSource }))}
` : ""}
`;
}
function renderSummary(summary, loading) {
if (loading) return "";
if (!summary) {
return `
${localize("KSA.Summary.NoAnalysis")}
`;
}
const kindLines = Object.entries(summary.byKind)
.sort((a, b) => b[1] - a[1])
.map(([kind, count]) => `
${humanizeKind(kind)}${count}`)
.join("");
return `
${localize("KSA.Summary.Files")}${summary.files}
${localize("KSA.Summary.References")}${summary.references}
${localize("KSA.Summary.Findings")}${summary.findings}
${localize("KSA.Summary.High")}${summary.bySeverity.high}
${localize("KSA.Summary.Warning")}${summary.bySeverity.warning}
${localize("KSA.Summary.ByType")}
${kindLines || `- ${localize("KSA.Summary.NoFindings")}0
`}
${localize("KSA.Summary.WorkBlocks")}
- ${localize("KSA.Summary.MissingTargets")}${summary.grouped?.brokenReferences ?? 0}
- ${localize("KSA.Summary.UnanchoredPackageTargets")}${summary.grouped?.packageReferences ?? 0}
- ${localize("KSA.Summary.OrphanCandidates")}${summary.grouped?.orphans ?? 0}
`;
}
function renderGroupedFindingList(groupedFindings, hasAnalysis, loading, showAll) {
if (loading || !hasAnalysis) return "";
const sections = [];
if (groupedFindings.nonPackageToPackage.length) {
sections.push(renderGroupedSection(
localize("KSA.Section.UnanchoredPackageTargets"),
localize("KSA.Section.UnanchoredPackageTargetsDesc"),
groupedFindings.nonPackageToPackage,
group => `
${severityLabel(group.severity)}
${formatCount(group.count, "KSA.Summary.References")}
${escapeHtml(group.target)}
${escapeHtml(group.shortReason)}
- ${localize("KSA.Field.OwnerPackage")}
${escapeHtml(group.ownerLabel)}
- ${localize("KSA.Field.Assessment")}
- ${escapeHtml(group.explanation)}
${escapeHtml(group.recommendation)}
${renderSampleSources(group.sources)}
`
));
}
if (groupedFindings.brokenReferences.length) {
sections.push(renderGroupedSection(
localize("KSA.Section.BrokenTargets"),
localize("KSA.Section.BrokenTargetsDesc"),
groupedFindings.brokenReferences,
group => `
${severityLabel(group.severity)}
${formatCount(group.count, "KSA.Summary.References")}
${escapeHtml(group.target)}
${escapeHtml(group.shortReason)}
${group.targetKind === "wildcard" ? `${localize("KSA.Finding.WildcardNoMatch")}
` : ""}
${escapeHtml(group.recommendation)}
${renderSampleSources(group.sources)}
`
));
}
if (showAll && groupedFindings.orphans.length) {
sections.push(renderGroupedSection(
localize("KSA.Section.OrphanCandidates"),
localize("KSA.Section.OrphanCandidatesDesc"),
groupedFindings.orphans,
group => `
${severityLabel(group.severity)}
${humanizeKind(group.kind)}
${escapeHtml(group.target)}
${escapeHtml(group.reason)}
${escapeHtml(group.recommendation)}
`
));
}
if (!sections.length) {
return `
`;
}
return ``;
}
function renderGroupedSection(title, description, groups, renderGroup) {
const items = groups.map(renderGroup).join("");
return `
`;
}
function renderFindingList(findings, hasAnalysis, loading, showAll, showRaw) {
if (loading) {
return `${localize("KSA.Section.Running")}
`;
}
if (!hasAnalysis) {
return `${localize("KSA.Section.NoPrompt")}
`;
}
if (!showRaw) {
return "";
}
if (!findings.length) {
return `${format("KSA.Section.NoRaw", { scope: localize(showAll ? "KSA.Scope.Empty" : "KSA.Scope.Warning") })}
`;
}
const items = findings.map(finding => `
${severityLabel(finding.severity)}
${humanizeKind(finding.kind)}
${escapeHtml(finding.reason)}
- ${localize("KSA.Field.Target")}
${escapeHtml(finding.target.locator ?? `${finding.target.storage}:${finding.target.path}`)}
${finding.source ? `- ${localize("KSA.Field.Source")}
- ${renderSourceLink(finding.source)}
` : ""}
${escapeHtml(finding.recommendation)}
`).join("");
return `
${localize("KSA.Section.RawEntries")}
${items}
`;
}
function renderSampleSources(sources) {
if (!sources.length) return "";
const rows = sources.map(source => `${renderSourceLink(source)}`).join("");
return `${localize("KSA.Section.Samples")} `;
}
function groupFindings(findings) {
return {
brokenReferences: groupByTarget(findings.filter(f => f.kind === "broken-reference")),
nonPackageToPackage: groupByTarget(findings.filter(f => f.kind === "non-package-to-package-reference")),
orphans: findings
.filter(f => f.kind === "orphan-file")
.sort(compareFindings)
.map(finding => ({
kind: finding.kind,
severity: finding.severity,
target: finding.target.locator ?? formatTarget(finding.target),
reason: finding.reason,
recommendation: finding.recommendation
}))
};
}
function groupByTarget(findings) {
const grouped = new Map();
for (const finding of findings) {
const target = finding.target.locator ?? formatTarget(finding.target);
const ownerLabel = deriveOwnerLabel(target);
const current = grouped.get(target) ?? {
kind: finding.kind,
severity: finding.severity,
target,
count: 0,
ownerLabel,
explanation: localize("KSA.Finding.PackageExplanation"),
shortReason: shortenReason(finding.reason),
reason: finding.reason,
recommendation: finding.recommendation,
targetKind: finding.target.targetKind ?? "local-file",
sources: new Map()
};
current.count += 1;
if (finding.severity === "high") current.severity = "high";
if (finding.source?.sourceLabel) {
const key = buildSourceKey(finding.source);
current.sources.set(key, {
sourceLabel: finding.source.sourceLabel,
sourceName: finding.source.sourceName ?? null,
sourceUuid: finding.source.sourceUuid ?? null,
sourceTrail: finding.source.sourceTrail ?? null
});
}
grouped.set(target, current);
}
return [...grouped.values()]
.map(group => ({ ...group, sources: [...group.sources.values()] }))
.sort((a, b) => compareSeverity(a.severity, b.severity) || (b.count - a.count) || a.target.localeCompare(b.target));
}
function shortenReason(reason) {
return String(reason ?? "")
.replace(/^world:[^ ]+ references /, "")
.replace(/^Referenced file /, "")
.replace(/^Wildcard reference /, "");
}
function deriveOwnerLabel(target) {
const [, path] = String(target).split(":", 2);
const parts = (path ?? "").split("/");
if ((parts[0] === "modules") || (parts[0] === "systems")) {
return `${parts[0]}:${parts[1] ?? "unknown"}`;
}
return localize("KSA.Owner.Unknown");
}
function compareFindings(a, b) {
return compareSeverity(a.severity, b.severity)
|| formatTarget(a.target).localeCompare(formatTarget(b.target));
}
function compareSeverity(a, b) {
const order = { high: 0, warning: 1, info: 2 };
return (order[a] ?? 9) - (order[b] ?? 9);
}
function formatTarget(target) {
return target.locator ?? `${target.storage}:${target.path}`;
}
function humanizeKind(kind) {
const key = `KSA.FindingKind.${kind}`;
const localized = localize(key);
return localized === key ? kind : localized;
}
function serializeFinding(finding) {
return {
kind: finding.kind,
severity: finding.severity,
reason: finding.reason,
recommendation: finding.recommendation,
confidence: finding.confidence,
target: finding.target,
source: finding.source
? {
sourceType: finding.source.sourceType,
sourceLabel: finding.source.sourceLabel,
sourceName: finding.source.sourceName,
sourceUuid: finding.source.sourceUuid,
sourceTrail: finding.source.sourceTrail,
sourceScope: finding.source.sourceScope,
rawValue: finding.source.rawValue,
normalized: finding.source.normalized
}
: null
};
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/\"/g, """);
}
function localize(key) {
return game.i18n?.localize(key) ?? key;
}
function format(key, data) {
return game.i18n?.format(key, data) ?? key;
}
function severityLabel(severity) {
const key = `KSA.Severity.${severity}`;
const localized = localize(key);
return localized === key ? severity : localized;
}
function formatCount(count, nounKey) {
return `${count} ${localize(nounKey)}`;
}
function renderLocalizedCodeText(key, data, codeValues) {
let text = format(key, data);
for (const value of codeValues) {
text = text.replaceAll(value, `@@CODE:${value}@@`);
}
return escapeHtml(text).replace(/@@CODE:([^@]+)@@/g, "$1");
}
function renderSourceLink(source) {
const trail = Array.isArray(source.sourceTrail) && source.sourceTrail.length ? source.sourceTrail : null;
if (trail) {
return `${trail.map(renderTrailNode).join('->')}`;
}
const label = source.sourceName ? `${source.sourceLabel} (${source.sourceName})` : source.sourceLabel;
if (!source.sourceUuid) return escapeHtml(label);
return renderUuidLink(source.sourceUuid, label);
}
async function openSourceUuid(uuid) {
if (!uuid) return;
const document = await fromUuid(uuid);
if (document?.sheet) {
document.sheet.render(true, { focus: true });
return;
}
if (document?.parent?.sheet) {
document.parent.sheet.render(true, { focus: true });
return;
}
ui.notifications.warn(format("KSA.Notify.OpenSourceFailed", { uuid }));
}
function renderTrailNode(node) {
if (!node?.uuid) return escapeHtml(node?.label ?? "");
return renderUuidLink(node.uuid, node.label ?? node.uuid);
}
function renderUuidLink(uuid, label) {
return `${escapeHtml(label)}`;
}
function buildSourceKey(source) {
const trailKey = Array.isArray(source.sourceTrail) && source.sourceTrail.length
? source.sourceTrail.map(node => node.uuid ?? node.label ?? "").join(">")
: "";
return `${source.sourceUuid ?? ""}|${trailKey}|${source.sourceLabel ?? ""}`;
}