622 lines
21 KiB
JavaScript
622 lines
21 KiB
JavaScript
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 = `
|
|
<section class="storage-audit__hero">
|
|
<div>
|
|
<div class="storage-audit__title-row">
|
|
<h2>${localize("KSA.Hero.Title")}</h2>
|
|
${context.moduleVersion ? `<span class="storage-audit__version">v${escapeHtml(context.moduleVersion)}</span>` : ""}
|
|
</div>
|
|
<p>${renderLocalizedCodeText("KSA.Hero.Intro1", { dataRoot: "data", publicRoot: "public" }, ["data", "public"])}</p>
|
|
<p>${renderLocalizedCodeText("KSA.Hero.Intro2", { dataRoot: "data" }, ["data"])}</p>
|
|
<p>${escapeHtml(localize("KSA.Hero.Intro3"))}</p>
|
|
</div>
|
|
<div class="storage-audit__actions">
|
|
<button type="button" class="button" data-action="runAnalysis" ${context.loading ? "disabled" : ""}>
|
|
<i class="fa-solid fa-magnifying-glass"></i>
|
|
<span>${context.loading ? localize("KSA.Action.Running") : localize("KSA.Action.Run")}</span>
|
|
</button>
|
|
<button type="button" class="button" data-action="exportReport" ${context.hasAnalysis ? "" : "disabled"}>
|
|
<i class="fa-solid fa-file-export"></i>
|
|
<span>${localize("KSA.Action.Export")}</span>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
${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 `
|
|
<section class="storage-audit__progress">
|
|
<div class="storage-audit__progress-bar">
|
|
<div class="storage-audit__progress-fill phase-${escapeHtml(progress.phase ?? "start")}"></div>
|
|
</div>
|
|
<div class="storage-audit__progress-meta">
|
|
<strong>${escapeHtml(progress.label ?? localize("KSA.Progress.Analyzing"))}</strong>
|
|
<span>${escapeHtml(format("KSA.Progress.Files", { count: progress.files ?? 0 }))}</span>
|
|
<span>${escapeHtml(format("KSA.Progress.Sources", { count: progress.sources ?? 0 }))}</span>
|
|
<span>${escapeHtml(format("KSA.Progress.References", { count: progress.references ?? 0 }))}</span>
|
|
${progress.findings != null ? `<span>${escapeHtml(format("KSA.Progress.Findings", { count: progress.findings }))}</span>` : ""}
|
|
</div>
|
|
${progress.currentSource ? `<p class="storage-audit__progress-source">${escapeHtml(format("KSA.Progress.Current", { source: progress.currentSource }))}</p>` : ""}
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderNotices(notices, loading) {
|
|
if (loading || !notices?.length) return "";
|
|
const items = notices.map(notice => `<li>${renderNoticeMessage(notice)}</li>`).join("");
|
|
return `
|
|
<section class="storage-audit__summary storage-audit__summary--notices">
|
|
<h3>${localize("KSA.Notice.Title")}</h3>
|
|
<ul class="storage-audit__notice-list">${items}</ul>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
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 => `<strong>${escapeHtml(label)}</strong>`).join(", ");
|
|
return escapeHtml(notice?.message ?? "").replace(escapeHtml(plainModules), highlightedModules);
|
|
}
|
|
|
|
function renderSummary(summary, loading) {
|
|
if (loading) return "";
|
|
if (!summary) {
|
|
return `
|
|
<section class="storage-audit__summary">
|
|
<p>${localize("KSA.Summary.NoAnalysis")}</p>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<section class="storage-audit__summary">
|
|
<div class="storage-audit__stats">
|
|
<article><span>${localize("KSA.Summary.Files")}</span><strong>${summary.files}</strong></article>
|
|
<article><span>${localize("KSA.Summary.References")}</span><strong>${summary.references}</strong></article>
|
|
<article><span>${localize("KSA.Summary.Findings")}</span><strong>${summary.findings}</strong></article>
|
|
<article><span>${localize("KSA.Summary.Missing")}</span><strong>${summary.byKind["broken-reference"] ?? 0}</strong></article>
|
|
<article><span>${localize("KSA.Summary.Deprecated")}</span><strong>${summary.byKind["non-package-to-package-reference"] ?? 0}</strong></article>
|
|
<article><span>${localize("KSA.Summary.Orphaned")}</span><strong>${summary.byKind["orphan-file"] ?? 0}</strong></article>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<section class="storage-audit__grouped">
|
|
<div class="storage-audit__group-header">
|
|
<h3>${localize("KSA.Section.WorkView")}</h3>
|
|
<p>${localize("KSA.Section.NoGrouped")}</p>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
return `<section class="storage-audit__grouped">${sections.join("")}</section>`;
|
|
}
|
|
|
|
function renderGroupedSection(title, description, prelude = "", content = "") {
|
|
if (!content) {
|
|
content = prelude;
|
|
prelude = "";
|
|
}
|
|
return `
|
|
<section class="storage-audit__group">
|
|
<div class="storage-audit__group-header">
|
|
<h3>${title}</h3>
|
|
<p>${description}</p>
|
|
</div>
|
|
${prelude}
|
|
<div class="storage-audit__table-wrap">${content}</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderOrphanFilters(filters) {
|
|
return `
|
|
<div class="storage-audit__orphan-filters">
|
|
<span class="storage-audit__orphan-filters-label">${localize("KSA.Filter.OrphansLabel")}</span>
|
|
${renderOrphanFilterToggle("modules", localize("KSA.Filter.OrphansModules"), filters.modules)}
|
|
${renderOrphanFilterToggle("systems", localize("KSA.Filter.OrphansSystems"), filters.systems)}
|
|
${renderOrphanFilterToggle("corePublic", localize("KSA.Filter.OrphansCorePublic"), filters.corePublic)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderOrphanFilterToggle(key, label, checked) {
|
|
return `
|
|
<label class="storage-audit__checkbox">
|
|
<input type="checkbox" data-orphan-filter="${escapeHtml(key)}" ${checked ? "checked" : ""}>
|
|
<span>${escapeHtml(label)}</span>
|
|
</label>
|
|
`;
|
|
}
|
|
|
|
function renderGroupedTable(groups, { includeOwner=false, includeReason=false, includeSources=false }={}) {
|
|
const headers = [
|
|
`<th>${localize("KSA.Table.Severity")}</th>`,
|
|
`<th>${localize("KSA.Field.Target")}</th>`,
|
|
`<th>${localize("KSA.Table.References")}</th>`
|
|
];
|
|
if (includeOwner) headers.push(`<th>${localize("KSA.Field.OwnerPackage")}</th>`);
|
|
if (includeSources) headers.push(`<th>${localize("KSA.Field.Source")}</th>`);
|
|
|
|
const rows = groups.map(group => {
|
|
const note = includeReason
|
|
? buildGroupedTooltip(group)
|
|
: "";
|
|
const cells = [
|
|
`<td><span class="storage-audit__severity storage-audit__severity--inline storage-audit__severity--hint severity-${group.severity}" ${note ? `title="${escapeHtml(note)}"` : ""}>${severityLabel(group.severity)}</span></td>`,
|
|
`<td><code>${escapeHtml(group.target)}</code></td>`,
|
|
`<td>${group.count ?? ""}</td>`
|
|
];
|
|
if (includeOwner) cells.push(`<td><code>${escapeHtml(group.ownerLabel ?? "")}</code></td>`);
|
|
if (includeSources) {
|
|
cells.push(`<td>${renderGroupedSourcesCell(group.sources ?? [])}</td>`);
|
|
}
|
|
return `<tr>${cells.join("")}</tr>`;
|
|
}).join("");
|
|
|
|
return `
|
|
<table class="storage-audit__table">
|
|
<thead><tr>${headers.join("")}</tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
`;
|
|
}
|
|
|
|
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 `<div class="storage-audit__source-list">${sources.map(source => `<div>${source.renderedSource ?? renderPlainSourceLabel(source)}</div>`).join("")}</div>`;
|
|
}
|
|
|
|
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, ">")
|
|
.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, "<code>$1</code>");
|
|
}
|
|
|
|
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/", "docs/", "icons/", "nue/", "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);
|
|
}
|