Compare commits
4 Commits
v0.9.0
...
6877c4d272
| Author | SHA1 | Date | |
|---|---|---|---|
| 6877c4d272 | |||
| d01e519925 | |||
| 11593eaa7c | |||
| 8065e6effa |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+2
-2
@@ -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,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>
|
||||
@@ -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).",
|
||||
|
||||
@@ -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": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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))
|
||||
@@ -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