feat(pres_mgmt): File Downloads report with clean filename presets

New report at Pres Mgmt > Reports > File Downloads. Groups all event files
by session and presenter, with 10 filename format presets (original, session
code/date/name combos, presenter variants). Per-file Download button and Copy
Link button using hosted_file endpoint for ?key= auth support.

Also fixes direct download links in element_manage_event_file_li and the
hosted_files download button — event_file endpoint does not yet propagate
?key= auth internally, so all direct links now use hosted_file endpoint
which supports it today.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-10 14:19:25 -04:00
parent 1b81b8873c
commit 6b122a065e
6 changed files with 597 additions and 25 deletions

View File

@@ -39,9 +39,11 @@ import Event_reports_page_menu from './event_reports_page_menu.svelte';
import Reports_sessions from './reports_sessions.svelte';
import Reports_presenters from './reports_presenters.svelte';
import Reports_files from './reports_files.svelte';
import Reports_file_downloads from './reports_file_downloads.svelte';
import {
Check,
ClipboardList,
Download,
File,
IdCard,
ListChecks,
@@ -90,7 +92,8 @@ let lq__event_obj = $derived(
let ae_triggers: key_val = $state({
rpt__event_files: true,
rpt__event_sessions: true,
rpt__event_presenters: true
rpt__event_presenters: true,
rpt__file_downloads: true
});
let url_hash: string = $state($page?.url?.hash);
@@ -311,6 +314,25 @@ $effect(() => {
<File size="1em" class="m-1" />
Event Files
</button>
<button
type="button"
disabled={!$ae_loc.trusted_access}
onclick={() => {
ae_triggers.rpt__file_downloads = true;
pres_mgmt_loc.current.show_report = 'file_downloads';
}}
class:hidden={$lq__event_obj?.mod_pres_mgmt_json
?.hide__report_kv.file_downloads}
class="btn btn-sm preset-tonal-success border-success-500 hover:preset-filled-success-500 m-1 border transition-all"
title="File Downloads — download files grouped by session and presenter with clean filename options.">
{#if pres_mgmt_loc.current.show_report == 'file_downloads' && $events_sess.pres_mgmt.status_rpt.file_downloads == 'loading'}
<LoaderCircle size="1em" class="animate-spin" />
{:else}
<Download size="1em" class="m-1" />
{/if}
File Downloads
</button>
</span>
</div>
@@ -362,4 +384,14 @@ $effect(() => {
hide_session_code={pres_mgmt_loc.current.hide__session_code}
{log_lvl} />
{/if}
<!-- File Downloads report — grouped by session/presenter with clean filename options -->
{#if pres_mgmt_loc.current.show_report == 'file_downloads'}
<Reports_file_downloads
{lq__event_obj}
rpt__limit={pres_mgmt_loc.current.qry_max}
bind:qry__status={$events_sess.pres_mgmt.status_qry__search}
bind:qry__trigger={ae_triggers.rpt__file_downloads}
{log_lvl} />
{/if}
{/if}

View File

@@ -0,0 +1,482 @@
<script lang="ts">
interface Props {
log_lvl?: number;
lq__event_obj: any;
event_file_obj_li?: any[];
qry__status?: string;
qry__count?: number;
qry__trigger?: boolean;
rpt__limit?: number;
}
let {
log_lvl = $bindable(0),
lq__event_obj,
event_file_obj_li = $bindable([]),
qry__status = $bindable(''),
qry__count = $bindable(0),
qry__trigger = $bindable(true),
rpt__limit = $bindable(500)
}: Props = $props();
import { ae_util } from '$lib/ae_utils/ae_utils';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_slct, events_sess } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import MyClipboard from '$lib/app_components/e_app_clipboard.svelte';
import {
ArrowUpDown,
Download,
FileText,
LoaderCircle,
RefreshCw,
User,
Users
} from '@lucide/svelte';
// ---------------------------------------------------------------------------
// Filename helpers
// ---------------------------------------------------------------------------
// Strip OS-unsafe chars AND periods (no periods in filename stems).
function clean_part(s: any, max?: number): string {
if (!s) return '';
const out = ae_util
.clean_filename(String(s))
.replace(/\./g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
return max ? out.substring(0, max) : out;
}
function date_part(dt: string | null | undefined): string {
return dt ? String(dt).substring(0, 10) : ''; // 'YYYY-MM-DD'
}
function time_part(dt: string | null | undefined): string {
if (!dt) return '';
return String(dt).substring(11, 16).replace(':', '-'); // 'HH-MM'
}
type FormatKey =
| 'original'
| 'session_code'
| 'session_code_name'
| 'session_code_date'
| 'session_code_date_time'
| 'session_code_date_name'
| 'session_presenter'
| 'session_date_presenter'
| 'session_presentation_presenter'
| 'session_date_presentation_presenter';
const FORMAT_PRESETS: Record<FormatKey, { label: string; group: string }> = {
original: { label: 'Original filename', group: 'Universal' },
session_code: { label: 'Session Code', group: 'Session' },
session_code_name: { label: 'Session Code + Name', group: 'Session' },
session_code_date: { label: 'Session Code + Date', group: 'Session' },
session_code_date_time: { label: 'Session Code + Date + Time', group: 'Session' },
session_code_date_name: { label: 'Session Code + Date + Name', group: 'Session' },
session_presenter: { label: 'Session Code + Presenter (Family, Given)', group: 'Presenter' },
session_date_presenter: { label: 'Session Code + Date + Presenter', group: 'Presenter' },
session_presentation_presenter: { label: 'Session Code + Presentation + Presenter', group: 'Presenter' },
session_date_presentation_presenter: { label: 'Session Code + Date + Presentation + Presenter (Full)', group: 'Presenter' }
};
// Separate group labels for the <optgroup> elements
const FORMAT_GROUPS: Record<string, FormatKey[]> = {
Universal: ['original'],
Session: ['session_code', 'session_code_name', 'session_code_date', 'session_code_date_time', 'session_code_date_name'],
Presenter: ['session_presenter', 'session_date_presenter', 'session_presentation_presenter', 'session_date_presentation_presenter']
};
let selected_format = $state<FormatKey>('session_code_name');
function build_filename(file: any, fmt: FormatKey): string {
const ext = file.extension ?? 'file';
const session_code = clean_part(file.event_session_code);
const session_name = clean_part(file.event_session_name, 30);
const session_date = date_part(file.event_session_start_datetime);
const session_time = time_part(file.event_session_start_datetime);
const pres_name = clean_part(file.event_presentation_name ?? file.event_session_name, 25);
const fam = clean_part(file.event_presenter_family_name);
const given = clean_part(file.event_presenter_given_name);
if (fmt === 'original') return file.filename ?? `file.${ext}`;
let parts: string[];
switch (fmt) {
case 'session_code': parts = [session_code]; break;
case 'session_code_name': parts = [session_code, session_name]; break;
case 'session_code_date': parts = [session_code, session_date]; break;
case 'session_code_date_time': parts = [session_code, session_date, session_time]; break;
case 'session_code_date_name': parts = [session_code, session_date, session_name]; break;
case 'session_presenter': parts = [session_code, fam, given]; break;
case 'session_date_presenter': parts = [session_code, session_date, fam, given]; break;
case 'session_presentation_presenter': parts = [session_code, pres_name, fam, given]; break;
case 'session_date_presentation_presenter': parts = [session_code, session_date, pres_name, fam, given]; break;
default: return file.filename ?? `file.${ext}`;
}
const stem = parts.filter(Boolean).join('_').replace(/_+/g, '_').replace(/^_|_$/, '');
return `${stem}.${ext}`;
}
function build_download_url(file: any, fmt: FormatKey): string {
const fname = build_filename(file, fmt);
// Using hosted_file endpoint — event_file ?key= auth not yet deployed on backend.
// hosted_file resolves event_file IDs automatically and supports ?key= today.
return encodeURI(
`${$ae_api.base_url}/v3/action/hosted_file/${file.hosted_file_id}/download?filename=${ae_util.clean_filename(fname)}&key=${$ae_api.account_id}`
);
}
// ---------------------------------------------------------------------------
// Grouping
// ---------------------------------------------------------------------------
type PresenterGroup = {
presenter_id: string;
presenter_full_name: string;
presenter_family_name: string;
presenter_given_name: string;
presentation_name: string | null;
files: any[];
};
type SessionGroup = {
session_id: string;
session_code: string;
session_name: string;
session_start_datetime: string | null;
session_files: any[]; // for_type = 'event_session'
presenter_groups: PresenterGroup[]; // for_type = 'event_presenter'
_presenter_map: Record<string, PresenterGroup>;
};
let session_groups = $derived.by((): SessionGroup[] => {
const map: Record<string, SessionGroup> = {};
for (const file of event_file_obj_li ?? []) {
if (file.for_type !== 'event_session' && file.for_type !== 'event_presenter') continue;
const sid = file.event_session_id ?? '__no_session__';
if (!map[sid]) {
map[sid] = {
session_id: sid,
session_code: file.event_session_code ?? '',
session_name: file.event_session_name ?? '',
session_start_datetime: file.event_session_start_datetime ?? null,
session_files: [],
presenter_groups: [],
_presenter_map: {}
};
}
const sg = map[sid];
if (file.for_type === 'event_session') {
sg.session_files.push(file);
} else {
const pid = file.event_presenter_id ?? '__unknown__';
if (!sg._presenter_map[pid]) {
const pg: PresenterGroup = {
presenter_id: pid,
presenter_full_name: file.event_presenter_full_name ?? '',
presenter_family_name: file.event_presenter_family_name ?? '',
presenter_given_name: file.event_presenter_given_name ?? '',
presentation_name: file.event_presentation_name ?? null,
files: []
};
sg._presenter_map[pid] = pg;
sg.presenter_groups.push(pg);
}
sg._presenter_map[pid].files.push(file);
}
}
const sorted = Object.values(map).sort((a, b) => {
if (a.session_start_datetime && b.session_start_datetime)
return a.session_start_datetime.localeCompare(b.session_start_datetime);
return a.session_name.localeCompare(b.session_name);
});
// Sort presenter groups within each session client-side (family name, then given name).
// This can't be pushed to the API order_by_li — those are view-only joined columns
// that trigger a backend "account_id" WHERE ambiguity when used in ORDER BY.
for (const sg of sorted) {
sg.presenter_groups.sort((a, b) => {
const fam = a.presenter_family_name.localeCompare(b.presenter_family_name);
if (fam !== 0) return fam;
return a.presenter_given_name.localeCompare(b.presenter_given_name);
});
}
return sorted;
});
let total_session_files = $derived(session_groups.reduce((n, sg) => n + sg.session_files.length, 0));
let total_presenter_files = $derived(session_groups.reduce((n, sg) => sg.presenter_groups.reduce((m, pg) => m + pg.files.length, 0) + n, 0));
// ---------------------------------------------------------------------------
// Query
// ---------------------------------------------------------------------------
$effect(() => {
if (qry__trigger) {
qry__trigger = false;
handle_qry();
}
});
async function handle_qry() {
qry__status = 'loading';
qry__count = 0;
$events_sess.pres_mgmt.status_qry__search = 'loading';
$events_sess.pres_mgmt.status_rpt.file_downloads = 'loading';
event_file_obj_li = [];
try {
// ORDER BY only uses raw event_file table columns + session-join columns.
// Presenter-level columns (event_presenter_family_name etc.) are view-only joined
// fields — per API guide §3B they are silently dropped from ORDER BY.
// Sort presenter groups client-side for predictable ordering.
const results = await events_func.qry__event_file({
api_cfg: $ae_api,
event_id: $events_slct.event_id,
enabled: 'enabled',
hidden: 'not_hidden',
view: 'alt', // required for joined fields: session code/name, presenter name, presentation name
limit: rpt__limit,
order_by_li: {
event_session_start_datetime: 'ASC',
event_session_name: 'ASC',
priority: 'DESC',
sort: 'DESC',
created_on: 'DESC'
},
log_lvl
}) ?? [];
event_file_obj_li = results;
qry__count = results.length;
qry__status = 'done';
} catch {
qry__status = 'error';
} finally {
$events_sess.pres_mgmt.status_qry__search = 'done';
$events_sess.pres_mgmt.status_rpt.file_downloads = 'done';
}
}
</script>
<!-- ------------------------------------------------------------------ -->
<!-- Header / controls -->
<!-- ------------------------------------------------------------------ -->
<header class="flex w-full flex-col gap-2">
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
<h3 class="h4 flex flex-row items-center gap-1">
{#if qry__status === 'loading'}
<LoaderCircle size="1em" class="animate-spin" />
{:else}
<Download size="1em" />
{/if}
File Downloads
{#if qry__count}
<span class="badge preset-tonal-surface text-sm">{qry__count} files</span>
{/if}
<span class="text-sm font-normal opacity-60">
&nbsp;({total_session_files} session &nbsp;/ {total_presenter_files} presenter)
</span>
</h3>
<button
type="button"
class="btn btn-sm preset-tonal-tertiary border-tertiary-500/30 border"
title="Re-fetch files from the API"
onclick={() => { qry__trigger = true; }}>
<RefreshCw size="1em" class="mr-1" />
Refresh
</button>
</div>
<!-- Format selector -->
<div class="flex flex-row flex-wrap items-center gap-2">
<label class="flex flex-row items-center gap-2 text-sm font-semibold">
<ArrowUpDown size="1em" />
Download filename format:
<select
class="select ae_btn_info w-auto max-w-xs text-sm"
bind:value={selected_format}>
{#each Object.entries(FORMAT_GROUPS) as [group_label, keys] (group_label)}
<optgroup label={group_label}>
{#each keys as key (key)}
<option value={key}>{FORMAT_PRESETS[key].label}</option>
{/each}
</optgroup>
{/each}
</select>
</label>
<span class="text-surface-500 text-xs italic">
Applied to all download buttons below. "Copy Link" copies the same URL.
</span>
</div>
</header>
<!-- ------------------------------------------------------------------ -->
<!-- Results -->
<!-- ------------------------------------------------------------------ -->
{#if qry__status === 'loading'}
<div class="flex items-center gap-2 p-4">
<LoaderCircle size="1em" class="animate-spin" />
<span>Loading files…</span>
</div>
{:else if qry__status === 'error'}
<div class="preset-tonal-error rounded-md p-4">
Failed to load files. Check your connection and try refreshing.
</div>
{:else if session_groups.length === 0 && qry__status === 'done'}
<p class="text-surface-500 p-4 text-center italic">
No session or presenter files found for this event.
</p>
{:else}
{#each session_groups as sg (sg.session_id)}
{@const has_any = sg.session_files.length > 0 || sg.presenter_groups.length > 0}
{#if has_any}
<!-- Session card -->
<section class="border-surface-300-700 bg-surface-50-900 my-2 rounded-lg border">
<!-- Session header -->
<div class="bg-surface-200-800 flex flex-row flex-wrap items-baseline gap-2 rounded-t-lg px-3 py-2">
{#if sg.session_code}
<span class="badge preset-tonal-primary font-mono text-xs font-bold">{sg.session_code}</span>
{/if}
<span class="font-semibold">{sg.session_name || '— session name not set —'}</span>
{#if sg.session_start_datetime}
<span class="text-surface-500 text-xs">
{ae_util.iso_datetime_formatter(sg.session_start_datetime, 'date_iso')}
&nbsp;{ae_util.iso_datetime_formatter(sg.session_start_datetime, 'time_12_short_no_leading')}
</span>
{/if}
<span class="text-surface-400 ml-auto text-xs">
{sg.session_files.length} session file{sg.session_files.length !== 1 ? 's' : ''},
&nbsp;{sg.presenter_groups.reduce((n, pg) => n + pg.files.length, 0)} presenter file{sg.presenter_groups.reduce((n, pg) => n + pg.files.length, 0) !== 1 ? 's' : ''}
</span>
</div>
<div class="divide-surface-200-800 divide-y px-2 py-1">
<!-- ---- Session-level files ---- -->
{#if sg.session_files.length > 0}
<div class="py-2">
<h4 class="mb-1 flex items-center gap-1 text-xs font-bold tracking-wider opacity-60">
<FileText size="0.9em" />
Session Files ({sg.session_files.length})
</h4>
<table class="w-full table-auto text-sm">
<tbody>
{#each sg.session_files as file (file.event_file_id)}
{@const ExtIcon = ae_util.file_extension_icon_lucide(file.extension)}
{@const computed_name = build_filename(file, selected_format)}
{@const dl_url = build_download_url(file, selected_format)}
<tr class="hover:bg-surface-100-900 border-surface-100-900 border-t transition-colors">
<td class="py-1 pr-2">
<span class="flex items-center gap-1 text-xs opacity-50" title={file.filename}>
<ExtIcon size="0.9em" />
{ae_util.shorten_filename({ filename: file.filename, max_length: 30 })}
</span>
</td>
<td class="px-2 py-1">
<span class="font-mono text-xs opacity-70" title="Filename that will be used for download">
{computed_name}
</span>
</td>
<td class="px-1 py-1 text-right text-xs opacity-50 whitespace-nowrap">
{ae_util.format_bytes(file.file_size)}
</td>
<td class="pl-2 py-1 whitespace-nowrap">
<div class="flex flex-row items-center gap-1">
<a
href={dl_url}
download={computed_name}
class="btn btn-xs preset-tonal-primary border-primary-500/30 border"
title="Download as: {computed_name}">
<Download size="0.9em" class="mr-0.5" />
Download
</a>
<MyClipboard
value={dl_url}
btn_text="Copy Link"
btn_title="Copy direct download link: {computed_name}"
btn_class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500 border-warning-500/30 border"
/>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- ---- Presenter-level files ---- -->
{#each sg.presenter_groups as pg (pg.presenter_id)}
<div class="py-2">
<h4 class="mb-1 flex items-center gap-1 text-xs font-bold tracking-wider opacity-60">
<User size="0.9em" />
{pg.presenter_full_name || '— presenter name not set —'}
{#if pg.presentation_name}
<span class="font-normal normal-case opacity-80">{pg.presentation_name}</span>
{/if}
<span class="font-normal normal-case">({pg.files.length} file{pg.files.length !== 1 ? 's' : ''})</span>
</h4>
<table class="w-full table-auto text-sm">
<tbody>
{#each pg.files as file (file.event_file_id)}
{@const ExtIcon = ae_util.file_extension_icon_lucide(file.extension)}
{@const computed_name = build_filename(file, selected_format)}
{@const dl_url = build_download_url(file, selected_format)}
<tr class="hover:bg-surface-100-900 border-surface-100-900 border-t transition-colors">
<td class="py-1 pr-2">
<span class="flex items-center gap-1 text-xs opacity-50" title={file.filename}>
<ExtIcon size="0.9em" />
{ae_util.shorten_filename({ filename: file.filename, max_length: 30 })}
</span>
</td>
<td class="px-2 py-1">
<span class="font-mono text-xs opacity-70" title="Filename that will be used for download">
{computed_name}
</span>
</td>
<td class="px-1 py-1 text-right text-xs opacity-50 whitespace-nowrap">
{ae_util.format_bytes(file.file_size)}
</td>
<td class="pl-2 py-1 whitespace-nowrap">
<div class="flex flex-row items-center gap-1">
<a
href={dl_url}
download={computed_name}
class="btn btn-xs preset-tonal-primary border-primary-500/30 border"
title="Download as: {computed_name}">
<Download size="0.9em" class="mr-0.5" />
Download
</a>
<MyClipboard
value={dl_url}
btn_text="Copy Link"
btn_title="Copy direct download link: {computed_name}"
btn_class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500 border-warning-500/30 border"
/>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
</div>
</section>
{/if}
{/each}
{/if}