feat(immich): per-album scheduled/memory dispatch + template tooling
Dispatch: honor {kind}_collection_mode on TrackingConfig — "per_collection"
fans out one event per album; "combined" pools assets as before. Extract
build_immich_dispatch_events shared by cron and test paths.
Assets: collect_scheduled_assets attaches album_name/album_url/album_public_url
to each asset so combined-mode templates can attribute rows to their source
album. Default scheduled_assets templates render a multi-album header with
inline album list and per-row album link; memory_mode follows the same pattern.
UI: "Reset to default" buttons on notification and command template slots
(per-slot and whole-template), backed by new GET /*-template-configs/defaults
endpoints. tracking-configs "Preview template" now opens an inline preview
modal with locale tabs instead of navigating away; Edit button deep-links
with ?edit_slot=<name> so the destination auto-opens the config and scrolls
to the slot. Reset confirmations use ConfirmModal instead of window.confirm.
Fixes:
* NotificationDispatcher._session_ctx infinite recursion when no shared
aiohttp.ClientSession was passed — broke test dispatch for periodic/
scheduled/memory (cron path was unaffected).
* telegram-bots /chats/{id}/test now resolves chat.language_override /
language_code instead of using the raw ?locale query param, matching
the resolution the tracker-target test endpoint already used.
* scheduled_assets default template no longer emits a blank line between
header and the first asset when the multi-album branch is taken.
This commit is contained in:
@@ -54,6 +54,11 @@
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
let confirmReset = $state<{
|
||||
kind: 'slot' | 'all';
|
||||
slotKey?: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
let slotPreview = $state<Record<string, string>>({});
|
||||
let slotErrors = $state<Record<string, string>>({});
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
@@ -253,6 +258,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resetSlotToDefault(slotKey: string) {
|
||||
if (!form.provider_type) return;
|
||||
confirmReset = {
|
||||
kind: 'slot',
|
||||
slotKey,
|
||||
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
function resetAllToDefaults() {
|
||||
if (!form.provider_type) return;
|
||||
confirmReset = {
|
||||
kind: 'all',
|
||||
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
async function performReset() {
|
||||
if (!confirmReset || !form.provider_type) return;
|
||||
const { kind, slotKey } = confirmReset;
|
||||
confirmReset = null;
|
||||
try {
|
||||
if (kind === 'slot' && slotKey) {
|
||||
const res = await api<Record<string, Record<string, string>>>(
|
||||
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||
);
|
||||
const text = res?.[slotKey]?.[activeLocale];
|
||||
if (!text) {
|
||||
snackError(t('templateConfig.resetNoDefault'));
|
||||
return;
|
||||
}
|
||||
setSlotValue(slotKey, text);
|
||||
validateSlot(slotKey, text, true);
|
||||
} else {
|
||||
const res = await api<Record<string, Record<string, string>>>(
|
||||
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||
);
|
||||
const nextSlots = { ...form.slots };
|
||||
for (const [key, localeMap] of Object.entries(res || {})) {
|
||||
const text = localeMap?.[activeLocale];
|
||||
if (text === undefined) continue;
|
||||
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
|
||||
}
|
||||
form.slots = nextSlots;
|
||||
refreshAllPreviews();
|
||||
}
|
||||
snackSuccess(t('templateConfig.resetApplied'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clone(c: CmdTemplateConfig) {
|
||||
const slotsCopy: Record<string, Record<string, string>> = {};
|
||||
for (const [k, v] of Object.entries(c.slots)) {
|
||||
@@ -343,7 +400,7 @@
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
|
||||
|
||||
<!-- Locale tabs -->
|
||||
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
{#each LOCALES as loc}
|
||||
<button type="button"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
|
||||
@@ -351,6 +408,14 @@
|
||||
{loc.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
{#if form.provider_type}
|
||||
<button type="button" onclick={resetAllToDefaults}
|
||||
title={t('templateConfig.resetAllToDefaults')}
|
||||
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
|
||||
<MdiIcon name="mdiRefresh" size={12} />
|
||||
{t('templateConfig.resetAllToDefaults')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Slot filter -->
|
||||
@@ -381,6 +446,11 @@
|
||||
<button type="button" onclick={() => showVarsFor = slot.name}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
|
||||
title={t('templateConfig.resetToDefault')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||
{t('templateConfig.resetToDefault')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
@@ -472,6 +542,14 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<ConfirmModal open={confirmReset !== null}
|
||||
title={t('templateConfig.resetToDefault')}
|
||||
message={confirmReset?.message || ''}
|
||||
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
|
||||
confirmIcon="mdiRefresh"
|
||||
onconfirm={performReset}
|
||||
oncancel={() => confirmReset = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
|
||||
@@ -84,17 +84,23 @@
|
||||
let testMenuStyle = $state('');
|
||||
|
||||
// Test types: basic is always available; periodic/scheduled/memory only for providers
|
||||
// that have those notification slots in their capabilities
|
||||
const allTestTypes: Record<string, { key: string; icon: string; labelKey: string; requiredSlot?: string }> = {
|
||||
// that have those notification slots in their capabilities AND have the feature
|
||||
// enabled on the tracker's default TrackingConfig. A disabled feature on the
|
||||
// default config means cron dispatch won't fire it in production either — so
|
||||
// the test button would just surface a silent skip.
|
||||
const allTestTypes: Record<string, {
|
||||
key: string; icon: string; labelKey: string;
|
||||
requiredSlot?: string; enabledField?: string;
|
||||
}> = {
|
||||
basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
||||
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message' },
|
||||
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message' },
|
||||
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message' },
|
||||
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message', enabledField: 'periodic_enabled' },
|
||||
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message', enabledField: 'scheduled_enabled' },
|
||||
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message', enabledField: 'memory_enabled' },
|
||||
};
|
||||
|
||||
let testMenuTrackerId = $state<number | null>(null);
|
||||
let testTypes = $derived.by(() => {
|
||||
const base = [allTestTypes.basic];
|
||||
const base: { key: string; icon: string; labelKey: string; disabledReason?: string }[] = [allTestTypes.basic];
|
||||
if (!testMenuTrackerId) return base;
|
||||
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
|
||||
if (!tracker) return base;
|
||||
@@ -103,8 +109,18 @@
|
||||
const caps = allCapabilities[provider.type];
|
||||
if (!caps) return base;
|
||||
const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name));
|
||||
const defaultTc = trackingConfigs.find(c => c.id === tracker.default_tracking_config_id);
|
||||
for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) {
|
||||
if (tt.requiredSlot && slotNames.has(tt.requiredSlot)) base.push(tt);
|
||||
if (!tt.requiredSlot || !slotNames.has(tt.requiredSlot)) continue;
|
||||
const enabled = !!defaultTc && !!tt.enabledField && !!(defaultTc as any)[tt.enabledField];
|
||||
base.push({
|
||||
key: tt.key, icon: tt.icon, labelKey: tt.labelKey,
|
||||
// When surfaced, the button still renders but is disabled and
|
||||
// shows *why* — users who land here via the test menu without
|
||||
// having toggled the feature on Tracking Config see a clear
|
||||
// pointer to the missing setting instead of a silent failure.
|
||||
disabledReason: enabled ? undefined : 'notificationTracker.testDisabledHint',
|
||||
});
|
||||
}
|
||||
return base;
|
||||
});
|
||||
@@ -516,6 +532,15 @@
|
||||
onclose={() => { linkWarning = null; }}
|
||||
onautoCreate={autoCreateLinks}
|
||||
ondismiss={dismissLinkWarning}
|
||||
onupdate={(remaining) => {
|
||||
if (!linkWarning) return;
|
||||
if (remaining.length === 0) {
|
||||
linkWarning = null;
|
||||
doSave();
|
||||
} else {
|
||||
linkWarning = { ...linkWarning, albums: remaining };
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
@@ -1,17 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import { api } from '$lib/api';
|
||||
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
interface AlbumIssue { id: string; name: string; issue: string }
|
||||
|
||||
interface Props {
|
||||
linkWarning: { albums: any[]; providerId: number } | null;
|
||||
linkWarning: { albums: AlbumIssue[]; providerId: number } | null;
|
||||
linkCreating: boolean;
|
||||
onclose: () => void;
|
||||
onautoCreate: () => void;
|
||||
ondismiss: () => void;
|
||||
/** Called with the updated warning list after a per-row replace. */
|
||||
onupdate?: (albums: AlbumIssue[]) => void;
|
||||
}
|
||||
|
||||
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss }: Props = $props();
|
||||
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss, onupdate }: Props = $props();
|
||||
|
||||
/** Per-row loading state for the "Replace" button. */
|
||||
let replacing = $state<Record<string, boolean>>({});
|
||||
|
||||
/**
|
||||
* Expired and password-protected links can't be repaired in place — the
|
||||
* Immich API has no "reset" endpoint. The only remedy is to recreate the
|
||||
* link (which the backend does by POSTing a new one and returning it).
|
||||
* We surface the action per-row so users don't have to leave the form.
|
||||
*/
|
||||
async function replaceOne(album: AlbumIssue) {
|
||||
if (!linkWarning) return;
|
||||
replacing = { ...replacing, [album.id]: true };
|
||||
try {
|
||||
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ replace: true }),
|
||||
});
|
||||
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
|
||||
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
|
||||
if (onupdate) onupdate(remaining);
|
||||
} catch (err: any) {
|
||||
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
|
||||
} finally {
|
||||
replacing = { ...replacing, [album.id]: false };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={onclose}>
|
||||
@@ -19,13 +52,26 @@
|
||||
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
|
||||
{t('notificationTracker.missingLinksDesc')}
|
||||
</p>
|
||||
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
|
||||
<div class="space-y-1.5 mb-4 max-h-60 overflow-y-auto">
|
||||
{#each linkWarning.albums as album}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<span class="font-medium">{album.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
<div class="flex items-center justify-between gap-2 text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="font-medium truncate block">{album.name}</span>
|
||||
{#if album.issue === 'password-protected'}
|
||||
<span class="text-[10px] block" style="color: var(--color-muted-foreground);">
|
||||
{t('notificationTracker.linkPasswordProtectedNote')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
|
||||
</span>
|
||||
{#if album.issue === 'expired' || album.issue === 'password-protected'}
|
||||
<button type="button" onclick={() => replaceOne(album)} disabled={replacing[album.id]}
|
||||
class="text-xs px-2 py-1 rounded border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50 shrink-0">
|
||||
{replacing[album.id] ? t('notificationTracker.linkReplacing') : t('notificationTracker.linkReplace')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
testMenuOpen: string | null;
|
||||
testMenuStyle: string;
|
||||
ttTesting: Record<string, string>;
|
||||
testTypes: { key: string; icon: string; labelKey: string }[];
|
||||
/**
|
||||
* When `disabledReason` is set, the button is rendered greyed out with a
|
||||
* tooltip pointing the user at the missing setting (e.g. "Enable Periodic
|
||||
* Summary in Tracking Config first"). Clicking is blocked — clicking an
|
||||
* unconfigured test would have surfaced as a silent server-side skip.
|
||||
*/
|
||||
testTypes: { key: string; icon: string; labelKey: string; disabledReason?: string }[];
|
||||
ontest: (ttId: number, testType: string) => void;
|
||||
onclose: () => void;
|
||||
}
|
||||
@@ -20,18 +26,27 @@
|
||||
onclick={onclose}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}>
|
||||
</div>
|
||||
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
|
||||
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:12rem;">
|
||||
{#each testTypes as tt}
|
||||
{@const busy = !!ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
{@const blocked = !!tt.disabledReason}
|
||||
<button
|
||||
onclick={() => ontest(Number(testMenuOpen), tt.key)}
|
||||
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
onclick={() => { if (!blocked) ontest(Number(testMenuOpen), tt.key); }}
|
||||
disabled={busy || blocked}
|
||||
title={blocked ? t(tt.disabledReason!) : ''}
|
||||
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
|
||||
<MdiIcon name={tt.icon} size={14} />
|
||||
{t(tt.labelKey)}
|
||||
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||
{#if blocked}
|
||||
<MdiIcon name="mdiLock" size={12} />
|
||||
{/if}
|
||||
{#if busy}
|
||||
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if blocked}
|
||||
<p class="px-3 pb-1 text-[10px]" style="color: var(--color-muted-foreground);">{t(tt.disabledReason!)}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||
import { getDescriptor } from '$lib/providers';
|
||||
@@ -199,6 +200,22 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
|
||||
live on the tracking config, not on the tracker itself. Surface this
|
||||
here so users don't have to stumble onto the feature by reading docs. -->
|
||||
{#if providerType === 'immich'}
|
||||
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
|
||||
<div class="flex-1 text-xs">
|
||||
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
|
||||
<a href="/tracking-configs" class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
|
||||
<MdiIcon name="mdiArrowRight" size={12} />
|
||||
{t('notificationTracker.openTrackingConfig')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
||||
</button>
|
||||
|
||||
@@ -42,6 +42,17 @@
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
/**
|
||||
* Reset-to-default confirmation prompt. ``kind: 'slot'`` confirms a
|
||||
* single-slot reset (slotKey populated); ``'all'`` confirms a full
|
||||
* locale-scoped wipe. Split from confirmDelete so the two flows can
|
||||
* coexist without stomping each other's state mid-dialog.
|
||||
*/
|
||||
let confirmReset = $state<{
|
||||
kind: 'slot' | 'all';
|
||||
slotKey?: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
let slotPreview = $state<Record<string, string>>({});
|
||||
let slotErrors = $state<Record<string, string>>({});
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
@@ -206,7 +217,40 @@
|
||||
supportedLocalesCache.fetch(),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; highlightFromUrl(); }
|
||||
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to ``?edit_slot=<slot_name>&provider=<type>`` deep-links from
|
||||
* other pages (currently the tracking-configs Preview-template modal).
|
||||
* Picks the first visible config matching ``provider``, opens it in edit
|
||||
* mode, and pre-expands the target slot. Strips the param from the URL so
|
||||
* a subsequent reload doesn't reopen the form unexpectedly.
|
||||
*/
|
||||
function handleDeepLink() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const slot = params.get('edit_slot');
|
||||
if (!slot) return;
|
||||
const provider = params.get('provider') || '';
|
||||
const target = allTemplateConfigs.find(
|
||||
c => !provider || c.provider_type === provider,
|
||||
);
|
||||
// Strip the deep-link param so reload/back doesn't replay it.
|
||||
params.delete('edit_slot');
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''));
|
||||
if (!target) {
|
||||
snackError(t('templateConfig.deepLinkNoConfig'));
|
||||
return;
|
||||
}
|
||||
edit(target);
|
||||
expandedSlots = new Set([slot]);
|
||||
// Scroll the slot into view once the form has rendered.
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(`slot-${slot}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
@@ -241,6 +285,65 @@
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the user to confirm a reset. The actual fetch+replace runs in
|
||||
* ``performReset`` after the ConfirmModal's onconfirm fires. Split into
|
||||
* two steps so we can use the app-wide ConfirmModal (consistent look,
|
||||
* keyboard handling) instead of ``window.confirm`` (blocks the page).
|
||||
*/
|
||||
function resetSlotToDefault(slotKey: string) {
|
||||
if (!form.provider_type) return;
|
||||
confirmReset = {
|
||||
kind: 'slot',
|
||||
slotKey,
|
||||
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
function resetAllToDefaults() {
|
||||
if (!form.provider_type) return;
|
||||
confirmReset = {
|
||||
kind: 'all',
|
||||
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
async function performReset() {
|
||||
if (!confirmReset || !form.provider_type) return;
|
||||
const { kind, slotKey } = confirmReset;
|
||||
confirmReset = null;
|
||||
try {
|
||||
if (kind === 'slot' && slotKey) {
|
||||
const res = await api<Record<string, Record<string, string>>>(
|
||||
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||
);
|
||||
const text = res?.[slotKey]?.[activeLocale];
|
||||
if (!text) {
|
||||
snackError(t('templateConfig.resetNoDefault'));
|
||||
return;
|
||||
}
|
||||
setSlotValue(slotKey, text);
|
||||
validateSlot(slotKey, text, true);
|
||||
} else {
|
||||
const res = await api<Record<string, Record<string, string>>>(
|
||||
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||
);
|
||||
// Replace current-locale slots; leave other locales' values untouched.
|
||||
const nextSlots = { ...form.slots };
|
||||
for (const [key, localeMap] of Object.entries(res || {})) {
|
||||
const text = localeMap?.[activeLocale];
|
||||
if (text === undefined) continue;
|
||||
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
|
||||
}
|
||||
form.slots = nextSlots;
|
||||
refreshAllPreviews();
|
||||
}
|
||||
snackSuccess(t('templateConfig.resetApplied'));
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clone(c: TemplateConfig) {
|
||||
form = {
|
||||
provider_type: c.provider_type,
|
||||
@@ -321,7 +424,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Locale tabs -->
|
||||
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
{#each LOCALES as loc}
|
||||
<button type="button"
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
|
||||
@@ -329,6 +432,14 @@
|
||||
{loc.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
{#if form.provider_type}
|
||||
<button type="button" onclick={resetAllToDefaults}
|
||||
title={t('templateConfig.resetAllToDefaults')}
|
||||
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
|
||||
<MdiIcon name="mdiRefresh" size={12} />
|
||||
{t('templateConfig.resetAllToDefaults')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Slot filter -->
|
||||
@@ -361,6 +472,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div id="slot-{slot.key}">
|
||||
<CollapsibleSlot
|
||||
label={slot.key}
|
||||
description={slot.description || t(`templateConfig.${slot.label}`, slot.label)}
|
||||
@@ -379,6 +491,11 @@
|
||||
<button type="button" onclick={() => showVarsFor = slot.key}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
<button type="button" onclick={() => resetSlotToDefault(slot.key)}
|
||||
title={t('templateConfig.resetToDefault')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||
{t('templateConfig.resetToDefault')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]}
|
||||
@@ -397,6 +514,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</CollapsibleSlot>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -466,6 +584,14 @@
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<ConfirmModal open={confirmReset !== null}
|
||||
title={t('templateConfig.resetToDefault')}
|
||||
message={confirmReset?.message || ''}
|
||||
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
|
||||
confirmIcon="mdiRefresh"
|
||||
onconfirm={performReset}
|
||||
oncancel={() => confirmReset = null} />
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { sanitizePreview } from '$lib/sanitize';
|
||||
import { supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
@@ -22,13 +25,150 @@
|
||||
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import type { TrackingConfig } from '$lib/types';
|
||||
|
||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||
const gridItemSources: Record<string, () => any[]> = {
|
||||
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
|
||||
};
|
||||
|
||||
/**
|
||||
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
|
||||
* dispatch accepts. Matched on blur for time-list fields; invalid values
|
||||
* are surfaced inline next to the input.
|
||||
*/
|
||||
const TIME_LIST_RE = /^\s*(?:[01]\d|2[0-3]):[0-5]\d(?:\s*,\s*(?:[01]\d|2[0-3]):[0-5]\d)*\s*$/;
|
||||
|
||||
/** Per-field error messages surfaced inline under time-list inputs. */
|
||||
let timeListErrors = $state<Record<string, string>>({});
|
||||
|
||||
/** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */
|
||||
function normalizeTimeList(key: string) {
|
||||
const raw = String(form[key] ?? '').trim();
|
||||
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
|
||||
if (!TIME_LIST_RE.test(raw)) {
|
||||
// Try a lenient normalization: split on commas, zero-pad each part.
|
||||
const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
|
||||
const fixed: string[] = [];
|
||||
let ok = true;
|
||||
for (const p of parts) {
|
||||
const m = /^(\d{1,2}):(\d{1,2})$/.exec(p);
|
||||
if (!m) { ok = false; break; }
|
||||
const hh = Number(m[1]);
|
||||
const mm = Number(m[2]);
|
||||
if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) { ok = false; break; }
|
||||
fixed.push(`${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`);
|
||||
}
|
||||
if (ok) {
|
||||
form[key] = fixed.join(',');
|
||||
timeListErrors = { ...timeListErrors, [key]: '' };
|
||||
return;
|
||||
}
|
||||
timeListErrors = { ...timeListErrors, [key]: t('trackingConfig.invalidTimeList') };
|
||||
return;
|
||||
}
|
||||
// Canonicalise spacing.
|
||||
form[key] = raw.split(',').map(s => s.trim()).join(',');
|
||||
timeListErrors = { ...timeListErrors, [key]: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
|
||||
* minutes — adjust times" when start equals end. Handles overnight ranges
|
||||
* (start > end) correctly.
|
||||
*/
|
||||
function quietHoursPreview(start: string, end: string): string {
|
||||
if (!start || !end) return '';
|
||||
const [sh, sm] = start.split(':').map(Number);
|
||||
const [eh, em] = end.split(':').map(Number);
|
||||
if (![sh, sm, eh, em].every(Number.isFinite)) return '';
|
||||
const sMin = sh * 60 + sm;
|
||||
const eMin = eh * 60 + em;
|
||||
if (sMin === eMin) return t('trackingConfig.quietHoursZero');
|
||||
const overnight = sMin > eMin;
|
||||
const span = overnight ? (24 * 60 - sMin) + eMin : eMin - sMin;
|
||||
const h = Math.floor(span / 60);
|
||||
const m = span % 60;
|
||||
const dur = m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
const arrow = overnight
|
||||
? `${start} → ${end} ${t('trackingConfig.nextDay')}`
|
||||
: `${start} → ${end}`;
|
||||
return `${arrow} (${dur})`;
|
||||
}
|
||||
|
||||
function gotoTemplateConfig(slotName: string) {
|
||||
// Deep-link to the template configs page: pass the slot as a query
|
||||
// param (``edit_slot``) so the destination can auto-open the first
|
||||
// matching config in edit mode and expand that slot. Plain hashes
|
||||
// like ``#slot-X`` were a no-op because slots don't exist in the DOM
|
||||
// until a config is being edited.
|
||||
const u = new URL('/template-configs', window.location.origin);
|
||||
u.searchParams.set('provider', 'immich');
|
||||
u.searchParams.set('edit_slot', slotName);
|
||||
window.location.href = u.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline preview of the shipped default template for a scheduled/periodic/
|
||||
* memory slot. Using the shipped default (not a tracker's current template)
|
||||
* keeps this scoped to the tracking-config page — which has no concept of
|
||||
* which TemplateConfig a given tracker uses. Users who want to edit the
|
||||
* actual config can click "Edit template" in the modal footer.
|
||||
*
|
||||
* ``previewLocale`` is modal-scoped so switching tabs only refetches for
|
||||
* this preview — the user's UI locale (and other previews) are untouched.
|
||||
*/
|
||||
let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null);
|
||||
let previewLoading = $state(false);
|
||||
let previewLocales = $derived(supportedLocalesCache.items);
|
||||
|
||||
async function openTemplatePreview(slotName: string) {
|
||||
await supportedLocalesCache.fetch();
|
||||
const initialLocale = previewLocales.includes('en') ? 'en' : (previewLocales[0] || 'en');
|
||||
await renderPreviewFor(slotName, initialLocale);
|
||||
}
|
||||
|
||||
async function renderPreviewFor(slotName: string, locale: string) {
|
||||
previewLoading = true;
|
||||
try {
|
||||
const defaults = await api<Record<string, Record<string, string>>>(
|
||||
`/template-configs/defaults?provider_type=immich&slot_name=${encodeURIComponent(slotName)}&locale=${encodeURIComponent(locale)}`,
|
||||
);
|
||||
const template = defaults?.[slotName]?.[locale];
|
||||
if (!template) {
|
||||
previewModal = { slotName, rendered: '', error: t('templateConfig.resetNoDefault'), locale };
|
||||
return;
|
||||
}
|
||||
const res = await api<{ rendered?: string; error?: string }>(
|
||||
'/template-configs/preview-raw',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
template,
|
||||
target_type: 'telegram',
|
||||
date_format: '%d.%m.%Y, %H:%M UTC',
|
||||
date_only_format: '%d.%m.%Y',
|
||||
}),
|
||||
},
|
||||
);
|
||||
previewModal = {
|
||||
slotName,
|
||||
rendered: res?.rendered || '',
|
||||
error: res?.error || '',
|
||||
locale,
|
||||
};
|
||||
} catch (err: any) {
|
||||
previewModal = { slotName, rendered: '', error: err.message, locale };
|
||||
} finally {
|
||||
previewLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const SLOT_FOR_SECTION: Record<string, string> = {
|
||||
periodic: 'periodic_summary_message',
|
||||
scheduled: 'scheduled_assets_message',
|
||||
memory: 'memory_mode_message',
|
||||
};
|
||||
|
||||
let allConfigs = $derived(trackingConfigsCache.items);
|
||||
let filterText = $state('');
|
||||
let filterType = $state('');
|
||||
@@ -161,10 +301,20 @@
|
||||
{t(section.legend)}
|
||||
{#if section.legendHint}<Hint text={t(section.legendHint)} />{/if}
|
||||
</legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1">
|
||||
<input type="checkbox" bind:checked={form[section.enabledField]} />
|
||||
{t('trackingConfig.enabled')}
|
||||
</label>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={form[section.enabledField]} />
|
||||
{t('trackingConfig.enabled')}
|
||||
</label>
|
||||
{#if SLOT_FOR_SECTION[section.key]}
|
||||
<button type="button" onclick={() => openTemplatePreview(SLOT_FOR_SECTION[section.key])}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline inline-flex items-center gap-1"
|
||||
disabled={previewLoading}>
|
||||
<MdiIcon name="mdiEyeOutline" size={14} />
|
||||
{t('trackingConfig.previewTemplate')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if form[section.enabledField]}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
{#each section.fields as field (field.key)}
|
||||
@@ -181,17 +331,32 @@
|
||||
{:else if field.type === 'grid-select' && field.gridItems}
|
||||
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
||||
{:else}
|
||||
<input type={field.key.includes('date') ? 'date'
|
||||
: field.key.startsWith('quiet_hours_') ? 'time'
|
||||
: field.key.includes('times') ? 'text'
|
||||
: 'number'}
|
||||
{@const inputType = field.type === 'date' ? 'date'
|
||||
: field.type === 'time' ? 'time'
|
||||
: field.type === 'time-list' ? 'text'
|
||||
: 'number'}
|
||||
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
|
||||
<input type={inputType}
|
||||
bind:value={form[field.key]} min={field.min} max={field.max}
|
||||
placeholder={field.key.includes('times') || field.key.startsWith('quiet_hours_') ? String(field.defaultValue ?? '') : ''}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
onblur={field.type === 'time-list' && field.validateFormat ? () => normalizeTimeList(field.key) : undefined}
|
||||
placeholder={field.type === 'time-list' || field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
|
||||
class="w-full px-2 py-1 border rounded-md text-sm bg-[var(--color-background)] {hasError ? 'border-[var(--color-error-fg)]' : 'border-[var(--color-border)]'}" />
|
||||
{#if field.inlineHelp}
|
||||
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
|
||||
{/if}
|
||||
{#if hasError}
|
||||
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if section.key === 'quietHours' && form.quiet_hours_start && form.quiet_hours_end}
|
||||
<p class="text-xs mt-2" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiWeatherNight" size={12} />
|
||||
{quietHoursPreview(String(form.quiet_hours_start), String(form.quiet_hours_end))}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/each}
|
||||
@@ -268,7 +433,63 @@
|
||||
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<Modal open={previewModal !== null}
|
||||
title={previewModal ? `${t('trackingConfig.previewTemplate')} — ${previewModal.slotName}` : ''}
|
||||
onclose={() => previewModal = null}>
|
||||
{#if previewModal}
|
||||
{#if previewLocales.length > 1}
|
||||
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||
{#each previewLocales as loc}
|
||||
<button type="button"
|
||||
onclick={() => renderPreviewFor(previewModal!.slotName, loc)}
|
||||
disabled={previewLoading}
|
||||
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {previewModal.locale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'} disabled:opacity-50">
|
||||
{loc.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-xs mb-3" style="color: var(--color-muted-foreground);">
|
||||
{t('trackingConfig.previewSampleNote')}
|
||||
</p>
|
||||
<!-- Keep the prior rendered/error box mounted while refetching on locale
|
||||
switch — just dim it. Unmounting and replacing with a small "…"
|
||||
placeholder caused a one-frame layout jump as the modal shrank and
|
||||
then re-expanded. -->
|
||||
<div class="relative mb-3" style="opacity: {previewLoading ? 0.5 : 1}; transition: opacity 0.15s ease;">
|
||||
{#if previewModal.error}
|
||||
<div class="p-3 rounded text-xs" style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
{previewModal.error}
|
||||
</div>
|
||||
{:else if previewModal.rendered}
|
||||
<div class="p-3 bg-[var(--color-muted)] rounded text-sm preview-html">
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(previewModal.rendered)}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);">…</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end mt-3">
|
||||
<button type="button" onclick={() => { const s = previewModal!.slotName; previewModal = null; gotoTemplateConfig(s); }}
|
||||
class="text-xs px-3 py-1.5 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)]">
|
||||
{t('trackingConfig.editTemplate')}
|
||||
</button>
|
||||
<button type="button" onclick={() => previewModal = null}
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-[var(--color-primary)] text-[var(--color-primary-foreground)]">
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
:global(.preview-html a) {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
:global(.preview-html a:hover) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
Reference in New Issue
Block a user