diff --git a/src/lib/ae_events/ae_events__event_file.ts b/src/lib/ae_events/ae_events__event_file.ts index 3fc0ed15..d2b9e1be 100644 --- a/src/lib/ae_events/ae_events__event_file.ts +++ b/src/lib/ae_events/ae_events__event_file.ts @@ -372,27 +372,59 @@ export async function delete_ae_obj_id__event_file({ }: { api_cfg: any; event_file_id: string; - // Providing hosted_file_id enables full cleanup: removes the hosted_file_link, - // then deletes the hosted_file record and physical file if no other links remain. - // Must be done BEFORE deleting the event_file record so the backend can still - // resolve the link_to_id via Redis. + // Providing hosted_file_id enables full cleanup of the physical file and + // hosted_file record. The /links fetch is done first because it calls + // get_id_random() on each linked object, which populates Redis. Without + // 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; params?: key_val; try_cache?: boolean; log_lvl?: number; }) { if (hosted_file_id) { - await api.delete_hosted_file({ - api_cfg, - hosted_file_id, - link_to_type: 'event_file', - link_to_id: event_file_id, - rm_orphan: true, - params: { method: 'delete' }, - log_lvl - }); + // 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({ + api_cfg, + hosted_file_id, + rm_orphan: true, + params: { method: 'delete' }, + log_lvl + }); + } } + // Step 3: Delete the event_file record after the hosted_file cleanup. const result = await api.delete_ae_obj({ api_cfg, obj_type: 'event_file', diff --git a/src/routes/core/files/+page.svelte b/src/routes/core/files/+page.svelte index b40c7c1e..101bfcb7 100644 --- a/src/routes/core/files/+page.svelte +++ b/src/routes/core/files/+page.svelte @@ -18,6 +18,7 @@ import { RefreshCw, Search, Trash2, + Unlink, X } 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)); + +// ── 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; +}
@@ -460,15 +508,45 @@ let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0)); {#if results.length > 0}
-
+
- {results.length} file{results.length !== 1 ? 's' : ''} + {#if orphan_filter} + + {orphan_ids.size} orphan{orphan_ids.size !== 1 ? 's' : ''} of {results.length} + + {:else} + {results.length} file{results.length !== 1 ? 's' : ''} + {/if} {#if results.length === page_limit} - (may be more — increase per-page or paginate) + (may be more) {/if} · {fmt_size(total_size)} total -
+
+ + {#if orphan_filter} + + {:else} + + {/if} {#if page_offset > 0}
{fmt_size(file.size)}