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; const currentWidth = this.position?.width ?? 0; if (currentWidth < 980) this.setPosition({ width: 980 }); 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")}

    ${localize("KSA.Summary.WorkBlocks")}

    `; } 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 `

    ${localize("KSA.Section.WorkView")}

    ${format("KSA.Section.NoGrouped", { scope: localize(showAll ? "KSA.Scope.Empty" : "KSA.Scope.Warning") })}

    `; } return `
    ${sections.join("")}
    `; } function renderGroupedSection(title, description, groups, renderGroup) { const items = groups.map(renderGroup).join(""); return `

    ${title}

    ${description}

    ${items}
    `; } 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 = finding.source.sourceUuid ?? finding.source.sourceLabel; 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)}`; }