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, 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; #progress = null; #orphanFilters = { modules: true, systems: true, corePublic: true }; constructor(options = {}) { super(options); this.options.window.title = localize("KSA.AppTitle"); } async _prepareContext() { const visibleAnalysis = this.#getVisibleAnalysis(); const findings = visibleAnalysis?.findings ?? []; const groupedFindings = await enrichGroupedFindings(groupFindings(findings)); return { loading: this.#loading, hasAnalysis: !!this.#analysis, progress: this.#progress, notices: this.#analysis?.notices ?? [], moduleVersion: game.modules.get("kosmos-storage-audit")?.version ?? null, summary: this.#summarize(visibleAnalysis), groupedFindings, orphanFilters: this.#orphanFilters }; } async _renderHTML(context) { const container = document.createElement("div"); container.className = "storage-audit"; container.innerHTML = `

${localize("KSA.Hero.Title")}

${context.moduleVersion ? `v${escapeHtml(context.moduleVersion)}` : ""}

${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)} ${renderNotices(context.notices, context.loading)} ${renderSummary(context.summary, context.loading)} ${renderGroupedFindingList(context.groupedFindings, context.hasAnalysis, context.loading, context.orphanFilters)} `; return container; } _replaceHTML(result, content) { content.replaceChildren(result); for (const input of content.querySelectorAll("[data-orphan-filter]")) { input.addEventListener("change", event => { const key = event.currentTarget.dataset.orphanFilter; this.#orphanFilters = { ...this.#orphanFilters, [key]: event.currentTarget.checked }; this.render({ force: true }); }); } } 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 }); } } exportReport() { const visibleAnalysis = this.#getVisibleAnalysis(); if (!visibleAnalysis) return; const payload = { exportedAt: new Date().toISOString(), orphanFilters: this.#orphanFilters, notices: this.#analysis.notices ?? [], summary: this.#summarize(visibleAnalysis), groupedFindings: groupFindings(visibleAnalysis.findings), findings: visibleAnalysis.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 }; } #getVisibleAnalysis() { if (!this.#analysis) return null; const findings = this.#analysis.findings.filter(finding => !shouldHideOrphanFinding(finding, this.#orphanFilters)); return { ...this.#analysis, findings }; } #updateProgress(update) { this.#progress = { ...(this.#progress ?? {}), ...update }; this.render({ force: true }); } static #onRunAnalysis(_event, _button) { return this.runAnalysis(); } 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 renderNotices(notices, loading) { if (loading || !notices?.length) return ""; const items = notices.map(notice => `
  • ${renderNoticeMessage(notice)}
  • `).join(""); return `

    ${localize("KSA.Notice.Title")}

    `; } function renderNoticeMessage(notice) { const moduleLabels = Array.isArray(notice?.moduleLabels) ? notice.moduleLabels : []; if (!moduleLabels.length) return escapeHtml(notice?.message ?? ""); const plainModules = moduleLabels.join(", "); const highlightedModules = moduleLabels.map(label => `${escapeHtml(label)}`).join(", "); return escapeHtml(notice?.message ?? "").replace(escapeHtml(plainModules), highlightedModules); } function renderSummary(summary, loading) { if (loading) return ""; if (!summary) { return `

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

    `; } return `
    ${localize("KSA.Summary.Files")}${summary.files}
    ${localize("KSA.Summary.References")}${summary.references}
    ${localize("KSA.Summary.Findings")}${summary.findings}
    ${localize("KSA.Summary.Missing")}${summary.byKind["broken-reference"] ?? 0}
    ${localize("KSA.Summary.Deprecated")}${summary.byKind["non-package-to-package-reference"] ?? 0}
    ${localize("KSA.Summary.Orphaned")}${summary.byKind["orphan-file"] ?? 0}
    `; } function renderGroupedFindingList(groupedFindings, hasAnalysis, loading, orphanFilters) { if (loading || !hasAnalysis) return ""; const sections = []; if (groupedFindings.nonPackageToPackage.length) { sections.push(renderGroupedSection( localize("KSA.Section.UnanchoredPackageTargets"), localize("KSA.Section.UnanchoredPackageTargetsDesc"), renderGroupedTable(groupedFindings.nonPackageToPackage, { includeOwner: true, includeReason: true, includeSources: true }) )); } if (groupedFindings.brokenReferences.length) { sections.push(renderGroupedSection( localize("KSA.Section.BrokenTargets"), localize("KSA.Section.BrokenTargetsDesc"), renderGroupedTable(groupedFindings.brokenReferences, { includeOwner: false, includeReason: true, includeSources: true }) )); } if (groupedFindings.orphans.length) { sections.push(renderGroupedSection( localize("KSA.Section.OrphanCandidates"), localize("KSA.Section.OrphanCandidatesDesc"), renderOrphanFilters(orphanFilters), renderGroupedTable(groupedFindings.orphans, { includeOwner: false, includeReason: true, includeSources: false }) )); } if (!sections.length) { return `

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

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

    `; } return `
    ${sections.join("")}
    `; } function renderGroupedSection(title, description, prelude = "", content = "") { if (!content) { content = prelude; prelude = ""; } return `

    ${title}

    ${description}

    ${prelude}
    ${content}
    `; } function renderOrphanFilters(filters) { return `
    ${localize("KSA.Filter.OrphansLabel")} ${renderOrphanFilterToggle("modules", localize("KSA.Filter.OrphansModules"), filters.modules)} ${renderOrphanFilterToggle("systems", localize("KSA.Filter.OrphansSystems"), filters.systems)} ${renderOrphanFilterToggle("corePublic", localize("KSA.Filter.OrphansCorePublic"), filters.corePublic)}
    `; } function renderOrphanFilterToggle(key, label, checked) { return ` `; } function renderGroupedTable(groups, { includeOwner=false, includeReason=false, includeSources=false }={}) { const headers = [ `${localize("KSA.Table.Severity")}`, `${localize("KSA.Field.Target")}`, `${localize("KSA.Table.References")}` ]; if (includeOwner) headers.push(`${localize("KSA.Field.OwnerPackage")}`); if (includeSources) headers.push(`${localize("KSA.Field.Source")}`); const rows = groups.map(group => { const note = includeReason ? buildGroupedTooltip(group) : ""; const cells = [ `${severityLabel(group.severity)}`, `${escapeHtml(group.target)}`, `${group.count ?? ""}` ]; if (includeOwner) cells.push(`${escapeHtml(group.ownerLabel ?? "")}`); if (includeSources) { cells.push(`${renderGroupedSourcesCell(group.sources ?? [])}`); } return `${cells.join("")}`; }).join(""); return ` ${headers.join("")}${rows}
    `; } function buildGroupedTooltip(group) { const note = group.targetKind === "wildcard" ? `${group.shortReason ?? group.reason ?? ""} ${localize("KSA.Finding.WildcardNoMatch")}` : (group.shortReason ?? group.reason ?? ""); return note.trim(); } function renderGroupedSourcesCell(sources) { if (!sources.length) return ""; return `
    ${sources.map(source => `
    ${source.renderedSource ?? renderPlainSourceLabel(source)}
    `).join("")}
    `; } 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 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 ?? ""}`; } async function enrichGroupedFindings(groupedFindings) { return { brokenReferences: await enrichGroupedSources(groupedFindings.brokenReferences), nonPackageToPackage: await enrichGroupedSources(groupedFindings.nonPackageToPackage), orphans: groupedFindings.orphans }; } function shouldHideOrphanFinding(finding, orphanFilters) { if (finding.kind !== "orphan-file") return false; const target = finding.target?.locator ?? formatTarget(finding.target ?? {}); const [, path = ""] = String(target).split(":", 2); if (orphanFilters.modules && path.startsWith("modules/")) return true; if (orphanFilters.systems && path.startsWith("systems/")) return true; if ( orphanFilters.corePublic && ["canvas/", "cards/", "icons/", "sounds/", "toolclips/", "ui/"].some(prefix => path.startsWith(prefix)) ) return true; return false; } async function enrichGroupedSources(groups) { const enriched = []; for (const group of groups) { const sources = []; for (const source of group.sources) { sources.push({ ...source, renderedSource: await renderSourceHtml(source) }); } enriched.push({ ...group, sources }); } return enriched; } async function renderSourceHtml(source) { const markup = buildSourceMarkup(source); if (!markup) return renderPlainSourceLabel(source); return TextEditor.enrichHTML(markup); } function buildSourceMarkup(source) { const trail = Array.isArray(source.sourceTrail) && source.sourceTrail.length ? source.sourceTrail : null; if (trail?.length) { return trail.map(node => renderTrailMarkup(node)).join(" -> "); } if (!source.sourceUuid) return null; return renderTrailMarkup({ uuid: source.sourceUuid, label: source.sourceName ? `${source.sourceLabel} (${source.sourceName})` : source.sourceLabel }); } function renderTrailMarkup(node) { if (!node?.uuid) return foundry.utils.escapeHTML(node?.label ?? ""); const label = foundry.utils.escapeHTML(node.label ?? node.uuid); return `@UUID[${node.uuid}]{${label}}`; } function renderPlainSourceLabel(source) { const label = source.sourceName ? `${source.sourceLabel} (${source.sourceName})` : source.sourceLabel; return escapeHtml(label); }