feat(pres_mgmt): prefix/suffix inputs, flex row layout, strip :443 from links

File Downloads report: add Prefix and Suffix inputs for filename customization
(e.g. "2026_06__"), longest-filename length indicator with warning above 120
chars, and switch file rows from table to flex layout so Download/Copy Link
buttons stay right-aligned regardless of filename length.

Strip redundant :443 from https download URLs in both the file downloads report
and the manage event file list clipboard links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-10 15:15:16 -04:00
parent e909c34874
commit e8a49562a9
2 changed files with 205 additions and 130 deletions

View File

@@ -78,6 +78,9 @@ let ae_promises: key_val = $state({});
let ae_tmp: key_val = $state({}); let ae_tmp: key_val = $state({});
ae_tmp.show__file_li = true; ae_tmp.show__file_li = true;
ae_tmp.show__direct_download = pres_mgmt_loc.current.show__direct_download; ae_tmp.show__direct_download = pres_mgmt_loc.current.show__direct_download;
// Strip :443 from https URLs — redundant and clutters shareable links.
let base_url = $derived($ae_api.base_url.replace(/^(https:\/\/[^/:]+):443(\/|$)/, '$1$2'));
// let ae_triggers: key_val = {}; // let ae_triggers: key_val = {};
onMount(() => { onMount(() => {
@@ -317,7 +320,7 @@ async function handle_convert_pdf_to_image(event_file_obj: key_val) {
</span> </span>
<MyClipboard <MyClipboard
value={encodeURI( value={encodeURI(
`${$ae_api.base_url}/v3/action/event_file/${event_file_obj?.event_file_id}/download?filename=${ae_util.clean_filename(event_file_obj?.filename)}&key=${$ae_api.account_id}` `${base_url}/v3/action/event_file/${event_file_obj?.event_file_id}/download?filename=${ae_util.clean_filename(event_file_obj?.filename)}&key=${$ae_api.account_id}`
)} )}
btn_text="Copy Original" btn_text="Copy Original"
btn_title="Copy the direct download link to the clipboard." btn_title="Copy the direct download link to the clipboard."
@@ -326,7 +329,7 @@ async function handle_convert_pdf_to_image(event_file_obj: key_val) {
<MyClipboard <MyClipboard
value={encodeURI( value={encodeURI(
`${$ae_api.base_url}/v3/action/event_file/${event_file_obj?.event_file_id}/download?filename=${ae_util.clean_filename(event_file_obj?.event_session_code ?? '')}_${ae_util.clean_filename(event_file_obj?.event_presentation_name ?? event_file_obj?.event_session_name ?? '').substring(0, 30)}_${ae_util.clean_filename(event_file_obj?.event_presenter_full_name ?? '')}.${event_file_obj?.extension}&key=${$ae_api.account_id}` `${base_url}/v3/action/event_file/${event_file_obj?.event_file_id}/download?filename=${ae_util.clean_filename(event_file_obj?.event_session_code ?? '')}_${ae_util.clean_filename(event_file_obj?.event_presentation_name ?? event_file_obj?.event_session_name ?? '').substring(0, 30)}_${ae_util.clean_filename(event_file_obj?.event_presenter_full_name ?? '')}.${event_file_obj?.extension}&key=${$ae_api.account_id}`
)} )}
btn_text="Copy Renamed" btn_text="Copy Renamed"
btn_title="Copy the renamed download link to the clipboard. Format: [session-code]_[presentation-name]_[presenter-name].[ext]" btn_title="Copy the renamed download link to the clipboard. Format: [session-code]_[presentation-name]_[presenter-name].[ext]"

View File

@@ -20,7 +20,7 @@ let {
}: Props = $props(); }: Props = $props();
import { ae_util } from '$lib/ae_utils/ae_utils'; import { ae_util } from '$lib/ae_utils/ae_utils';
import { ae_api, ae_loc } from '$lib/stores/ae_stores'; import { ae_api } from '$lib/stores/ae_stores';
import { events_slct, events_sess } from '$lib/stores/ae_events_stores'; import { events_slct, events_sess } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import MyClipboard from '$lib/app_components/e_app_clipboard.svelte'; import MyClipboard from '$lib/app_components/e_app_clipboard.svelte';
@@ -30,15 +30,25 @@ import {
FileText, FileText,
LoaderCircle, LoaderCircle,
RefreshCw, RefreshCw,
User, TriangleAlert,
Users User
} from '@lucide/svelte'; } from '@lucide/svelte';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Filename helpers // Filename helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Strip OS-unsafe chars AND periods (no periods in filename stems). let prefix_val = $state('');
let suffix_val = $state('');
// For prefix/suffix: clean OS-unsafe chars and periods, but preserve leading/trailing
// underscores — users intentionally use them as separators (e.g. "2026_06__").
function clean_affix(s: string): string {
if (!s) return '';
return ae_util.clean_filename(s).replace(/\./g, '_');
}
// For filename parts: also strip leading/trailing underscores and collapse runs.
function clean_part(s: any, max?: number): string { function clean_part(s: any, max?: number): string {
if (!s) return ''; if (!s) return '';
const out = ae_util const out = ae_util
@@ -71,19 +81,18 @@ type FormatKey =
| 'session_date_presentation_presenter'; | 'session_date_presentation_presenter';
const FORMAT_PRESETS: Record<FormatKey, { label: string; group: string }> = { const FORMAT_PRESETS: Record<FormatKey, { label: string; group: string }> = {
original: { label: 'Original filename', group: 'Universal' }, original: { label: 'Original filename', group: 'Universal' },
session_code: { label: 'Session Code', group: 'Session' }, session_code: { label: 'Session Code', group: 'Session' },
session_code_name: { label: 'Session Code + Name', group: 'Session' }, session_code_name: { label: 'Session Code + Name', group: 'Session' },
session_code_date: { label: 'Session Code + Date', 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_time: { label: 'Session Code + Date + Time', group: 'Session' },
session_code_date_name: { label: 'Session Code + Date + Name', group: 'Session' }, session_code_date_name: { label: 'Session Code + Date + Name', group: 'Session' },
session_presenter: { label: 'Session Code + Presenter (Family, Given)', group: 'Presenter' }, session_presenter: { label: 'Session Code + Presenter (Family, Given)', group: 'Presenter' },
session_date_presenter: { label: 'Session Code + Date + Presenter', group: 'Presenter' }, session_date_presenter: { label: 'Session Code + Date + Presenter', group: 'Presenter' },
session_presentation_presenter: { label: 'Session Code + Presentation + 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' } 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[]> = { const FORMAT_GROUPS: Record<string, FormatKey[]> = {
Universal: ['original'], Universal: ['original'],
Session: ['session_code', 'session_code_name', 'session_code_date', 'session_code_date_time', 'session_code_date_name'], Session: ['session_code', 'session_code_name', 'session_code_date', 'session_code_date_time', 'session_code_date_name'],
@@ -92,43 +101,73 @@ const FORMAT_GROUPS: Record<string, FormatKey[]> = {
let selected_format = $state<FormatKey>('session_code_name'); let selected_format = $state<FormatKey>('session_code_name');
const FILENAME_WARN_LEN = 120; // show warning above this char count
function build_filename(file: any, fmt: FormatKey): string { function build_filename(file: any, fmt: FormatKey): string {
const ext = file.extension ?? 'file'; const ext = file.extension ?? 'file';
const session_code = clean_part(file.event_session_code); const pre = clean_affix(prefix_val);
const session_name = clean_part(file.event_session_name, 30); const suf = clean_affix(suffix_val);
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 stem: string;
if (fmt === 'original') {
// Preserve the original stem as-is; only the prefix/suffix are user-controlled.
stem = file.filename_no_ext ?? (file.filename ?? 'file').replace(/\.[^.]+$/, '');
} else {
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);
let parts: string[]; let parts: string[];
switch (fmt) { switch (fmt) {
case 'session_code': parts = [session_code]; break; case 'session_code': parts = [session_code]; break;
case 'session_code_name': parts = [session_code, session_name]; 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': parts = [session_code, session_date]; break;
case 'session_code_date_time': parts = [session_code, session_date, session_time]; 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_code_date_name': parts = [session_code, session_date, session_name]; break;
case 'session_presenter': parts = [session_code, fam, given]; break; case 'session_presenter': parts = [session_code, fam, given]; break;
case 'session_date_presenter': parts = [session_code, session_date, 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_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; case 'session_date_presentation_presenter': parts = [session_code, session_date, pres_name, fam, given]; break;
default: return file.filename ?? `file.${ext}`; default: stem = file.filename_no_ext ?? 'file'; return `${pre}${stem}${suf}.${ext}`;
}
stem = parts.filter(Boolean).join('_').replace(/_+/g, '_').replace(/^_|_$/, '');
} }
const stem = parts.filter(Boolean).join('_').replace(/_+/g, '_').replace(/^_|_$/, ''); return `${pre}${stem}${suf}.${ext}`;
return `${stem}.${ext}`;
} }
// Strip :443 from https URLs — redundant and clutters shareable links.
let base_url = $derived($ae_api.base_url.replace(/^(https:\/\/[^/:]+):443(\/|$)/, '$1$2'));
function build_download_url(file: any, fmt: FormatKey): string { function build_download_url(file: any, fmt: FormatKey): string {
const fname = build_filename(file, fmt); const fname = build_filename(file, fmt);
return encodeURI( return encodeURI(
`${$ae_api.base_url}/v3/action/event_file/${file.event_file_id}/download?filename=${ae_util.clean_filename(fname)}&key=${$ae_api.account_id}` `${base_url}/v3/action/event_file/${file.event_file_id}/download?filename=${ae_util.clean_filename(fname)}&key=${$ae_api.account_id}`
); );
} }
// Longest computed filename across all files — reactive to format + prefix + suffix changes.
let max_computed_len = $derived.by(() => {
let max = 0;
for (const sg of session_groups) {
for (const f of sg.session_files) {
const len = build_filename(f, selected_format).length;
if (len > max) max = len;
}
for (const pg of sg.presenter_groups) {
for (const f of pg.files) {
const len = build_filename(f, selected_format).length;
if (len > max) max = len;
}
}
}
return max;
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Grouping // Grouping
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -147,8 +186,8 @@ type SessionGroup = {
session_code: string; session_code: string;
session_name: string; session_name: string;
session_start_datetime: string | null; session_start_datetime: string | null;
session_files: any[]; // for_type = 'event_session' session_files: any[];
presenter_groups: PresenterGroup[]; // for_type = 'event_presenter' presenter_groups: PresenterGroup[];
_presenter_map: Record<string, PresenterGroup>; _presenter_map: Record<string, PresenterGroup>;
}; };
@@ -199,8 +238,8 @@ let session_groups = $derived.by((): SessionGroup[] => {
}); });
// Sort presenter groups within each session client-side (family name, then given 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 // Presenter-level columns are view-only joined fields — per API guide §3B they are
// that trigger a backend "account_id" WHERE ambiguity when used in ORDER BY. // silently dropped from ORDER BY, so sorting must be done client-side.
for (const sg of sorted) { for (const sg of sorted) {
sg.presenter_groups.sort((a, b) => { sg.presenter_groups.sort((a, b) => {
const fam = a.presenter_family_name.localeCompare(b.presenter_family_name); const fam = a.presenter_family_name.localeCompare(b.presenter_family_name);
@@ -212,8 +251,8 @@ let session_groups = $derived.by((): SessionGroup[] => {
return sorted; return sorted;
}); });
let total_session_files = $derived(session_groups.reduce((n, sg) => n + sg.session_files.length, 0)); 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)); let total_presenter_files = $derived(session_groups.reduce((n, sg) => sg.presenter_groups.reduce((m, pg) => m + pg.files.length, 0) + n, 0));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Query // Query
@@ -301,7 +340,7 @@ async function handle_qry() {
<div class="flex flex-row flex-wrap items-center gap-2"> <div class="flex flex-row flex-wrap items-center gap-2">
<label class="flex flex-row items-center gap-2 text-sm font-semibold"> <label class="flex flex-row items-center gap-2 text-sm font-semibold">
<ArrowUpDown size="1em" /> <ArrowUpDown size="1em" />
Download filename format: Format:
<select <select
class="select ae_btn_info w-auto max-w-xs text-sm" class="select ae_btn_info w-auto max-w-xs text-sm"
bind:value={selected_format}> bind:value={selected_format}>
@@ -314,9 +353,38 @@ async function handle_qry() {
{/each} {/each}
</select> </select>
</label> </label>
<span class="text-surface-500 text-xs italic"> </div>
Applied to all download buttons below. "Copy Link" copies the same URL.
</span> <!-- Prefix / suffix -->
<div class="flex flex-row flex-wrap items-center gap-3 text-sm">
<label class="flex flex-row items-center gap-1 font-semibold">
Prefix:
<input
type="text"
class="input text-sm w-48"
placeholder="e.g. 2026_06__"
bind:value={prefix_val} />
</label>
<label class="flex flex-row items-center gap-1 font-semibold">
Suffix:
<input
type="text"
class="input text-sm w-36"
placeholder="e.g. __final"
bind:value={suffix_val} />
</label>
{#if qry__count > 0}
<span
class="text-xs"
class:text-warning-600={max_computed_len > FILENAME_WARN_LEN}
class:opacity-50={max_computed_len <= FILENAME_WARN_LEN}
title="Longest computed filename across all files">
{#if max_computed_len > FILENAME_WARN_LEN}
<TriangleAlert size="0.9em" class="mr-0.5 inline-block" />
{/if}
longest: {max_computed_len} chars
</span>
{/if}
</div> </div>
</header> </header>
@@ -370,49 +438,51 @@ async function handle_qry() {
<FileText size="0.9em" /> <FileText size="0.9em" />
Session Files ({sg.session_files.length}) Session Files ({sg.session_files.length})
</h4> </h4>
<table class="w-full table-auto text-sm"> <div class="flex flex-col">
<tbody> {#each sg.session_files as file (file.event_file_id)}
{#each sg.session_files as file (file.event_file_id)} {@const ExtIcon = ae_util.file_extension_icon_lucide(file.extension)}
{@const ExtIcon = ae_util.file_extension_icon_lucide(file.extension)} {@const computed_name = build_filename(file, selected_format)}
{@const computed_name = build_filename(file, selected_format)} {@const dl_url = build_download_url(file, selected_format)}
{@const dl_url = build_download_url(file, selected_format)} {@const is_long = computed_name.length > FILENAME_WARN_LEN}
<tr class="hover:bg-surface-100-900 border-surface-100-900 border-t transition-colors"> <div class="border-surface-100-900 hover:bg-surface-100-900 flex flex-row items-center gap-2 border-t py-1.5 transition-colors">
<td class="py-1 pr-2"> <!-- Left: filenames (grows, min-w-0 allows truncation) -->
<span class="flex items-center gap-1 text-xs opacity-50" title={file.filename}> <div class="flex min-w-0 grow flex-col gap-0.5">
<ExtIcon size="0.9em" /> <span class="flex items-center gap-1 truncate text-xs opacity-50" title={file.filename}>
{ae_util.shorten_filename({ filename: file.filename, max_length: 30 })} <ExtIcon size="0.9em" class="shrink-0" />
</span> {ae_util.shorten_filename({ filename: file.filename, max_length: 40 })}
</td> </span>
<td class="px-2 py-1"> <span
<span class="font-mono text-xs opacity-70" title="Filename that will be used for download"> class="truncate font-mono text-xs"
{computed_name} class:opacity-70={!is_long}
</span> class:text-warning-600={is_long}
</td> title="{computed_name}{is_long ? ` (${computed_name.length} chars may be long for some systems)` : ''}">
<td class="px-1 py-1 text-right text-xs opacity-50 whitespace-nowrap"> {computed_name}
{ae_util.format_bytes(file.file_size)} </span>
</td> </div>
<td class="pl-2 py-1 whitespace-nowrap"> <!-- File size -->
<div class="flex flex-row items-center gap-1"> <span class="shrink-0 whitespace-nowrap text-xs opacity-50">
<a {ae_util.format_bytes(file.file_size)}
href={dl_url} </span>
download={computed_name} <!-- Buttons -->
class="btn btn-xs preset-tonal-primary border-primary-500/30 border" <div class="flex shrink-0 flex-row items-center gap-1">
title="Download as: {computed_name}"> <a
<Download size="0.9em" class="mr-0.5" /> href={dl_url}
Download download={computed_name}
</a> class="btn btn-xs preset-tonal-primary border-primary-500/30 border"
<MyClipboard title="Download as: {computed_name}">
value={dl_url} <Download size="0.9em" class="mr-0.5" />
btn_text="Copy Link" Download
btn_title="Copy direct download link: {computed_name}" </a>
btn_class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500 border-warning-500/30 border" <MyClipboard
/> value={dl_url}
</div> btn_text="Copy Link"
</td> btn_title="Copy direct download link: {computed_name}"
</tr> btn_class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500 border-warning-500/30 border"
{/each} />
</tbody> </div>
</table> </div>
{/each}
</div>
</div> </div>
{/if} {/if}
@@ -427,49 +497,51 @@ async function handle_qry() {
{/if} {/if}
<span class="font-normal normal-case">({pg.files.length} file{pg.files.length !== 1 ? 's' : ''})</span> <span class="font-normal normal-case">({pg.files.length} file{pg.files.length !== 1 ? 's' : ''})</span>
</h4> </h4>
<table class="w-full table-auto text-sm"> <div class="flex flex-col">
<tbody> {#each pg.files as file (file.event_file_id)}
{#each pg.files as file (file.event_file_id)} {@const ExtIcon = ae_util.file_extension_icon_lucide(file.extension)}
{@const ExtIcon = ae_util.file_extension_icon_lucide(file.extension)} {@const computed_name = build_filename(file, selected_format)}
{@const computed_name = build_filename(file, selected_format)} {@const dl_url = build_download_url(file, selected_format)}
{@const dl_url = build_download_url(file, selected_format)} {@const is_long = computed_name.length > FILENAME_WARN_LEN}
<tr class="hover:bg-surface-100-900 border-surface-100-900 border-t transition-colors"> <div class="border-surface-100-900 hover:bg-surface-100-900 flex flex-row items-center gap-2 border-t py-1.5 transition-colors">
<td class="py-1 pr-2"> <!-- Left: filenames (grows, min-w-0 allows truncation) -->
<span class="flex items-center gap-1 text-xs opacity-50" title={file.filename}> <div class="flex min-w-0 grow flex-col gap-0.5">
<ExtIcon size="0.9em" /> <span class="flex items-center gap-1 truncate text-xs opacity-50" title={file.filename}>
{ae_util.shorten_filename({ filename: file.filename, max_length: 30 })} <ExtIcon size="0.9em" class="shrink-0" />
</span> {ae_util.shorten_filename({ filename: file.filename, max_length: 40 })}
</td> </span>
<td class="px-2 py-1"> <span
<span class="font-mono text-xs opacity-70" title="Filename that will be used for download"> class="truncate font-mono text-xs"
{computed_name} class:opacity-70={!is_long}
</span> class:text-warning-600={is_long}
</td> title="{computed_name}{is_long ? ` (${computed_name.length} chars may be long for some systems)` : ''}">
<td class="px-1 py-1 text-right text-xs opacity-50 whitespace-nowrap"> {computed_name}
{ae_util.format_bytes(file.file_size)} </span>
</td> </div>
<td class="pl-2 py-1 whitespace-nowrap"> <!-- File size -->
<div class="flex flex-row items-center gap-1"> <span class="shrink-0 whitespace-nowrap text-xs opacity-50">
<a {ae_util.format_bytes(file.file_size)}
href={dl_url} </span>
download={computed_name} <!-- Buttons -->
class="btn btn-xs preset-tonal-primary border-primary-500/30 border" <div class="flex shrink-0 flex-row items-center gap-1">
title="Download as: {computed_name}"> <a
<Download size="0.9em" class="mr-0.5" /> href={dl_url}
Download download={computed_name}
</a> class="btn btn-xs preset-tonal-primary border-primary-500/30 border"
<MyClipboard title="Download as: {computed_name}">
value={dl_url} <Download size="0.9em" class="mr-0.5" />
btn_text="Copy Link" Download
btn_title="Copy direct download link: {computed_name}" </a>
btn_class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500 border-warning-500/30 border" <MyClipboard
/> value={dl_url}
</div> btn_text="Copy Link"
</td> btn_title="Copy direct download link: {computed_name}"
</tr> btn_class="btn btn-xs preset-tonal-warning hover:preset-filled-warning-500 border-warning-500/30 border"
{/each} />
</tbody> </div>
</table> </div>
{/each}
</div>
</div> </div>
{/each} {/each}