From b61394f0570652ea756661cad2515f05cd2976f8 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 24 Apr 2026 19:15:54 +0300 Subject: [PATCH] feat(immich): per-album scheduled/memory dispatch + template tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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. --- frontend/src/lib/i18n/en.json | 30 +- frontend/src/lib/i18n/ru.json | 30 +- frontend/src/lib/providers/immich.ts | 35 +- frontend/src/lib/providers/index.ts | 7 +- frontend/src/lib/providers/types.ts | 21 +- .../command-template-configs/+page.svelte | 80 ++++- .../routes/notification-trackers/+page.svelte | 39 ++- .../SharedLinkModal.svelte | 58 +++- .../notification-trackers/TestMenu.svelte | 25 +- .../notification-trackers/TrackerForm.svelte | 17 + .../src/routes/template-configs/+page.svelte | 130 +++++++- .../src/routes/tracking-configs/+page.svelte | 243 +++++++++++++- .../notifications/dispatcher.py | 2 +- .../providers/immich/asset_utils.py | 28 +- .../command_defaults/en/favorites.jinja2 | 2 +- .../command_defaults/en/latest.jinja2 | 2 +- .../command_defaults/en/memory.jinja2 | 3 +- .../command_defaults/en/random.jinja2 | 2 +- .../command_defaults/en/search.jinja2 | 2 +- .../command_defaults/en/summary.jinja2 | 4 +- .../command_defaults/ru/favorites.jinja2 | 2 +- .../command_defaults/ru/latest.jinja2 | 2 +- .../command_defaults/ru/memory.jinja2 | 3 +- .../command_defaults/ru/random.jinja2 | 2 +- .../command_defaults/ru/search.jinja2 | 2 +- .../command_defaults/ru/summary.jinja2 | 4 +- .../templates/defaults/en/memory_mode.jinja2 | 5 +- .../defaults/en/periodic_summary.jinja2 | 4 +- .../defaults/en/scheduled_assets.jinja2 | 7 + .../templates/defaults/ru/memory_mode.jinja2 | 5 +- .../defaults/ru/periodic_summary.jinja2 | 4 +- .../defaults/ru/scheduled_assets.jinja2 | 7 + .../api/command_template_configs.py | 61 +++- .../src/notify_bridge_server/api/providers.py | 28 +- .../notify_bridge_server/api/telegram_bots.py | 24 +- .../api/template_configs.py | 58 +++- .../commands/immich/common.py | 20 +- .../services/manual_dispatch.py | 139 ++++++-- .../services/sample_context.py | 21 +- .../services/scheduled_dispatch.py | 301 ++++++++++++------ 40 files changed, 1235 insertions(+), 224 deletions(-) diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index ebd130e..6288269 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -262,7 +262,14 @@ "testPeriodic": "Test periodic summary", "testScheduled": "Test scheduled assets", "testMemory": "Test memory / On This Day", + "testDisabledHint": "Enable this feature in the tracker's default Tracking Config first.", "checkingLinks": "Checking links...", + "featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.", + "openTrackingConfig": "Open Tracking Config", + "linkReplace": "Replace", + "linkReplacing": "Replacing...", + "linkReplaceFailed": "Failed to replace link for \"{name}\"", + "linkPasswordProtectedNote": "Telegram users can't open password-protected links without the password. Remove the password in Immich or replace the link.", "missingLinksTitle": "Albums Missing Public Links", "missingLinksDesc": "The following albums don't have public shared links. Without links, notification recipients won't be able to view photos.", "expired": "Expired", @@ -552,7 +559,14 @@ "renamed": "renamed", "deleted": "deleted", "providerType": "Provider Type", - "sortRandom": "Random" + "sortRandom": "Random", + "timesInlineHelp": "HH:MM, comma-separated", + "invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30", + "previewTemplate": "Preview template", + "previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.", + "editTemplate": "Edit template", + "quietHoursZero": "Quiet period is 0 minutes — adjust times", + "nextDay": "next day" }, "templateConfig": { "title": "Template Configs", @@ -598,7 +612,14 @@ "confirmDelete": "Delete this template config?", "invalidFormat": "Invalid format string", "filterSlots": "Filter slots...", - "slots": "slots" + "slots": "slots", + "resetToDefault": "Reset to default", + "resetAllToDefaults": "Reset all to defaults", + "resetSlotConfirm": "Replace this slot's {locale} template with the shipped default? Your current edits will be lost.", + "resetAllConfirm": "Replace every slot's {locale} template with the shipped defaults? All your {locale} edits will be lost.", + "resetNoDefault": "No shipped default for this slot.", + "resetApplied": "Reset to default (not saved yet — click Save to persist)", + "deepLinkNoConfig": "No template config found for this provider. Create one first." }, "templateVars": { "message_assets_added": { @@ -715,9 +736,12 @@ "quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:00–07:00 are supported.", "favoritesOnly": "Only include assets marked as favorites.", "maxAssets": "Maximum number of asset details to include in a single notification message.", - "periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.", + "periodicStartDate": "Reference date in the app timezone. The first summary fires at the next configured HH:MM on/after this date, then every N days.", + "intervalDays": "Days between successive summaries. 1 = daily, 7 = weekly.", "times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00", "albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.", + "scheduledAlbumMode": "How albums are grouped in scheduled deliveries. Default: Per album (one notification per tracked album).", + "memoryAlbumMode": "How albums are grouped in memory deliveries. Default: Combined (a single notification aggregating matches from all tracked albums).", "minRating": "Only include assets with at least this star rating (0 = no filter).", "eventMessages": "Templates for real-time event notifications. Use {variables} for dynamic content.", "assetFormatting": "How individual assets are formatted within notification messages.", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index a0d96e5..59fdcc8 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -262,7 +262,14 @@ "testPeriodic": "Тест периодической сводки", "testScheduled": "Тест запланированных фото", "testMemory": "Тест воспоминаний", + "testDisabledHint": "Сначала включите эту функцию в привязанной конфигурации отслеживания.", "checkingLinks": "Проверка ссылок...", + "featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.", + "openTrackingConfig": "Открыть конфигурацию отслеживания", + "linkReplace": "Пересоздать", + "linkReplacing": "Пересоздание...", + "linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»", + "linkPasswordProtectedNote": "Получатели в Telegram не смогут открыть защищённую паролем ссылку без пароля. Снимите пароль в Immich или пересоздайте ссылку.", "missingLinksTitle": "Альбомы без публичных ссылок", "missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.", "expired": "Истёк", @@ -552,7 +559,14 @@ "renamed": "переименование", "deleted": "удалён", "providerType": "Тип провайдера", - "sortRandom": "Случайный" + "sortRandom": "Случайный", + "timesInlineHelp": "ЧЧ:ММ, через запятую", + "invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30", + "previewTemplate": "Предпросмотр шаблона", + "previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.", + "editTemplate": "Редактировать шаблон", + "quietHoursZero": "Тихий период 0 минут — скорректируйте время", + "nextDay": "след. день" }, "templateConfig": { "title": "Конфигурации шаблонов", @@ -598,7 +612,14 @@ "confirmDelete": "Удалить эту конфигурацию шаблона?", "invalidFormat": "Некорректная строка формата", "filterSlots": "Фильтр слотов...", - "slots": "слотов" + "slots": "слотов", + "resetToDefault": "Сбросить к умолчанию", + "resetAllToDefaults": "Сбросить все к умолчаниям", + "resetSlotConfirm": "Заменить шаблон этого слота ({locale}) на исходный по умолчанию? Ваши правки будут потеряны.", + "resetAllConfirm": "Заменить шаблоны всех слотов ({locale}) на исходные по умолчанию? Все ваши правки для {locale} будут потеряны.", + "resetNoDefault": "Для этого слота нет шаблона по умолчанию.", + "resetApplied": "Сброшено к умолчанию (ещё не сохранено — нажмите «Сохранить»)", + "deepLinkNoConfig": "Не найдено конфигурации шаблонов для этого провайдера. Сначала создайте её." }, "templateVars": { "message_assets_added": { @@ -715,9 +736,12 @@ "quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:00–07:00.", "favoritesOnly": "Включать только ассеты, отмеченные как избранные.", "maxAssets": "Максимальное количество ассетов в одном уведомлении.", - "periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.", + "periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.", + "intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.", "times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00", "albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.", + "scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).", + "memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).", "minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).", "eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.", "assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.", diff --git a/frontend/src/lib/providers/immich.ts b/frontend/src/lib/providers/immich.ts index 0439f2a..a6ec7ed 100644 --- a/frontend/src/lib/providers/immich.ts +++ b/frontend/src/lib/providers/immich.ts @@ -1,5 +1,12 @@ import type { ProviderDescriptor } from './types'; +/** + * Today's date in ISO (YYYY-MM-DD) — used as the default for + * `periodic_start_date` so new configs anchor to "today" rather than a + * hardcoded date that gets further into the past on every release. + */ +const todayIso = (): string => new Date().toISOString().slice(0, 10); + export const immichDescriptor: ProviderDescriptor = { type: 'immich', defaultName: 'Immich', @@ -58,17 +65,17 @@ export const immichDescriptor: ProviderDescriptor = { key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary', enabledField: 'periodic_enabled', enabledDefault: false, fields: [ - { key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1 }, - { key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'number', defaultValue: '2025-01-01' }, // rendered as date input - { key: 'periodic_times', label: 'trackingConfig.times', type: 'number', defaultValue: '12:00' }, // rendered as text input + { key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' }, + { key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'date', defaultValue: todayIso, hint: 'hints.periodicStartDate' }, + { key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true }, ], }, { key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets', enabledField: 'scheduled_enabled', enabledDefault: false, fields: [ - { key: 'scheduled_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' }, - { key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection' }, + { key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true }, + { key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' }, { key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' }, { key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' }, { key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' }, @@ -79,21 +86,21 @@ export const immichDescriptor: ProviderDescriptor = { key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode', enabledField: 'memory_enabled', enabledDefault: false, fields: [ - { key: 'memory_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' }, - { key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined' }, - { key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10 }, + { key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true }, + { key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined', hint: 'hints.memoryAlbumMode' }, + { key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' }, { key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' }, - { key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0 }, + { key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' }, { key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' }, - { key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' }, + { key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums', hint: 'hints.memorySource' }, ], }, { key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours', enabledField: 'quiet_hours_enabled', enabledDefault: false, fields: [ - { key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'number', defaultValue: '22:00' }, - { key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'number', defaultValue: '07:00' }, + { key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'time', defaultValue: '22:00' }, + { key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'time', defaultValue: '07:00' }, ], }, ], @@ -114,7 +121,9 @@ export const immichDescriptor: ProviderDescriptor = { const warnings: { id: string; name: string; issue: string }[] = []; // Run shared-link checks in parallel with a concurrency cap so a large - // album set doesn't stall the save button for seconds. + // album set doesn't stall the save button for seconds. Cap of 6 keeps + // the save dialog responsive for users with 50+ albums while staying + // well under typical Immich per-IP rate limits. const CONCURRENCY = 6; async function checkOne(albumId: string): Promise { try { diff --git a/frontend/src/lib/providers/index.ts b/frontend/src/lib/providers/index.ts index 99a4b64..1f3f061 100644 --- a/frontend/src/lib/providers/index.ts +++ b/frontend/src/lib/providers/index.ts @@ -47,17 +47,20 @@ export function allProviderTypes(): string[] { */ export function buildTrackingFormDefaults(): Record { const defaults: Record = {}; + // `defaultValue` may be a function (for time-sensitive defaults like + // today's date) so the computed value is fresh each time the form resets. + const resolve = (v: unknown): unknown => (typeof v === 'function' ? (v as () => unknown)() : v); for (const desc of REGISTRY.values()) { for (const field of desc.eventFields) { defaults[field.key] = field.default; } for (const extra of desc.extraTrackingFields ?? []) { - defaults[extra.key] = extra.defaultValue ?? ''; + defaults[extra.key] = resolve(extra.defaultValue) ?? ''; } for (const section of desc.featureSections ?? []) { defaults[section.enabledField] = section.enabledDefault; for (const f of section.fields) { - defaults[f.key] = f.defaultValue ?? ''; + defaults[f.key] = resolve(f.defaultValue) ?? ''; } for (const cb of section.checkboxes ?? []) { defaults[cb.key] = cb.default; diff --git a/frontend/src/lib/providers/types.ts b/frontend/src/lib/providers/types.ts index 4647192..567a533 100644 --- a/frontend/src/lib/providers/types.ts +++ b/frontend/src/lib/providers/types.ts @@ -60,14 +60,31 @@ export interface EventTrackingField { export interface ExtraTrackingField { key: string; label: string; - type: 'number' | 'grid-select' | 'toggle'; + /** + * Control kind: + * - `number` — numeric spinner + * - `grid-select` — icon-grid chooser (requires `gridItems`) + * - `toggle` — on/off switch + * - `date` — HTML date picker (YYYY-MM-DD) + * - `time` — HTML time picker (HH:MM) + * - `time-list` — comma-separated HH:MM list, validated on blur + */ + type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list'; /** Grid-select item source function name from grid-items.ts. */ gridItems?: string; gridColumns?: number; hint?: string; + /** Inline helper text rendered under the input (not a tooltip). */ + inlineHelp?: string; min?: number; max?: number; - defaultValue?: string | number | boolean; + /** For time-list: show live validation + auto-normalize on blur. */ + validateFormat?: boolean; + /** + * Default value. Can be a function for dynamic values (e.g. today's date) + * evaluated each time the form is reset. + */ + defaultValue?: string | number | boolean | (() => string | number | boolean); } /** A feature section like periodic summary, scheduled assets, memory mode. */ diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte index d84770e..981d7bb 100644 --- a/frontend/src/routes/command-template-configs/+page.svelte +++ b/frontend/src/routes/command-template-configs/+page.svelte @@ -54,6 +54,11 @@ let editing = $state(null); let error = $state(''); let confirmDelete = $state<{ id: number; onconfirm: () => Promise } | null>(null); + let confirmReset = $state<{ + kind: 'slot' | 'all'; + slotKey?: string; + message: string; + } | null>(null); let slotPreview = $state>({}); let slotErrors = $state>({}); let slotErrorLines = $state>({}); @@ -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>>( + `/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>>( + `/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> = {}; for (const [k, v] of Object.entries(c.slots)) { @@ -343,7 +400,7 @@

{t('cmdTemplateConfig.commandResponsesHint')}

-
+
{#each LOCALES as loc} + {/if}
@@ -381,6 +446,11 @@ {/if} +
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]} @@ -472,6 +542,14 @@ confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> + confirmReset = null} /> + blockedBy = null} /> diff --git a/frontend/src/routes/notification-trackers/+page.svelte b/frontend/src/routes/notification-trackers/+page.svelte index 108efa0..b0c70bc 100644 --- a/frontend/src/routes/notification-trackers/+page.svelte +++ b/frontend/src/routes/notification-trackers/+page.svelte @@ -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 = { + // 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 = { 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(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 }; + } + }} /> 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>({}); + + /** + * 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 }; + } + } @@ -19,13 +52,26 @@

{t('notificationTracker.missingLinksDesc')}

-
+
{#each linkWarning.albums as album} -
- {album.name} - +
+
+ {album.name} + {#if album.issue === 'password-protected'} + + {t('notificationTracker.linkPasswordProtectedNote')} + + {/if} +
+ {album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')} + {#if album.issue === 'expired' || album.issue === 'password-protected'} + + {/if}
{/each}
diff --git a/frontend/src/routes/notification-trackers/TestMenu.svelte b/frontend/src/routes/notification-trackers/TestMenu.svelte index 79142d1..0faa4a4 100644 --- a/frontend/src/routes/notification-trackers/TestMenu.svelte +++ b/frontend/src/routes/notification-trackers/TestMenu.svelte @@ -6,7 +6,13 @@ testMenuOpen: string | null; testMenuStyle: string; ttTesting: Record; - 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(); }}>
-
+
{#each testTypes as tt} + {@const busy = !!ttTesting[`${testMenuOpen}_${tt.key}`]} + {@const blocked = !!tt.disabledReason} + {#if blocked} +

{t(tt.disabledReason!)}

+ {/if} {/each}
{/if} diff --git a/frontend/src/routes/notification-trackers/TrackerForm.svelte b/frontend/src/routes/notification-trackers/TrackerForm.svelte index 1428fd1..07458f3 100644 --- a/frontend/src/routes/notification-trackers/TrackerForm.svelte +++ b/frontend/src/routes/notification-trackers/TrackerForm.svelte @@ -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 @@
{/if} + + {#if providerType === 'immich'} +
+ +
+

{t('notificationTracker.featureDiscovery')}

+ + + {t('notificationTracker.openTrackingConfig')} + +
+
+ {/if} + diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index a11739b..cac9980 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -42,6 +42,17 @@ let editing = $state(null); let error = $state(''); let confirmDelete = $state<{ id: number; onconfirm: () => Promise } | 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>({}); let slotErrors = $state>({}); let slotErrorLines = $state>({}); @@ -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=&provider=`` 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>>( + `/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>>( + `/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 @@
-
+
{#each LOCALES as loc} + {/if}
@@ -361,6 +472,7 @@ {/if}
{:else} +
showVarsFor = slot.key} class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')} {/if} +
{#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]} @@ -397,6 +514,7 @@ {/if} {/if} + {/if} {/each} @@ -466,6 +584,14 @@ confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> + confirmReset = null} /> + blockedBy = null} /> diff --git a/frontend/src/routes/tracking-configs/+page.svelte b/frontend/src/routes/tracking-configs/+page.svelte index d4d9496..ce73808 100644 --- a/frontend/src/routes/tracking-configs/+page.svelte +++ b/frontend/src/routes/tracking-configs/+page.svelte @@ -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 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>({}); + + /** 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>>( + `/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 = { + 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}{/if} - +
+ + {#if SLOT_FOR_SECTION[section.key]} + + {/if} +
{#if form[section.enabledField]}
{#each section.fields as field (field.key)} @@ -181,17 +331,32 @@ {:else if field.type === 'grid-select' && field.gridItems} {:else} - + 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} +

{t(field.inlineHelp)}

+ {/if} + {#if hasError} +

{timeListErrors[field.key]}

+ {/if} {/if}
{/each} + {#if section.key === 'quietHours' && form.quiet_hours_start && form.quiet_hours_end} +

+ + {quietHoursPreview(String(form.quiet_hours_start), String(form.quiet_hours_end))} +

+ {/if} {/if} {/each} @@ -268,7 +433,63 @@ blockedBy = null} /> + previewModal = null}> + {#if previewModal} + {#if previewLocales.length > 1} +
+ {#each previewLocales as loc} + + {/each} +
+ {/if} +

+ {t('trackingConfig.previewSampleNote')} +

+ +
+ {#if previewModal.error} +
+ {previewModal.error} +
+ {:else if previewModal.rendered} +
+
{@html sanitizePreview(previewModal.rendered)}
+
+ {:else} +
+ {/if} +
+
+ + +
+ {/if} +
+