Compare commits

...

4 Commits

Author SHA1 Message Date
alexei.dolgolyov 6877c4d272 chore: release v0.10.0
Release / test-backend (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
2026-06-05 21:04:37 +03:00
alexei.dolgolyov d01e519925 chore: add vex semantic-search config
Check in the .vex.toml that enables semantic embeddings, call-graph,
BM25, and auto-update for the vex code-search index.
2026-06-05 21:02:04 +03:00
alexei.dolgolyov 11593eaa7c fix(ci): pin aiohttp<3.14 in backend test deps
aioresponses 0.7.8 (latest) does not pass the stream_writer keyword
argument that aiohttp 3.14 made required on ClientResponse, so every
aioresponses-mocked HTTP test fails to construct a response. core
declares aiohttp>=3.9 with no upper bound, so CI floated to 3.14.0 and
the backend job would break on the next run. Pin <3.14 in the test
install in both build.yml and release.yml until aioresponses ships an
aiohttp-3.14-compatible release.
2026-06-05 21:01:44 +03:00
alexei.dolgolyov 8065e6effa 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.
2026-05-29 14:57:41 +03:00
19 changed files with 815 additions and 118 deletions
+5 -1
View File
@@ -66,7 +66,11 @@ jobs:
- name: Install backend + test deps
run: |
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses prometheus_client
# Pin aiohttp <3.14: aioresponses 0.7.8 (latest) doesn't pass the
# stream_writer kwarg that aiohttp 3.14 made required on ClientResponse,
# breaking every aioresponses-mocked test. Drop once aioresponses ships
# an aiohttp-3.14-compatible release.
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses 'aiohttp<3.14' prometheus_client
- name: Run pytest
env:
+5 -1
View File
@@ -33,7 +33,11 @@ jobs:
- name: Install backend + test deps
run: |
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses prometheus_client
# Pin aiohttp <3.14: aioresponses 0.7.8 (latest) doesn't pass the
# stream_writer kwarg that aiohttp 3.14 made required on ClientResponse,
# breaking every aioresponses-mocked test. Drop once aioresponses ships
# an aiohttp-3.14-compatible release.
/tmp/venv/bin/pip install /tmp/wheels/*.whl pytest pytest-asyncio httpx aioresponses 'aiohttp<3.14' prometheus_client
- name: Run pytest
env:
+65
View File
@@ -0,0 +1,65 @@
# vex configuration — https://github.com/tenatarika/vex
#
# Place this file in your project root as .vex.toml
# --- Active settings (maximum capability) ---
# Semantic embeddings on, call-graph + BM25 on, and auto-refresh the index when
# stale so queries never run against an out-of-date graph.
semantic = true
auto_update = true
call_graph = true
bm25 = true
# Glob patterns to exclude from indexing (gitignore syntax, on top of .gitignore)
# exclude = [
# "vendor/**",
# "node_modules/**",
# "*.generated.go",
# "dist/**",
# ]
# Default output format: "text", "json", or "compact"
# format = "text"
# Enable semantic embeddings by default (slower indexing, enables meaning-based search)
# semantic = false
# Automatically run `vex update` before search if the index is stale
# auto_update = false
# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default).
# Changing the embedder requires a full reindex.
# embedder = "minilm-l6-v2"
# Cache directory override. Defaults to the platform cache location.
# macOS: ~/Library/Caches/vex
# Linux: $XDG_CACHE_HOME/vex (fallback: ~/.cache/vex)
# Windows: %LOCALAPPDATA%\vex (fallback: %USERPROFILE%\AppData\Local\vex)
# Accepts absolute paths, "~/..." or paths relative to this file (e.g. "./.vex/cache").
# Can also be overridden per-invocation with --cache-dir or $VEX_CACHE_DIR.
# cache_dir = "./.vex/cache"
# Store the index inside the project as `<project>/.vex_cache/`. Useful when
# the cache should travel with the project (e.g. on a moved or renamed
# directory). vex writes a `.gitignore` inside it so contents are not
# committed. Overridden by `cache_dir`, `--cache-dir`, or $VEX_CACHE_DIR.
# local_cache = false
# Thread count for parallel indexing (index/update/watch).
# * unset — 80% of available cores, rounded up (default, leaves headroom)
# * 0 — use all cores (explicit opt-in to max throughput)
# * N — exactly N workers
# Overridable per-invocation with `-j/--jobs` or $VEX_JOBS.
# jobs = 4
# Build the persistent call-graph section. Disabling falls back to live-scan
# for `vex callers`/`vex callees` (slower per-query, but saves indexing
# time on large monorepos). The opt-out is persisted in the manifest so
# `vex update` does not silently re-add the section.
# Per-invocation override: `vex index --no-call-graph`.
# call_graph = true
# Build the BM25 channel. Disabling drops the third RRF channel and keeps
# only structural (+ semantic). Same persistence rules as `call_graph`.
# Per-invocation override: `vex index --no-bm25`.
# bm25 = true
+17 -22
View File
@@ -1,52 +1,47 @@
# v0.9.0 (2026-05-28)
# v0.10.0 (2026-06-05)
A feature + observability release. The headline additions are per-receiver Telegram options (silent send and forum-topic routing), an oversized-video fallback that bypasses Telegram's 50 MB `sendVideo` cap by switching to `sendDocument`, partial-failure visibility on the dashboard via a new `dispatch_summary` block on every `EventLog` row, and an admin diagnostic-mode panel for temporary per-module DEBUG logging with auto-revert. End-to-end correlation IDs (`dispatch_id`, `X-Request-Id`) now tie log lines to the database rows they produced. No breaking changes; no migrations required.
Multi-time-point scheduling for Immich. The single comma-separated "times" text box is replaced with an add/remove list of native HH:MM pickers for the **scheduled assets**, **periodic summary**, and **memory** slots — entering several fire times per day is now discoverable, and the read/write path is hardened end to end. This release also pins `aiohttp<3.14` in the backend test environment so the suite stays green against the current `aioresponses`. No breaking changes; no migrations required.
## User-facing changes
### Features
- **Per-receiver Telegram options.** Each Telegram chat receiver can now be configured to send silently (`disable_notification` — no sound or vibration on the recipient) and to route into a specific forum topic (`message_thread_id`) on supergroups with topics enabled. A new cog-icon button on the receiver row opens an inline editor; active options surface as a bell-off icon and a `#thread-id` chip on the receiver header. The plumbing uses a `ContextVar` bound at the public send entry points, so every internal payload builder (`sendMessage`, `sendPhoto`/`Video`/`Document`, `sendMediaGroup`, cache-hit POST) picks them up without a signature change ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Send oversized videos as documents.** A new per-target toggle, `send_large_videos_as_documents`, falls back to `sendDocument` when a video would exceed Telegram's 50 MB `sendVideo` limit. Useful for archival use cases (Immich library shares with users on free Telegram accounts) where the video would otherwise be silently dropped or noisily 413'd. Pairs with the existing `send_large_photos_as_documents` toggle. Translated copy lives under `targets.sendLargeVideosAsDocuments` ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Diagnostic mode for temporary DEBUG logging.** A new Diagnostics cassette on the Settings page lets an admin flip one module to DEBUG for a bounded window (1 minute to 4 hours) with an automatic revert. Useful for investigating a specific dispatch failure without flooding stderr; the revert reads the current DB-configured `log_levels` at expiry so a setting change made *during* the window is honored. State is in-memory only — a restart wipes overrides, and `setup_logging()` re-applies the DB baseline at boot, so a forgotten override cannot silently survive a deploy ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Partial-delivery visibility in the dashboard.** Every event-, watcher-, scheduled-, deferred-, action-, and command-dispatch path now writes a `dispatch_summary` block onto `EventLog.details`: per-target succeeded/failed counts, Telegram media `delivered_count` / `skipped_count` / `failed_count`, and a truncated list of error strings. Partial outcomes (one target out of three failed, two of ten assets dropped) are no longer indistinguishable from a clean success ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Inbound request correlation IDs.** A new middleware accepts `X-Request-Id` from the inbound request (so an upstream proxy with its own correlation system can propagate its id) and echoes the value back on the response. Values are sanitised to a bounded charset to prevent CR/LF injection into log lines. The id is bound into log context for every request and copied onto any `EventLog` row written during the same request, so the SPA can surface it for bug reports ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Immich multi-time scheduling UI.** A new `TimeListEditor` replaces the comma-separated text box with an add/remove list of native HH:MM pickers for the scheduled-assets, periodic-summary, and memory slots. It dedupes and sorts on save, enforces a per-day cap, collapses on-screen duplicates, and gives each row an aria-label. Keyboard entry is no longer clobbered mid-edit — it syncs from the value prop only on external changes via an `untrack` guard ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
- **Per-section save guard.** An enabled feature section (scheduled / periodic / memory) must now have at least one time before it can be saved, enforced in the provider descriptor ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
- **Refreshed copy.** New en/ru i18n keys and updated help text for the editor; the dead `invalidTimeList` string was removed ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
### Bug Fixes
- **sendMediaGroup byte-budget enforcement.** Telegram's `sendMediaGroup` envelope tops out near 50 MB total (multipart bytes including form overhead). Previously the per-item budget admitted items that, when summed, busted Telegram's request cap and 413'd. A new 45 MB total-bytes budget (`TELEGRAM_MAX_GROUP_TOTAL_BYTES`) splits groups before the overhead pushes us over, with safety margin ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Per-item fallback inside `sendMediaGroup`.** A stale `file_id` reference (cache poisoning by Telegram's GC, or a video that's no longer downloadable from the cached URL) used to silently lose the cached asset because the failed chunk had no re-download path. Each cached item now retains its `source_url` + `download_headers` so the per-item fallback can re-fetch and retry as a single send when its `file_id` POST returns transient errors ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Server-side `*_times` normalization.** The `tracking_configs` API now normalizes `scheduled_times` / `periodic_times` / `memory_times` on every write (validate, dedup, sort, cap at 24), returns **422** on malformed input, and refuses to enable a slot whose times list is empty ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
- **Restored scheduler observability.** The scheduler now warns when an enabled slot has zero or dropped fire times, restoring the visibility that was lost when the old per-call warning was removed ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
---
## Development / Internal
### Observability
### CI/Build
- **Shared `dispatch_id` across log lines and `EventLog` rows.** A `disp:<12 hex>` correlation id is bound at the top of every dispatch entry point (`dispatch_provider_event`, `check_tracker`, `dispatch_scheduled_for_tracker`, `_process_row` in deferred dispatch, `run_action`, command handler, HA status logger) via `ContextVar`. Nested dispatcher calls reuse the bound id instead of generating their own, so a single dispatch's log lines and the `EventLog.details.dispatch_id` field share one searchable id ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **`enrich_details_with_correlation()` helper.** New helper in `notify_bridge_core.log_context` merges the bound `dispatch_id` and `request_id` onto `EventLog.details` dicts at write time without overwriting caller-provided keys. Every `EventLog` insertion site has been migrated to use it ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Pin `aiohttp<3.14` in the backend test install.** `aioresponses` 0.7.8 (latest) does not pass the `stream_writer` keyword argument that aiohttp 3.14 made required on `ClientResponse`, so every aioresponses-mocked HTTP test failed to construct a response. `notify-bridge-core` declares `aiohttp>=3.9` with no upper bound, so CI floated to 3.14.0 — the pin keeps the test environment reproducible in both `build.yml` and `release.yml` until aioresponses ships an aiohttp-3.14-compatible release ([11593ea](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/11593ea))
### Architecture
### Refactoring
- **`split_media_by_upload_size` retired.** Per-item upload accounting moved onto the new `_MediaItem` dataclass (`upload_bytes` property) and the splitter logic moved into `_send_media_group`, where the byte budget and per-item fallback live ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **API endpoints for diagnostic mode.** New routes under `/api/app-settings/diagnostic-mode` (`GET` list, `POST` activate, `DELETE /{module:path}` revert) with admin-auth requirement and a curated module allowlist that blocks the root logger ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Shared time-list service.** A new `services/time_list.py` exposes `normalize_time_list` (raising `TimeListError`) and a lenient `parse_hhmm_list`; the scheduler drops its private parser copy in favour of the shared one ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
### Tests
- **New suites:** `test_diagnostic_mode.py`, `test_dispatch_summary.py`, `test_request_correlation.py`, `test_telegram_media_group_partial.py`, `test_telegram_per_send_options.py`. Total: 294 tests passing (up from 283 in v0.8.2) ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Test scaffolding fix:** `_reset_state()` in `test_diagnostic_mode.py` now also clears the `_bg_tasks` set so a long-window schedule from one test doesn't pollute `len(_bg_tasks)` in the next test's assertion. Cross-loop `.cancel()` is intentionally skipped — the prior test's loop is closed and cancelling there raises `RuntimeError` on the next test's setup ([9aada75](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9aada75))
- **Time-list coverage.** New tests for time-list normalization and parsing (including non-ASCII and odd-shaped input) and for the "enabled implies at least one time" validation ([8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e))
### Documentation
### Chores
- **Six-axis production-readiness review** (`/.claude/reviews/`) covering backend, frontend, security, performance/DB, UI/UX, and bugs+features. Snapshots the v0.8.1 codebase; informed several items shipped in v0.8.2 and this release ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Functional review of Telegram / Immich / Logging subsystems** (`.claude/docs/functional-review-2026-05-28.md`). Captures what's in place, in-flight work, and ranked gaps for each subsystem; pairs with the existing feature backlog ([6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374))
- **Checked-in vex config.** A `.vex.toml` enabling semantic embeddings, call-graph, BM25, and auto-update for the vex code-search index ([d01e519](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d01e519))
---
<details>
<summary>All Commits</summary>
- [6a8f374](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a8f374) — `feat: observability, per-receiver Telegram options, oversized-video fallback` (alexei.dolgolyov)
- [9aada75](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9aada75) — `fix(tests): clear diagnostic_mode _bg_tasks between cases` (alexei.dolgolyov)
- [8065e6e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8065e6e) — `feat(immich): multi-time-point scheduling for scheduled/periodic/memory` (alexei.dolgolyov)
- [11593ea](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/11593ea) — `fix(ci): pin aiohttp<3.14 in backend test deps` (alexei.dolgolyov)
- [d01e519](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d01e519) — `chore: add vex semantic-search config` (alexei.dolgolyov)
</details>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "notify-bridge-frontend",
"version": "0.9.0",
"version": "0.10.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "notify-bridge-frontend",
"version": "0.9.0",
"version": "0.10.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/lang-html": "^6.4.11",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.9.0",
"version": "0.10.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -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>
+8 -3
View File
@@ -728,8 +728,13 @@
"deleted": "deleted",
"providerType": "Provider Type",
"sortRandom": "Random",
"timesInlineHelp": "HH:MM, comma-separated",
"invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30",
"timesInlineHelp": "One or more times per day",
"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",
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
"editTemplate": "Edit template",
@@ -1011,7 +1016,7 @@
"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.",
"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.",
"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).",
+8 -3
View File
@@ -728,8 +728,13 @@
"deleted": "удалён",
"providerType": "Тип провайдера",
"sortRandom": "Случайный",
"timesInlineHelp": "ЧЧ:ММ, через запятую",
"invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30",
"timesInlineHelp": "Одно или несколько значений времени в день",
"addTime": "Добавить время",
"removeTime": "Удалить время",
"timeRowLabel": "Время {n}",
"noTimes": "Время не задано — добавьте хотя бы одно",
"maxTimesReached": "Достигнут максимум: {n}",
"timesRequiredFor": "Добавьте хотя бы одно время для «{slot}»",
"previewTemplate": "Предпросмотр шаблона",
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
"editTemplate": "Редактировать шаблон",
@@ -1011,7 +1016,7 @@
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
"times": "Время отправки уведомлений. Добавьте сколько угодно значений времени в день.",
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
+3 -3
View File
@@ -68,14 +68,14 @@ export const immichDescriptor: ProviderDescriptor = {
fields: [
{ 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: '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',
enabledField: 'scheduled_enabled', enabledDefault: false,
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_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' },
@@ -87,7 +87,7 @@ 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: '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_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' },
+2 -3
View File
@@ -67,7 +67,8 @@ export interface ExtraTrackingField {
* - `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
* - `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';
/** Grid-select item source function name from grid-items.ts. */
@@ -78,8 +79,6 @@ export interface ExtraTrackingField {
inlineHelp?: string;
min?: 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)
* evaluated each time the form is reset.
@@ -27,6 +27,7 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.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. */
const gridItemSources: Record<string, () => any[]> = {
@@ -34,44 +35,12 @@
};
/**
* 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.
* Max distinct dispatch times per slot — mirrors the backend cap
* (`MAX_DISPATCH_TIMES` in services/time_list.py). The TimeListEditor
* 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*$/;
/** 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]: '' };
}
const MAX_DISPATCH_TIMES = 24;
/**
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
@@ -280,6 +249,18 @@
async function save(e: SubmitEvent) {
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 {
if (editing) await api(`/tracking-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
@@ -413,22 +394,24 @@
</label>
{:else if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else}
{@const inputType = field.type === 'date' ? 'date'
: field.type === 'time' ? 'time'
: field.type === 'time-list' ? 'text'
: 'number'}
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
<input type={inputType}
bind:value={form[field.key]} min={field.min} max={field.max}
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)]'}" />
{:else if field.type === 'time-list'}
<TimeListEditor
value={String(form[field.key] ?? '')}
onchange={(v) => form[field.key] = v}
max={MAX_DISPATCH_TIMES} />
{#if field.inlineHelp}
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
{/if}
{#if hasError}
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
{:else}
{@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}
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.9.0"
version = "0.10.0"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.9.0"
version = "0.10.0"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -11,11 +11,66 @@ from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TrackingConfig, User
from ..services.scheduler import reschedule_immich_dispatch_jobs
from ..services.time_list import TimeListError, normalize_time_list
_LOGGER = logging.getLogger(__name__)
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):
provider_type: str
@@ -124,7 +179,10 @@ async def create_config(
user: User = Depends(get_current_user),
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)
await session.commit()
await session.refresh(config)
@@ -150,7 +208,10 @@ async def update_config(
session: AsyncSession = Depends(get_session),
):
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)
session.add(config)
await session.commit()
@@ -7,6 +7,8 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from .time_list import parse_hhmm_list
_LOGGER = logging.getLogger(__name__)
@@ -896,30 +898,6 @@ _IMMICH_DISPATCH_KINDS = ("scheduled", "periodic", "memory")
_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:
"""APScheduler entry point — wraps the dispatch helper to swallow errors."""
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):
continue
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}"
scheduler.add_job(
_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))
+116
View File
@@ -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
)