feat: run history for rename jobs

This commit is contained in:
Sam Chau
2026-01-19 01:52:26 +10:30
parent 1b3a5828c4
commit f6d99bc267
8 changed files with 801 additions and 3 deletions

View File

@@ -28,6 +28,7 @@ import { migration as migration023 } from './migrations/023_create_pattern_match
import { migration as migration024 } from './migrations/024_create_arr_rename_settings.ts';
import { migration as migration025 } from './migrations/025_add_rename_notification_mode.ts';
import { migration as migration026 } from './migrations/026_create_upgrade_runs.ts';
import { migration as migration027 } from './migrations/027_create_rename_runs.ts';
export interface Migration {
version: number;
@@ -268,7 +269,8 @@ export function loadMigrations(): Migration[] {
migration023,
migration024,
migration025,
migration026
migration026,
migration027
];
// Sort by version number

View File

@@ -0,0 +1,91 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 027: Create rename_runs table
*
* Creates the table for storing rename run history.
* Similar to upgrade_runs but with rename-specific fields.
*
* Fields:
* - id: UUID primary key
* - instance_id: Foreign key to arr_instances
* - started_at, completed_at: Timestamps for the run
* - status: success, partial, failed, skipped
* - dry_run: Whether this was a dry run
* - manual: Whether this was manually triggered
*
* Config snapshot:
* - rename_folders, ignore_tag
*
* Stats (flat for easy querying):
* - library_total, after_ignore_tag, skipped_by_tag
* - files_needing_rename, files_renamed, folders_renamed
* - commands_triggered, commands_completed, commands_failed
*
* Complex data as JSON:
* - items: Array of renamed items with file paths
* - errors: Array of error strings
*/
export const migration: Migration = {
version: 27,
name: 'Create rename_runs table',
up: `
CREATE TABLE rename_runs (
id TEXT PRIMARY KEY,
-- Relationship
instance_id INTEGER NOT NULL,
-- Timing
started_at TEXT NOT NULL,
completed_at TEXT NOT NULL,
-- Status
status TEXT NOT NULL CHECK (status IN ('success', 'partial', 'failed', 'skipped')),
dry_run INTEGER NOT NULL DEFAULT 1,
manual INTEGER NOT NULL DEFAULT 0,
-- Config snapshot
rename_folders INTEGER NOT NULL DEFAULT 0,
ignore_tag TEXT,
-- Library stats
library_total INTEGER NOT NULL,
library_fetch_ms INTEGER NOT NULL,
-- Filtering stats
after_ignore_tag INTEGER NOT NULL,
skipped_by_tag INTEGER NOT NULL,
-- Results stats
files_needing_rename INTEGER NOT NULL,
files_renamed INTEGER NOT NULL,
folders_renamed INTEGER NOT NULL,
commands_triggered INTEGER NOT NULL,
commands_completed INTEGER NOT NULL,
commands_failed INTEGER NOT NULL,
-- Complex data as JSON
items TEXT NOT NULL DEFAULT '[]',
errors TEXT NOT NULL DEFAULT '[]',
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
);
CREATE INDEX idx_rename_runs_instance ON rename_runs(instance_id);
CREATE INDEX idx_rename_runs_started_at ON rename_runs(started_at DESC);
CREATE INDEX idx_rename_runs_status ON rename_runs(status);
`,
down: `
DROP INDEX IF EXISTS idx_rename_runs_status;
DROP INDEX IF EXISTS idx_rename_runs_started_at;
DROP INDEX IF EXISTS idx_rename_runs_instance;
DROP TABLE IF EXISTS rename_runs;
`
};

View File

@@ -0,0 +1,251 @@
import { db } from '../db.ts';
import type { RenameJobLog } from '$lib/server/rename/types.ts';
/**
* Database row type for rename_runs table
*/
interface RenameRunRow {
id: string;
instance_id: number;
started_at: string;
completed_at: string;
status: string;
dry_run: number;
manual: number;
rename_folders: number;
ignore_tag: string | null;
library_total: number;
library_fetch_ms: number;
after_ignore_tag: number;
skipped_by_tag: number;
files_needing_rename: number;
files_renamed: number;
folders_renamed: number;
commands_triggered: number;
commands_completed: number;
commands_failed: number;
items: string;
errors: string;
created_at: string;
}
/**
* Renamed item stored in the database
*/
interface RenamedItemRow {
id: number;
title: string;
files: { existingPath: string; newPath: string }[];
}
/**
* Convert database row to RenameJobLog format
*/
function rowToLog(row: RenameRunRow): RenameJobLog {
return {
id: row.id,
instanceId: row.instance_id,
instanceName: '', // Not stored, can be joined if needed
instanceType: 'radarr', // Not stored, default
startedAt: row.started_at,
completedAt: row.completed_at,
status: row.status as 'success' | 'partial' | 'failed' | 'skipped',
config: {
dryRun: row.dry_run === 1,
renameFolders: row.rename_folders === 1,
ignoreTag: row.ignore_tag,
manual: row.manual === 1
},
library: {
totalItems: row.library_total,
fetchDurationMs: row.library_fetch_ms
},
filtering: {
afterIgnoreTag: row.after_ignore_tag,
skippedByTag: row.skipped_by_tag
},
results: {
filesNeedingRename: row.files_needing_rename,
filesRenamed: row.files_renamed,
foldersRenamed: row.folders_renamed,
commandsTriggered: row.commands_triggered,
commandsCompleted: row.commands_completed,
commandsFailed: row.commands_failed,
errors: JSON.parse(row.errors) as string[]
},
renamedItems: JSON.parse(row.items) as RenamedItemRow[]
};
}
/**
* All queries for rename_runs table
*/
export const renameRunsQueries = {
/**
* Insert a new rename run
*/
insert(log: RenameJobLog): void {
db.execute(
`INSERT INTO rename_runs (
id, instance_id, started_at, completed_at, status, dry_run, manual,
rename_folders, ignore_tag,
library_total, library_fetch_ms,
after_ignore_tag, skipped_by_tag,
files_needing_rename, files_renamed, folders_renamed,
commands_triggered, commands_completed, commands_failed,
items, errors
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
log.id,
log.instanceId,
log.startedAt,
log.completedAt,
log.status,
log.config.dryRun ? 1 : 0,
log.config.manual ? 1 : 0,
log.config.renameFolders ? 1 : 0,
log.config.ignoreTag,
log.library.totalItems,
log.library.fetchDurationMs,
log.filtering.afterIgnoreTag,
log.filtering.skippedByTag,
log.results.filesNeedingRename,
log.results.filesRenamed,
log.results.foldersRenamed,
log.results.commandsTriggered,
log.results.commandsCompleted,
log.results.commandsFailed,
JSON.stringify(log.renamedItems),
JSON.stringify(log.results.errors)
);
},
/**
* Get all rename runs for an instance, newest first
*/
getByInstanceId(instanceId: number, limit = 100): RenameJobLog[] {
const rows = db.query<RenameRunRow>(
`SELECT * FROM rename_runs
WHERE instance_id = ?
ORDER BY started_at DESC
LIMIT ?`,
instanceId,
limit
);
return rows.map(rowToLog);
},
/**
* Get a single rename run by ID
*/
getById(id: string): RenameJobLog | undefined {
const row = db.queryFirst<RenameRunRow>(
'SELECT * FROM rename_runs WHERE id = ?',
id
);
return row ? rowToLog(row) : undefined;
},
/**
* Get recent runs across all instances
*/
getRecent(limit = 50): RenameJobLog[] {
const rows = db.query<RenameRunRow>(
`SELECT * FROM rename_runs
ORDER BY started_at DESC
LIMIT ?`,
limit
);
return rows.map(rowToLog);
},
/**
* Get runs by status for an instance
*/
getByStatus(instanceId: number, status: string, limit = 50): RenameJobLog[] {
const rows = db.query<RenameRunRow>(
`SELECT * FROM rename_runs
WHERE instance_id = ? AND status = ?
ORDER BY started_at DESC
LIMIT ?`,
instanceId,
status,
limit
);
return rows.map(rowToLog);
},
/**
* Delete old runs (for retention policy)
* Returns number of rows deleted
*/
deleteOlderThan(days: number): number {
return db.execute(
`DELETE FROM rename_runs
WHERE datetime(started_at) < datetime('now', '-' || ? || ' days')`,
days
);
},
/**
* Delete all runs for an instance
*/
deleteByInstanceId(instanceId: number): number {
return db.execute(
'DELETE FROM rename_runs WHERE instance_id = ?',
instanceId
);
},
/**
* Get count of runs for an instance
*/
getCount(instanceId: number): number {
const result = db.queryFirst<{ count: number }>(
'SELECT COUNT(*) as count FROM rename_runs WHERE instance_id = ?',
instanceId
);
return result?.count ?? 0;
},
/**
* Get stats summary for an instance
*/
getStats(instanceId: number): {
totalRuns: number;
successfulRuns: number;
failedRuns: number;
totalFilesRenamed: number;
totalFoldersRenamed: number;
} {
const result = db.queryFirst<{
total_runs: number;
successful_runs: number;
failed_runs: number;
total_files_renamed: number;
total_folders_renamed: number;
}>(
`SELECT
COUNT(*) as total_runs,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successful_runs,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_runs,
SUM(files_renamed) as total_files_renamed,
SUM(folders_renamed) as total_folders_renamed
FROM rename_runs
WHERE instance_id = ?`,
instanceId
);
return {
totalRuns: result?.total_runs ?? 0,
successfulRuns: result?.successful_runs ?? 0,
failedRuns: result?.failed_runs ?? 0,
totalFilesRenamed: result?.total_files_renamed ?? 0,
totalFoldersRenamed: result?.total_folders_renamed ?? 0
};
}
};

View File

@@ -555,3 +555,59 @@ CREATE TABLE upgrade_runs (
CREATE INDEX idx_upgrade_runs_instance ON upgrade_runs(instance_id);
CREATE INDEX idx_upgrade_runs_started_at ON upgrade_runs(started_at DESC);
CREATE INDEX idx_upgrade_runs_status ON upgrade_runs(status);
-- ==============================================================================
-- TABLE: rename_runs
-- Purpose: Store rename run history for each arr instance
-- Migration: 027_create_rename_runs.ts
-- ==============================================================================
CREATE TABLE rename_runs (
id TEXT PRIMARY KEY, -- UUID
-- Relationship
instance_id INTEGER NOT NULL, -- Foreign key to arr_instances
-- Timing
started_at TEXT NOT NULL, -- ISO timestamp when run started
completed_at TEXT NOT NULL, -- ISO timestamp when run completed
-- Status
status TEXT NOT NULL CHECK (status IN ('success', 'partial', 'failed', 'skipped')),
dry_run INTEGER NOT NULL DEFAULT 1, -- 1=dry run, 0=live
manual INTEGER NOT NULL DEFAULT 0, -- 1=manually triggered, 0=scheduled
-- Config snapshot
rename_folders INTEGER NOT NULL DEFAULT 0, -- 1=rename folders too
ignore_tag TEXT, -- Tag name to skip
-- Library stats
library_total INTEGER NOT NULL, -- Total items in library
library_fetch_ms INTEGER NOT NULL, -- Time to fetch library in ms
-- Filtering stats
after_ignore_tag INTEGER NOT NULL, -- Items after ignore tag filter
skipped_by_tag INTEGER NOT NULL, -- Items skipped due to tag
-- Results stats
files_needing_rename INTEGER NOT NULL, -- Files that need renaming
files_renamed INTEGER NOT NULL, -- Files actually renamed
folders_renamed INTEGER NOT NULL, -- Folders renamed
commands_triggered INTEGER NOT NULL, -- Rename commands triggered
commands_completed INTEGER NOT NULL, -- Commands completed successfully
commands_failed INTEGER NOT NULL, -- Commands that failed
-- Complex data as JSON
items TEXT NOT NULL DEFAULT '[]', -- JSON array of renamed items
errors TEXT NOT NULL DEFAULT '[]', -- JSON array of error strings
-- Metadata
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
);
-- Rename runs indexes (Migration: 027_create_rename_runs.ts)
CREATE INDEX idx_rename_runs_instance ON rename_runs(instance_id);
CREATE INDEX idx_rename_runs_started_at ON rename_runs(started_at DESC);
CREATE INDEX idx_rename_runs_status ON rename_runs(status);

View File

@@ -1,9 +1,11 @@
/**
* Structured logging for rename jobs
* Uses the shared logger with source 'RenameJob'
* Stores run history in the database
*/
import { logger } from '$logger/logger.ts';
import { renameRunsQueries } from '$db/queries/renameRuns.ts';
import type { RenameJobLog } from './types.ts';
const SOURCE = 'RenameJob';
@@ -41,6 +43,16 @@ export async function logRenameRun(log: RenameJobLog): Promise<void> {
items
}
});
// Save full structured data to database
try {
renameRunsQueries.insert(log);
} catch (err) {
await logger.error(`Failed to save rename run to database: ${err}`, {
source: SOURCE,
meta: { runId: log.id, error: err }
});
}
}
/**

View File

@@ -2,6 +2,7 @@ import { error, fail } from '@sveltejs/kit';
import type { Actions, ServerLoad } from '@sveltejs/kit';
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
import { arrRenameSettingsQueries } from '$db/queries/arrRenameSettings.ts';
import { renameRunsQueries } from '$db/queries/renameRuns.ts';
import { logger } from '$logger/logger.ts';
import { processRenameConfig } from '$lib/server/rename/processor.ts';
@@ -19,10 +20,12 @@ export const load: ServerLoad = ({ params }) => {
}
const settings = arrRenameSettingsQueries.getByInstanceId(id);
const renameRuns = renameRunsQueries.getByInstanceId(id);
return {
instance,
settings: settings ?? null
settings: settings ?? null,
renameRuns
};
};

View File

@@ -4,8 +4,9 @@
import { onMount } from 'svelte';
import { alertStore } from '$lib/client/alerts/store';
import { isDirty, initEdit, initCreate, update, clear } from '$lib/client/stores/dirty';
import { Info, Save, Play, Settings } from 'lucide-svelte';
import { Info, Save, Play, Settings, History } from 'lucide-svelte';
import RenameSettings from './components/RenameSettings.svelte';
import RenameRunHistory from './components/RenameRunHistory.svelte';
import RenameInfoModal from './components/RenameInfoModal.svelte';
import DirtyModal from '$lib/client/ui/modal/DirtyModal.svelte';
import StickyCard from '$ui/card/StickyCard.svelte';
@@ -136,6 +137,14 @@
</section>
</div>
<section class="mt-6">
<h2 class="mb-3 flex items-center gap-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
<History size={18} class="text-neutral-500 dark:text-neutral-400" />
Run History
</h2>
<RenameRunHistory runs={data.renameRuns} />
</section>
<!-- Hidden forms -->
<form
id="save-form"

View File

@@ -0,0 +1,374 @@
<script lang="ts">
import { AlertTriangle, X, FileText, Calendar, CircleDot, Check } from 'lucide-svelte';
import type { RenameJobLog } from '$lib/server/rename/types.ts';
import { createSearchStore } from '$lib/client/stores/search';
import ActionsBar from '$ui/actions/ActionsBar.svelte';
import SearchAction from '$ui/actions/SearchAction.svelte';
import ActionButton from '$ui/actions/ActionButton.svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import Badge from '$ui/badge/Badge.svelte';
import type { Column } from '$ui/table/types';
const searchStore = createSearchStore();
const debouncedQuery = searchStore.debouncedQuery;
export let runs: RenameJobLog[] = [];
// Filter state
let dateFilter: 'all' | 'today' | 'yesterday' | 'week' | 'month' = 'all';
let statusFilter: 'all' | 'success' | 'partial' | 'failed' | 'skipped' = 'all';
// Filter runs based on all criteria
$: filteredRuns = filterRuns(runs, $debouncedQuery, dateFilter, statusFilter);
function filterRuns(
items: RenameJobLog[],
query: string,
date: typeof dateFilter,
status: typeof statusFilter
): RenameJobLog[] {
let result = items;
// Text search (search in renamed item titles)
if (query) {
const queryLower = query.toLowerCase();
result = result.filter(
(item) =>
item.renamedItems.some((r) => r.title.toLowerCase().includes(queryLower)) ||
item.status.toLowerCase().includes(queryLower)
);
}
// Date filter
if (date !== 'all') {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
const monthAgo = new Date(today);
monthAgo.setDate(monthAgo.getDate() - 30);
result = result.filter((item) => {
const itemDate = new Date(item.startedAt);
switch (date) {
case 'today':
return itemDate >= today;
case 'yesterday':
return itemDate >= yesterday && itemDate < today;
case 'week':
return itemDate >= weekAgo;
case 'month':
return itemDate >= monthAgo;
default:
return true;
}
});
}
// Status filter
if (status !== 'all') {
result = result.filter((item) => item.status === status);
}
return result;
}
// Check if any filters are active
$: hasActiveFilters = dateFilter !== 'all' || statusFilter !== 'all';
let expandedIds: Set<string> = new Set();
const columns: Column<RenameJobLog>[] = [
{ key: 'runNumber', header: '#', sortable: false },
{ key: 'date', header: 'Date', sortable: true },
{ key: 'duration', header: 'Duration', sortable: false },
{ key: 'status', header: 'Status', sortable: true },
{ key: 'summary', header: 'Summary', sortable: false }
];
// Status badge config
const statusConfig = {
success: { variant: 'success' as const, icon: Check },
partial: { variant: 'warning' as const, icon: AlertTriangle },
failed: { variant: 'danger' as const, icon: X },
skipped: { variant: 'neutral' as const, icon: X }
};
function getRunNumber(row: RenameJobLog): number {
const originalIndex = runs.findIndex((r) => r.id === row.id);
return runs.length - originalIndex;
}
function formatDate(isoString: string): string {
const date = new Date(isoString);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
let dateStr: string;
if (date.toDateString() === today.toDateString()) {
dateStr = 'Today';
} else if (date.toDateString() === yesterday.toDateString()) {
dateStr = 'Yesterday';
} else {
dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
const timeStr = date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
return `${dateStr}, ${timeStr}`;
}
function formatDuration(startedAt: string, completedAt: string): string {
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
}
function getFileName(path: string): string {
return path.split('/').pop() || path;
}
</script>
<div class="-mx-8 bg-neutral-50 px-8 pb-6 pt-2 dark:bg-neutral-900">
<div class="mb-4">
<ActionsBar>
<SearchAction {searchStore} placeholder="Search runs..." />
<!-- Date Filter -->
<ActionButton icon={Calendar} hasDropdown square title="Filter by date">
<Dropdown slot="dropdown" position="right">
{#each [
{ value: 'all', label: 'All time' },
{ value: 'today', label: 'Today' },
{ value: 'yesterday', label: 'Yesterday' },
{ value: 'week', label: 'Last 7 days' },
{ value: 'month', label: 'Last 30 days' }
] as option}
<DropdownItem
label={option.label}
selected={dateFilter === option.value}
on:click={() => (dateFilter = option.value)}
/>
{/each}
</Dropdown>
</ActionButton>
<!-- Status Filter -->
<ActionButton icon={CircleDot} hasDropdown square title="Filter by status">
<Dropdown slot="dropdown" position="right">
{#each [
{ value: 'all', label: 'All' },
{ value: 'success', label: 'Success' },
{ value: 'partial', label: 'Partial' },
{ value: 'failed', label: 'Failed' },
{ value: 'skipped', label: 'Skipped' }
] as option}
<DropdownItem
label={option.label}
selected={statusFilter === option.value}
on:click={() => (statusFilter = option.value)}
/>
{/each}
</Dropdown>
</ActionButton>
</ActionsBar>
</div>
<ExpandableTable
{columns}
data={filteredRuns}
getRowId={(row) => row.id}
bind:expandedRows={expandedIds}
chevronPosition="right"
flushExpanded={true}
emptyMessage="No rename runs yet. Configure and enable rename to start."
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'runNumber'}
<span class="font-mono text-neutral-500 dark:text-neutral-500">
#{getRunNumber(row)}
</span>
{:else if column.key === 'date'}
<div class="flex items-center gap-2">
<span class="text-neutral-600 dark:text-neutral-400">
{formatDate(row.startedAt)}
</span>
{#if row.config.dryRun}
<span
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-400"
>
DRY
</span>
{/if}
{#if row.config.manual}
<span
class="rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/50 dark:text-blue-400"
>
MANUAL
</span>
{/if}
</div>
{:else if column.key === 'duration'}
<span class="font-mono text-xs text-neutral-600 dark:text-neutral-400">
{formatDuration(row.startedAt, row.completedAt)}
</span>
{:else if column.key === 'status'}
{@const config = statusConfig[row.status] || statusConfig.failed}
<Badge variant={config.variant} icon={config.icon} size="md">
{row.status.charAt(0).toUpperCase() + row.status.slice(1)}
</Badge>
{:else if column.key === 'summary'}
<span class="text-sm text-neutral-600 dark:text-neutral-400">
<span class="font-mono">{row.library.totalItems.toLocaleString()}</span> scanned
<span class="mx-1 text-neutral-300 dark:text-neutral-600">&rarr;</span>
<span class="font-mono">{row.results.filesNeedingRename}</span> need rename
<span class="mx-1 text-neutral-300 dark:text-neutral-600">&rarr;</span>
<span class="font-mono">{row.results.filesRenamed}</span>
{row.config.dryRun ? 'would rename' : 'renamed'}
</span>
{/if}
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
<div class="space-y-3 p-6">
<!-- Config -->
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400"
>Config</span
>
<span class="text-sm text-neutral-900 dark:text-neutral-100">
{#if row.config.renameFolders}Folders enabled{:else}Files only{/if}
{#if row.config.ignoreTag}
| Ignore tag: "{row.config.ignoreTag}"
{/if}
</span>
</div>
<!-- Library -->
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400"
>Library</span
>
<span class="text-sm text-neutral-900 dark:text-neutral-100">
<span class="font-mono">{row.library.totalItems.toLocaleString()}</span> items
<span class="ml-1 font-mono text-xs text-neutral-500 dark:text-neutral-400">
({row.library.fetchDurationMs}ms)
</span>
</span>
</div>
<!-- Filtering -->
{#if row.filtering.skippedByTag > 0}
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400"
>Filtered</span
>
<span class="text-sm text-neutral-900 dark:text-neutral-100">
<span class="font-mono">{row.filtering.skippedByTag}</span> skipped by tag
<span class="mx-1 text-neutral-400">&rarr;</span>
<span class="font-mono font-medium">{row.filtering.afterIgnoreTag}</span> remaining
</span>
</div>
{/if}
<!-- Results -->
<div class="flex">
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400"
>Results</span
>
<span class="text-sm text-neutral-900 dark:text-neutral-100">
{#if row.config.dryRun}
<span class="font-mono">{row.results.filesNeedingRename}</span> files would be renamed
{:else}
<span class="font-mono">{row.results.filesRenamed}</span>/<span class="font-mono"
>{row.results.filesNeedingRename}</span
> files renamed
{#if row.results.foldersRenamed > 0}
, <span class="font-mono">{row.results.foldersRenamed}</span> folders
{/if}
{/if}
{#if row.results.commandsFailed > 0}
<span class="font-mono text-red-600 dark:text-red-400"
>, {row.results.commandsFailed} failed</span
>
{/if}
</span>
</div>
<!-- Renamed Items -->
{#if row.renamedItems.length > 0}
{@const itemsColumns = [
{ key: 'title', header: 'Title', sortable: false },
{ key: 'files', header: 'Files', sortable: false, align: 'center' as const }
]}
<div class="mt-4 border-t border-neutral-200 pt-4 dark:border-neutral-700">
<div
class="mb-3 flex items-center gap-1.5 text-sm font-medium text-neutral-700 dark:text-neutral-300"
>
<FileText size={14} />
Items {row.config.dryRun ? 'Needing Rename' : 'Renamed'}
</div>
<ExpandableTable
columns={itemsColumns}
data={row.renamedItems}
getRowId={(item) => item.id}
compact={true}
emptyMessage="No items"
>
<svelte:fragment slot="cell" let:row={item} let:column>
{#if column.key === 'title'}
<span class="text-neutral-900 dark:text-neutral-100">{item.title}</span>
{:else if column.key === 'files'}
<Badge variant="neutral" mono>{item.files.length}</Badge>
{/if}
</svelte:fragment>
<svelte:fragment slot="expanded" let:row={item}>
<div class="space-y-2 py-2 pl-2">
{#each item.files as file}
<div class="space-y-1">
<div class="flex gap-2">
<span
class="w-12 shrink-0 text-xs font-medium text-neutral-500 dark:text-neutral-400"
>From:</span
>
<span
class="truncate font-mono text-xs text-neutral-700 dark:text-neutral-300"
title={file.existingPath}
>
{getFileName(file.existingPath)}
</span>
</div>
<div class="flex gap-2">
<span
class="w-12 shrink-0 text-xs font-medium text-emerald-600 dark:text-emerald-400"
>To:</span
>
<span
class="truncate font-mono text-xs text-neutral-700 dark:text-neutral-300"
title={file.newPath}
>
{getFileName(file.newPath)}
</span>
</div>
</div>
{/each}
</div>
</svelte:fragment>
</ExpandableTable>
</div>
{/if}
</div>
</svelte:fragment>
</ExpandableTable>
</div>