Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ Use the Campaign panel's Restore Revision option to review and restore available
### Interface Notes

- GM side panels and the Token Library drawer can be resized and collapsed.
- Campaign maintenance includes asset cleanup with a dry-run preview for unreferenced assets, orphaned campaign asset files, and stale thumbnail references.
- Layer settings are collapsible. Map, Grid, Fog of War, and Tokens expose controls only when relevant.
- Empty Campaign, Scenes, Layers, and Token Library areas show contextual helper text.
- Fog and Grid color controls open a modal picker with native color selection and reusable swatches.
Expand Down
1 change: 1 addition & 0 deletions docs/release-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ Before packaging or sharing a build, run through these workflows:
- Use Table Display Setup to choose a Player View display, preview no-grid/square/hex/physical test patterns, set the scene grid, import or fit a map, and preview Player View.
- Replace a scene map asset from the Map layer and confirm dimension warnings are understandable.
- Run Regenerate Thumbnails from the Campaign panel and confirm map and token previews are rebuilt.
- Run Asset Cleanup from the Campaign panel, review the dry-run preview, and confirm only unreferenced assets or orphaned campaign asset files are selected for deletion.
- Open Restore Revision from the Campaign panel, review available metadata backups, and confirm Open Backups Folder still opens Explorer.
- Use Player View Setup to save, rename, switch, and delete a display profile; confirm saved display/fullscreen and physical table scale still apply on at least one manual-grid scene.
- Use Advanced Map Calibration on at least one static image map with a drawn calibration area or printed-grid alignment case.
Expand Down
149 changes: 149 additions & 0 deletions electron/assetCleanupPlanning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import type {
Asset,
AssetCleanupPreviewAsset,
AssetCleanupPreviewOrphanedFile,
Campaign
} from "../src/shared/localvtt.js";
import type { CampaignHealthReport } from "../src/shared/campaignHealth.js";
import { getAssetFileRemovalPaths, requireCampaignRelativePath } from "./assetFiles.js";
import { assertInsidePath } from "./campaignPathSafety.js";
import { unlinkIfExists } from "./fileOperations.js";

export interface AssetCleanupPlan {
unreferencedAssets: AssetCleanupPreviewAsset[];
staleThumbnailReferences: Array<{
assetId: string;
assetName: string;
relativePath: string;
}>;
orphanedFiles: AssetCleanupPreviewOrphanedFile[];
retainedAssetCount: number;
totalFilesToRemove: number;
totalBytesToRemove: number;
}

export type RemoveCleanupFile = (filePath: string) => Promise<void>;

const CLEANUP_ASSET_FOLDERS: Array<{ relativePath: string; kind: Asset["kind"] | "thumbnail" }> = [
{ relativePath: "assets/maps", kind: "map" },
{ relativePath: "assets/tokens", kind: "token" },
{ relativePath: "assets/thumbnails", kind: "thumbnail" }
];

export async function createAssetCleanupPlan(campaignPath: string, campaign: Campaign, health: CampaignHealthReport): Promise<AssetCleanupPlan> {
const unreferencedAssetIds = new Set(health.unreferencedAssets.map((asset) => asset.assetId));
const unreferencedAssets = campaign.assets
.filter((asset) => unreferencedAssetIds.has(asset.id))
.map((asset) => toCleanupAsset(campaignPath, asset));
const orphanedFiles = await findOrphanedAssetFiles(campaignPath, campaign);
const totalFilesToRemove =
new Set([
...unreferencedAssets.flatMap((asset) => asset.fileRelativePaths),
...orphanedFiles.map((file) => file.relativePath)
]).size;

return {
unreferencedAssets,
staleThumbnailReferences: health.staleThumbnailReferences.map((reference) => ({
assetId: reference.assetId,
assetName: reference.assetName,
relativePath: reference.relativePath
})),
orphanedFiles,
retainedAssetCount: campaign.assets.length - unreferencedAssets.length,
totalFilesToRemove,
totalBytesToRemove: orphanedFiles.reduce((total, file) => total + file.sizeBytes, 0)
};
}

export async function removeOrphanedAssetFiles(
campaignPath: string,
orphanedFiles: readonly AssetCleanupPreviewOrphanedFile[],
removeFile: RemoveCleanupFile = unlinkIfExists
): Promise<{ removed: number; failed: Array<{ relativePath: string; reason: string }> }> {
let removed = 0;
const failed: Array<{ relativePath: string; reason: string }> = [];
for (const file of orphanedFiles) {
try {
const absolutePath = requireCampaignRelativePath(campaignPath, file.relativePath);
assertInsideCleanupFolder(campaignPath, absolutePath);
await removeFile(absolutePath);
removed += 1;
} catch (caught) {
failed.push({
relativePath: file.relativePath,
reason: caught instanceof Error ? caught.message : "Orphaned file could not be removed."
});
}
}
return { removed, failed };
}

async function findOrphanedAssetFiles(campaignPath: string, campaign: Campaign): Promise<AssetCleanupPreviewOrphanedFile[]> {
const referencedRelativePaths = new Set(
campaign.assets
.flatMap((asset) => [asset.relativePath, asset.thumbnailRelativePath])
.filter((candidate): candidate is string => Boolean(candidate))
.map(normalizeRelativePath)
);
const files: AssetCleanupPreviewOrphanedFile[] = [];
for (const folder of CLEANUP_ASSET_FOLDERS) {
const folderPath = requireCampaignRelativePath(campaignPath, folder.relativePath);
for (const absolutePath of await listFilesIfPresent(folderPath)) {
assertInsideCleanupFolder(campaignPath, absolutePath);
const relativePath = normalizeRelativePath(path.relative(campaignPath, absolutePath));
if (referencedRelativePaths.has(relativePath)) {
continue;
}
const stats = await stat(absolutePath);
files.push({ relativePath, kind: folder.kind, sizeBytes: stats.size });
}
}
return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
}

async function listFilesIfPresent(folderPath: string): Promise<string[]> {
let entries;
try {
entries = await readdir(folderPath, { withFileTypes: true });
} catch {
return [];
}
const files: string[] = [];
for (const entry of entries) {
const entryPath = path.join(folderPath, entry.name);
if (entry.isDirectory()) {
files.push(...await listFilesIfPresent(entryPath));
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
}

function toCleanupAsset(campaignPath: string, asset: Asset): AssetCleanupPreviewAsset {
return {
assetId: asset.id,
assetName: asset.name,
kind: asset.kind,
relativePath: asset.relativePath,
fileRelativePaths: getAssetFileRemovalPaths(campaignPath, asset).map((assetPath) => normalizeRelativePath(path.relative(campaignPath, assetPath))),
usageCount: 0
};
}

function assertInsideCleanupFolder(campaignPath: string, absolutePath: string): void {
assertInsidePath(campaignPath, absolutePath);
if (!CLEANUP_ASSET_FOLDERS.some((folder) => {
const folderPath = requireCampaignRelativePath(campaignPath, folder.relativePath);
return absolutePath === folderPath || absolutePath.startsWith(`${folderPath}${path.sep}`);
})) {
throw new Error("Cleanup file is outside campaign asset folders.");
}
}

function normalizeRelativePath(relativePath: string): string {
return relativePath.replaceAll(path.sep, "/");
}
10 changes: 9 additions & 1 deletion electron/assetMaintenanceIpc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { IpcMain, IpcMainInvokeEvent, WebContents } from "electron";
import type {
AssetCleanupPreviewResult,
AssetCleanupResult,
AssetPruneResult,
ThumbnailRegenerationProgress,
ThumbnailRegenerationResult,
Expand All @@ -8,8 +10,9 @@ import type {

export interface RegisterAssetMaintenanceIpcOptions {
assertKnownCampaignPath: (campaignPath: string) => void;
previewCampaignAssetCleanup: (campaignPath: string) => Promise<AssetCleanupPreviewResult>;
promoteCampaignTokenAssets: (campaignPath: string) => Promise<TokenAssetPromotionResult>;
pruneCampaignUnreferencedAssets: (campaignPath: string) => Promise<AssetPruneResult>;
pruneCampaignUnreferencedAssets: (campaignPath: string) => Promise<AssetCleanupResult | AssetPruneResult>;
regenerateCampaignThumbnails: (
campaignPath: string,
onProgress?: (progress: ThumbnailRegenerationProgress) => void,
Expand All @@ -30,6 +33,11 @@ export function registerAssetMaintenanceIpc(ipcMain: Pick<IpcMain, "handle">, op
return options.promoteCampaignTokenAssets(campaignPath);
});

ipcMain.handle("asset:previewCleanup", async (_event: IpcMainInvokeEvent, campaignPath: string) => {
options.assertKnownCampaignPath(campaignPath);
return options.previewCampaignAssetCleanup(campaignPath);
});

ipcMain.handle("asset:pruneUnreferencedAssets", async (_event: IpcMainInvokeEvent, campaignPath: string) => {
options.assertKnownCampaignPath(campaignPath);
return options.pruneCampaignUnreferencedAssets(campaignPath);
Expand Down
32 changes: 28 additions & 4 deletions electron/assetMaintenanceServices.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { WebContents } from "electron";
import type {
AssetPruneResult,
AssetCleanupPreviewResult,
AssetCleanupResult,
Campaign,
CampaignSummary,
ThumbnailRegenerationProgress,
ThumbnailRegenerationResult,
TokenAssetPromotionResult
} from "../src/shared/localvtt.js";
import { requireCampaignRelativePath } from "./assetFiles.js";
import { createAssetCleanupPlan, removeOrphanedAssetFiles } from "./assetCleanupPlanning.js";
import { inspectCampaignHealth } from "./campaignHealth.js";
import { unlinkIfExists } from "./fileOperations.js";
import type { MapThumbnailResult } from "./mapThumbnailRepair.js";
Expand All @@ -17,8 +19,9 @@ import { promoteTokenAssetThumbnails } from "./tokenAssetPromotion.js";
import { pruneUnreferencedAssets } from "./unreferencedAssetPruning.js";

export interface AssetMaintenanceServices {
previewCampaignAssetCleanup: (campaignPath: string) => Promise<AssetCleanupPreviewResult>;
promoteCampaignTokenAssets: (campaignPath: string) => Promise<TokenAssetPromotionResult>;
pruneCampaignUnreferencedAssets: (campaignPath: string) => Promise<AssetPruneResult>;
pruneCampaignUnreferencedAssets: (campaignPath: string) => Promise<AssetCleanupResult>;
regenerateCampaignThumbnails: (
campaignPath: string,
onProgress?: (progress: ThumbnailRegenerationProgress) => void,
Expand All @@ -30,7 +33,9 @@ export interface CreateAssetMaintenanceServicesOptions {
createMapThumbnail: (campaignPath: string, sourcePath: string, assetId: string, rendererWebContents?: WebContents) => Promise<MapThumbnailResult>;
createTokenThumbnail: (campaignPath: string, sourcePath: string, assetId: string) => Promise<MapThumbnailResult>;
inspectCampaignHealth?: typeof inspectCampaignHealth;
createAssetCleanupPlan?: typeof createAssetCleanupPlan;
loadCampaignFromPath: (campaignPath: string) => Promise<CampaignSummary>;
removeOrphanedAssetFiles?: typeof removeOrphanedAssetFiles;
promoteTokenAssetThumbnails?: typeof promoteTokenAssetThumbnails;
pruneUnreferencedAssets?: typeof pruneUnreferencedAssets;
regenerateThumbnailAssets?: typeof regenerateThumbnailAssets;
Expand All @@ -43,17 +48,32 @@ export interface CreateAssetMaintenanceServicesOptions {
export function createAssetMaintenanceServices({
createMapThumbnail,
createTokenThumbnail,
createAssetCleanupPlan: createCleanupPlan = createAssetCleanupPlan,
inspectCampaignHealth: inspectHealth = inspectCampaignHealth,
loadCampaignFromPath,
promoteTokenAssetThumbnails: promoteTokens = promoteTokenAssetThumbnails,
pruneUnreferencedAssets: pruneAssets = pruneUnreferencedAssets,
regenerateThumbnailAssets: regenerateThumbnails = regenerateThumbnailAssets,
removeOrphanedAssetFiles: removeOrphans = removeOrphanedAssetFiles,
removeThumbnailIfUnused: removeUnusedThumbnail = removeThumbnailIfUnused,
requireCampaignRelativePath: requireRelativePath = requireCampaignRelativePath,
unlinkIfExists: unlinkPathIfExists = unlinkIfExists,
writeCampaign
}: CreateAssetMaintenanceServicesOptions): AssetMaintenanceServices {
return {
async previewCampaignAssetCleanup(campaignPath) {
const summary = await loadCampaignFromPath(campaignPath);
const health = await inspectHealth(campaignPath, summary.campaign);
const plan = await createCleanupPlan(campaignPath, summary.campaign, health);
return {
campaignSummary: {
...summary,
health
},
...plan
};
},

async regenerateCampaignThumbnails(campaignPath, onProgress, rendererWebContents) {
const summary = await loadCampaignFromPath(campaignPath);
const plan = await regenerateThumbnails(
Expand Down Expand Up @@ -107,8 +127,10 @@ export function createAssetMaintenanceServices({
async pruneCampaignUnreferencedAssets(campaignPath) {
const summary = await loadCampaignFromPath(campaignPath);
const health = await inspectHealth(campaignPath, summary.campaign);
const cleanupPlan = await createCleanupPlan(campaignPath, summary.campaign, health);
const unreferencedAssetIds = new Set(health.unreferencedAssets.map((asset) => asset.assetId));
const plan = await pruneAssets(campaignPath, summary.campaign, unreferencedAssetIds);
const orphanedRemoval = await removeOrphans(campaignPath, cleanupPlan.orphanedFiles);
if (plan.pruned > 0) {
await writeCampaign(campaignPath, plan.campaign);
}
Expand All @@ -117,8 +139,10 @@ export function createAssetMaintenanceServices({
campaignSummary: await loadCampaignFromPath(campaignPath),
pruned: plan.pruned,
skipped: plan.skipped,
removedFiles: plan.removedFiles,
failed: plan.failed
removedFiles: plan.removedFiles + orphanedRemoval.removed,
failed: plan.failed,
removedOrphanedFiles: orphanedRemoval.removed,
failedOrphanedFiles: orphanedRemoval.failed
};
}
};
Expand Down
1 change: 1 addition & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ registerTokenAssetIpc(ipcMain, {

registerAssetMaintenanceIpc(ipcMain, {
assertKnownCampaignPath: campaignRuntimeServices.assertKnownCampaignPath,
previewCampaignAssetCleanup: assetMaintenanceServices.previewCampaignAssetCleanup,
promoteCampaignTokenAssets: assetMaintenanceServices.promoteCampaignTokenAssets,
pruneCampaignUnreferencedAssets: assetMaintenanceServices.pruneCampaignUnreferencedAssets,
regenerateCampaignThumbnails: assetMaintenanceServices.regenerateCampaignThumbnails
Expand Down
6 changes: 5 additions & 1 deletion electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { contextBridge, ipcRenderer } from "electron";
import type { LocalVttApi } from "../src/shared/localVttApi.js";
import type {
Asset,
AssetCleanupPreviewResult,
AssetCleanupResult,
AssetPruneResult,
Campaign,
CampaignSummary,
Expand Down Expand Up @@ -72,8 +74,10 @@ const api: LocalVttApi = {
ipcRenderer.invoke("asset:regenerateThumbnails", campaignPath) as Promise<ThumbnailRegenerationResult>,
promoteTokenAssets: (campaignPath: string) =>
ipcRenderer.invoke("asset:promoteTokenAssets", campaignPath) as Promise<TokenAssetPromotionResult>,
previewAssetCleanup: (campaignPath: string) =>
ipcRenderer.invoke("asset:previewCleanup", campaignPath) as Promise<AssetCleanupPreviewResult>,
pruneUnreferencedAssets: (campaignPath: string) =>
ipcRenderer.invoke("asset:pruneUnreferencedAssets", campaignPath) as Promise<AssetPruneResult>,
ipcRenderer.invoke("asset:pruneUnreferencedAssets", campaignPath) as Promise<AssetCleanupResult | AssetPruneResult>,
onThumbnailRegenerationProgress: (callback: (progress: ThumbnailRegenerationProgress) => void) => {
const listener = (_event: Electron.IpcRendererEvent, progress: ThumbnailRegenerationProgress) => callback(progress);
ipcRenderer.on("asset:thumbnailRegenerationProgress", listener);
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/campaign/CampaignPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export function CampaignPanel({
}}
>
<Trash2 size={16} aria-hidden="true" />
<span>Prune Unreferenced Assets</span>
<span>Asset Cleanup</span>
</button>
</FloatingCampaignMaintenanceMenu>,
document.body
Expand Down
Loading