diff --git a/module.json b/module.json index 2984a63..ec6ca33 100644 --- a/module.json +++ b/module.json @@ -2,7 +2,7 @@ "id": "kosmos-storage-audit", "title": "Kosmos Storage Audit", "description": "Analyzes media references and risky storage locations across Foundry data and public roots.", - "version": "0.0.8", + "version": "0.0.9", "compatibility": { "minimum": "13", "verified": "13" diff --git a/scripts/adapters/foundry-runtime.js b/scripts/adapters/foundry-runtime.js index 2292c77..726d686 100644 --- a/scripts/adapters/foundry-runtime.js +++ b/scripts/adapters/foundry-runtime.js @@ -41,6 +41,7 @@ function worldCollectionEntries() { sourceLabel: `${doc.documentName ?? "Document"} ${doc.id}`, sourceName: doc.name ?? null, sourceUuid: doc.uuid ?? null, + resolveSourceTrail: candidatePath => resolveSourceTrail(doc, candidatePath), value: doc.toObject ? doc.toObject() : doc }); } @@ -95,6 +96,7 @@ async function* packagePackEntries(onProgress = null) { sourceLabel: `${pack.collection} ${document.id}`, sourceName: document.name ?? null, sourceUuid: document.uuid ?? null, + resolveSourceTrail: candidatePath => resolveSourceTrail(document, candidatePath), value: document.toObject ? document.toObject() : document }; } @@ -119,3 +121,73 @@ export async function runRuntimeAnalysis({ onProgress }={}) { function format(key, data) { return game.i18n?.format(key, data) ?? key; } + +function resolveSourceTrail(document, candidatePath = []) { + const trail = [createTrailNode(document.uuid, document.name ?? `${document.documentName ?? "Document"} ${document.id}`)]; + const path = Array.isArray(candidatePath) ? candidatePath : []; + if (!path.length) return trail; + + if (document.documentName === "Actor") { + const itemNode = resolveActorItemTrail(document, path); + if (itemNode) trail.push(itemNode); + return trail; + } + + if (document.documentName === "Scene") { + const sceneTrail = resolveSceneTrail(document, path); + if (sceneTrail.length) trail.push(...sceneTrail); + } + + return trail; +} + +function resolveActorItemTrail(actor, path) { + if ((path[0] !== "items") || !Number.isInteger(path[1])) return null; + const itemData = actor.items?.contents?.[path[1]] ?? actor.toObject?.().items?.[path[1]] ?? null; + const itemId = itemData?.id ?? itemData?._id ?? null; + if (!itemId) return null; + const itemName = itemData?.name ?? `Item ${itemId}`; + return createTrailNode(`${actor.uuid}.Item.${itemId}`, itemName); +} + +function resolveSceneTrail(scene, path) { + if ((path[0] !== "tokens") || !Number.isInteger(path[1])) return []; + const token = scene.tokens?.contents?.[path[1]] ?? scene.toObject?.().tokens?.[path[1]] ?? null; + const actorId = token?.actorId ?? token?.actor?.id ?? null; + const actor = actorId ? game.actors?.get(actorId) ?? null : null; + if (!actor) return []; + + const trail = [createTrailNode(actor.uuid, actor.name ?? `Actor ${actor.id}`)]; + + const itemPath = extractTokenItemPath(path); + if (!itemPath) return trail; + + const tokenData = scene.toObject?.().tokens?.[path[1]] ?? null; + const itemData = resolveTokenItemData(tokenData, itemPath.itemIndex); + const itemId = itemData?._id ?? itemData?.id ?? null; + if (!itemId) return trail; + + trail.push(createTrailNode(`${actor.uuid}.Item.${itemId}`, itemData?.name ?? `Item ${itemId}`)); + return trail; +} + +function extractTokenItemPath(path) { + if ((path[2] === "actorData") && (path[3] === "items") && Number.isInteger(path[4])) { + return { itemIndex: path[4] }; + } + if ((path[2] === "delta") && (path[3] === "items") && Number.isInteger(path[4])) { + return { itemIndex: path[4] }; + } + return null; +} + +function resolveTokenItemData(tokenData, itemIndex) { + if (!tokenData || !Number.isInteger(itemIndex)) return null; + if (Array.isArray(tokenData.actorData?.items)) return tokenData.actorData.items[itemIndex] ?? null; + if (Array.isArray(tokenData.delta?.items)) return tokenData.delta.items[itemIndex] ?? null; + return null; +} + +function createTrailNode(uuid, label) { + return { uuid, label }; +} diff --git a/scripts/apps/audit-report-app.js b/scripts/apps/audit-report-app.js index 2fa58aa..1c40caf 100644 --- a/scripts/apps/audit-report-app.js +++ b/scripts/apps/audit-report-app.js @@ -78,7 +78,7 @@ export class StorageAuditReportApp extends foundry.applications.api.ApplicationV ${renderProgress(context.progress, context.loading)} - ${renderSummary(context.summary)} + ${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)} `; @@ -228,7 +228,8 @@ function renderProgress(progress, loading) { `; } -function renderSummary(summary) { +function renderSummary(summary, loading) { + if (loading) return ""; if (!summary) { return `
@@ -447,7 +448,8 @@ function groupByTarget(findings) { current.sources.set(key, { sourceLabel: finding.source.sourceLabel, sourceName: finding.source.sourceName ?? null, - sourceUuid: finding.source.sourceUuid ?? null + sourceUuid: finding.source.sourceUuid ?? null, + sourceTrail: finding.source.sourceTrail ?? null }); } grouped.set(target, current); @@ -507,6 +509,7 @@ function serializeFinding(finding) { 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 @@ -550,9 +553,14 @@ function renderLocalizedCodeText(key, data, codeValues) { } 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 `${escapeHtml(source.sourceUuid)}${label ? ` ${escapeHtml(label)}` : ""}`; + return renderUuidLink(source.sourceUuid, label); } async function openSourceUuid(uuid) { @@ -568,3 +576,12 @@ async function openSourceUuid(uuid) { } 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)}`; +} diff --git a/scripts/core/reference-extractor.js b/scripts/core/reference-extractor.js index 4edcb73..4c3e399 100644 --- a/scripts/core/reference-extractor.js +++ b/scripts/core/reference-extractor.js @@ -5,23 +5,23 @@ const ATTRIBUTE_PATTERNS = [ /url\(\s*["']?([^"')]+)["']?\s*\)/gi ]; -export function collectStringCandidates(value, visit) { +export function collectStringCandidates(value, visit, path = []) { if (typeof value === "string") { - visit(value); + visit(value, path); return; } if (Array.isArray(value)) { - for (const entry of value) collectStringCandidates(entry, visit); + for (const [index, entry] of value.entries()) collectStringCandidates(entry, visit, [...path, index]); return; } if ((value !== null) && (typeof value === "object")) { - for (const entry of Object.values(value)) collectStringCandidates(entry, visit); + for (const [key, entry] of Object.entries(value)) collectStringCandidates(entry, visit, [...path, key]); } } export function extractReferencesFromValue(value, source) { const references = []; - collectStringCandidates(value, candidate => { + collectStringCandidates(value, (candidate, candidatePath) => { const direct = resolveReference(candidate, source); if (direct && isMediaPath(direct.path)) { references.push({ @@ -30,6 +30,7 @@ export function extractReferencesFromValue(value, source) { sourceLabel: source.sourceLabel, sourceName: source.sourceName, sourceUuid: source.sourceUuid, + sourceTrail: source.resolveSourceTrail?.(candidatePath) ?? null, rawValue: candidate, normalized: { ...direct, @@ -50,6 +51,7 @@ export function extractReferencesFromValue(value, source) { sourceLabel: source.sourceLabel, sourceName: source.sourceName, sourceUuid: source.sourceUuid, + sourceTrail: source.resolveSourceTrail?.(candidatePath) ?? null, rawValue: match[1], normalized: { ...nested, diff --git a/styles/audit.css b/styles/audit.css index 186ea46..173b98c 100644 --- a/styles/audit.css +++ b/styles/audit.css @@ -270,6 +270,16 @@ padding-left: 1.25rem; } +.storage-audit__samples li, +.storage-audit__finding dd { + overflow-wrap: anywhere; +} + +.storage-audit__trail-separator { + opacity: 0.65; + margin: 0 0.25rem; +} + @media (max-width: 900px) { .storage-audit__hero { flex-direction: column;