fix(files): reliable hosted_file cleanup + orphan detection in /core/files/
Event file delete (Pres Mgmt):
- Re-implement cleanup using /links pre-fetch before delete_hosted_file calls.
The /links endpoint calls get_id_random() per link which populates Redis.
Without this, redis_lookup_id_random('event_file', id) raises 404 in the
delete handler → silent skip → physical file never removed.
Now mirrors the same pattern used by the /core/files/ admin page delete.
/core/files/ admin page:
- Add orphan check mode: "Check Orphans" button batch-fetches links for all
visible results in parallel (reusing links_map cache), then filters table
to show only files with zero links.
- Orphan files get a warning badge in the filename column.
- Results header toggles to show "N orphans of M" when filter is active.
- Unlink icon imported from lucide for orphan UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -372,27 +372,59 @@ export async function delete_ae_obj_id__event_file({
|
|||||||
}: {
|
}: {
|
||||||
api_cfg: any;
|
api_cfg: any;
|
||||||
event_file_id: string;
|
event_file_id: string;
|
||||||
// Providing hosted_file_id enables full cleanup: removes the hosted_file_link,
|
// Providing hosted_file_id enables full cleanup of the physical file and
|
||||||
// then deletes the hosted_file record and physical file if no other links remain.
|
// hosted_file record. The /links fetch is done first because it calls
|
||||||
// Must be done BEFORE deleting the event_file record so the backend can still
|
// get_id_random() on each linked object, which populates Redis. Without
|
||||||
// resolve the link_to_id via Redis.
|
// this pre-fetch, redis_lookup_id_random('event_file', id) raises 404
|
||||||
|
// in the delete handler — the cleanup is silently skipped.
|
||||||
hosted_file_id?: string;
|
hosted_file_id?: string;
|
||||||
params?: key_val;
|
params?: key_val;
|
||||||
try_cache?: boolean;
|
try_cache?: boolean;
|
||||||
log_lvl?: number;
|
log_lvl?: number;
|
||||||
}) {
|
}) {
|
||||||
if (hosted_file_id) {
|
if (hosted_file_id) {
|
||||||
|
// Step 1: Fetch links — populates Redis for all linked object IDs.
|
||||||
|
let file_links: { link_to_type: string; link_to_id_random: string | null }[] = [];
|
||||||
|
try {
|
||||||
|
const links_result = await api.get_object({
|
||||||
|
api_cfg,
|
||||||
|
endpoint: `/v3/action/hosted_file/${hosted_file_id}/links`,
|
||||||
|
log_lvl: 0
|
||||||
|
});
|
||||||
|
file_links = links_result?.data ?? links_result ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
if (log_lvl) console.warn('[delete_event_file] /links fetch failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Remove each link. rm_orphan=true on the last one triggers
|
||||||
|
// physical file + hosted_file record cleanup if nothing else uses it.
|
||||||
|
if (file_links.length > 0) {
|
||||||
|
for (let i = 0; i < file_links.length; i++) {
|
||||||
|
const lnk = file_links[i];
|
||||||
|
const is_last = i === file_links.length - 1;
|
||||||
|
await api.delete_hosted_file({
|
||||||
|
api_cfg,
|
||||||
|
hosted_file_id,
|
||||||
|
link_to_type: lnk.link_to_type,
|
||||||
|
link_to_id: lnk.link_to_id_random ?? undefined,
|
||||||
|
rm_orphan: is_last,
|
||||||
|
params: { method: 'delete' },
|
||||||
|
log_lvl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No links recorded — attempt direct orphan cleanup.
|
||||||
await api.delete_hosted_file({
|
await api.delete_hosted_file({
|
||||||
api_cfg,
|
api_cfg,
|
||||||
hosted_file_id,
|
hosted_file_id,
|
||||||
link_to_type: 'event_file',
|
|
||||||
link_to_id: event_file_id,
|
|
||||||
rm_orphan: true,
|
rm_orphan: true,
|
||||||
params: { method: 'delete' },
|
params: { method: 'delete' },
|
||||||
log_lvl
|
log_lvl
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Delete the event_file record after the hosted_file cleanup.
|
||||||
const result = await api.delete_ae_obj({
|
const result = await api.delete_ae_obj({
|
||||||
api_cfg,
|
api_cfg,
|
||||||
obj_type: 'event_file',
|
obj_type: 'event_file',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Unlink,
|
||||||
X
|
X
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
|
|
||||||
@@ -336,6 +337,53 @@ function fmt_date(val: string | Date | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
|
let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
|
||||||
|
|
||||||
|
// ── Orphan check ──────────────────────────────────────────────────────────────
|
||||||
|
// A file is a confirmed orphan once links_map has been populated for it AND the
|
||||||
|
// links array is empty. links_map is a SvelteMap so this derived stays reactive.
|
||||||
|
let orphan_checking = $state(false);
|
||||||
|
let orphan_filter = $state(false);
|
||||||
|
|
||||||
|
let orphan_ids = $derived(
|
||||||
|
new Set(
|
||||||
|
results
|
||||||
|
.filter((f) => links_map.has(f.hosted_file_id) && (links_map.get(f.hosted_file_id)?.length ?? 1) === 0)
|
||||||
|
.map((f) => f.hosted_file_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let displayed_results = $derived(
|
||||||
|
orphan_filter ? results.filter((f) => orphan_ids.has(f.hosted_file_id)) : results
|
||||||
|
);
|
||||||
|
|
||||||
|
async function check_all_for_orphans() {
|
||||||
|
orphan_checking = true;
|
||||||
|
orphan_filter = false;
|
||||||
|
// Fetch links for all visible results in parallel; skip already-cached entries.
|
||||||
|
await Promise.all(
|
||||||
|
results.map(async (file) => {
|
||||||
|
const id = file.hosted_file_id;
|
||||||
|
if (links_map.has(id)) return;
|
||||||
|
links_loading.set(id, true);
|
||||||
|
try {
|
||||||
|
const result = await api.get_object({
|
||||||
|
api_cfg: $ae_api,
|
||||||
|
endpoint: `/v3/action/hosted_file/${id}/links`,
|
||||||
|
log_lvl: 0
|
||||||
|
});
|
||||||
|
links_map.set(id, result?.data ?? result ?? []);
|
||||||
|
// Also resolve nav URLs for any links found
|
||||||
|
await Promise.all((result?.data ?? result ?? []).map((lnk: any) => resolve_link_url(lnk.link_to_type, lnk.link_to_id_random)));
|
||||||
|
} catch {
|
||||||
|
links_map.set(id, []);
|
||||||
|
} finally {
|
||||||
|
links_loading.delete(id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
orphan_filter = true;
|
||||||
|
orphan_checking = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -460,15 +508,45 @@ let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
|
|||||||
{#if results.length > 0}
|
{#if results.length > 0}
|
||||||
<div class="card preset-tonal-surface border-surface-500/10 border shadow-xl">
|
<div class="card preset-tonal-surface border-surface-500/10 border shadow-xl">
|
||||||
|
|
||||||
<div class="border-surface-500/20 flex items-center justify-between border-b px-4 py-2">
|
<div class="border-surface-500/20 flex flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
|
||||||
<span class="text-xs font-bold opacity-60">
|
<span class="text-xs font-bold opacity-60">
|
||||||
|
{#if orphan_filter}
|
||||||
|
<span class="text-warning-600 dark:text-warning-400">
|
||||||
|
{orphan_ids.size} orphan{orphan_ids.size !== 1 ? 's' : ''} of {results.length}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
{results.length} file{results.length !== 1 ? 's' : ''}
|
{results.length} file{results.length !== 1 ? 's' : ''}
|
||||||
|
{/if}
|
||||||
{#if results.length === page_limit}
|
{#if results.length === page_limit}
|
||||||
<span class="opacity-50"> (may be more — increase per-page or paginate)</span>
|
<span class="opacity-50"> (may be more)</span>
|
||||||
{/if}
|
{/if}
|
||||||
· <span class="tabular-nums">{fmt_size(total_size)}</span> total
|
· <span class="tabular-nums">{fmt_size(total_size)}</span> total
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<!-- Orphan check -->
|
||||||
|
{#if orphan_filter}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm preset-tonal-warning text-xs"
|
||||||
|
onclick={() => { orphan_filter = false; }}
|
||||||
|
title="Show all files">
|
||||||
|
<Unlink size={12} class="mr-1" />
|
||||||
|
{orphan_ids.size} orphan{orphan_ids.size !== 1 ? 's' : ''} — Show All
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm preset-tonal-surface text-xs"
|
||||||
|
onclick={check_all_for_orphans}
|
||||||
|
disabled={orphan_checking}
|
||||||
|
title="Check all visible files for missing links (orphan detection)">
|
||||||
|
{#if orphan_checking}
|
||||||
|
<LoaderCircle size={12} class="mr-1 animate-spin" /> Checking…
|
||||||
|
{:else}
|
||||||
|
<Unlink size={12} class="mr-1" /> Check Orphans
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if page_offset > 0}
|
{#if page_offset > 0}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -544,7 +622,7 @@ let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each results as file (file.hosted_file_id)}
|
{#each displayed_results as file (file.hosted_file_id)}
|
||||||
<tr
|
<tr
|
||||||
class="border-surface-500/10 hover:bg-surface-500/5 border-b transition-colors duration-200"
|
class="border-surface-500/10 hover:bg-surface-500/5 border-b transition-colors duration-200"
|
||||||
class:opacity-50={!file.enable}>
|
class:opacity-50={!file.enable}>
|
||||||
@@ -560,6 +638,11 @@ let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
|
|||||||
{#if file.hide}
|
{#if file.hide}
|
||||||
<span class="badge preset-tonal-surface ml-1 text-[9px]">hidden</span>
|
<span class="badge preset-tonal-surface ml-1 text-[9px]">hidden</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if orphan_ids.has(file.hosted_file_id)}
|
||||||
|
<span class="badge preset-tonal-warning ml-1 text-[9px]" title="No links found — safe to delete">
|
||||||
|
<Unlink size={9} class="mr-0.5" /> orphan
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 tabular-nums">{fmt_size(file.size)}</td>
|
<td class="px-3 py-2 tabular-nums">{fmt_size(file.size)}</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user