feat(immich): multi-time-point scheduling for scheduled/periodic/memory
Replace the single comma-separated text box with an add/remove list of native HH:MM pickers for the Immich scheduled-assets, periodic-summary, and memory slots. The backend already stored comma-separated *_times and scheduled one cron job per time; this makes entering several times per day discoverable and hardens the read/write path. Backend: - services/time_list.py: normalize_time_list (validate / dedup / sort / cap at 24, raising TimeListError) + lenient parse_hhmm_list; the scheduler now uses the shared parser (drops its private copy). - tracking_configs API normalizes *_times on every write (422 on bad input) and rejects enabling a slot whose times list is empty. - scheduler warns when an enabled slot has zero or dropped fire times, restoring the observability lost with the old per-call warning. Frontend: - TimeListEditor.svelte: add/remove native time rows, dedupe + sort on emit, per-day cap, collapses on-screen duplicates, aria-labelled rows; syncs from the value prop only on external changes (untrack guard) so keyboard entry isn't clobbered mid-edit. - Descriptor-driven save guard: an enabled feature section must have at least one time. - i18n (en/ru) keys; refreshed help text; removed dead invalidTimeList. Tests: time_list normalization/parsing (incl. non-ASCII/odd shapes) and the enabled-implies-times validation.
This commit is contained in:
@@ -0,0 +1,252 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Multi-time-point editor — an add/remove list of native HH:MM pickers.
|
||||||
|
*
|
||||||
|
* Used in the tracking-configs form for the Immich scheduled / periodic /
|
||||||
|
* memory slots, which fire at one or more wall-clock times per day. The
|
||||||
|
* value crosses the wire as a single comma-separated string ("09:00,18:30")
|
||||||
|
* matching the backend `normalize_time_list`: each emitted value is
|
||||||
|
* de-duplicated and sorted ascending.
|
||||||
|
*
|
||||||
|
* Internally `rows` is the rendering source of truth and preserves the
|
||||||
|
* user's current edit state (including a blank, not-yet-filled row) so the
|
||||||
|
* list never jumps around mid-edit. Normalisation is applied only to the
|
||||||
|
* emitted value, not to what's on screen; the on-screen order re-settles to
|
||||||
|
* sorted form on the next external load.
|
||||||
|
*/
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Comma-separated HH:MM list, e.g. "09:00,18:30". */
|
||||||
|
value: string;
|
||||||
|
/** Called with a normalised (deduped, sorted) comma-separated string. */
|
||||||
|
onchange: (value: string) => void;
|
||||||
|
/** Maximum number of distinct times allowed. */
|
||||||
|
max?: number;
|
||||||
|
/** Placeholder forwarded to each native time input. */
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value, onchange, max = 24, placeholder = '' }: Props = $props();
|
||||||
|
|
||||||
|
let rows = $state<string[]>([]);
|
||||||
|
|
||||||
|
// The exact string we last synced-from or emitted. The resync effect below
|
||||||
|
// compares against this so it fires ONLY for genuine external `value` changes
|
||||||
|
// (form reset, config switch) — never for the user's own keystrokes. Without
|
||||||
|
// this guard the effect re-ran on every `bind:value` mutation while `value`
|
||||||
|
// still held the old committed string, clobbering the row being typed in (the
|
||||||
|
// native picker reset just as the user reached the AM/PM segment).
|
||||||
|
let lastValue = '';
|
||||||
|
|
||||||
|
function parse(raw: string): string[] {
|
||||||
|
return (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct, non-blank times, in first-seen order. */
|
||||||
|
function distinct(list: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const r of list) {
|
||||||
|
const v = r.trim();
|
||||||
|
if (v) seen.add(v);
|
||||||
|
}
|
||||||
|
return [...seen];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate on mount and re-sync on external `value` changes only. `value` is
|
||||||
|
// the sole reactive dependency; `rows` is read/written inside `untrack`, so
|
||||||
|
// editing a row never re-triggers this and can't clobber an in-progress edit.
|
||||||
|
// `$effect.pre` runs before the first paint, so initial rows show without a
|
||||||
|
// flash of the empty state. HH:MM is zero-padded, so a lexical sort is
|
||||||
|
// chronological.
|
||||||
|
$effect.pre(() => {
|
||||||
|
const incoming = value ?? '';
|
||||||
|
untrack(() => {
|
||||||
|
if (incoming !== lastValue) {
|
||||||
|
rows = parse(incoming);
|
||||||
|
lastValue = incoming;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function emit(): void {
|
||||||
|
const next = distinct(rows).sort().join(',');
|
||||||
|
// Record before emitting so the value round-trip back through the parent
|
||||||
|
// isn't mistaken for an external change and used to reset `rows`.
|
||||||
|
lastValue = next;
|
||||||
|
onchange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fired when a row's picker changes. Collapse any on-screen duplicate of an
|
||||||
|
// already-present time (keeping the first occurrence and any blank
|
||||||
|
// in-progress row) so the displayed rows match what's persisted — emit()
|
||||||
|
// dedups the saved value, and without this the screen would keep showing two
|
||||||
|
// identical rows. Then emit the canonical value.
|
||||||
|
function onRowChange(): void {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const next: string[] = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const v = r.trim();
|
||||||
|
if (!v) { next.push(r); continue; }
|
||||||
|
if (seen.has(v)) continue;
|
||||||
|
seen.add(v);
|
||||||
|
next.push(r);
|
||||||
|
}
|
||||||
|
if (next.length !== rows.length) rows = next;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
const filledCount = $derived(distinct(rows).length);
|
||||||
|
const hasBlankRow = $derived(rows.some((r) => !r.trim()));
|
||||||
|
const atMax = $derived(filledCount >= max);
|
||||||
|
// Block adding while a blank row is open (fill it first) or the cap is hit —
|
||||||
|
// keeps the list from stacking empty rows and respects the per-day limit.
|
||||||
|
const canAdd = $derived(!atMax && !hasBlankRow);
|
||||||
|
|
||||||
|
function addRow(): void {
|
||||||
|
if (!canAdd) return;
|
||||||
|
rows = [...rows, ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(index: number): void {
|
||||||
|
rows = rows.filter((_, i) => i !== index);
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="time-list">
|
||||||
|
{#each rows as _, i (i)}
|
||||||
|
<div class="time-row">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
bind:value={rows[i]}
|
||||||
|
onchange={onRowChange}
|
||||||
|
{placeholder}
|
||||||
|
aria-label={t('trackingConfig.timeRowLabel').replace('{n}', String(i + 1))}
|
||||||
|
class="time-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="time-remove"
|
||||||
|
aria-label={t('trackingConfig.removeTime')}
|
||||||
|
onclick={() => removeRow(i)}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiClose" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if rows.length === 0}
|
||||||
|
<p class="time-empty">
|
||||||
|
<MdiIcon name="mdiClockOutline" size={12} />
|
||||||
|
<span>{t('trackingConfig.noTimes')}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if atMax}
|
||||||
|
<p class="time-cap">{t('trackingConfig.maxTimesReached').replace('{n}', String(max))}</p>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="time-add" disabled={!canAdd} onclick={addRow}>
|
||||||
|
<MdiIcon name="mdiPlus" size={14} />
|
||||||
|
<span>{t('trackingConfig.addTime')}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.time-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.3125rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s ease, background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-remove:hover {
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-error-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3125rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.3125rem 0.5rem;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-add:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-add:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-empty {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-cap {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -728,8 +728,13 @@
|
|||||||
"deleted": "deleted",
|
"deleted": "deleted",
|
||||||
"providerType": "Provider Type",
|
"providerType": "Provider Type",
|
||||||
"sortRandom": "Random",
|
"sortRandom": "Random",
|
||||||
"timesInlineHelp": "HH:MM, comma-separated",
|
"timesInlineHelp": "One or more times per day",
|
||||||
"invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30",
|
"addTime": "Add time",
|
||||||
|
"removeTime": "Remove time",
|
||||||
|
"timeRowLabel": "Time {n}",
|
||||||
|
"noTimes": "No times set — add at least one",
|
||||||
|
"maxTimesReached": "Maximum {n} times reached",
|
||||||
|
"timesRequiredFor": "Add at least one time for \"{slot}\"",
|
||||||
"previewTemplate": "Preview template",
|
"previewTemplate": "Preview template",
|
||||||
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
|
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
|
||||||
"editTemplate": "Edit template",
|
"editTemplate": "Edit template",
|
||||||
@@ -1011,7 +1016,7 @@
|
|||||||
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
||||||
"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.",
|
"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.",
|
"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",
|
"times": "Time(s) of day to send notifications. Add as many time points per day as you need.",
|
||||||
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
|
"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).",
|
"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).",
|
"memoryAlbumMode": "How albums are grouped in memory deliveries. Default: Combined (a single notification aggregating matches from all tracked albums).",
|
||||||
|
|||||||
@@ -728,8 +728,13 @@
|
|||||||
"deleted": "удалён",
|
"deleted": "удалён",
|
||||||
"providerType": "Тип провайдера",
|
"providerType": "Тип провайдера",
|
||||||
"sortRandom": "Случайный",
|
"sortRandom": "Случайный",
|
||||||
"timesInlineHelp": "ЧЧ:ММ, через запятую",
|
"timesInlineHelp": "Одно или несколько значений времени в день",
|
||||||
"invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30",
|
"addTime": "Добавить время",
|
||||||
|
"removeTime": "Удалить время",
|
||||||
|
"timeRowLabel": "Время {n}",
|
||||||
|
"noTimes": "Время не задано — добавьте хотя бы одно",
|
||||||
|
"maxTimesReached": "Достигнут максимум: {n}",
|
||||||
|
"timesRequiredFor": "Добавьте хотя бы одно время для «{slot}»",
|
||||||
"previewTemplate": "Предпросмотр шаблона",
|
"previewTemplate": "Предпросмотр шаблона",
|
||||||
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
|
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
|
||||||
"editTemplate": "Редактировать шаблон",
|
"editTemplate": "Редактировать шаблон",
|
||||||
@@ -1011,7 +1016,7 @@
|
|||||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||||
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
|
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
|
||||||
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
|
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
|
||||||
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
|
"times": "Время отправки уведомлений. Добавьте сколько угодно значений времени в день.",
|
||||||
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
|
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
|
||||||
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
|
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
|
||||||
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
|
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
|
||||||
|
|||||||
@@ -68,14 +68,14 @@ export const immichDescriptor: ProviderDescriptor = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' },
|
{ 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_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: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
|
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
|
||||||
enabledField: 'scheduled_enabled', enabledDefault: false,
|
enabledField: 'scheduled_enabled', enabledDefault: false,
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
|
||||||
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' },
|
{ 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_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_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
||||||
@@ -87,7 +87,7 @@ export const immichDescriptor: ProviderDescriptor = {
|
|||||||
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
|
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
|
||||||
enabledField: 'memory_enabled', enabledDefault: false,
|
enabledField: 'memory_enabled', enabledDefault: false,
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp' },
|
||||||
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined', hint: 'hints.memoryAlbumMode' },
|
{ 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_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_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ export interface ExtraTrackingField {
|
|||||||
* - `toggle` — on/off switch
|
* - `toggle` — on/off switch
|
||||||
* - `date` — HTML date picker (YYYY-MM-DD)
|
* - `date` — HTML date picker (YYYY-MM-DD)
|
||||||
* - `time` — HTML time picker (HH:MM)
|
* - `time` — HTML time picker (HH:MM)
|
||||||
* - `time-list` — comma-separated HH:MM list, validated on blur
|
* - `time-list` — add/remove list of HH:MM pickers (TimeListEditor),
|
||||||
|
* serialized as a comma-separated string
|
||||||
*/
|
*/
|
||||||
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
|
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
|
||||||
/** Grid-select item source function name from grid-items.ts. */
|
/** Grid-select item source function name from grid-items.ts. */
|
||||||
@@ -78,8 +79,6 @@ export interface ExtraTrackingField {
|
|||||||
inlineHelp?: string;
|
inlineHelp?: string;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
/** 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)
|
* Default value. Can be a function for dynamic values (e.g. today's date)
|
||||||
* evaluated each time the form is reset.
|
* evaluated each time the form is reset.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||||
|
import TimeListEditor from '$lib/components/TimeListEditor.svelte';
|
||||||
|
|
||||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||||
const gridItemSources: Record<string, () => any[]> = {
|
const gridItemSources: Record<string, () => any[]> = {
|
||||||
@@ -34,44 +35,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
|
* Max distinct dispatch times per slot — mirrors the backend cap
|
||||||
* dispatch accepts. Matched on blur for time-list fields; invalid values
|
* (`MAX_DISPATCH_TIMES` in services/time_list.py). The TimeListEditor
|
||||||
* are surfaced inline next to the input.
|
* disables "+ Add time" once this many rows are filled; the server enforces
|
||||||
|
* the same limit on write.
|
||||||
*/
|
*/
|
||||||
const TIME_LIST_RE = /^\s*(?:[01]\d|2[0-3]):[0-5]\d(?:\s*,\s*(?:[01]\d|2[0-3]):[0-5]\d)*\s*$/;
|
const MAX_DISPATCH_TIMES = 24;
|
||||||
|
|
||||||
/** 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
|
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
|
||||||
@@ -280,6 +249,18 @@
|
|||||||
|
|
||||||
async function save(e: SubmitEvent) {
|
async function save(e: SubmitEvent) {
|
||||||
e.preventDefault(); error = '';
|
e.preventDefault(); error = '';
|
||||||
|
// Descriptor-driven guard: an enabled feature section that uses a
|
||||||
|
// time-list must have at least one time, otherwise it saves but the
|
||||||
|
// scheduler creates no cron job and the slot silently never fires.
|
||||||
|
for (const section of descriptor?.featureSections ?? []) {
|
||||||
|
const timeField = section.fields.find((f) => f.type === 'time-list');
|
||||||
|
if (!timeField) continue;
|
||||||
|
if (form[section.enabledField] && !String(form[timeField.key] ?? '').trim()) {
|
||||||
|
const msg = t('trackingConfig.timesRequiredFor').replace('{slot}', t(section.legend));
|
||||||
|
error = msg; snackError(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (editing) await api(`/tracking-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
if (editing) await api(`/tracking-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||||
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||||
@@ -413,22 +394,24 @@
|
|||||||
</label>
|
</label>
|
||||||
{:else if field.type === 'grid-select' && field.gridItems}
|
{:else if field.type === 'grid-select' && field.gridItems}
|
||||||
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
||||||
{:else}
|
{:else if field.type === 'time-list'}
|
||||||
{@const inputType = field.type === 'date' ? 'date'
|
<TimeListEditor
|
||||||
: field.type === 'time' ? 'time'
|
value={String(form[field.key] ?? '')}
|
||||||
: field.type === 'time-list' ? 'text'
|
onchange={(v) => form[field.key] = v}
|
||||||
: 'number'}
|
max={MAX_DISPATCH_TIMES} />
|
||||||
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
|
|
||||||
<input type={inputType}
|
|
||||||
bind:value={form[field.key]} min={field.min} max={field.max}
|
|
||||||
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}
|
{#if field.inlineHelp}
|
||||||
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
|
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasError}
|
{:else}
|
||||||
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
|
{@const inputType = field.type === 'date' ? 'date'
|
||||||
|
: field.type === 'time' ? 'time'
|
||||||
|
: 'number'}
|
||||||
|
<input type={inputType}
|
||||||
|
bind:value={form[field.key]} min={field.min} max={field.max}
|
||||||
|
placeholder={field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
|
||||||
|
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
|
{#if field.inlineHelp}
|
||||||
|
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,11 +11,66 @@ from ..auth.dependencies import get_current_user
|
|||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import TrackingConfig, User
|
from ..database.models import TrackingConfig, User
|
||||||
from ..services.scheduler import reschedule_immich_dispatch_jobs
|
from ..services.scheduler import reschedule_immich_dispatch_jobs
|
||||||
|
from ..services.time_list import TimeListError, normalize_time_list
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
|
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
|
||||||
|
|
||||||
|
# Immich dispatch slots that fire on a wall-clock schedule. Each has a
|
||||||
|
# ``{kind}_enabled`` flag and a ``{kind}_times`` comma-separated HH:MM list.
|
||||||
|
_DISPATCH_KINDS = ("periodic", "scheduled", "memory")
|
||||||
|
# TrackingConfig fields holding comma-separated ``HH:MM`` dispatch schedules.
|
||||||
|
# Normalized (validated, de-duplicated, sorted, capped) on every write so the
|
||||||
|
# scheduler only ever reads clean values and cron jobs stay deterministic.
|
||||||
|
_TIME_LIST_FIELDS = tuple(f"{k}_times" for k in _DISPATCH_KINDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_time_fields(values: dict) -> None:
|
||||||
|
"""Canonicalize any ``*_times`` keys present in ``values`` (in place).
|
||||||
|
|
||||||
|
Raises HTTP 422 with a field-scoped message when an entry is malformed or
|
||||||
|
the list exceeds the per-day cap, so the client surfaces exactly which slot
|
||||||
|
was rejected instead of the input being silently dropped at schedule time.
|
||||||
|
"""
|
||||||
|
for field in _TIME_LIST_FIELDS:
|
||||||
|
if values.get(field) is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
values[field] = normalize_time_list(values[field])
|
||||||
|
except TimeListError as err:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"{field}: {err}",
|
||||||
|
) from err
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_enabled_have_times(values: dict, existing: TrackingConfig | None) -> None:
|
||||||
|
"""Reject enabling a dispatch slot with no fire times.
|
||||||
|
|
||||||
|
An enabled slot whose ``{kind}_times`` normalizes to empty would save fine
|
||||||
|
but the scheduler creates zero cron jobs for it — a silently dead slot that
|
||||||
|
shows as "enabled" in the UI yet never fires. We fail the write with a 422
|
||||||
|
instead. Only kinds the request actually touches (enabled flag or times) are
|
||||||
|
checked, so unrelated edits to a pre-existing config aren't blocked; the
|
||||||
|
effective state is the request value merged over ``existing``.
|
||||||
|
"""
|
||||||
|
for kind in _DISPATCH_KINDS:
|
||||||
|
enabled_key, times_key = f"{kind}_enabled", f"{kind}_times"
|
||||||
|
if enabled_key not in values and times_key not in values:
|
||||||
|
continue
|
||||||
|
enabled = values.get(
|
||||||
|
enabled_key, getattr(existing, enabled_key, False) if existing else False
|
||||||
|
)
|
||||||
|
times = values.get(
|
||||||
|
times_key, getattr(existing, times_key, "") if existing else ""
|
||||||
|
)
|
||||||
|
if enabled and not (times or "").strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"{times_key}: add at least one time when {kind} is enabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TrackingConfigCreate(BaseModel):
|
class TrackingConfigCreate(BaseModel):
|
||||||
provider_type: str
|
provider_type: str
|
||||||
@@ -124,7 +179,10 @@ async def create_config(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
config = TrackingConfig(user_id=user.id, **body.model_dump())
|
data = body.model_dump()
|
||||||
|
_normalize_time_fields(data)
|
||||||
|
_validate_enabled_have_times(data, None)
|
||||||
|
config = TrackingConfig(user_id=user.id, **data)
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
@@ -150,7 +208,10 @@ async def update_config(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
config = await _get(session, config_id, user.id)
|
config = await _get(session, config_id, user.id)
|
||||||
for field, value in body.model_dump(exclude_unset=True).items():
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
_normalize_time_fields(updates)
|
||||||
|
_validate_enabled_have_times(updates, config)
|
||||||
|
for field, value in updates.items():
|
||||||
setattr(config, field, value)
|
setattr(config, field, value)
|
||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
|
from .time_list import parse_hhmm_list
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -896,30 +898,6 @@ _IMMICH_DISPATCH_KINDS = ("scheduled", "periodic", "memory")
|
|||||||
_IMMICH_DISPATCH_PREFIX = "immich_dispatch_"
|
_IMMICH_DISPATCH_PREFIX = "immich_dispatch_"
|
||||||
|
|
||||||
|
|
||||||
def _parse_hhmm_list(raw: str) -> list[tuple[int, int]]:
|
|
||||||
"""Parse ``"09:00,18:30"`` → ``[(9, 0), (18, 30)]``, skipping bad entries.
|
|
||||||
|
|
||||||
A typo in one slot must not prevent the others from scheduling — we log
|
|
||||||
and move on rather than raising.
|
|
||||||
"""
|
|
||||||
out: list[tuple[int, int]] = []
|
|
||||||
for part in (raw or "").split(","):
|
|
||||||
part = part.strip()
|
|
||||||
if not part:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
h_str, m_str = part.split(":", 1)
|
|
||||||
hour, minute = int(h_str), int(m_str)
|
|
||||||
except ValueError:
|
|
||||||
_LOGGER.warning("Skipping invalid time literal %r", part)
|
|
||||||
continue
|
|
||||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
|
||||||
_LOGGER.warning("Skipping out-of-range time %r", part)
|
|
||||||
continue
|
|
||||||
out.append((hour, minute))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_immich_dispatch(tracker_id: int, kind: str) -> None:
|
async def _run_immich_dispatch(tracker_id: int, kind: str) -> None:
|
||||||
"""APScheduler entry point — wraps the dispatch helper to swallow errors."""
|
"""APScheduler entry point — wraps the dispatch helper to swallow errors."""
|
||||||
from .scheduled_dispatch import dispatch_scheduled_for_tracker
|
from .scheduled_dispatch import dispatch_scheduled_for_tracker
|
||||||
@@ -994,7 +972,24 @@ async def _load_immich_dispatch_jobs() -> None:
|
|||||||
if not getattr(tc, f"{kind}_enabled", False):
|
if not getattr(tc, f"{kind}_enabled", False):
|
||||||
continue
|
continue
|
||||||
times_raw = getattr(tc, f"{kind}_times", "") or ""
|
times_raw = getattr(tc, f"{kind}_times", "") or ""
|
||||||
for hour, minute in _parse_hhmm_list(times_raw):
|
parsed = parse_hhmm_list(times_raw)
|
||||||
|
# Observability for misconfigured/legacy data: warn when some tokens
|
||||||
|
# were unparseable (the lenient parser drops them silently) and when
|
||||||
|
# an enabled slot resolves to zero fire times (it will never fire).
|
||||||
|
raw_tokens = [p for p in times_raw.split(",") if p.strip()]
|
||||||
|
if len(parsed) < len(raw_tokens):
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Tracker %d %s: dropped %d unparseable time(s) from %r",
|
||||||
|
tracker.id, kind, len(raw_tokens) - len(parsed), times_raw,
|
||||||
|
)
|
||||||
|
if not parsed:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Tracker %d has %s enabled but no valid fire times (%r); "
|
||||||
|
"slot will not fire",
|
||||||
|
tracker.id, kind, times_raw,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for hour, minute in parsed:
|
||||||
job_id = f"{_IMMICH_DISPATCH_PREFIX}{kind}_{tracker.id}_{hour:02d}{minute:02d}"
|
job_id = f"{_IMMICH_DISPATCH_PREFIX}{kind}_{tracker.id}_{hour:02d}{minute:02d}"
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_immich_dispatch,
|
_run_immich_dispatch,
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Parsing + normalization for comma-separated ``HH:MM`` dispatch time lists.
|
||||||
|
|
||||||
|
The Immich scheduled / periodic / memory slots fire at one or more wall-clock
|
||||||
|
times per day, stored on ``TrackingConfig`` as a comma-separated ``HH:MM``
|
||||||
|
string (e.g. ``"09:00,18:30"``). Two consumers share this module:
|
||||||
|
|
||||||
|
* the API layer (``api.tracking_configs``) calls :func:`normalize_time_list`
|
||||||
|
to validate + canonicalize user input before persisting — rejecting malformed
|
||||||
|
entries, de-duplicating, sorting ascending, and capping the count.
|
||||||
|
* the scheduler (``services.scheduler``) calls :func:`parse_hhmm_list` to turn a
|
||||||
|
stored (already-normalized) string into ``(hour, minute)`` tuples, tolerating
|
||||||
|
legacy or hand-edited values by skipping anything unparseable rather than
|
||||||
|
letting one bad slot stop the others from firing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# A generous ceiling: 24 distinct fire times per day is already an unusual
|
||||||
|
# config. The cap mainly exists so a pathological paste can't spawn hundreds of
|
||||||
|
# cron jobs for a single tracker.
|
||||||
|
MAX_DISPATCH_TIMES = 24
|
||||||
|
|
||||||
|
|
||||||
|
class TimeListError(ValueError):
|
||||||
|
"""Raised by :func:`normalize_time_list` on malformed input or over-cap.
|
||||||
|
|
||||||
|
Subclasses ``ValueError`` so callers that only care about "bad input" can
|
||||||
|
catch the broad type; the API layer catches this specifically to map it to
|
||||||
|
an HTTP 422 with the human-readable message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_one(token: str) -> tuple[int, int] | None:
|
||||||
|
"""Parse a single ``HH:MM`` token; return ``None`` if malformed/out-of-range.
|
||||||
|
|
||||||
|
Strict on shape (exactly one ``:``, both parts 1-2 plain ASCII digits) but
|
||||||
|
lenient on width so ``"9:0"`` parses to ``(9, 0)`` — :func:`normalize_time_list`
|
||||||
|
re-emits it zero-padded. ``int()`` alone is too permissive here: it would
|
||||||
|
accept signs (``"+9"``), PEP 515 underscores (``"1_0"``), and non-ASCII
|
||||||
|
decimal digits (Arabic-Indic ``"٠٩"``), none of which are valid wall-clock
|
||||||
|
literals — so we gate on ``str.isdigit()``/ASCII before converting.
|
||||||
|
"""
|
||||||
|
h_str, sep, m_str = token.partition(":")
|
||||||
|
if not sep:
|
||||||
|
return None
|
||||||
|
for part in (h_str, m_str):
|
||||||
|
if not (1 <= len(part) <= 2 and part.isascii() and part.isdigit()):
|
||||||
|
return None
|
||||||
|
hour, minute = int(h_str), int(m_str)
|
||||||
|
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||||
|
return None
|
||||||
|
return hour, minute
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hhmm_list(raw: str) -> list[tuple[int, int]]:
|
||||||
|
"""Lenient parse: ``"09:00,18:30"`` → ``[(9, 0), (18, 30)]``.
|
||||||
|
|
||||||
|
Skips blank/invalid entries rather than raising — the scheduler must keep
|
||||||
|
firing the valid times even if one slot was hand-edited to garbage. Order is
|
||||||
|
preserved and duplicates are *not* collapsed (the scheduler keys jobs by
|
||||||
|
time, so a duplicate simply replaces its own job id).
|
||||||
|
"""
|
||||||
|
out: list[tuple[int, int]] = []
|
||||||
|
for part in (raw or "").split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
hm = _parse_one(part)
|
||||||
|
if hm is not None:
|
||||||
|
out.append(hm)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_time_list(raw: str, *, max_count: int = MAX_DISPATCH_TIMES) -> str:
|
||||||
|
"""Validate + canonicalize a comma-separated ``HH:MM`` list.
|
||||||
|
|
||||||
|
Returns the canonical form: zero-padded, de-duplicated, sorted ascending,
|
||||||
|
and comma-joined with no spaces — e.g. ``" 9:0, 18:30 ,09:00"`` →
|
||||||
|
``"09:00,18:30"``. An empty/whitespace input returns ``""`` (the valid
|
||||||
|
"no scheduled fires" state).
|
||||||
|
|
||||||
|
Raises :class:`TimeListError` when any entry is not a valid ``HH:MM`` time,
|
||||||
|
or when the de-duplicated count exceeds ``max_count``. The caller maps this
|
||||||
|
to an HTTP 422 so the user gets a clear message instead of silent dropping.
|
||||||
|
"""
|
||||||
|
seen: set[tuple[int, int]] = set()
|
||||||
|
for part in (raw or "").split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
hm = _parse_one(part)
|
||||||
|
if hm is None:
|
||||||
|
raise TimeListError(f"Invalid time {part!r}; use HH:MM (00:00-23:59)")
|
||||||
|
seen.add(hm)
|
||||||
|
if len(seen) > max_count:
|
||||||
|
raise TimeListError(
|
||||||
|
f"Too many times ({len(seen)}); at most {max_count} are allowed"
|
||||||
|
)
|
||||||
|
return ",".join(f"{h:02d}:{m:02d}" for h, m in sorted(seen))
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Unit tests for the shared HH:MM dispatch time-list parser/normalizer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from notify_bridge_server.services.time_list import (
|
||||||
|
MAX_DISPATCH_TIMES,
|
||||||
|
TimeListError,
|
||||||
|
normalize_time_list,
|
||||||
|
parse_hhmm_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeTimeList:
|
||||||
|
def test_single_time_passthrough(self):
|
||||||
|
assert normalize_time_list("09:00") == "09:00"
|
||||||
|
|
||||||
|
def test_zero_pads_short_parts(self):
|
||||||
|
assert normalize_time_list("9:0") == "09:00"
|
||||||
|
|
||||||
|
def test_trims_surrounding_and_inner_whitespace(self):
|
||||||
|
assert normalize_time_list(" 09:00 , 18:30 ") == "09:00,18:30"
|
||||||
|
|
||||||
|
def test_sorts_ascending(self):
|
||||||
|
assert normalize_time_list("18:30,09:00,12:15") == "09:00,12:15,18:30"
|
||||||
|
|
||||||
|
def test_deduplicates(self):
|
||||||
|
# Duplicates collapse even when written with different padding.
|
||||||
|
assert normalize_time_list("09:00,9:00,09:00") == "09:00"
|
||||||
|
|
||||||
|
def test_empty_string_returns_empty(self):
|
||||||
|
assert normalize_time_list("") == ""
|
||||||
|
|
||||||
|
def test_whitespace_only_returns_empty(self):
|
||||||
|
assert normalize_time_list(" ") == ""
|
||||||
|
|
||||||
|
def test_trailing_comma_ignored(self):
|
||||||
|
assert normalize_time_list("09:00,") == "09:00"
|
||||||
|
|
||||||
|
def test_midnight_and_last_minute_are_valid(self):
|
||||||
|
assert normalize_time_list("00:00,23:59") == "00:00,23:59"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad",
|
||||||
|
["24:00", "09:60", "noon", "9", "09-00", "-1:00", "09:5a", "1:2:3"],
|
||||||
|
)
|
||||||
|
def test_invalid_entry_raises(self, bad):
|
||||||
|
with pytest.raises(TimeListError):
|
||||||
|
normalize_time_list(bad)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad",
|
||||||
|
[
|
||||||
|
"+9:00", # sign not allowed
|
||||||
|
"1_0:00", # PEP 515 underscore not allowed
|
||||||
|
"09:0_0", # underscore in minutes
|
||||||
|
"٩:00", # Arabic-Indic digit (non-ASCII)
|
||||||
|
"٠٩:٠٠", # Arabic-Indic "09:00"
|
||||||
|
"09: 00", # inner whitespace in part
|
||||||
|
"9 :00", # inner whitespace in part
|
||||||
|
"009:00", # 3-digit hour part
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rejects_non_ascii_or_oddly_shaped_digits(self, bad):
|
||||||
|
# int() alone would accept these; the parser must not.
|
||||||
|
with pytest.raises(TimeListError):
|
||||||
|
normalize_time_list(bad)
|
||||||
|
|
||||||
|
def test_one_bad_entry_rejects_whole_list(self):
|
||||||
|
# Strict on writes: a single bad slot fails the request rather than
|
||||||
|
# being silently dropped.
|
||||||
|
with pytest.raises(TimeListError):
|
||||||
|
normalize_time_list("09:00,bad,18:30")
|
||||||
|
|
||||||
|
def test_at_cap_is_allowed(self):
|
||||||
|
times = ",".join(f"{h:02d}:00" for h in range(MAX_DISPATCH_TIMES))
|
||||||
|
assert normalize_time_list(times) == times
|
||||||
|
|
||||||
|
def test_over_cap_raises(self):
|
||||||
|
# 25 distinct times (minute-granular) exceeds the default cap of 24.
|
||||||
|
times = ",".join(f"00:{m:02d}" for m in range(MAX_DISPATCH_TIMES + 1))
|
||||||
|
with pytest.raises(TimeListError):
|
||||||
|
normalize_time_list(times)
|
||||||
|
|
||||||
|
def test_custom_max_count(self):
|
||||||
|
with pytest.raises(TimeListError):
|
||||||
|
normalize_time_list("09:00,10:00,11:00", max_count=2)
|
||||||
|
|
||||||
|
def test_duplicates_do_not_count_against_cap(self):
|
||||||
|
# Three entries but one distinct → fits under a cap of 1.
|
||||||
|
assert normalize_time_list("09:00,09:00,9:00", max_count=1) == "09:00"
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseHhmmList:
|
||||||
|
def test_basic(self):
|
||||||
|
assert parse_hhmm_list("09:00,18:30") == [(9, 0), (18, 30)]
|
||||||
|
|
||||||
|
def test_preserves_order_and_duplicates(self):
|
||||||
|
# Lenient parser keeps input order and does not collapse duplicates —
|
||||||
|
# the scheduler keys jobs by time so a dup just replaces its own id.
|
||||||
|
assert parse_hhmm_list("18:30,09:00,18:30") == [(18, 30), (9, 0), (18, 30)]
|
||||||
|
|
||||||
|
def test_skips_invalid_entries(self):
|
||||||
|
assert parse_hhmm_list("09:00,bad,18:30") == [(9, 0), (18, 30)]
|
||||||
|
|
||||||
|
def test_empty_returns_empty_list(self):
|
||||||
|
assert parse_hhmm_list("") == []
|
||||||
|
assert parse_hhmm_list(" ") == []
|
||||||
|
|
||||||
|
def test_skips_out_of_range(self):
|
||||||
|
assert parse_hhmm_list("24:00,09:00") == [(9, 0)]
|
||||||
|
|
||||||
|
def test_skips_non_ascii_and_oddly_shaped(self):
|
||||||
|
# Lenient parser drops the odd shapes but keeps the valid neighbour.
|
||||||
|
assert parse_hhmm_list("+9:00,1_0:00,٠٩:٠٠,18:30") == [(18, 30)]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Tests for the tracking-config write-time guards.
|
||||||
|
|
||||||
|
Covers the cross-field rule that an *enabled* Immich dispatch slot
|
||||||
|
(periodic / scheduled / memory) must carry at least one fire time — otherwise
|
||||||
|
the config saves but the scheduler creates zero cron jobs and the slot is
|
||||||
|
silently dead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from notify_bridge_server.api.tracking_configs import (
|
||||||
|
_normalize_time_fields,
|
||||||
|
_validate_enabled_have_times,
|
||||||
|
)
|
||||||
|
from notify_bridge_server.database.models import TrackingConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeTimeFields:
|
||||||
|
def test_normalizes_each_field_in_place(self):
|
||||||
|
values = {
|
||||||
|
"periodic_times": "18:30, 09:00",
|
||||||
|
"scheduled_times": "9:0",
|
||||||
|
"memory_times": "",
|
||||||
|
}
|
||||||
|
_normalize_time_fields(values)
|
||||||
|
assert values["periodic_times"] == "09:00,18:30"
|
||||||
|
assert values["scheduled_times"] == "09:00"
|
||||||
|
assert values["memory_times"] == ""
|
||||||
|
|
||||||
|
def test_ignores_absent_fields(self):
|
||||||
|
values = {"name": "x"}
|
||||||
|
_normalize_time_fields(values) # no KeyError, no-op
|
||||||
|
assert values == {"name": "x"}
|
||||||
|
|
||||||
|
def test_malformed_raises_422(self):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_normalize_time_fields({"scheduled_times": "25:00"})
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
assert "scheduled_times" in str(exc.value.detail)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateEnabledHaveTimes:
|
||||||
|
# --- create path (existing=None) ---
|
||||||
|
def test_create_enabled_with_times_ok(self):
|
||||||
|
_validate_enabled_have_times(
|
||||||
|
{"scheduled_enabled": True, "scheduled_times": "09:00"}, None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_enabled_without_times_raises(self):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_validate_enabled_have_times(
|
||||||
|
{"scheduled_enabled": True, "scheduled_times": ""}, None
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
assert "scheduled_times" in str(exc.value.detail)
|
||||||
|
|
||||||
|
def test_create_disabled_without_times_ok(self):
|
||||||
|
_validate_enabled_have_times(
|
||||||
|
{"memory_enabled": False, "memory_times": ""}, None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_three_kinds_checked(self):
|
||||||
|
for kind in ("periodic", "scheduled", "memory"):
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
_validate_enabled_have_times(
|
||||||
|
{f"{kind}_enabled": True, f"{kind}_times": ""}, None
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- update path (effective state = values merged over existing) ---
|
||||||
|
def test_update_enable_without_providing_times_uses_existing_empty(self):
|
||||||
|
existing = TrackingConfig(provider_type="immich", name="t", scheduled_times="")
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
_validate_enabled_have_times({"scheduled_enabled": True}, existing)
|
||||||
|
|
||||||
|
def test_update_enable_with_existing_times_ok(self):
|
||||||
|
existing = TrackingConfig(
|
||||||
|
provider_type="immich", name="t", scheduled_times="09:00"
|
||||||
|
)
|
||||||
|
_validate_enabled_have_times({"scheduled_enabled": True}, existing)
|
||||||
|
|
||||||
|
def test_update_clear_times_while_enabled_in_existing_raises(self):
|
||||||
|
existing = TrackingConfig(
|
||||||
|
provider_type="immich",
|
||||||
|
name="t",
|
||||||
|
scheduled_enabled=True,
|
||||||
|
scheduled_times="09:00",
|
||||||
|
)
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
_validate_enabled_have_times({"scheduled_times": ""}, existing)
|
||||||
|
|
||||||
|
def test_update_unrelated_field_does_not_trigger_check(self):
|
||||||
|
# A pre-existing enabled-but-empty config must not block edits that don't
|
||||||
|
# touch the slot's enabled flag or times (only touched kinds are checked).
|
||||||
|
existing = TrackingConfig(
|
||||||
|
provider_type="immich",
|
||||||
|
name="t",
|
||||||
|
scheduled_enabled=True,
|
||||||
|
scheduled_times="",
|
||||||
|
)
|
||||||
|
_validate_enabled_have_times({"name": "renamed"}, existing)
|
||||||
|
|
||||||
|
def test_update_disabling_slot_clears_requirement(self):
|
||||||
|
existing = TrackingConfig(
|
||||||
|
provider_type="immich",
|
||||||
|
name="t",
|
||||||
|
scheduled_enabled=True,
|
||||||
|
scheduled_times="09:00",
|
||||||
|
)
|
||||||
|
_validate_enabled_have_times(
|
||||||
|
{"scheduled_enabled": False, "scheduled_times": ""}, existing
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user