Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 770c198ac3 | |||
| ab621b6abc | |||
| 187b889c45 | |||
| b61394f057 | |||
| be15463fd2 | |||
| 461fb495d7 | |||
| 309dec2b44 | |||
| 90def11b8d | |||
| 8f0346ea03 | |||
| a6a854ad21 | |||
| 19036a90bb | |||
| 592e1b6114 | |||
| bbcdf1c5d1 | |||
| f9040370bc | |||
| 3b683ce82c | |||
| 2bec25353b | |||
| e44d387c7f | |||
| 7cbb02b1ef | |||
| 920920bc67 | |||
| f50d465c0e | |||
| 1f880daa0c | |||
| 1024085cdd | |||
| 5604c733d1 | |||
| 3b7808aa9c | |||
| 155d25edf9 | |||
| 69711bbc84 | |||
| fe38d20b96 | |||
| d02616069d | |||
| 7dae68fd93 | |||
| e6481605ca | |||
| 6de9a1289e | |||
| 325eabd751 | |||
| fab6169cf9 | |||
| 85311684d9 | |||
| d7daadadc2 | |||
| e04ad16ca6 | |||
| d7d0a5d921 | |||
| 93df538819 | |||
| 2be608ba95 | |||
| 5028f15f4f | |||
| 5a232f18b8 | |||
| 3b76a09759 | |||
| 4ff3876e49 | |||
| 83215473c7 | |||
| 4e23d2b054 | |||
| f7d51b27d2 | |||
| 3bb0585e43 | |||
| 58cba88c92 | |||
| 645331d320 | |||
| 6c3dd67c1b | |||
| 56993d2ca3 | |||
| fe92b206b7 | |||
| cf4976da2f | |||
| 80c034d2af | |||
| a7a2b4efa4 |
@@ -1,13 +1,56 @@
|
|||||||
name: Build Docker Image
|
name: Build and Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test-frontend:
|
||||||
|
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Set up Node
|
||||||
run: docker build -t notify-bridge:dev .
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
- name: Svelte check
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm run check || echo "::warning::svelte-check reported warnings"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
build-image:
|
||||||
|
if: ${{ !startsWith(gitea.event.head_commit.message, 'chore: release v') }}
|
||||||
|
needs: [test-frontend]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build Docker image (no push)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
tags: notify-bridge:ci-${{ gitea.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.tag }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.tag }}
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ gitea.sha }}
|
||||||
${{ steps.version.outputs.is_pre == 'false' && format('{0}/{1}:latest', env.REGISTRY, env.IMAGE_NAME) || '' }}
|
${{ steps.version.outputs.is_pre == 'false' && format('{0}/{1}:latest', env.REGISTRY, env.IMAGE_NAME) || '' }}
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||||
|
|||||||
+48
-4
@@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 1: Build frontend (SvelteKit static output)
|
# Stage 1: Build frontend (SvelteKit static output)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -14,7 +15,7 @@ COPY frontend/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 2: Build Python wheels
|
# Stage 2: Build Python wheels + extract external dependency list
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM python:3.12-slim AS python-build
|
FROM python:3.12-slim AS python-build
|
||||||
|
|
||||||
@@ -30,16 +31,59 @@ RUN python -m build packages/core/ --wheel --outdir /wheels
|
|||||||
COPY packages/server/ packages/server/
|
COPY packages/server/ packages/server/
|
||||||
RUN python -m build packages/server/ --wheel --outdir /wheels
|
RUN python -m build packages/server/ --wheel --outdir /wheels
|
||||||
|
|
||||||
|
# Emit /wheels/deps.txt with ONLY external (PyPI) deps — filter out
|
||||||
|
# notify-bridge-* siblings, which are installed from local wheels below.
|
||||||
|
# This file is the cache key for the external-deps install layer: as long as
|
||||||
|
# pyproject.toml dependency lines don't change, the runtime install layer is
|
||||||
|
# served from registry buildcache and no wheels are re-downloaded.
|
||||||
|
RUN python <<'PY'
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
deps: list[str] = []
|
||||||
|
for p in ("packages/core/pyproject.toml", "packages/server/pyproject.toml"):
|
||||||
|
with open(p, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
for d in data["project"].get("dependencies", []):
|
||||||
|
if not d.lstrip().lower().startswith("notify-bridge-"):
|
||||||
|
deps.append(d)
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
with open("/wheels/deps.txt", "w") as f:
|
||||||
|
for d in deps:
|
||||||
|
if d not in seen:
|
||||||
|
seen.add(d)
|
||||||
|
f.write(d + "\n")
|
||||||
|
PY
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Stage 3: Runtime
|
# Stage 3: Runtime
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# uv — fast pip replacement. Installed from PyPI (Fastly CDN) rather than
|
||||||
|
# ghcr.io/astral-sh/uv, because GHCR pulls from this runner crawl at a few
|
||||||
|
# hundred KB/s and take longer than the install savings would recoup.
|
||||||
|
RUN pip install --no-cache-dir uv==0.11.7
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install wheels
|
# Install external deps first — layer cache key is deps.txt content, which
|
||||||
COPY --from=python-build /wheels/ /tmp/wheels/
|
# only changes when pyproject.toml dependency lines change (not on version
|
||||||
RUN pip install --no-cache-dir /tmp/wheels/*.whl && rm -rf /tmp/wheels
|
# bumps). The cache mount persists downloaded wheels across local rebuilds;
|
||||||
|
# in CI, the registry buildcache serves the whole layer when unchanged.
|
||||||
|
COPY --from=python-build /wheels/deps.txt /tmp/deps.txt
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv pip install --system -r /tmp/deps.txt \
|
||||||
|
&& rm /tmp/deps.txt
|
||||||
|
|
||||||
|
# Install local wheels without re-resolving — all external deps are present.
|
||||||
|
COPY --from=python-build /wheels/*.whl /tmp/wheels/
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv pip install --system --no-deps /tmp/wheels/*.whl \
|
||||||
|
&& rm -rf /tmp/wheels
|
||||||
|
|
||||||
# Copy frontend build
|
# Copy frontend build
|
||||||
COPY --from=frontend-build /build/build/ /app/static/
|
COPY --from=frontend-build /build/build/ /app/static/
|
||||||
|
|||||||
+23
-232
@@ -1,245 +1,36 @@
|
|||||||
## v0.1.0 (2026-04-21)
|
# v0.5.2 (2026-04-24)
|
||||||
|
|
||||||
First public release of **Notify Bridge** — a self-hosted bridge that turns events from home-lab services into rich, localized notifications and accepts chat commands in return.
|
Two related improvements to the notification-tracker stack: the display-filter fields on `TrackingConfig` (favorites-only, sort, max-assets, strip-tags, strip-asset-details) are now actually honored by every dispatch path — they previously existed in the model but were silently ignored on watcher / webhook / scheduled / memory / test fires. And the fixed `batch_duration` knob on `NotificationTracker` is replaced by a per-tracker `adaptive_max_skip`, so quiet trackers can opt into back-off without affecting busy ones.
|
||||||
|
|
||||||
### Highlights
|
## Features
|
||||||
|
|
||||||
- Six service providers out of the box: Immich, Google Photos, Planka, Gitea, NUT (Network UPS Tools), plus a generic JSONPath webhook provider and a built-in Scheduler.
|
- **Tracking-config display filters wired into every dispatch path** — the filter fields on Immich `TrackingConfig` now apply consistently across watcher events, inbound webhooks, scheduled / periodic / memory cron fires, and manual test dispatch ([ab621b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ab621b6)):
|
||||||
- Multi-channel delivery: Telegram, Discord, Slack, ntfy, Matrix, Email, and a broadcast target that fans out to multiple receivers.
|
- `favorites_only` drops events with no favorited new assets, or filters `added_assets` down to favorites only
|
||||||
- Provider-agnostic bot command system with rich, locale-aware command templates (Telegram + Matrix + Email bots).
|
- `assets_order_by` / `assets_order` sort the rendered list (date / name / rating / random / none)
|
||||||
- Jinja2 slot-based template system with autocomplete, live preview, locale switching, and a sandbox with timeout protection.
|
- `max_assets_to_show` caps rendered + attached media (default raised from 5 → 10)
|
||||||
- Actions engine for scheduled mutations on external services (e.g. timed Immich operations).
|
- `include_tags` strips people from event extras and tags from each asset when disabled
|
||||||
- Dashboard with filtered charts, grouped navigation tree with badges, Ctrl+K search palette, cross-entity crosslinks, card-highlight navigation, and a global provider filter.
|
- `include_asset_details` strips `city` / `country` / `state` / `lat` / `lon` / `is_favorite` / `rating` / `description` when disabled — load-bearing fields (`thumbhash`, `file_size`, `playback_size`, cache keys) are preserved either way
|
||||||
- Docker deployment with a Gitea CI/CD pipeline, full backup & restore, webhook payload history, person excludes for auto-organize rules, and SSRF-hardened outgoing requests.
|
- New `apply_tracking_display_filters` helper in `dispatch_helpers` is the single source of truth
|
||||||
|
- Targets sharing a `TrackingConfig` are dispatched together; targets with different configs each see their own shaped event
|
||||||
|
- **Per-tracker adaptive polling** — replaces the global-feeling `NotificationTracker.batch_duration` with `adaptive_max_skip`, an opt-in cap on poll back-off ([ab621b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ab621b6)):
|
||||||
|
- `NULL` / `0` → disabled, every tick runs (previous default behavior preserved)
|
||||||
|
- Positive `N` → caps the skip factor at `(N-1)-in-N` after a long idle stretch
|
||||||
|
- Scheduler caches the cap in module state for the tick fast-path
|
||||||
|
- Migration adds the new column; API schemas / responses, frontend types, i18n, and the tracker form are all updated to match
|
||||||
|
|
||||||
---
|
## Upgrade Notes
|
||||||
|
|
||||||
### Features
|
- **`batch_duration` → `adaptive_max_skip`** on `NotificationTracker`. The migration runs automatically; existing trackers default to disabled (every tick polls), matching previous behavior. Set a positive value per-tracker if you want quiet trackers to back off.
|
||||||
|
- **Default `max_assets_to_show` is now 10** (was 5). Existing tracking configs with a stored value are unaffected; only the default for newly created configs (or unset fields) changes. If you relied on the 5-asset implicit cap, set it explicitly.
|
||||||
#### Service providers
|
- **Display filters now actually take effect.** If you had configured `favorites_only`, `include_tags`, `include_asset_details`, etc. previously and expected them to do something — they will now. Review your tracking configs after upgrade if you don't want the filtering applied.
|
||||||
- Phase 3 — Immich service provider ([cc02558](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cc02558))
|
|
||||||
- Google Photos provider backend + API hardening ([307871c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/307871c))
|
|
||||||
- Planka service provider with full notification and command support ([0fde3c6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0fde3c6))
|
|
||||||
- Gitea as webhook-based service provider ([6d28cfb](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6d28cfb))
|
|
||||||
- NUT (Network UPS Tools) service provider + provider-agnostic UI ([68ac13b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/68ac13b))
|
|
||||||
- Generic webhook provider with JSONPath payload extraction ([616b221](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/616b221))
|
|
||||||
- Scheduler provider + multi-provider UX fixes ([0562f78](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0562f78))
|
|
||||||
|
|
||||||
#### Notification targets & delivery
|
|
||||||
- Discord / Slack / ntfy / Matrix targets, command templates, delete protection, email/matrix bots ([3e3a6f0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3e3a6f0))
|
|
||||||
- Broadcast notification target + UX improvements ([d8ecb60](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8ecb60))
|
|
||||||
- Provider-strict configs, slot-based templates, broadcast targets, email bots, command templates ([846d480](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/846d480))
|
|
||||||
- Receiver OOP hierarchy with per-receiver locale resolution ([1cfa728](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1cfa728))
|
|
||||||
- Rewrite asset URLs to internal provider URL for LAN fetching ([ad2fd33](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ad2fd33))
|
|
||||||
|
|
||||||
#### Bots & commands
|
|
||||||
- Telegram commands, app settings, bot polling, webhook handling, UI improvements ([03ec9b3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03ec9b3))
|
|
||||||
- Per-chat command toggle, listener name + toggle in bot tab ([b3b6c31](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b3b6c31))
|
|
||||||
- Rich command templates with public links + media text-first flow ([d0bc767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d0bc767))
|
|
||||||
- Locale-aware command templates, debounced auto-sync, entity pickers ([1167d13](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1167d13))
|
|
||||||
- Remove hardcoded command templates, enforce template system exclusively ([ddcbfda](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ddcbfda))
|
|
||||||
|
|
||||||
#### Template system
|
|
||||||
- Phase 4 — template system ([f36f070](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f36f070))
|
|
||||||
- Locale-aware notification templates + UX improvements ([37388c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/37388c4))
|
|
||||||
- Collapsible accordion slots for template editing UX ([b1ab5b8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b1ab5b8))
|
|
||||||
- Smart video size warnings + Jinja2 template autocomplete ([39bac82](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/39bac82))
|
|
||||||
- Collapsible chart, paginator controls, localized template slots ([3372761](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3372761))
|
|
||||||
- Fix template preview links, default chat action, update default templates ([371ea70](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/371ea70))
|
|
||||||
|
|
||||||
#### Entities, targets & rules
|
|
||||||
- Port full CRUD API routes and frontend pages from Immich Watcher ([9eec21a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9eec21a))
|
|
||||||
- Entity relationship refactor — notification trackers, command system, chat actions ([1d445f3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1d445f3))
|
|
||||||
- Person excludes for auto-organize rules, backup & restore system ([6b22113](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6b22113))
|
|
||||||
- Actions system — scheduled mutations on external services ([6a559bf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a559bf))
|
|
||||||
- Default tracker configs, email validation, expandable target links ([6e35926](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e35926))
|
|
||||||
- Webhook payload history — store and display recent incoming payloads ([6113a00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6113a00))
|
|
||||||
- Test menu dropdown, split text/media messages, target settings, provider URL links ([5015e37](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5015e37))
|
|
||||||
|
|
||||||
#### UI / navigation / UX
|
|
||||||
- Phase 7 — frontend restructuring ([9dfd1b7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9dfd1b7))
|
|
||||||
- Port original frontend UI to Notify Bridge ([c9cab93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c9cab93))
|
|
||||||
- Grouped nav tree with badges, dashboard events section with filtered chart ([2c740ff](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2c740ff))
|
|
||||||
- Entity cache system, nav UX improvements, split CLAUDE.md ([563716f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/563716f))
|
|
||||||
- IconGridSelect, CrossLink, SearchPalette components + entity crosslinks ([06b2463](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/06b2463))
|
|
||||||
- Card highlight system for cross-entity navigation ([f0f49db](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0f49db))
|
|
||||||
- Search button in sidebar with Ctrl+K shortcut hint ([637a467](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/637a467))
|
|
||||||
- EntitySelect palette-style entity picker, replace select dropdowns ([a3a1fe3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a3a1fe3))
|
|
||||||
- Provider type selector for tracking-configs, use IconGridSelect everywhere ([9d3abd9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9d3abd9))
|
|
||||||
- Replace all select dropdowns with IconGridSelect, fix EN template seed ([a9bb912](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a9bb912))
|
|
||||||
- Filter search to IconGridSelect when item count > 4 ([a7829c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7829c4))
|
|
||||||
- Consistent IconGridSelect sizing + descriptions + filter upgrades ([31584c5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/31584c5))
|
|
||||||
- Filtering on all entity list pages ([7cbba9d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7cbba9d))
|
|
||||||
- Filter entity selectors by global provider filter ([c451f3d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c451f3d))
|
|
||||||
- Chat language display, disabled EntitySelect items, dev scripts ([82e400d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/82e400d))
|
|
||||||
- API docs link button in sidebar footer ([f90cc36](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f90cc36))
|
|
||||||
- UX & notification improvements — icons, events, chat names, link validation, templates ([03c5c66](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03c5c66))
|
|
||||||
- UX improvements — secure webhooks, locale fixes, dynamic languages, UI polish ([734e5c9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/734e5c9))
|
|
||||||
|
|
||||||
#### Security & hardening
|
|
||||||
- Security hardening — SSRF guard, template sandbox timeout, webhook log prune, auth & backup polish ([f0739ca](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0739ca))
|
|
||||||
- Comprehensive code review fixes — security, performance, quality ([e0bae39](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e0bae39))
|
|
||||||
- Comprehensive code review fixes + receivers-only architecture ([751097b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/751097b))
|
|
||||||
|
|
||||||
#### Deployment
|
|
||||||
- Docker deployment + Gitea CI/CD workflow ([1ac6a17](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1ac6a17))
|
|
||||||
|
|
||||||
#### Foundation
|
|
||||||
- Phase 9 — HAOS integration planning ([786fe5e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/786fe5e))
|
|
||||||
- Phase 8 — integration and wiring ([08814e9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/08814e9))
|
|
||||||
- Phase 6 — database models and server API ([7f99c89](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7f99c89))
|
|
||||||
- Phase 5 — notification system ([16a41ef](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/16a41ef))
|
|
||||||
- Phase 2 — core abstractions ([3ed0d8c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3ed0d8c))
|
|
||||||
- Phase 1 — project scaffolding ([b724447](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b724447))
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Simplify add-target UX — single EntitySelect click to add ([21d8ef7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/21d8ef7))
|
|
||||||
- Provider-aware collection count labels in tracker list ([c6bb2b5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c6bb2b5))
|
|
||||||
- NUT template preview + tracking config event checkboxes ([2cc4bf6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2cc4bf6))
|
|
||||||
- Dashboard provider card shows filtered count, fix provider update 400 ([0702ec7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0702ec7))
|
|
||||||
- UI polish — overflow, placeholders, dashboard provider card ([4049efe](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4049efe))
|
|
||||||
- Pass chat_action from target config to Telegram client ([e90c128](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e90c128))
|
|
||||||
- Remove all transform from stagger/fade animations ([d8a1af0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8a1af0))
|
|
||||||
- Stagger animation breaking position:fixed overlays ([f9a4ccf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9a4ccf))
|
|
||||||
- Remove Card hover transform that breaks fixed-position overlays ([bd254de](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bd254de))
|
|
||||||
- Clipboard copy fallback for non-HTTPS contexts ([c26b71d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c26b71d))
|
|
||||||
- Nav active state — plain path link not highlighted when sibling query-param link matches ([f64ada5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f64ada5))
|
|
||||||
- Re-create missing EN default template, provider type as IconGridSelect ([db7aac5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/db7aac5))
|
|
||||||
- Search palette triggers highlight, restore CSS keyframe blink ([86115f5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/86115f5))
|
|
||||||
- Switch highlight to global store instead of URL params ([88e21e4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/88e21e4))
|
|
||||||
- Replace CSS keyframe highlight with direct style pulse for reliability ([f47df93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f47df93))
|
|
||||||
- Card highlight animation — kill stagger before highlight, keep animation:none on cleanup ([4b59f40](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4b59f40))
|
|
||||||
- Prevent stagger animation replay after card highlight ends ([4c1d5a8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4c1d5a8))
|
|
||||||
- Rename bots → telegramBots in targets page to fix undefined reference ([227b9c2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/227b9c2))
|
|
||||||
- Comprehensive API/UI review — 26 bug fixes and improvements ([91e5cd5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/91e5cd5))
|
|
||||||
- Remove auto-redirect from API client on 401 ([e43c2ed](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e43c2ed))
|
|
||||||
- Add auth guard to root layout with setup/login redirects ([7d01ae6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7d01ae6))
|
|
||||||
- Local fonts via @fontsource, favicon, autocomplete attrs ([f9c41fa](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9c41fa))
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- Rewrite asset URLs to internal provider URL for LAN fetching ([ad2fd33](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ad2fd33))
|
|
||||||
- Lazy-load @mdi/js to reduce Vite dev server memory usage ([826be4c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/826be4c))
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
- Comprehensive consistency review — UI/UX, code quality, functional parity ([6e51164](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e51164))
|
|
||||||
- Comprehensive codebase review — security, performance, quality, UX ([b803d00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b803d00))
|
|
||||||
- Provider descriptor registry — eliminate provider-specific hardcoding ([8cb836e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8cb836e))
|
|
||||||
- Provider-agnostic bot command system + Gitea commands ([63437c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/63437c1))
|
|
||||||
- Unify test dispatch with real NotificationDispatcher ([d4cb388](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d4cb388))
|
|
||||||
- Replace favorites checkbox with toggle switch in grid layout ([1a8c95e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1a8c95e))
|
|
||||||
- Rename /telegram-bots route to /bots ([b525e3e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b525e3e))
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Development / Internal
|
|
||||||
|
|
||||||
#### CI/Build
|
|
||||||
- Consolidate release tokens to single DEPLOY_TOKEN, rename redeploy step ([eecc9e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/eecc9e2))
|
|
||||||
- Sync release workflow with CI/CD docs, add manual build ([c41182f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c41182f))
|
|
||||||
|
|
||||||
#### Chores
|
|
||||||
- Pre-release cleanup ([90bc3cc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/90bc3cc))
|
|
||||||
- Remove accidentally committed __pycache__ ([0dcca2f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0dcca2f))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
| Hash | Message | Author |
|
||||||
|------|---------|--------|
|
|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|------------------|
|
||||||
| [90bc3cc](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/90bc3cc) | chore: pre-release cleanup | alexei.dolgolyov |
|
| [ab621b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ab621b6) | feat: wire tracking-config display filters + per-tracker adaptive polling | alexei.dolgolyov |
|
||||||
| [eecc9e2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/eecc9e2) | ci: consolidate release tokens to single DEPLOY_TOKEN, rename redeploy step | alexei.dolgolyov |
|
|
||||||
| [f0739ca](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0739ca) | feat: security hardening — SSRF guard, template sandbox timeout, webhook log prune, auth & backup polish | alexei.dolgolyov |
|
|
||||||
| [734e5c9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/734e5c9) | feat: UX improvements — secure webhooks, locale fixes, dynamic languages, UI polish | alexei.dolgolyov |
|
|
||||||
| [6b22113](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6b22113) | feat: person excludes for auto-organize rules, backup & restore system | alexei.dolgolyov |
|
|
||||||
| [6e51164](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e51164) | refactor: comprehensive consistency review — UI/UX, code quality, functional parity | alexei.dolgolyov |
|
|
||||||
| [6113a00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6113a00) | feat: webhook payload history — store and display recent incoming payloads | alexei.dolgolyov |
|
|
||||||
| [c41182f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c41182f) | ci: sync release workflow with CI/CD docs, add manual build | alexei.dolgolyov |
|
|
||||||
| [b803d00](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b803d00) | refactor: comprehensive codebase review — security, performance, quality, UX | alexei.dolgolyov |
|
|
||||||
| [616b221](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/616b221) | feat: generic webhook provider with JSONPath payload extraction | alexei.dolgolyov |
|
|
||||||
| [307871c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/307871c) | feat: Google Photos provider backend + API hardening | alexei.dolgolyov |
|
|
||||||
| [3372761](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3372761) | feat: collapsible chart, paginator controls, localized template slots | alexei.dolgolyov |
|
|
||||||
| [21d8ef7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/21d8ef7) | fix: simplify add-target UX — single EntitySelect click to add | alexei.dolgolyov |
|
|
||||||
| [6e35926](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6e35926) | feat: default tracker configs, email validation, expandable target links | alexei.dolgolyov |
|
|
||||||
| [d4cb388](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d4cb388) | refactor: unify test dispatch with real NotificationDispatcher | alexei.dolgolyov |
|
|
||||||
| [1a8c95e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1a8c95e) | refactor: replace favorites checkbox with toggle switch in grid layout | alexei.dolgolyov |
|
|
||||||
| [b1ab5b8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b1ab5b8) | feat: collapsible accordion slots for template editing UX | alexei.dolgolyov |
|
|
||||||
| [d0bc767](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d0bc767) | feat: rich command templates with public links + media text-first flow | alexei.dolgolyov |
|
|
||||||
| [f90cc36](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f90cc36) | feat: add API docs link button in sidebar footer | alexei.dolgolyov |
|
|
||||||
| [ad2fd33](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ad2fd33) | perf: rewrite asset URLs to internal provider URL for LAN fetching | alexei.dolgolyov |
|
|
||||||
| [d8ecb60](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8ecb60) | feat: broadcast notification target + UX improvements | alexei.dolgolyov |
|
|
||||||
| [8cb836e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/8cb836e) | refactor: provider descriptor registry — eliminate provider-specific hardcoding | alexei.dolgolyov |
|
|
||||||
| [c6bb2b5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c6bb2b5) | fix: provider-aware collection count labels in tracker list | alexei.dolgolyov |
|
|
||||||
| [2cc4bf6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2cc4bf6) | fix: NUT template preview + tracking config event checkboxes | alexei.dolgolyov |
|
|
||||||
| [68ac13b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/68ac13b) | feat: NUT (Network UPS Tools) service provider + provider-agnostic UI | alexei.dolgolyov |
|
|
||||||
| [c451f3d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c451f3d) | feat: filter entity selectors by global provider filter | alexei.dolgolyov |
|
|
||||||
| [0702ec7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0702ec7) | fix: dashboard provider card shows filtered count, fix provider update 400 | alexei.dolgolyov |
|
|
||||||
| [4049efe](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4049efe) | fix: UI polish — overflow, placeholders, dashboard provider card | alexei.dolgolyov |
|
|
||||||
| [1cfa728](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1cfa728) | feat: Receiver OOP hierarchy with per-receiver locale resolution | alexei.dolgolyov |
|
|
||||||
| [b3b6c31](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b3b6c31) | feat: per-chat command toggle, listener name + toggle in bot tab | alexei.dolgolyov |
|
|
||||||
| [37388c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/37388c4) | feat: locale-aware notification templates + UX improvements | alexei.dolgolyov |
|
|
||||||
| [6a559bf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6a559bf) | feat: Actions system — scheduled mutations on external services | alexei.dolgolyov |
|
|
||||||
| [0fde3c6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0fde3c6) | feat: add Planka service provider with full notification and command support | alexei.dolgolyov |
|
|
||||||
| [39bac82](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/39bac82) | feat: smart video size warnings + Jinja2 template autocomplete | alexei.dolgolyov |
|
|
||||||
| [1ac6a17](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1ac6a17) | feat: Docker deployment + Gitea CI/CD workflow | alexei.dolgolyov |
|
|
||||||
| [e0bae39](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e0bae39) | feat: comprehensive code review fixes — security, performance, quality | alexei.dolgolyov |
|
|
||||||
| [31584c5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/31584c5) | feat: consistent IconGridSelect sizing + descriptions + filter upgrades | alexei.dolgolyov |
|
|
||||||
| [82e400d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/82e400d) | feat: chat language display, disabled EntitySelect items, dev scripts | alexei.dolgolyov |
|
|
||||||
| [e90c128](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e90c128) | fix: pass chat_action from target config to Telegram client | alexei.dolgolyov |
|
|
||||||
| [d8a1af0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/d8a1af0) | fix: remove all transform from stagger/fade animations | alexei.dolgolyov |
|
|
||||||
| [f9a4ccf](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9a4ccf) | fix: stagger animation breaking position:fixed overlays | alexei.dolgolyov |
|
|
||||||
| [bd254de](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/bd254de) | fix: remove Card hover transform that breaks fixed-position overlays | alexei.dolgolyov |
|
|
||||||
| [c26b71d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c26b71d) | fix: clipboard copy fallback for non-HTTPS contexts | alexei.dolgolyov |
|
|
||||||
| [7cbba9d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7cbba9d) | feat: add filtering to all entity list pages | alexei.dolgolyov |
|
|
||||||
| [63437c1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/63437c1) | refactor: provider-agnostic bot command system + Gitea commands | alexei.dolgolyov |
|
|
||||||
| [0562f78](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0562f78) | feat: add Scheduler provider + multi-provider UX fixes | alexei.dolgolyov |
|
|
||||||
| [6d28cfb](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/6d28cfb) | feat: add Gitea as webhook-based service provider | alexei.dolgolyov |
|
|
||||||
| [1167d13](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1167d13) | feat: locale-aware command templates, debounced auto-sync, entity pickers | alexei.dolgolyov |
|
|
||||||
| [751097b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/751097b) | feat: comprehensive code review fixes + receivers-only architecture | alexei.dolgolyov |
|
|
||||||
| [b525e3e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b525e3e) | refactor: rename /telegram-bots route to /bots | alexei.dolgolyov |
|
|
||||||
| [f64ada5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f64ada5) | fix: nav active state — plain path link not highlighted when sibling query-param link matches | alexei.dolgolyov |
|
|
||||||
| [826be4c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/826be4c) | perf: lazy-load @mdi/js to reduce Vite dev server memory usage | alexei.dolgolyov |
|
|
||||||
| [a7829c4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7829c4) | feat: add filter search to IconGridSelect when item count > 4 | alexei.dolgolyov |
|
|
||||||
| [a9bb912](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a9bb912) | feat: replace all select dropdowns with IconGridSelect, fix EN template seed | alexei.dolgolyov |
|
|
||||||
| [db7aac5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/db7aac5) | fix: re-create missing EN default template, provider type as IconGridSelect | alexei.dolgolyov |
|
|
||||||
| [9d3abd9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9d3abd9) | feat: add provider type selector to tracking-configs, use IconGridSelect everywhere | alexei.dolgolyov |
|
|
||||||
| [a3a1fe3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a3a1fe3) | feat: EntitySelect palette-style entity picker, replace select dropdowns | alexei.dolgolyov |
|
|
||||||
| [86115f5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/86115f5) | fix: search palette triggers highlight, restore CSS keyframe blink | alexei.dolgolyov |
|
|
||||||
| [88e21e4](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/88e21e4) | fix: switch highlight to global store instead of URL params | alexei.dolgolyov |
|
|
||||||
| [f47df93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f47df93) | fix: replace CSS keyframe highlight with direct style pulse for reliability | alexei.dolgolyov |
|
|
||||||
| [4b59f40](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4b59f40) | fix: card highlight animation — kill stagger before highlight, keep animation:none on cleanup | alexei.dolgolyov |
|
|
||||||
| [637a467](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/637a467) | feat: add search button to sidebar with Ctrl+K shortcut hint | alexei.dolgolyov |
|
|
||||||
| [4c1d5a8](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4c1d5a8) | fix: prevent stagger animation replay after card highlight ends | alexei.dolgolyov |
|
|
||||||
| [f0f49db](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f0f49db) | feat: card highlight system for cross-entity navigation | alexei.dolgolyov |
|
|
||||||
| [227b9c2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/227b9c2) | fix: rename bots → telegramBots in targets page to fix undefined reference | alexei.dolgolyov |
|
|
||||||
| [06b2463](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/06b2463) | feat: IconGridSelect, CrossLink, SearchPalette components + entity crosslinks | alexei.dolgolyov |
|
|
||||||
| [563716f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/563716f) | feat: entity cache system, nav UX improvements, split CLAUDE.md | alexei.dolgolyov |
|
|
||||||
| [2c740ff](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2c740ff) | feat: grouped nav tree with badges, dashboard events section with filtered chart | alexei.dolgolyov |
|
|
||||||
| [ddcbfda](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ddcbfda) | feat: remove hardcoded command templates, enforce template system exclusively | alexei.dolgolyov |
|
|
||||||
| [3e3a6f0](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3e3a6f0) | feat: Discord/Slack/ntfy/Matrix targets, command templates, delete protection, email/matrix bots | alexei.dolgolyov |
|
|
||||||
| [846d480](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/846d480) | feat: provider-strict configs, slot-based templates, broadcast targets, email bots, command templates | alexei.dolgolyov |
|
|
||||||
| [371ea70](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/371ea70) | feat: fix template preview links, default chat action, update default templates | alexei.dolgolyov |
|
|
||||||
| [1d445f3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1d445f3) | feat: entity relationship refactor — notification trackers, command system, chat actions | alexei.dolgolyov |
|
|
||||||
| [0dcca2f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/0dcca2f) | chore: remove accidentally committed __pycache__ | alexei.dolgolyov |
|
|
||||||
| [03ec9b3](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03ec9b3) | feat: telegram commands, app settings, bot polling, webhook handling, UI improvements | alexei.dolgolyov |
|
|
||||||
| [5015e37](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5015e37) | feat: test menu dropdown, split text/media messages, target settings, provider URL links | alexei.dolgolyov |
|
|
||||||
| [03c5c66](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/03c5c66) | feat: UX & notification improvements — icons, events, chat names, link validation, templates | alexei.dolgolyov |
|
|
||||||
| [91e5cd5](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/91e5cd5) | fix: comprehensive API/UI review — 26 bug fixes and improvements | alexei.dolgolyov |
|
|
||||||
| [9eec21a](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9eec21a) | feat: port full CRUD API routes and frontend pages from Immich Watcher | alexei.dolgolyov |
|
|
||||||
| [c9cab93](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/c9cab93) | feat: port original frontend UI to Notify Bridge | alexei.dolgolyov |
|
|
||||||
| [e43c2ed](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/e43c2ed) | fix: remove auto-redirect from API client on 401 | alexei.dolgolyov |
|
|
||||||
| [7d01ae6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7d01ae6) | fix: add auth guard to root layout with setup/login redirects | alexei.dolgolyov |
|
|
||||||
| [f9c41fa](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f9c41fa) | fix: local fonts via @fontsource, favicon, autocomplete attrs | alexei.dolgolyov |
|
|
||||||
| [786fe5e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/786fe5e) | feat(notify-bridge): phase 9 - HAOS integration planning | alexei.dolgolyov |
|
|
||||||
| [08814e9](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/08814e9) | feat(notify-bridge): phase 8 - integration and wiring | alexei.dolgolyov |
|
|
||||||
| [9dfd1b7](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9dfd1b7) | feat(notify-bridge): phase 7 - frontend restructuring | alexei.dolgolyov |
|
|
||||||
| [7f99c89](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/7f99c89) | feat(notify-bridge): phase 6 - database models and server API | alexei.dolgolyov |
|
|
||||||
| [16a41ef](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/16a41ef) | feat(notify-bridge): phase 5 - notification system | alexei.dolgolyov |
|
|
||||||
| [f36f070](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f36f070) | feat(notify-bridge): phase 4 - template system | alexei.dolgolyov |
|
|
||||||
| [cc02558](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cc02558) | feat(notify-bridge): phase 3 - Immich service provider | alexei.dolgolyov |
|
|
||||||
| [3ed0d8c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3ed0d8c) | feat(notify-bridge): phase 2 - core abstractions | alexei.dolgolyov |
|
|
||||||
| [b724447](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b724447) | feat(notify-bridge): phase 1 - project scaffolding | alexei.dolgolyov |
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
+27
-3
@@ -10,14 +10,38 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- notify-bridge-data:/data
|
- notify-bridge-data:/data
|
||||||
environment:
|
environment:
|
||||||
|
# REQUIRED — any 32+ byte random string. `openssl rand -hex 32` is one way.
|
||||||
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
|
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
|
||||||
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-*}
|
# Comma-separated list of allowed browser origins. Wildcard `*` is
|
||||||
|
# rejected on startup because credentials are enabled.
|
||||||
|
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-http://localhost:8420}
|
||||||
|
# Trusted proxy IPs whose X-Forwarded-For / X-Forwarded-Proto we honor.
|
||||||
|
# Set this to your reverse proxy's IP (e.g. 172.17.0.1 for the default
|
||||||
|
# docker bridge, or `*` only if the container is NOT reachable from the
|
||||||
|
# public internet).
|
||||||
|
- NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS=${NOTIFY_BRIDGE_FORWARDED_ALLOW_IPS:-127.0.0.1}
|
||||||
|
# Opt-in SSRF bypass for private/loopback/link-local hosts (homelab
|
||||||
|
# scenario — tracking an Immich/Gitea instance on the same LAN). DO NOT
|
||||||
|
# enable on a publicly exposed instance.
|
||||||
|
# - NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"]
|
# Use /api/ready (not /api/health) so the container is only reported
|
||||||
|
# healthy after migrations and the scheduler finish booting.
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/ready', timeout=3)"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 30s
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
mem_limit: 512m
|
||||||
|
cpus: 1.0
|
||||||
|
pids_limit: 256
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
notify-bridge-data:
|
notify-bridge-data:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.5.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
+115
-1
@@ -11,6 +11,37 @@ export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Structured 409 blocked-by payload attached to ApiError.blockedBy. */
|
||||||
|
export interface BlockedByDetail {
|
||||||
|
message: string;
|
||||||
|
entity: string;
|
||||||
|
blocked_by: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
blockedBy?: BlockedByDetail;
|
||||||
|
constructor(message: string, status: number, blockedBy?: BlockedByDetail) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.blockedBy = blockedBy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a server-issued datetime string as UTC (appends Z if no timezone info present). */
|
||||||
|
export function parseDate(dateStr: string): Date {
|
||||||
|
if (!dateStr) return new Date(NaN);
|
||||||
|
if (!/Z$|[+-]\d{2}:?\d{2}$/.test(dateStr)) return new Date(dateStr + 'Z');
|
||||||
|
return new Date(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If the thrown error was a structured 409 from delete_protection, return its payload. */
|
||||||
|
export function getBlockedBy(err: unknown): BlockedByDetail | null {
|
||||||
|
if (err instanceof ApiError && err.blockedBy) return err.blockedBy;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
return localStorage.getItem('access_token');
|
return localStorage.getItem('access_token');
|
||||||
@@ -63,6 +94,9 @@ async function doRefreshAccessToken(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||||
|
// Longer cap for fetchAuth — it's used for multipart uploads (backup restore)
|
||||||
|
// and binary downloads where a 30s limit can cut off a legit slow upload.
|
||||||
|
const DEFAULT_FETCHAUTH_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
export async function api<T = any>(
|
export async function api<T = any>(
|
||||||
path: string,
|
path: string,
|
||||||
@@ -106,7 +140,17 @@ export async function api<T = any>(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
// Structured blocked-by detail (from delete_protection.raise_if_used)
|
||||||
|
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
|
||||||
|
const bb: BlockedByDetail = {
|
||||||
|
message: err.detail.message || `HTTP ${res.status}`,
|
||||||
|
entity: err.detail.entity || '',
|
||||||
|
blocked_by: err.detail.blocked_by,
|
||||||
|
};
|
||||||
|
throw new ApiError(bb.message, res.status, bb);
|
||||||
|
}
|
||||||
|
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
|
||||||
|
throw new ApiError(msg, res.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -114,3 +158,73 @@ export async function api<T = any>(
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth-aware ``fetch`` wrapper for calls that can't go through ``api()`` —
|
||||||
|
* typically multipart/form-data uploads or binary downloads where we need the
|
||||||
|
* raw ``Response`` object rather than parsed JSON.
|
||||||
|
*
|
||||||
|
* - Injects the Bearer token automatically.
|
||||||
|
* - Does NOT set ``Content-Type`` (the caller's body — e.g. ``FormData`` —
|
||||||
|
* decides the encoding; browsers add the boundary).
|
||||||
|
* - Attempts a one-shot token refresh on 401, matching ``api()``.
|
||||||
|
* - Translates non-OK responses to ``ApiError`` so callers can use the same
|
||||||
|
* ``getBlockedBy`` / ``err.message`` handling pattern.
|
||||||
|
*/
|
||||||
|
export async function fetchAuth(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit & { timeoutMs?: number } = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const token = getToken();
|
||||||
|
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||||
|
|
||||||
|
// Abort after timeout so uploads/downloads don't hang indefinitely if
|
||||||
|
// the backend stops responding. Callers can override per-request via
|
||||||
|
// options.timeoutMs or pass their own signal to opt out.
|
||||||
|
const { timeoutMs, ...fetchOptions } = options;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
timeoutMs ?? DEFAULT_FETCHAUTH_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
const signal = options.signal ?? controller.signal;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res = await fetch(url, { ...fetchOptions, headers, signal });
|
||||||
|
|
||||||
|
if (res.status === 401 && token) {
|
||||||
|
const refreshed = await refreshAccessToken();
|
||||||
|
if (refreshed) {
|
||||||
|
headers['Authorization'] = `Bearer ${getToken()}`;
|
||||||
|
res = await fetch(url, { ...fetchOptions, headers, signal });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
clearTokens();
|
||||||
|
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||||
|
throw new ApiError('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
|
||||||
|
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
|
||||||
|
const bb: BlockedByDetail = {
|
||||||
|
message: err.detail.message || `HTTP ${res.status}`,
|
||||||
|
entity: err.detail.entity || '',
|
||||||
|
blocked_by: err.detail.blocked_by,
|
||||||
|
};
|
||||||
|
throw new ApiError(bb.message, res.status, bb);
|
||||||
|
}
|
||||||
|
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
|
||||||
|
throw new ApiError(msg, res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
import type { BlockedByDetail } from '$lib/api';
|
||||||
|
|
||||||
|
let { open = false, detail = null, onclose } = $props<{
|
||||||
|
open: boolean;
|
||||||
|
detail: BlockedByDetail | null;
|
||||||
|
onclose: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const blockedCount = $derived(detail?.blocked_by?.length ?? 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {open} title={t('common.cannotDelete')} onclose={onclose}>
|
||||||
|
{#if detail}
|
||||||
|
<div class="flex items-start gap-3 mb-4">
|
||||||
|
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
|
||||||
|
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||||
|
<MdiIcon name="mdiLinkVariant" size={20} />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium mb-1 break-words">{detail.message}</p>
|
||||||
|
{#if detail.entity}
|
||||||
|
<p class="text-xs break-all" style="color: var(--color-muted-foreground);">{detail.entity}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('common.blockedByIntro')}</p>
|
||||||
|
{#if blockedCount > 0}
|
||||||
|
<span class="text-[0.65rem] font-mono px-1.5 py-0.5 rounded"
|
||||||
|
style="background: var(--color-muted); color: var(--color-muted-foreground);">
|
||||||
|
{blockedCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if blockedCount > 0}
|
||||||
|
<ul class="space-y-1.5 max-h-64 overflow-y-auto pr-1 mb-5">
|
||||||
|
{#each detail.blocked_by as consumer}
|
||||||
|
<li class="flex items-start gap-2 text-sm px-3 py-2 rounded-md"
|
||||||
|
style="background: var(--color-muted); border: 1px solid var(--color-border);">
|
||||||
|
<span class="flex-shrink-0 mt-0.5" style="color: var(--color-muted-foreground);">
|
||||||
|
<MdiIcon name="mdiChevronRight" size={14} />
|
||||||
|
</span>
|
||||||
|
<span class="font-mono text-xs break-all min-w-0 flex-1">{consumer}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button onclick={onclose} class="blocked-by-close-btn">
|
||||||
|
{t('common.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.blocked-by-close-btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.blocked-by-close-btn:hover {
|
||||||
|
background: var(--color-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { parseDate } from '$lib/api';
|
||||||
import MdiIcon from './MdiIcon.svelte';
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
|
||||||
interface DayData {
|
interface DayData {
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
|
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
const d = parseDate(dateStr);
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,764 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface LocaleMeta {
|
||||||
|
code: string;
|
||||||
|
name: string; // English name
|
||||||
|
native: string; // Native script
|
||||||
|
rtl?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATALOG: LocaleMeta[] = [
|
||||||
|
{ code: 'en', name: 'English', native: 'English' },
|
||||||
|
{ code: 'ru', name: 'Russian', native: 'Русский' },
|
||||||
|
{ code: 'de', name: 'German', native: 'Deutsch' },
|
||||||
|
{ code: 'fr', name: 'French', native: 'Français' },
|
||||||
|
{ code: 'es', name: 'Spanish', native: 'Español' },
|
||||||
|
{ code: 'it', name: 'Italian', native: 'Italiano' },
|
||||||
|
{ code: 'pt', name: 'Portuguese', native: 'Português' },
|
||||||
|
{ code: 'pl', name: 'Polish', native: 'Polski' },
|
||||||
|
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
|
||||||
|
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
|
||||||
|
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
|
||||||
|
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
|
||||||
|
{ code: 'da', name: 'Danish', native: 'Dansk' },
|
||||||
|
{ code: 'cs', name: 'Czech', native: 'Čeština' },
|
||||||
|
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
|
||||||
|
{ code: 'ro', name: 'Romanian', native: 'Română' },
|
||||||
|
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
|
||||||
|
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
|
||||||
|
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
|
||||||
|
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
|
||||||
|
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
|
||||||
|
{ code: 'sr', name: 'Serbian', native: 'Српски' },
|
||||||
|
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
|
||||||
|
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
|
||||||
|
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
|
||||||
|
{ code: 'zh', name: 'Chinese', native: '中文' },
|
||||||
|
{ code: 'ja', name: 'Japanese', native: '日本語' },
|
||||||
|
{ code: 'ko', name: 'Korean', native: '한국어' },
|
||||||
|
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
|
||||||
|
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
|
||||||
|
{ code: 'th', name: 'Thai', native: 'ไทย' },
|
||||||
|
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Locales that ship with default notification & command templates.
|
||||||
|
const SHIPPED = new Set(['en', 'ru']);
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable<string>(''),
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Parse the comma-separated backend string into an ordered array of codes.
|
||||||
|
const codes = $derived.by<string[]>(() => {
|
||||||
|
if (!value) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of value.split(',')) {
|
||||||
|
const c = raw.trim().toLowerCase();
|
||||||
|
if (!c || seen.has(c)) continue;
|
||||||
|
seen.add(c);
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function commit(next: string[]) {
|
||||||
|
// De-dupe (preserve order) and serialise back to the backend format.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const clean = next.map(c => c.trim().toLowerCase())
|
||||||
|
.filter(c => c && !seen.has(c) && (seen.add(c), true));
|
||||||
|
value = clean.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function meta(code: string): LocaleMeta {
|
||||||
|
return CATALOG.find(l => l.code === code) ?? {
|
||||||
|
code,
|
||||||
|
name: code.toUpperCase(),
|
||||||
|
native: code.toUpperCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(code: string) {
|
||||||
|
commit(codes.filter(c => c !== code));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePrimary(code: string) {
|
||||||
|
commit([code, ...codes.filter(c => c !== code)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveUp(code: string) {
|
||||||
|
const i = codes.indexOf(code);
|
||||||
|
if (i <= 0) return;
|
||||||
|
const next = [...codes];
|
||||||
|
[next[i - 1], next[i]] = [next[i], next[i - 1]];
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDown(code: string) {
|
||||||
|
const i = codes.indexOf(code);
|
||||||
|
if (i < 0 || i >= codes.length - 1) return;
|
||||||
|
const next = [...codes];
|
||||||
|
[next[i], next[i + 1]] = [next[i + 1], next[i]];
|
||||||
|
commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Add flow ----------------------------------------------------------
|
||||||
|
|
||||||
|
let addOpen = $state(false);
|
||||||
|
let addQuery = $state('');
|
||||||
|
let addInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
let highlightIdx = $state(0);
|
||||||
|
|
||||||
|
// Valid BCP 47-ish: 2–3 letter primary, optional '-' subtag(s) 2-8 chars.
|
||||||
|
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
|
||||||
|
|
||||||
|
const selectedSet = $derived(new Set(codes));
|
||||||
|
|
||||||
|
const suggestions = $derived.by(() => {
|
||||||
|
const q = addQuery.trim().toLowerCase();
|
||||||
|
const available = CATALOG.filter(l => !selectedSet.has(l.code));
|
||||||
|
if (!q) return available;
|
||||||
|
return available.filter(l =>
|
||||||
|
l.code.includes(q)
|
||||||
|
|| l.name.toLowerCase().includes(q)
|
||||||
|
|| l.native.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canAddCustom = $derived.by(() => {
|
||||||
|
const q = addQuery.trim().toLowerCase();
|
||||||
|
if (!q) return false;
|
||||||
|
if (!CUSTOM_RE.test(q)) return false;
|
||||||
|
if (selectedSet.has(q)) return false;
|
||||||
|
// Skip "custom" entry when it matches an existing catalog entry exactly.
|
||||||
|
if (CATALOG.some(l => l.code === q)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
addOpen = true;
|
||||||
|
addQuery = '';
|
||||||
|
highlightIdx = 0;
|
||||||
|
requestAnimationFrame(() => addInputEl?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAdd() {
|
||||||
|
addOpen = false;
|
||||||
|
addQuery = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCode(code: string) {
|
||||||
|
const c = code.trim().toLowerCase();
|
||||||
|
if (!c) return;
|
||||||
|
commit([...codes, c]);
|
||||||
|
addQuery = '';
|
||||||
|
highlightIdx = 0;
|
||||||
|
requestAnimationFrame(() => addInputEl?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { closeAdd(); return; }
|
||||||
|
const total = suggestions.length + (canAddCustom ? 1 : 0);
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.min(highlightIdx + 1, Math.max(0, total - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (highlightIdx < suggestions.length) {
|
||||||
|
addCode(suggestions[highlightIdx].code);
|
||||||
|
} else if (canAddCustom) {
|
||||||
|
addCode(addQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { addQuery; highlightIdx = 0; });
|
||||||
|
|
||||||
|
// --- Drag & drop -------------------------------------------------------
|
||||||
|
|
||||||
|
let dragCode = $state<string | null>(null);
|
||||||
|
let dragOverCode = $state<string | null>(null);
|
||||||
|
|
||||||
|
function onDragStart(e: DragEvent, code: string) {
|
||||||
|
dragCode = code;
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, code: string) {
|
||||||
|
if (!dragCode || dragCode === code) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragOverCode = code;
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent, code: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dragCode || dragCode === code) return;
|
||||||
|
const from = codes.indexOf(dragCode);
|
||||||
|
const to = codes.indexOf(code);
|
||||||
|
if (from < 0 || to < 0) return;
|
||||||
|
const next = [...codes];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
commit(next);
|
||||||
|
dragCode = null;
|
||||||
|
dragOverCode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
dragCode = null;
|
||||||
|
dragOverCode = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ls-root">
|
||||||
|
{#if codes.length === 0}
|
||||||
|
<div class="ls-empty">
|
||||||
|
<div class="ls-empty-glyph" aria-hidden="true">A あ Я</div>
|
||||||
|
<p class="ls-empty-text">{t('locales.empty')}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="ls-list" role="list">
|
||||||
|
{#each codes as code, i (code)}
|
||||||
|
{@const m = meta(code)}
|
||||||
|
{@const isPrimary = i === 0}
|
||||||
|
{@const isShipped = SHIPPED.has(code)}
|
||||||
|
<li
|
||||||
|
class="ls-row"
|
||||||
|
class:ls-row-primary={isPrimary}
|
||||||
|
class:ls-row-dragover={dragOverCode === code}
|
||||||
|
class:ls-row-dragging={dragCode === code}
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e) => onDragStart(e, code)}
|
||||||
|
ondragover={(e) => onDragOver(e, code)}
|
||||||
|
ondrop={(e) => onDrop(e, code)}
|
||||||
|
ondragend={onDragEnd}
|
||||||
|
>
|
||||||
|
<span class="ls-rail" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ls-handle"
|
||||||
|
aria-label={t('locales.reorder')}
|
||||||
|
title={t('locales.reorder')}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiDragVertical" size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="ls-text">
|
||||||
|
<div class="ls-native" dir={m.rtl ? 'rtl' : 'ltr'} lang={code}>{m.native}</div>
|
||||||
|
<div class="ls-meta">
|
||||||
|
<span class="ls-name">{m.name}</span>
|
||||||
|
<span class="ls-dot" aria-hidden="true">·</span>
|
||||||
|
<span class="ls-code">{code}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ls-badges">
|
||||||
|
{#if isPrimary}
|
||||||
|
<span class="ls-tag ls-tag-primary">
|
||||||
|
<MdiIcon name="mdiStar" size={10} />
|
||||||
|
{t('locales.primary')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if isShipped}
|
||||||
|
<span class="ls-tag ls-tag-shipped" title={t('locales.shippedHint')}>
|
||||||
|
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||||
|
{t('locales.shipped')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ls-actions">
|
||||||
|
{#if !isPrimary}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ls-icon-btn"
|
||||||
|
onclick={() => makePrimary(code)}
|
||||||
|
aria-label={t('locales.makePrimary')}
|
||||||
|
title={t('locales.makePrimary')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiStarOutline" size={14} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ls-icon-btn"
|
||||||
|
onclick={() => moveUp(code)}
|
||||||
|
disabled={i === 0}
|
||||||
|
aria-label={t('locales.moveUp')}
|
||||||
|
title={t('locales.moveUp')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiChevronUp" size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ls-icon-btn"
|
||||||
|
onclick={() => moveDown(code)}
|
||||||
|
disabled={i === codes.length - 1}
|
||||||
|
aria-label={t('locales.moveDown')}
|
||||||
|
title={t('locales.moveDown')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiChevronDown" size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ls-icon-btn ls-icon-danger"
|
||||||
|
onclick={() => remove(code)}
|
||||||
|
disabled={codes.length <= 1}
|
||||||
|
aria-label={t('locales.remove')}
|
||||||
|
title={codes.length <= 1 ? t('locales.removeLast') : t('locales.remove')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiClose" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add zone -->
|
||||||
|
<div class="ls-add" class:ls-add-open={addOpen}>
|
||||||
|
{#if !addOpen}
|
||||||
|
<button type="button" class="ls-add-trigger" onclick={openAdd}>
|
||||||
|
<MdiIcon name="mdiPlus" size={14} />
|
||||||
|
<span>{t('locales.add')}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="ls-add-panel">
|
||||||
|
<div class="ls-add-input-row">
|
||||||
|
<MdiIcon name="mdiMagnify" size={14} />
|
||||||
|
<input
|
||||||
|
bind:this={addInputEl}
|
||||||
|
bind:value={addQuery}
|
||||||
|
onkeydown={onAddKeydown}
|
||||||
|
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
|
||||||
|
placeholder={t('locales.searchPlaceholder')}
|
||||||
|
class="ls-add-input"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
|
||||||
|
<MdiIcon name="mdiClose" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ls-add-list" role="listbox">
|
||||||
|
{#each suggestions as s, i (s.code)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === highlightIdx}
|
||||||
|
class="ls-sugg"
|
||||||
|
class:ls-sugg-hl={i === highlightIdx}
|
||||||
|
onmouseenter={() => highlightIdx = i}
|
||||||
|
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
|
||||||
|
>
|
||||||
|
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
|
||||||
|
<span class="ls-sugg-name">{s.name}</span>
|
||||||
|
<span class="ls-sugg-code">{s.code}</span>
|
||||||
|
{#if SHIPPED.has(s.code)}
|
||||||
|
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
|
||||||
|
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if canAddCustom}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={highlightIdx === suggestions.length}
|
||||||
|
class="ls-sugg ls-sugg-custom"
|
||||||
|
class:ls-sugg-hl={highlightIdx === suggestions.length}
|
||||||
|
onmouseenter={() => highlightIdx = suggestions.length}
|
||||||
|
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiPlusCircleOutline" size={14} />
|
||||||
|
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
|
||||||
|
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if suggestions.length === 0 && !canAddCustom}
|
||||||
|
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="ls-hint">
|
||||||
|
<MdiIcon name="mdiInformationOutline" size={12} />
|
||||||
|
<span>{t('locales.orderHint')}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ls-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Empty state -------------------------------------------------- */
|
||||||
|
.ls-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg,
|
||||||
|
color-mix(in srgb, var(--color-primary) 4%, transparent) 0%,
|
||||||
|
transparent 60%),
|
||||||
|
var(--color-background);
|
||||||
|
}
|
||||||
|
.ls-empty-glyph {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-weight: 300;
|
||||||
|
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.ls-empty-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- List --------------------------------------------------------- */
|
||||||
|
.ls-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.625rem 0.75rem 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
transition: border-color 0.15s, background 0.15s, transform 0.15s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ls-row:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
|
||||||
|
}
|
||||||
|
.ls-row.ls-row-dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.ls-row.ls-row-dragover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-background));
|
||||||
|
}
|
||||||
|
.ls-row.ls-row-primary {
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg,
|
||||||
|
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
|
||||||
|
transparent 30%),
|
||||||
|
var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accent rail — pronounced on primary, near-invisible otherwise */
|
||||||
|
.ls-rail {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ls-row.ls-row-primary .ls-rail {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.125rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: grab;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.ls-row:hover .ls-handle {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.ls-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ls-native {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ls-name {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-dot {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.ls-code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.05rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ls-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-tag-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-foreground, #fff);
|
||||||
|
}
|
||||||
|
.ls-tag-shipped {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.0625rem;
|
||||||
|
}
|
||||||
|
.ls-icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.ls-icon-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
.ls-icon-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ls-icon-btn.ls-icon-danger:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, #ef4444 14%, transparent);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Add zone ----------------------------------------------------- */
|
||||||
|
.ls-add {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
.ls-add-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.ls-add-trigger:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-add-panel {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: ls-pop 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes ls-pop {
|
||||||
|
from { opacity: 0; transform: translateY(-2px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.ls-add-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ls-add-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-add-list {
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.ls-sugg {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.625rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.ls-sugg.ls-sugg-hl {
|
||||||
|
background: var(--color-muted);
|
||||||
|
}
|
||||||
|
.ls-sugg-native {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-sugg-name {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ls-sugg-code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.05rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.ls-sugg.ls-sugg-hl .ls-sugg-code {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||||
|
}
|
||||||
|
.ls-sugg-shipped {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-primary);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-sugg-custom {
|
||||||
|
border-top: 1px dashed var(--color-border);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.ls-sugg-custom-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ls-sugg-empty {
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Hint --------------------------------------------------------- */
|
||||||
|
.ls-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0.125rem 0 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable<string>('UTC'),
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// --- Catalog -----------------------------------------------------------
|
||||||
|
|
||||||
|
const timezones = $derived.by<string[]>(() => {
|
||||||
|
try {
|
||||||
|
const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] };
|
||||||
|
if (typeof intl.supportedValuesOf === 'function') {
|
||||||
|
return intl.supportedValuesOf('timeZone');
|
||||||
|
}
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
return ['UTC'];
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedTz = (() => {
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
|
||||||
|
catch { return 'UTC'; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- Live clock --------------------------------------------------------
|
||||||
|
|
||||||
|
let now = $state(new Date());
|
||||||
|
let tickHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
onMount(() => {
|
||||||
|
tickHandle = setInterval(() => { now = new Date(); }, 1000);
|
||||||
|
});
|
||||||
|
onDestroy(() => { if (tickHandle) clearInterval(tickHandle); });
|
||||||
|
|
||||||
|
function splitTz(tz: string): { region: string; city: string } {
|
||||||
|
if (!tz || tz === 'UTC' || tz === 'Etc/UTC') return { region: 'UTC', city: 'UTC' };
|
||||||
|
const parts = tz.split('/');
|
||||||
|
if (parts.length === 1) return { region: 'Other', city: parts[0].replace(/_/g, ' ') };
|
||||||
|
const city = parts[parts.length - 1].replace(/_/g, ' ');
|
||||||
|
const region = parts.slice(0, -1).join(' / ').replace(/_/g, ' ');
|
||||||
|
return { region, city };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(tz: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: tz,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).format(now);
|
||||||
|
} catch { return '--:--:--'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(tz: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
timeZone: tz,
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
}).format(now);
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtOffset(tz: string): string {
|
||||||
|
try {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: tz,
|
||||||
|
timeZoneName: 'shortOffset',
|
||||||
|
}).formatToParts(now);
|
||||||
|
const off = parts.find(p => p.type === 'timeZoneName')?.value ?? '';
|
||||||
|
return off || 'UTC';
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Selected state ----------------------------------------------------
|
||||||
|
|
||||||
|
const selected = $derived.by(() => {
|
||||||
|
const s = splitTz(value || 'UTC');
|
||||||
|
return {
|
||||||
|
iana: value || 'UTC',
|
||||||
|
region: s.region,
|
||||||
|
city: s.city,
|
||||||
|
time: fmtTime(value || 'UTC'),
|
||||||
|
date: fmtDate(value || 'UTC'),
|
||||||
|
offset: fmtOffset(value || 'UTC'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Picker ------------------------------------------------------------
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let query = $state('');
|
||||||
|
let highlightIdx = $state(0);
|
||||||
|
let inputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
let panelEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const q = query.trim().toLowerCase().replace(/\s+/g, '_');
|
||||||
|
if (!q) return timezones;
|
||||||
|
return timezones.filter(tz => tz.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group filtered tz list by region prefix for visual hierarchy.
|
||||||
|
interface Group { region: string; items: string[] }
|
||||||
|
const groups = $derived.by<Group[]>(() => {
|
||||||
|
const map = new Map<string, string[]>();
|
||||||
|
for (const tz of filtered) {
|
||||||
|
const region = tz.includes('/') ? tz.split('/')[0] : 'Other';
|
||||||
|
if (!map.has(region)) map.set(region, []);
|
||||||
|
map.get(region)!.push(tz);
|
||||||
|
}
|
||||||
|
const REGION_ORDER = ['UTC', 'Europe', 'America', 'Asia', 'Africa', 'Australia', 'Pacific', 'Atlantic', 'Indian', 'Antarctica', 'Arctic', 'Etc', 'Other'];
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
const ai = REGION_ORDER.indexOf(a);
|
||||||
|
const bi = REGION_ORDER.indexOf(b);
|
||||||
|
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||||
|
})
|
||||||
|
.map(([region, items]) => ({ region, items }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flattened index for keyboard navigation.
|
||||||
|
const flat = $derived<string[]>(groups.flatMap(g => g.items));
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
open = true;
|
||||||
|
query = '';
|
||||||
|
highlightIdx = Math.max(0, flat.indexOf(value));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputEl?.focus();
|
||||||
|
scrollToHighlight();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
open = false;
|
||||||
|
query = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTz(tz: string) {
|
||||||
|
value = tz;
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') { closePicker(); return; }
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.min(highlightIdx + 1, flat.length - 1);
|
||||||
|
scrollToHighlight();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||||
|
scrollToHighlight();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (flat[highlightIdx]) selectTz(flat[highlightIdx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHighlight() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
panelEl?.querySelector('.tz-opt-hl')?.scrollIntoView({ block: 'nearest' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => { query; highlightIdx = 0; });
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
function onDocClick(e: MouseEvent) {
|
||||||
|
if (!open) return;
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (panelEl && !panelEl.contains(target)) closePicker();
|
||||||
|
}
|
||||||
|
onMount(() => {
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
});
|
||||||
|
onDestroy(() => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tz-root">
|
||||||
|
<!-- Selected card -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tz-card"
|
||||||
|
class:tz-card-open={open}
|
||||||
|
onclick={() => (open ? closePicker() : openPicker())}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<div class="tz-card-left">
|
||||||
|
<div class="tz-region">{selected.region}</div>
|
||||||
|
<div class="tz-city">{selected.city}</div>
|
||||||
|
<div class="tz-sub">
|
||||||
|
<span class="tz-iana">{selected.iana}</span>
|
||||||
|
{#if selected.date}
|
||||||
|
<span class="tz-dot">·</span>
|
||||||
|
<span class="tz-date">{selected.date}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tz-card-right">
|
||||||
|
<div class="tz-clock">{selected.time}</div>
|
||||||
|
<div class="tz-offset">{selected.offset}</div>
|
||||||
|
</div>
|
||||||
|
<span class="tz-chev" aria-hidden="true">
|
||||||
|
<MdiIcon name={open ? 'mdiChevronUp' : 'mdiChevronDown'} size={16} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="tz-panel" bind:this={panelEl} role="listbox">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="tz-search-row">
|
||||||
|
<MdiIcon name="mdiMagnify" size={14} />
|
||||||
|
<input
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={query}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
placeholder={t('timezone.searchPlaceholder')}
|
||||||
|
class="tz-search"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<kbd class="tz-kbd">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick picks -->
|
||||||
|
{#if !query}
|
||||||
|
<div class="tz-quick">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tz-quick-btn"
|
||||||
|
class:tz-quick-active={value === detectedTz}
|
||||||
|
onclick={() => selectTz(detectedTz)}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiCrosshairsGps" size={12} />
|
||||||
|
<span class="tz-quick-label">{t('timezone.detect')}</span>
|
||||||
|
<span class="tz-quick-val">{detectedTz}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tz-quick-btn"
|
||||||
|
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
|
||||||
|
onclick={() => selectTz('UTC')}
|
||||||
|
>
|
||||||
|
<MdiIcon name="mdiEarth" size={12} />
|
||||||
|
<span class="tz-quick-label">{t('timezone.utc')}</span>
|
||||||
|
<span class="tz-quick-val">UTC+00</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Grouped list -->
|
||||||
|
<div class="tz-list">
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<div class="tz-empty">{t('timezone.noMatches')}</div>
|
||||||
|
{:else}
|
||||||
|
{#each groups as g (g.region)}
|
||||||
|
<div class="tz-group">
|
||||||
|
<div class="tz-group-head">
|
||||||
|
<span class="tz-group-name">{g.region}</span>
|
||||||
|
<span class="tz-group-count">{g.items.length}</span>
|
||||||
|
</div>
|
||||||
|
{#each g.items as tz (tz)}
|
||||||
|
{@const parts = splitTz(tz)}
|
||||||
|
{@const idx = flat.indexOf(tz)}
|
||||||
|
{@const hl = idx === highlightIdx}
|
||||||
|
{@const sel = tz === value}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={sel}
|
||||||
|
class="tz-opt"
|
||||||
|
class:tz-opt-hl={hl}
|
||||||
|
class:tz-opt-sel={sel}
|
||||||
|
onmouseenter={() => (highlightIdx = idx)}
|
||||||
|
onclick={() => selectTz(tz)}
|
||||||
|
>
|
||||||
|
<span class="tz-opt-city">{parts.city}</span>
|
||||||
|
<span class="tz-opt-iana">{tz}</span>
|
||||||
|
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tz-root {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Selected card ------------------------------------------------ */
|
||||||
|
.tz-card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem 0.75rem 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg,
|
||||||
|
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
|
||||||
|
transparent 55%),
|
||||||
|
var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.tz-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||||
|
}
|
||||||
|
.tz-card.tz-card-open {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-card-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tz-region {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
|
||||||
|
}
|
||||||
|
.tz-city {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tz-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tz-iana {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tz-dot { opacity: 0.5; }
|
||||||
|
|
||||||
|
.tz-card-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.tz-clock {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
line-height: 1;
|
||||||
|
/* Stable width so seconds ticker doesn't shift layout */
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.tz-offset {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.1rem 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-chev {
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Panel -------------------------------------------------------- */
|
||||||
|
.tz-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.375rem);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--color-card, var(--color-background));
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 26rem;
|
||||||
|
animation: tz-pop 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes tz-pop {
|
||||||
|
from { opacity: 0; transform: translateY(-3px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
.tz-search {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.tz-kbd {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-quick {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tz-quick-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-background);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.tz-quick-btn:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.tz-quick-active {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.tz-quick-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.tz-quick-val {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.tz-empty {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-group {
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
.tz-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.375rem 0.75rem 0.25rem;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--color-card, var(--color-background));
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.tz-group-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tz-opt {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.tz-opt.tz-opt-hl {
|
||||||
|
background: var(--color-muted);
|
||||||
|
}
|
||||||
|
.tz-opt.tz-opt-sel {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
.tz-opt-city {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tz-opt.tz-opt-sel .tz-opt-city {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.tz-opt-iana {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tz-opt-offset {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
padding: 0.1rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--color-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tz-opt.tz-opt-hl .tz-opt-offset {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -89,6 +89,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
|
|||||||
{ value: 'collection_renamed', icon: 'mdiRename', label: t('dashboard.filterRenamed'), desc: t('gridDesc.renamed') },
|
{ value: 'collection_renamed', icon: 'mdiRename', label: t('dashboard.filterRenamed'), desc: t('gridDesc.renamed') },
|
||||||
{ value: 'collection_deleted', icon: 'mdiDeleteAlert', label: t('dashboard.filterDeleted'), desc: t('gridDesc.deleted') },
|
{ value: 'collection_deleted', icon: 'mdiDeleteAlert', label: t('dashboard.filterDeleted'), desc: t('gridDesc.deleted') },
|
||||||
{ value: 'sharing_changed', icon: 'mdiShareVariant', label: t('dashboard.filterSharingChanged'), desc: t('gridDesc.sharingChanged') },
|
{ value: 'sharing_changed', icon: 'mdiShareVariant', label: t('dashboard.filterSharingChanged'), desc: t('gridDesc.sharingChanged') },
|
||||||
|
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
|
||||||
|
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
|
||||||
|
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- Sort filter (dashboard) ---
|
// --- Sort filter (dashboard) ---
|
||||||
|
|||||||
+129
-11
@@ -55,7 +55,8 @@
|
|||||||
"passwordTooShort": "Password must be at least 8 characters",
|
"passwordTooShort": "Password must be at least 8 characters",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"loginFailed": "Login failed",
|
"loginFailed": "Login failed",
|
||||||
"setupFailed": "Setup failed"
|
"setupFailed": "Setup failed",
|
||||||
|
"backendUnreachable": "Cannot reach the server. Check that it's running and try again."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -64,6 +65,8 @@
|
|||||||
"activeTrackers": "Active Trackers",
|
"activeTrackers": "Active Trackers",
|
||||||
"targets": "Targets",
|
"targets": "Targets",
|
||||||
"recentEvents": "Events",
|
"recentEvents": "Events",
|
||||||
|
"clearEvents": "Clear",
|
||||||
|
"confirmClearEvents": "Delete all event log entries? This cannot be undone.",
|
||||||
"chart": "Event chart",
|
"chart": "Event chart",
|
||||||
"noEvents": "No events yet. Create a tracker to start monitoring.",
|
"noEvents": "No events yet. Create a tracker to start monitoring.",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -76,6 +79,10 @@
|
|||||||
"collectionRenamed": "collection renamed",
|
"collectionRenamed": "collection renamed",
|
||||||
"collectionDeleted": "collection deleted",
|
"collectionDeleted": "collection deleted",
|
||||||
"sharingChanged": "sharing changed",
|
"sharingChanged": "sharing changed",
|
||||||
|
"scheduledMessage": "scheduled message",
|
||||||
|
"actionSuccess": "action run",
|
||||||
|
"actionPartial": "action partial",
|
||||||
|
"actionFailed": "action failed",
|
||||||
"searchEvents": "Search events...",
|
"searchEvents": "Search events...",
|
||||||
"allEvents": "All Events",
|
"allEvents": "All Events",
|
||||||
"filterAssetsAdded": "Assets Added",
|
"filterAssetsAdded": "Assets Added",
|
||||||
@@ -83,6 +90,9 @@
|
|||||||
"filterRenamed": "Renamed",
|
"filterRenamed": "Renamed",
|
||||||
"filterDeleted": "Deleted",
|
"filterDeleted": "Deleted",
|
||||||
"filterSharingChanged": "Sharing Changed",
|
"filterSharingChanged": "Sharing Changed",
|
||||||
|
"filterActionSuccess": "Action Success",
|
||||||
|
"filterActionPartial": "Action Partial",
|
||||||
|
"filterActionFailed": "Action Failed",
|
||||||
"allProviders": "All Providers",
|
"allProviders": "All Providers",
|
||||||
"newestFirst": "Newest first",
|
"newestFirst": "Newest first",
|
||||||
"oldestFirst": "Oldest first",
|
"oldestFirst": "Oldest first",
|
||||||
@@ -240,7 +250,8 @@
|
|||||||
"descending": "Descending",
|
"descending": "Descending",
|
||||||
"quietHoursStart": "Quiet hours start",
|
"quietHoursStart": "Quiet hours start",
|
||||||
"quietHoursEnd": "Quiet hours end",
|
"quietHoursEnd": "Quiet hours end",
|
||||||
"batchDuration": "Batch duration (seconds)",
|
"adaptiveMaxSkip": "Adaptive polling cap",
|
||||||
|
"adaptiveMaxSkipPlaceholder": "Off (blank or 0)",
|
||||||
"defaultTrackingConfig": "Default tracking config",
|
"defaultTrackingConfig": "Default tracking config",
|
||||||
"defaultTemplateConfig": "Default template config",
|
"defaultTemplateConfig": "Default template config",
|
||||||
"linkedTargets": "targets",
|
"linkedTargets": "targets",
|
||||||
@@ -252,7 +263,14 @@
|
|||||||
"testPeriodic": "Test periodic summary",
|
"testPeriodic": "Test periodic summary",
|
||||||
"testScheduled": "Test scheduled assets",
|
"testScheduled": "Test scheduled assets",
|
||||||
"testMemory": "Test memory / On This Day",
|
"testMemory": "Test memory / On This Day",
|
||||||
|
"testDisabledHint": "Enable this feature in the tracker's default Tracking Config first.",
|
||||||
"checkingLinks": "Checking links...",
|
"checkingLinks": "Checking links...",
|
||||||
|
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
|
||||||
|
"openTrackingConfig": "Open Tracking Config",
|
||||||
|
"linkReplace": "Replace",
|
||||||
|
"linkReplacing": "Replacing...",
|
||||||
|
"linkReplaceFailed": "Failed to replace link for \"{name}\"",
|
||||||
|
"linkPasswordProtectedNote": "Telegram users can't open password-protected links without the password. Remove the password in Immich or replace the link.",
|
||||||
"missingLinksTitle": "Albums Missing Public Links",
|
"missingLinksTitle": "Albums Missing Public Links",
|
||||||
"missingLinksDesc": "The following albums don't have public shared links. Without links, notification recipients won't be able to view photos.",
|
"missingLinksDesc": "The following albums don't have public shared links. Without links, notification recipients won't be able to view photos.",
|
||||||
"expired": "Expired",
|
"expired": "Expired",
|
||||||
@@ -365,6 +383,7 @@
|
|||||||
"roleAdmin": "Admin",
|
"roleAdmin": "Admin",
|
||||||
"create": "Create User",
|
"create": "Create User",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"edit": "Edit user",
|
||||||
"confirmDelete": "Delete this user?",
|
"confirmDelete": "Delete this user?",
|
||||||
"joined": "joined",
|
"joined": "joined",
|
||||||
"noUsers": "No users found"
|
"noUsers": "No users found"
|
||||||
@@ -422,6 +441,8 @@
|
|||||||
"webhookRegistered": "Webhook registered",
|
"webhookRegistered": "Webhook registered",
|
||||||
"webhookUnregistered": "Webhook unregistered",
|
"webhookUnregistered": "Webhook unregistered",
|
||||||
"updateMode": "Update mode",
|
"updateMode": "Update mode",
|
||||||
|
"none": "None",
|
||||||
|
"noneActive": "Listener disabled",
|
||||||
"polling": "Polling",
|
"polling": "Polling",
|
||||||
"webhook": "Webhook",
|
"webhook": "Webhook",
|
||||||
"webhookStatus": "Webhook status",
|
"webhookStatus": "Webhook status",
|
||||||
@@ -514,6 +535,9 @@
|
|||||||
"memorySource": "Memory source",
|
"memorySource": "Memory source",
|
||||||
"memorySourceAlbums": "Scan tracked albums",
|
"memorySourceAlbums": "Scan tracked albums",
|
||||||
"memorySourceNative": "Immich native memories",
|
"memorySourceNative": "Immich native memories",
|
||||||
|
"quietHours": "Quiet hours",
|
||||||
|
"quietHoursStart": "Start",
|
||||||
|
"quietHoursEnd": "End",
|
||||||
"test": "Test",
|
"test": "Test",
|
||||||
"confirmDelete": "Delete this tracking config?",
|
"confirmDelete": "Delete this tracking config?",
|
||||||
"sortNone": "None",
|
"sortNone": "None",
|
||||||
@@ -536,7 +560,14 @@
|
|||||||
"renamed": "renamed",
|
"renamed": "renamed",
|
||||||
"deleted": "deleted",
|
"deleted": "deleted",
|
||||||
"providerType": "Provider Type",
|
"providerType": "Provider Type",
|
||||||
"sortRandom": "Random"
|
"sortRandom": "Random",
|
||||||
|
"timesInlineHelp": "HH:MM, comma-separated",
|
||||||
|
"invalidTimeList": "Use HH:MM format, e.g. 09:00 or 09:00, 18:30",
|
||||||
|
"previewTemplate": "Preview template",
|
||||||
|
"previewSampleNote": "Rendered with sample data — not your real assets. Shows the shipped default template.",
|
||||||
|
"editTemplate": "Edit template",
|
||||||
|
"quietHoursZero": "Quiet period is 0 minutes — adjust times",
|
||||||
|
"nextDay": "next day"
|
||||||
},
|
},
|
||||||
"templateConfig": {
|
"templateConfig": {
|
||||||
"title": "Template Configs",
|
"title": "Template Configs",
|
||||||
@@ -582,7 +613,14 @@
|
|||||||
"confirmDelete": "Delete this template config?",
|
"confirmDelete": "Delete this template config?",
|
||||||
"invalidFormat": "Invalid format string",
|
"invalidFormat": "Invalid format string",
|
||||||
"filterSlots": "Filter slots...",
|
"filterSlots": "Filter slots...",
|
||||||
"slots": "slots"
|
"slots": "slots",
|
||||||
|
"resetToDefault": "Reset to default",
|
||||||
|
"resetAllToDefaults": "Reset all to defaults",
|
||||||
|
"resetSlotConfirm": "Replace this slot's {locale} template with the shipped default? Your current edits will be lost.",
|
||||||
|
"resetAllConfirm": "Replace every slot's {locale} template with the shipped defaults? All your {locale} edits will be lost.",
|
||||||
|
"resetNoDefault": "No shipped default for this slot.",
|
||||||
|
"resetApplied": "Reset to default (not saved yet — click Save to persist)",
|
||||||
|
"deepLinkNoConfig": "No template config found for this provider. Create one first."
|
||||||
},
|
},
|
||||||
"templateVars": {
|
"templateVars": {
|
||||||
"message_assets_added": {
|
"message_assets_added": {
|
||||||
@@ -659,11 +697,36 @@
|
|||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"webhookSecret": "Webhook Secret",
|
"webhookSecret": "Webhook Secret",
|
||||||
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
||||||
"cacheTtl": "Media Cache TTL (hours)",
|
"cacheTtl": "URL Cache TTL (hours)",
|
||||||
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
|
"cacheTtlHint": "How long to keep URL-keyed Telegram file_ids (e.g. shared links). Set 0 to disable TTL. The asset cache uses content hashing (thumbhash) and ignores this.",
|
||||||
|
"cacheMaxEntries": "Cache Max Entries",
|
||||||
|
"cacheMaxEntriesHint": "Upper bound per cache (URL and asset). Oldest entries are evicted first (LRU). Default 5000.",
|
||||||
|
"cacheStats": "Cache contents",
|
||||||
|
"cacheStatsHint": "Size shown is the total bytes of media originally uploaded to Telegram for cached entries — i.e. approximate re-upload bandwidth the cache is saving. The cache file itself is only a few KB; the media lives on Telegram's servers.",
|
||||||
|
"cacheStatsUrl": "URL cache",
|
||||||
|
"cacheStatsAsset": "Asset cache",
|
||||||
|
"cacheStatsEntries": "entries",
|
||||||
|
"cacheStatsEmpty": "empty",
|
||||||
|
"cacheStatsOldest": "oldest",
|
||||||
|
"cacheStatsNewest": "newest",
|
||||||
|
"clearCache": "Clear Media Cache",
|
||||||
|
"clearCacheHint": "Delete cached Telegram file_ids. Next send will re-upload media.",
|
||||||
|
"clearCacheConfirmTitle": "Clear Telegram cache?",
|
||||||
|
"clearCacheConfirm": "This removes all cached Telegram file_ids. Subsequent notifications will re-upload media, which may take longer and use more bandwidth.",
|
||||||
|
"clearCacheConfirmBtn": "Clear cache",
|
||||||
|
"clearCacheDone": "Telegram cache cleared",
|
||||||
|
"timezone": "Timezone",
|
||||||
|
"timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
|
||||||
"locales": "Template Languages",
|
"locales": "Template Languages",
|
||||||
"supportedLocales": "Supported Locales",
|
"supportedLocales": "Supported Locales",
|
||||||
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
|
"supportedLocalesHint": "Languages available when authoring notification and command templates. Built-in defaults ship for English and Russian; other languages start empty.",
|
||||||
|
"logging": "Logging",
|
||||||
|
"logLevel": "Log Level",
|
||||||
|
"logLevelHint": "Root log level for the server. Raise to DEBUG while investigating; keep at INFO in production. WARNING/ERROR hide per-command progress lines.",
|
||||||
|
"logFormat": "Log Format",
|
||||||
|
"logFormatHint": "Output format. 'text' is human-readable; 'json' emits one object per line for log aggregators (Loki, ELK). Changing this requires a server restart.",
|
||||||
|
"logLevels": "Per-Module Overrides",
|
||||||
|
"logLevelsHint": "Comma-separated 'module=LEVEL' pairs to silence noisy modules or drill into one area. Example: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
||||||
"saved": "Settings saved"
|
"saved": "Settings saved"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
@@ -671,11 +734,15 @@
|
|||||||
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
|
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
|
||||||
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
|
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
|
||||||
"memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).",
|
"memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).",
|
||||||
|
"quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:00–07:00 are supported.",
|
||||||
"favoritesOnly": "Only include assets marked as favorites.",
|
"favoritesOnly": "Only include assets marked as favorites.",
|
||||||
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
||||||
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
|
"periodicStartDate": "Reference date in the app timezone. The first summary fires at the next configured HH:MM on/after this date, then every N days.",
|
||||||
|
"intervalDays": "Days between successive summaries. 1 = daily, 7 = weekly.",
|
||||||
"times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00",
|
"times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00",
|
||||||
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
|
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
|
||||||
|
"scheduledAlbumMode": "How albums are grouped in scheduled deliveries. Default: Per album (one notification per tracked album).",
|
||||||
|
"memoryAlbumMode": "How albums are grouped in memory deliveries. Default: Combined (a single notification aggregating matches from all tracked albums).",
|
||||||
"minRating": "Only include assets with at least this star rating (0 = no filter).",
|
"minRating": "Only include assets with at least this star rating (0 = no filter).",
|
||||||
"eventMessages": "Templates for real-time event notifications. Use {variables} for dynamic content.",
|
"eventMessages": "Templates for real-time event notifications. Use {variables} for dynamic content.",
|
||||||
"assetFormatting": "How individual assets are formatted within notification messages.",
|
"assetFormatting": "How individual assets are formatted within notification messages.",
|
||||||
@@ -689,7 +756,7 @@
|
|||||||
"trackingConfig": "Controls which events trigger notifications and how assets are filtered.",
|
"trackingConfig": "Controls which events trigger notifications and how assets are filtered.",
|
||||||
"templateConfig": "Controls the message format. Uses default templates if not set.",
|
"templateConfig": "Controls the message format. Uses default templates if not set.",
|
||||||
"scanInterval": "How often to poll the provider for changes, in seconds. Lower = faster detection but more API calls.",
|
"scanInterval": "How often to poll the provider for changes, in seconds. Lower = faster detection but more API calls.",
|
||||||
"batchDuration": "Time to accumulate changes before dispatching notifications. 0 = send immediately.",
|
"adaptiveMaxSkip": "Reduces polling when the tracker is idle, to save load on the upstream server. Leave blank or set to 0 for snappy notifications — every tick runs at the configured interval. Set to 2 to allow up to 2× slower polling after ~5 min of silence, or 4 for up to 4× slower polling after ~15 min. Activity resets back to the base rate immediately.",
|
||||||
"defaultTrackingConfig": "Applied to all linked targets unless overridden per target.",
|
"defaultTrackingConfig": "Applied to all linked targets unless overridden per target.",
|
||||||
"defaultTemplateConfig": "Applied to all linked targets unless overridden per target.",
|
"defaultTemplateConfig": "Applied to all linked targets unless overridden per target.",
|
||||||
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
|
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
|
||||||
@@ -785,13 +852,44 @@
|
|||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"noListeners": "No listeners attached.",
|
"noListeners": "No listeners attached.",
|
||||||
"selectBot": "Select bot...",
|
"selectBot": "Select bot...",
|
||||||
"listenerType": "telegram_bot"
|
"listenerType": "telegram_bot",
|
||||||
|
"editScope": "Edit album scope",
|
||||||
|
"scopeAll": "derived from notification routing",
|
||||||
|
"albumsShort": "albums",
|
||||||
|
"scopeTitle": "Album Scope Override for This Bot",
|
||||||
|
"scopeDescription": "By default this bot's commands see only the albums that actually deliver notifications to the chats it speaks to (computed from your notification trackers). Set an explicit override here to widen or narrow that set for every chat this bot serves.",
|
||||||
|
"scopeInherit": "Inherit: derive from notification routing",
|
||||||
|
"noCollections": "No albums available."
|
||||||
},
|
},
|
||||||
"snackbar": {
|
"snackbar": {
|
||||||
"showDetails": "Show details",
|
"showDetails": "Show details",
|
||||||
"hideDetails": "Hide details"
|
"hideDetails": "Hide details"
|
||||||
},
|
},
|
||||||
|
"timezone": {
|
||||||
|
"searchPlaceholder": "Search cities or IANA codes…",
|
||||||
|
"detect": "Detect",
|
||||||
|
"utc": "UTC",
|
||||||
|
"noMatches": "No timezones match"
|
||||||
|
},
|
||||||
|
"locales": {
|
||||||
|
"empty": "No languages selected. Add one below to start authoring templates.",
|
||||||
|
"add": "Add language",
|
||||||
|
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
|
||||||
|
"addCustom": "Add custom code",
|
||||||
|
"noSuggestions": "No matches. Type a valid locale code (2–3 letters).",
|
||||||
|
"primary": "Primary",
|
||||||
|
"shipped": "Built-in",
|
||||||
|
"shippedHint": "Default notification & command templates ship for this language.",
|
||||||
|
"makePrimary": "Make primary",
|
||||||
|
"moveUp": "Move up",
|
||||||
|
"moveDown": "Move down",
|
||||||
|
"remove": "Remove",
|
||||||
|
"removeLast": "At least one language is required",
|
||||||
|
"reorder": "Drag to reorder",
|
||||||
|
"orderHint": "First language is the primary fallback when a translation is missing. Drag to reorder."
|
||||||
|
},
|
||||||
"snack": {
|
"snack": {
|
||||||
|
"eventsCleared": "{count} event(s) cleared",
|
||||||
"providerSaved": "Provider saved",
|
"providerSaved": "Provider saved",
|
||||||
"providerDeleted": "Provider deleted",
|
"providerDeleted": "Provider deleted",
|
||||||
"trackerCreated": "Tracker created",
|
"trackerCreated": "Tracker created",
|
||||||
@@ -810,6 +908,7 @@
|
|||||||
"botDeleted": "Bot deleted",
|
"botDeleted": "Bot deleted",
|
||||||
"userCreated": "User created",
|
"userCreated": "User created",
|
||||||
"userDeleted": "User deleted",
|
"userDeleted": "User deleted",
|
||||||
|
"userUpdated": "User updated",
|
||||||
"passwordChanged": "Password changed",
|
"passwordChanged": "Password changed",
|
||||||
"copied": "Copied to clipboard",
|
"copied": "Copied to clipboard",
|
||||||
"genericError": "Something went wrong",
|
"genericError": "Something went wrong",
|
||||||
@@ -827,6 +926,7 @@
|
|||||||
"commandTrackerDisabled": "Command tracker disabled",
|
"commandTrackerDisabled": "Command tracker disabled",
|
||||||
"listenerAdded": "Listener added",
|
"listenerAdded": "Listener added",
|
||||||
"listenerRemoved": "Listener removed",
|
"listenerRemoved": "Listener removed",
|
||||||
|
"listenerScopeSaved": "Scope updated",
|
||||||
"cmdTemplateSaved": "Command template saved",
|
"cmdTemplateSaved": "Command template saved",
|
||||||
"cmdTemplateDeleted": "Command template deleted",
|
"cmdTemplateDeleted": "Command template deleted",
|
||||||
"emailBotCreated": "Email bot created",
|
"emailBotCreated": "Email bot created",
|
||||||
@@ -848,6 +948,8 @@
|
|||||||
"description": "Description",
|
"description": "Description",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
"cannotDelete": "Cannot delete",
|
||||||
|
"blockedByIntro": "Referenced by:",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
@@ -960,6 +1062,9 @@
|
|||||||
"renamed": "Album was renamed",
|
"renamed": "Album was renamed",
|
||||||
"deleted": "Album was deleted",
|
"deleted": "Album was deleted",
|
||||||
"sharingChanged": "Album sharing toggled",
|
"sharingChanged": "Album sharing toggled",
|
||||||
|
"actionSuccess": "Scheduled action completed",
|
||||||
|
"actionPartial": "Scheduled action partially succeeded",
|
||||||
|
"actionFailed": "Scheduled action failed",
|
||||||
"newestFirst": "Most recent events on top",
|
"newestFirst": "Most recent events on top",
|
||||||
"oldestFirst": "Oldest events on top",
|
"oldestFirst": "Oldest events on top",
|
||||||
"chatActionNone": "No indicator shown",
|
"chatActionNone": "No indicator shown",
|
||||||
@@ -1021,6 +1126,7 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"schedule": "Schedule",
|
"schedule": "Schedule",
|
||||||
"interval": "Interval",
|
"interval": "Interval",
|
||||||
|
"cronMode": "Cron expression",
|
||||||
"seconds": "seconds",
|
"seconds": "seconds",
|
||||||
"cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)",
|
"cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
@@ -1126,6 +1232,18 @@
|
|||||||
"savedFiles": "Saved Backups",
|
"savedFiles": "Saved Backups",
|
||||||
"noFiles": "No backup files yet.",
|
"noFiles": "No backup files yet.",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"fileDeleted": "Backup file deleted"
|
"fileDeleted": "Backup file deleted",
|
||||||
|
"createManual": "Create backup",
|
||||||
|
"manualCreated": "Backup created",
|
||||||
|
"pendingTitle": "Restore pending — restart to apply",
|
||||||
|
"pendingBy": "Uploaded by {by}",
|
||||||
|
"pendingAt": "at {at}",
|
||||||
|
"pendingCancelled": "Pending restore cancelled",
|
||||||
|
"restorePrepared": "Restore prepared",
|
||||||
|
"restoreApplyPrompt": "Apply the restore now (the backend will restart) or later on the next natural restart?",
|
||||||
|
"applyLater": "Apply later",
|
||||||
|
"restartNow": "Restart now",
|
||||||
|
"restartingTitle": "Restarting backend…",
|
||||||
|
"restartingDescription": "The page will reload once the server is back online."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+129
-11
@@ -55,7 +55,8 @@
|
|||||||
"passwordTooShort": "Пароль должен быть не менее 8 символов",
|
"passwordTooShort": "Пароль должен быть не менее 8 символов",
|
||||||
"or": "или",
|
"or": "или",
|
||||||
"loginFailed": "Ошибка входа",
|
"loginFailed": "Ошибка входа",
|
||||||
"setupFailed": "Ошибка настройки"
|
"setupFailed": "Ошибка настройки",
|
||||||
|
"backendUnreachable": "Не удалось подключиться к серверу. Убедитесь, что он запущен, и повторите попытку."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Главная",
|
"title": "Главная",
|
||||||
@@ -64,6 +65,8 @@
|
|||||||
"activeTrackers": "Активные трекеры",
|
"activeTrackers": "Активные трекеры",
|
||||||
"targets": "Получатели",
|
"targets": "Получатели",
|
||||||
"recentEvents": "События",
|
"recentEvents": "События",
|
||||||
|
"clearEvents": "Очистить",
|
||||||
|
"confirmClearEvents": "Удалить все записи журнала событий? Это действие нельзя отменить.",
|
||||||
"chart": "График событий",
|
"chart": "График событий",
|
||||||
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
|
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -76,6 +79,10 @@
|
|||||||
"collectionRenamed": "альбом переименован",
|
"collectionRenamed": "альбом переименован",
|
||||||
"collectionDeleted": "альбом удалён",
|
"collectionDeleted": "альбом удалён",
|
||||||
"sharingChanged": "изменение доступа",
|
"sharingChanged": "изменение доступа",
|
||||||
|
"scheduledMessage": "запланированное сообщение",
|
||||||
|
"actionSuccess": "действие выполнено",
|
||||||
|
"actionPartial": "действие частично",
|
||||||
|
"actionFailed": "действие провалено",
|
||||||
"searchEvents": "Поиск событий...",
|
"searchEvents": "Поиск событий...",
|
||||||
"allEvents": "Все события",
|
"allEvents": "Все события",
|
||||||
"filterAssetsAdded": "Добавление файлов",
|
"filterAssetsAdded": "Добавление файлов",
|
||||||
@@ -83,6 +90,9 @@
|
|||||||
"filterRenamed": "Переименование",
|
"filterRenamed": "Переименование",
|
||||||
"filterDeleted": "Удаление",
|
"filterDeleted": "Удаление",
|
||||||
"filterSharingChanged": "Изменение доступа",
|
"filterSharingChanged": "Изменение доступа",
|
||||||
|
"filterActionSuccess": "Действие выполнено",
|
||||||
|
"filterActionPartial": "Действие частично",
|
||||||
|
"filterActionFailed": "Действие провалено",
|
||||||
"allProviders": "Все провайдеры",
|
"allProviders": "Все провайдеры",
|
||||||
"newestFirst": "Сначала новые",
|
"newestFirst": "Сначала новые",
|
||||||
"oldestFirst": "Сначала старые",
|
"oldestFirst": "Сначала старые",
|
||||||
@@ -240,7 +250,8 @@
|
|||||||
"descending": "По убыванию",
|
"descending": "По убыванию",
|
||||||
"quietHoursStart": "Тихие часы начало",
|
"quietHoursStart": "Тихие часы начало",
|
||||||
"quietHoursEnd": "Тихие часы конец",
|
"quietHoursEnd": "Тихие часы конец",
|
||||||
"batchDuration": "Длительность пакета (секунды)",
|
"adaptiveMaxSkip": "Предел адаптивного опроса",
|
||||||
|
"adaptiveMaxSkipPlaceholder": "Выкл. (пусто или 0)",
|
||||||
"defaultTrackingConfig": "Конфигурация отслеживания по умолчанию",
|
"defaultTrackingConfig": "Конфигурация отслеживания по умолчанию",
|
||||||
"defaultTemplateConfig": "Шаблон уведомлений по умолчанию",
|
"defaultTemplateConfig": "Шаблон уведомлений по умолчанию",
|
||||||
"linkedTargets": "получатели",
|
"linkedTargets": "получатели",
|
||||||
@@ -252,7 +263,14 @@
|
|||||||
"testPeriodic": "Тест периодической сводки",
|
"testPeriodic": "Тест периодической сводки",
|
||||||
"testScheduled": "Тест запланированных фото",
|
"testScheduled": "Тест запланированных фото",
|
||||||
"testMemory": "Тест воспоминаний",
|
"testMemory": "Тест воспоминаний",
|
||||||
|
"testDisabledHint": "Сначала включите эту функцию в привязанной конфигурации отслеживания.",
|
||||||
"checkingLinks": "Проверка ссылок...",
|
"checkingLinks": "Проверка ссылок...",
|
||||||
|
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
|
||||||
|
"openTrackingConfig": "Открыть конфигурацию отслеживания",
|
||||||
|
"linkReplace": "Пересоздать",
|
||||||
|
"linkReplacing": "Пересоздание...",
|
||||||
|
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
|
||||||
|
"linkPasswordProtectedNote": "Получатели в Telegram не смогут открыть защищённую паролем ссылку без пароля. Снимите пароль в Immich или пересоздайте ссылку.",
|
||||||
"missingLinksTitle": "Альбомы без публичных ссылок",
|
"missingLinksTitle": "Альбомы без публичных ссылок",
|
||||||
"missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.",
|
"missingLinksDesc": "У следующих альбомов нет публичных ссылок. Без ссылок получатели уведомлений не смогут просматривать фото.",
|
||||||
"expired": "Истёк",
|
"expired": "Истёк",
|
||||||
@@ -365,6 +383,7 @@
|
|||||||
"roleAdmin": "Администратор",
|
"roleAdmin": "Администратор",
|
||||||
"create": "Создать",
|
"create": "Создать",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
|
"edit": "Редактировать пользователя",
|
||||||
"confirmDelete": "Удалить этого пользователя?",
|
"confirmDelete": "Удалить этого пользователя?",
|
||||||
"joined": "зарегистрирован",
|
"joined": "зарегистрирован",
|
||||||
"noUsers": "Пользователи не найдены"
|
"noUsers": "Пользователи не найдены"
|
||||||
@@ -422,6 +441,8 @@
|
|||||||
"webhookRegistered": "Вебхук зарегистрирован",
|
"webhookRegistered": "Вебхук зарегистрирован",
|
||||||
"webhookUnregistered": "Вебхук удалён",
|
"webhookUnregistered": "Вебхук удалён",
|
||||||
"updateMode": "Режим обновлений",
|
"updateMode": "Режим обновлений",
|
||||||
|
"none": "Откл.",
|
||||||
|
"noneActive": "Приём обновлений отключён",
|
||||||
"polling": "Опрос",
|
"polling": "Опрос",
|
||||||
"webhook": "Вебхук",
|
"webhook": "Вебхук",
|
||||||
"webhookStatus": "Статус вебхука",
|
"webhookStatus": "Статус вебхука",
|
||||||
@@ -514,6 +535,9 @@
|
|||||||
"memorySource": "Источник воспоминаний",
|
"memorySource": "Источник воспоминаний",
|
||||||
"memorySourceAlbums": "Сканировать альбомы",
|
"memorySourceAlbums": "Сканировать альбомы",
|
||||||
"memorySourceNative": "Встроенные воспоминания Immich",
|
"memorySourceNative": "Встроенные воспоминания Immich",
|
||||||
|
"quietHours": "Тихие часы",
|
||||||
|
"quietHoursStart": "Начало",
|
||||||
|
"quietHoursEnd": "Конец",
|
||||||
"test": "Тест",
|
"test": "Тест",
|
||||||
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
||||||
"sortNone": "Нет",
|
"sortNone": "Нет",
|
||||||
@@ -536,7 +560,14 @@
|
|||||||
"renamed": "переименование",
|
"renamed": "переименование",
|
||||||
"deleted": "удалён",
|
"deleted": "удалён",
|
||||||
"providerType": "Тип провайдера",
|
"providerType": "Тип провайдера",
|
||||||
"sortRandom": "Случайный"
|
"sortRandom": "Случайный",
|
||||||
|
"timesInlineHelp": "ЧЧ:ММ, через запятую",
|
||||||
|
"invalidTimeList": "Используйте формат ЧЧ:ММ, например 09:00 или 09:00, 18:30",
|
||||||
|
"previewTemplate": "Предпросмотр шаблона",
|
||||||
|
"previewSampleNote": "Отрисовано на демо-данных, не на ваших реальных фото. Показан шаблон по умолчанию.",
|
||||||
|
"editTemplate": "Редактировать шаблон",
|
||||||
|
"quietHoursZero": "Тихий период 0 минут — скорректируйте время",
|
||||||
|
"nextDay": "след. день"
|
||||||
},
|
},
|
||||||
"templateConfig": {
|
"templateConfig": {
|
||||||
"title": "Конфигурации шаблонов",
|
"title": "Конфигурации шаблонов",
|
||||||
@@ -582,7 +613,14 @@
|
|||||||
"confirmDelete": "Удалить эту конфигурацию шаблона?",
|
"confirmDelete": "Удалить эту конфигурацию шаблона?",
|
||||||
"invalidFormat": "Некорректная строка формата",
|
"invalidFormat": "Некорректная строка формата",
|
||||||
"filterSlots": "Фильтр слотов...",
|
"filterSlots": "Фильтр слотов...",
|
||||||
"slots": "слотов"
|
"slots": "слотов",
|
||||||
|
"resetToDefault": "Сбросить к умолчанию",
|
||||||
|
"resetAllToDefaults": "Сбросить все к умолчаниям",
|
||||||
|
"resetSlotConfirm": "Заменить шаблон этого слота ({locale}) на исходный по умолчанию? Ваши правки будут потеряны.",
|
||||||
|
"resetAllConfirm": "Заменить шаблоны всех слотов ({locale}) на исходные по умолчанию? Все ваши правки для {locale} будут потеряны.",
|
||||||
|
"resetNoDefault": "Для этого слота нет шаблона по умолчанию.",
|
||||||
|
"resetApplied": "Сброшено к умолчанию (ещё не сохранено — нажмите «Сохранить»)",
|
||||||
|
"deepLinkNoConfig": "Не найдено конфигурации шаблонов для этого провайдера. Сначала создайте её."
|
||||||
},
|
},
|
||||||
"templateVars": {
|
"templateVars": {
|
||||||
"message_assets_added": {
|
"message_assets_added": {
|
||||||
@@ -659,11 +697,36 @@
|
|||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"webhookSecret": "Секрет вебхука",
|
"webhookSecret": "Секрет вебхука",
|
||||||
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
||||||
"cacheTtl": "TTL кэша медиа (часы)",
|
"cacheTtl": "TTL URL-кэша (часы)",
|
||||||
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
"cacheTtlHint": "Сколько хранить Telegram file_id, привязанные к URL (напр. публичные ссылки). 0 — отключить TTL. Кэш ассетов использует хэширование содержимого (thumbhash) и не зависит от этой настройки.",
|
||||||
|
"cacheMaxEntries": "Макс. записей в кэше",
|
||||||
|
"cacheMaxEntriesHint": "Верхний предел записей в каждом кэше (URL и ассеты). При превышении удаляются самые старые (LRU). По умолчанию 5000.",
|
||||||
|
"cacheStats": "Содержимое кэша",
|
||||||
|
"cacheStatsHint": "Показываемый размер — это суммарный объём медиа, который был изначально загружен в Telegram для закэшированных записей, т.е. приблизительный объём повторных загрузок, который экономит кэш. Сам файл кэша занимает лишь несколько КБ; медиа хранится на серверах Telegram.",
|
||||||
|
"cacheStatsUrl": "Кэш URL",
|
||||||
|
"cacheStatsAsset": "Кэш ассетов",
|
||||||
|
"cacheStatsEntries": "записей",
|
||||||
|
"cacheStatsEmpty": "пусто",
|
||||||
|
"cacheStatsOldest": "самая старая",
|
||||||
|
"cacheStatsNewest": "самая свежая",
|
||||||
|
"clearCache": "Очистить кэш медиа",
|
||||||
|
"clearCacheHint": "Удалить кэшированные Telegram file_id. При следующей отправке медиа будут загружены заново.",
|
||||||
|
"clearCacheConfirmTitle": "Очистить кэш Telegram?",
|
||||||
|
"clearCacheConfirm": "Это удалит все кэшированные Telegram file_id. Следующие уведомления будут повторно загружать медиа, что может занять больше времени и трафика.",
|
||||||
|
"clearCacheConfirmBtn": "Очистить кэш",
|
||||||
|
"clearCacheDone": "Кэш Telegram очищен",
|
||||||
|
"timezone": "Часовой пояс",
|
||||||
|
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
|
||||||
"locales": "Языки шаблонов",
|
"locales": "Языки шаблонов",
|
||||||
"supportedLocales": "Поддерживаемые локали",
|
"supportedLocales": "Поддерживаемые локали",
|
||||||
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
|
"supportedLocalesHint": "Языки, доступные для редактирования шаблонов уведомлений и команд. Встроенные шаблоны поставляются для английского и русского; другие языки начинают с пустых.",
|
||||||
|
"logging": "Логирование",
|
||||||
|
"logLevel": "Уровень логов",
|
||||||
|
"logLevelHint": "Уровень логирования сервера. Поднимайте до DEBUG при отладке; оставляйте INFO в продакшене. WARNING/ERROR скрывают пошаговые строки по командам.",
|
||||||
|
"logFormat": "Формат логов",
|
||||||
|
"logFormatHint": "Формат вывода. 'text' — читаемый человеком; 'json' — по одному объекту в строке для агрегаторов (Loki, ELK). Смена требует перезапуска сервера.",
|
||||||
|
"logLevels": "Переопределения по модулям",
|
||||||
|
"logLevelsHint": "Пары 'модуль=УРОВЕНЬ' через запятую, чтобы приглушить шумные модули или углубиться в один. Пример: sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG",
|
||||||
"saved": "Настройки сохранены"
|
"saved": "Настройки сохранены"
|
||||||
},
|
},
|
||||||
"hints": {
|
"hints": {
|
||||||
@@ -671,11 +734,15 @@
|
|||||||
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
|
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
|
||||||
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
|
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
|
||||||
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
|
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
|
||||||
|
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:00–07:00.",
|
||||||
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
|
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
|
||||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||||
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
|
"periodicStartDate": "Опорная дата в часовом поясе приложения. Первая сводка отправится в ближайшее заданное время ЧЧ:ММ, начиная с этой даты, затем каждые N дней.",
|
||||||
|
"intervalDays": "Период между сводками в днях. 1 = ежедневно, 7 = еженедельно.",
|
||||||
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
|
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
|
||||||
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
|
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
|
||||||
|
"scheduledAlbumMode": "Как альбомы группируются в запланированных отправках. По умолчанию: По альбому (одно уведомление на каждый отслеживаемый альбом).",
|
||||||
|
"memoryAlbumMode": "Как альбомы группируются в воспоминаниях. По умолчанию: Объединённый (одно уведомление со всеми совпадениями из всех альбомов).",
|
||||||
"minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).",
|
"minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).",
|
||||||
"eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.",
|
"eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.",
|
||||||
"assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.",
|
"assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.",
|
||||||
@@ -689,7 +756,7 @@
|
|||||||
"trackingConfig": "Управляет тем, какие события вызывают уведомления и как фильтруются ассеты.",
|
"trackingConfig": "Управляет тем, какие события вызывают уведомления и как фильтруются ассеты.",
|
||||||
"templateConfig": "Управляет форматом сообщений. Используются шаблоны по умолчанию, если не задано.",
|
"templateConfig": "Управляет форматом сообщений. Используются шаблоны по умолчанию, если не задано.",
|
||||||
"scanInterval": "Как часто опрашивать провайдер на предмет изменений (в секундах). Меньше = быстрее обнаружение, но больше запросов к API.",
|
"scanInterval": "Как часто опрашивать провайдер на предмет изменений (в секундах). Меньше = быстрее обнаружение, но больше запросов к API.",
|
||||||
"batchDuration": "Время накопления изменений перед отправкой уведомлений. 0 = отправлять сразу.",
|
"adaptiveMaxSkip": "Снижает частоту опроса, когда отслеживание простаивает — уменьшает нагрузку на сервер-источник. Оставьте пустым или 0, чтобы уведомления приходили без задержки: каждый тик выполняется с заданным интервалом. Значение 2 позволит замедлять опрос до 2× после ~5 мин простоя, а 4 — до 4× после ~15 мин. Любая активность сразу возвращает базовую частоту.",
|
||||||
"defaultTrackingConfig": "Применяется ко всем привязанным получателям, если не переопределено.",
|
"defaultTrackingConfig": "Применяется ко всем привязанным получателям, если не переопределено.",
|
||||||
"defaultTemplateConfig": "Применяется ко всем привязанным получателям, если не переопределено.",
|
"defaultTemplateConfig": "Применяется ко всем привязанным получателям, если не переопределено.",
|
||||||
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
|
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
|
||||||
@@ -785,13 +852,44 @@
|
|||||||
"disabled": "Отключён",
|
"disabled": "Отключён",
|
||||||
"noListeners": "Нет подключённых слушателей.",
|
"noListeners": "Нет подключённых слушателей.",
|
||||||
"selectBot": "Выберите бота...",
|
"selectBot": "Выберите бота...",
|
||||||
"listenerType": "telegram_bot"
|
"listenerType": "telegram_bot",
|
||||||
|
"editScope": "Изменить область альбомов",
|
||||||
|
"scopeAll": "из маршрутизации уведомлений",
|
||||||
|
"albumsShort": "альбомов",
|
||||||
|
"scopeTitle": "Переопределение области альбомов для этого бота",
|
||||||
|
"scopeDescription": "По умолчанию команды этого бота видят только альбомы, уведомления которых приходят в его чаты (вычисляется из ваших трекеров уведомлений). Задайте явный список здесь, чтобы расширить или сузить этот набор для всех чатов данного бота.",
|
||||||
|
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||||
|
"noCollections": "Нет доступных альбомов."
|
||||||
},
|
},
|
||||||
"snackbar": {
|
"snackbar": {
|
||||||
"showDetails": "Показать детали",
|
"showDetails": "Показать детали",
|
||||||
"hideDetails": "Скрыть детали"
|
"hideDetails": "Скрыть детали"
|
||||||
},
|
},
|
||||||
|
"timezone": {
|
||||||
|
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
|
||||||
|
"detect": "Определить",
|
||||||
|
"utc": "UTC",
|
||||||
|
"noMatches": "Нет совпадений"
|
||||||
|
},
|
||||||
|
"locales": {
|
||||||
|
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
|
||||||
|
"add": "Добавить язык",
|
||||||
|
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
|
||||||
|
"addCustom": "Добавить свой код",
|
||||||
|
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
|
||||||
|
"primary": "Основной",
|
||||||
|
"shipped": "Встроенный",
|
||||||
|
"shippedHint": "Для этого языка есть встроенные шаблоны уведомлений и команд.",
|
||||||
|
"makePrimary": "Сделать основным",
|
||||||
|
"moveUp": "Выше",
|
||||||
|
"moveDown": "Ниже",
|
||||||
|
"remove": "Удалить",
|
||||||
|
"removeLast": "Должен быть хотя бы один язык",
|
||||||
|
"reorder": "Перетащите для изменения порядка",
|
||||||
|
"orderHint": "Первый язык используется как основной при отсутствии перевода. Перетаскивайте, чтобы изменить порядок."
|
||||||
|
},
|
||||||
"snack": {
|
"snack": {
|
||||||
|
"eventsCleared": "Очищено событий: {count}",
|
||||||
"providerSaved": "Провайдер сохранён",
|
"providerSaved": "Провайдер сохранён",
|
||||||
"providerDeleted": "Провайдер удалён",
|
"providerDeleted": "Провайдер удалён",
|
||||||
"trackerCreated": "Трекер создан",
|
"trackerCreated": "Трекер создан",
|
||||||
@@ -810,6 +908,7 @@
|
|||||||
"botDeleted": "Бот удалён",
|
"botDeleted": "Бот удалён",
|
||||||
"userCreated": "Пользователь создан",
|
"userCreated": "Пользователь создан",
|
||||||
"userDeleted": "Пользователь удалён",
|
"userDeleted": "Пользователь удалён",
|
||||||
|
"userUpdated": "Пользователь обновлён",
|
||||||
"passwordChanged": "Пароль изменён",
|
"passwordChanged": "Пароль изменён",
|
||||||
"copied": "Скопировано",
|
"copied": "Скопировано",
|
||||||
"genericError": "Что-то пошло не так",
|
"genericError": "Что-то пошло не так",
|
||||||
@@ -827,6 +926,7 @@
|
|||||||
"commandTrackerDisabled": "Трекер команд отключён",
|
"commandTrackerDisabled": "Трекер команд отключён",
|
||||||
"listenerAdded": "Слушатель добавлен",
|
"listenerAdded": "Слушатель добавлен",
|
||||||
"listenerRemoved": "Слушатель удалён",
|
"listenerRemoved": "Слушатель удалён",
|
||||||
|
"listenerScopeSaved": "Область обновлена",
|
||||||
"cmdTemplateSaved": "Шаблон команд сохранён",
|
"cmdTemplateSaved": "Шаблон команд сохранён",
|
||||||
"cmdTemplateDeleted": "Шаблон команд удалён",
|
"cmdTemplateDeleted": "Шаблон команд удалён",
|
||||||
"emailBotCreated": "Email бот создан",
|
"emailBotCreated": "Email бот создан",
|
||||||
@@ -848,6 +948,8 @@
|
|||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
"close": "Закрыть",
|
"close": "Закрыть",
|
||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
|
"cannotDelete": "Невозможно удалить",
|
||||||
|
"blockedByIntro": "На объект ссылаются:",
|
||||||
"error": "Ошибка",
|
"error": "Ошибка",
|
||||||
"success": "Успешно",
|
"success": "Успешно",
|
||||||
"none": "Нет",
|
"none": "Нет",
|
||||||
@@ -960,6 +1062,9 @@
|
|||||||
"renamed": "Альбом переименован",
|
"renamed": "Альбом переименован",
|
||||||
"deleted": "Альбом удалён",
|
"deleted": "Альбом удалён",
|
||||||
"sharingChanged": "Изменён доступ к альбому",
|
"sharingChanged": "Изменён доступ к альбому",
|
||||||
|
"actionSuccess": "Запланированное действие выполнено",
|
||||||
|
"actionPartial": "Запланированное действие выполнено частично",
|
||||||
|
"actionFailed": "Запланированное действие провалено",
|
||||||
"newestFirst": "Сначала новые события",
|
"newestFirst": "Сначала новые события",
|
||||||
"oldestFirst": "Сначала старые события",
|
"oldestFirst": "Сначала старые события",
|
||||||
"chatActionNone": "Индикатор не показывается",
|
"chatActionNone": "Индикатор не показывается",
|
||||||
@@ -1021,6 +1126,7 @@
|
|||||||
"name": "Название",
|
"name": "Название",
|
||||||
"schedule": "Расписание",
|
"schedule": "Расписание",
|
||||||
"interval": "Интервал",
|
"interval": "Интервал",
|
||||||
|
"cronMode": "Cron выражение",
|
||||||
"seconds": "секунд",
|
"seconds": "секунд",
|
||||||
"cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)",
|
"cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)",
|
||||||
"enabled": "Включено",
|
"enabled": "Включено",
|
||||||
@@ -1126,6 +1232,18 @@
|
|||||||
"savedFiles": "Сохранённые бэкапы",
|
"savedFiles": "Сохранённые бэкапы",
|
||||||
"noFiles": "Файлов бэкапа пока нет.",
|
"noFiles": "Файлов бэкапа пока нет.",
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"fileDeleted": "Файл бэкапа удалён"
|
"fileDeleted": "Файл бэкапа удалён",
|
||||||
|
"createManual": "Создать бэкап",
|
||||||
|
"manualCreated": "Бэкап создан",
|
||||||
|
"pendingTitle": "Восстановление ожидает — перезапустите для применения",
|
||||||
|
"pendingBy": "Загружено пользователем {by}",
|
||||||
|
"pendingAt": "в {at}",
|
||||||
|
"pendingCancelled": "Ожидающее восстановление отменено",
|
||||||
|
"restorePrepared": "Восстановление подготовлено",
|
||||||
|
"restoreApplyPrompt": "Применить восстановление сейчас (бэкенд перезапустится) или позже при следующем штатном перезапуске?",
|
||||||
|
"applyLater": "Применить позже",
|
||||||
|
"restartNow": "Перезапустить сейчас",
|
||||||
|
"restartingTitle": "Перезапуск бэкенда…",
|
||||||
|
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import type { ProviderDescriptor } from './types';
|
import type { ProviderDescriptor } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Today's date in ISO (YYYY-MM-DD) — used as the default for
|
||||||
|
* `periodic_start_date` so new configs anchor to "today" rather than a
|
||||||
|
* hardcoded date that gets further into the past on every release.
|
||||||
|
*/
|
||||||
|
const todayIso = (): string => new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
export const immichDescriptor: ProviderDescriptor = {
|
export const immichDescriptor: ProviderDescriptor = {
|
||||||
type: 'immich',
|
type: 'immich',
|
||||||
defaultName: 'Immich',
|
defaultName: 'Immich',
|
||||||
@@ -48,7 +55,7 @@ export const immichDescriptor: ProviderDescriptor = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
extraTrackingFields: [
|
extraTrackingFields: [
|
||||||
{ key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 5, hint: 'hints.maxAssets' },
|
{ key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 10, hint: 'hints.maxAssets' },
|
||||||
{ key: 'assets_order_by', label: 'trackingConfig.sortBy', type: 'grid-select', gridItems: 'sortByItems', gridColumns: 2, defaultValue: 'none' },
|
{ key: 'assets_order_by', label: 'trackingConfig.sortBy', type: 'grid-select', gridItems: 'sortByItems', gridColumns: 2, defaultValue: 'none' },
|
||||||
{ key: 'assets_order', label: 'trackingConfig.sortOrder', type: 'grid-select', gridItems: 'sortOrderItems', gridColumns: 2, defaultValue: 'descending' },
|
{ key: 'assets_order', label: 'trackingConfig.sortOrder', type: 'grid-select', gridItems: 'sortOrderItems', gridColumns: 2, defaultValue: 'descending' },
|
||||||
],
|
],
|
||||||
@@ -58,17 +65,17 @@ export const immichDescriptor: ProviderDescriptor = {
|
|||||||
key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary',
|
key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary',
|
||||||
enabledField: 'periodic_enabled', enabledDefault: false,
|
enabledField: 'periodic_enabled', enabledDefault: false,
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1 },
|
{ key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1, hint: 'hints.intervalDays' },
|
||||||
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'number', defaultValue: '2025-01-01' }, // rendered as date input
|
{ key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'date', defaultValue: todayIso, hint: 'hints.periodicStartDate' },
|
||||||
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'number', defaultValue: '12:00' }, // rendered as text input
|
{ key: 'periodic_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '12:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
|
key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets',
|
||||||
enabledField: 'scheduled_enabled', enabledDefault: false,
|
enabledField: 'scheduled_enabled', enabledDefault: false,
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
|
{ key: 'scheduled_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
||||||
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection' },
|
{ key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection', hint: 'hints.scheduledAlbumMode' },
|
||||||
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
|
{ key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
|
||||||
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
{ key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
||||||
{ key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
|
{ key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
|
||||||
@@ -79,13 +86,21 @@ export const immichDescriptor: ProviderDescriptor = {
|
|||||||
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
|
key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode',
|
||||||
enabledField: 'memory_enabled', enabledDefault: false,
|
enabledField: 'memory_enabled', enabledDefault: false,
|
||||||
fields: [
|
fields: [
|
||||||
{ key: 'memory_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' },
|
{ key: 'memory_times', label: 'trackingConfig.times', type: 'time-list', defaultValue: '09:00', hint: 'hints.times', inlineHelp: 'trackingConfig.timesInlineHelp', validateFormat: true },
|
||||||
{ key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined' },
|
{ 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 },
|
{ key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' },
|
||||||
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
{ key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' },
|
||||||
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0 },
|
{ key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' },
|
||||||
{ key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' },
|
{ key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', type: 'toggle', defaultValue: false, hint: 'hints.favoritesOnly' },
|
||||||
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' },
|
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums', hint: 'hints.memorySource' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
|
||||||
|
enabledField: 'quiet_hours_enabled', enabledDefault: false,
|
||||||
|
fields: [
|
||||||
|
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'time', defaultValue: '22:00' },
|
||||||
|
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'time', defaultValue: '07:00' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -105,7 +120,12 @@ export const immichDescriptor: ProviderDescriptor = {
|
|||||||
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
|
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
|
||||||
const warnings: { id: string; name: string; issue: string }[] = [];
|
const warnings: { id: string; name: string; issue: string }[] = [];
|
||||||
|
|
||||||
for (const albumId of newIds) {
|
// Run shared-link checks in parallel with a concurrency cap so a large
|
||||||
|
// album set doesn't stall the save button for seconds. Cap of 6 keeps
|
||||||
|
// the save dialog responsive for users with 50+ albums while staying
|
||||||
|
// well under typical Immich per-IP rate limits.
|
||||||
|
const CONCURRENCY = 6;
|
||||||
|
async function checkOne(albumId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
|
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
|
||||||
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
|
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
|
||||||
@@ -123,6 +143,19 @@ export const immichDescriptor: ProviderDescriptor = {
|
|||||||
} catch { /* shared-link check failed, proceed */ }
|
} catch { /* shared-link check failed, proceed */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queue = [...newIds];
|
||||||
|
const workers: Promise<void>[] = [];
|
||||||
|
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
|
||||||
|
workers.push((async () => {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const next = queue.shift();
|
||||||
|
if (next === undefined) return;
|
||||||
|
await checkOne(next);
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
if (warnings.length > 0) return { warnings, proceed: false };
|
if (warnings.length > 0) return { warnings, proceed: false };
|
||||||
return { proceed: true };
|
return { proceed: true };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,17 +47,20 @@ export function allProviderTypes(): string[] {
|
|||||||
*/
|
*/
|
||||||
export function buildTrackingFormDefaults(): Record<string, any> {
|
export function buildTrackingFormDefaults(): Record<string, any> {
|
||||||
const defaults: Record<string, any> = {};
|
const defaults: Record<string, any> = {};
|
||||||
|
// `defaultValue` may be a function (for time-sensitive defaults like
|
||||||
|
// today's date) so the computed value is fresh each time the form resets.
|
||||||
|
const resolve = (v: unknown): unknown => (typeof v === 'function' ? (v as () => unknown)() : v);
|
||||||
for (const desc of REGISTRY.values()) {
|
for (const desc of REGISTRY.values()) {
|
||||||
for (const field of desc.eventFields) {
|
for (const field of desc.eventFields) {
|
||||||
defaults[field.key] = field.default;
|
defaults[field.key] = field.default;
|
||||||
}
|
}
|
||||||
for (const extra of desc.extraTrackingFields ?? []) {
|
for (const extra of desc.extraTrackingFields ?? []) {
|
||||||
defaults[extra.key] = extra.defaultValue ?? '';
|
defaults[extra.key] = resolve(extra.defaultValue) ?? '';
|
||||||
}
|
}
|
||||||
for (const section of desc.featureSections ?? []) {
|
for (const section of desc.featureSections ?? []) {
|
||||||
defaults[section.enabledField] = section.enabledDefault;
|
defaults[section.enabledField] = section.enabledDefault;
|
||||||
for (const f of section.fields) {
|
for (const f of section.fields) {
|
||||||
defaults[f.key] = f.defaultValue ?? '';
|
defaults[f.key] = resolve(f.defaultValue) ?? '';
|
||||||
}
|
}
|
||||||
for (const cb of section.checkboxes ?? []) {
|
for (const cb of section.checkboxes ?? []) {
|
||||||
defaults[cb.key] = cb.default;
|
defaults[cb.key] = cb.default;
|
||||||
|
|||||||
@@ -60,14 +60,31 @@ export interface EventTrackingField {
|
|||||||
export interface ExtraTrackingField {
|
export interface ExtraTrackingField {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'number' | 'grid-select' | 'toggle';
|
/**
|
||||||
|
* Control kind:
|
||||||
|
* - `number` — numeric spinner
|
||||||
|
* - `grid-select` — icon-grid chooser (requires `gridItems`)
|
||||||
|
* - `toggle` — on/off switch
|
||||||
|
* - `date` — HTML date picker (YYYY-MM-DD)
|
||||||
|
* - `time` — HTML time picker (HH:MM)
|
||||||
|
* - `time-list` — comma-separated HH:MM list, validated on blur
|
||||||
|
*/
|
||||||
|
type: 'number' | 'grid-select' | 'toggle' | 'date' | 'time' | 'time-list';
|
||||||
/** Grid-select item source function name from grid-items.ts. */
|
/** Grid-select item source function name from grid-items.ts. */
|
||||||
gridItems?: string;
|
gridItems?: string;
|
||||||
gridColumns?: number;
|
gridColumns?: number;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
/** Inline helper text rendered under the input (not a tooltip). */
|
||||||
|
inlineHelp?: string;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
defaultValue?: string | number | boolean;
|
/** For time-list: show live validation + auto-normalize on blur. */
|
||||||
|
validateFormat?: boolean;
|
||||||
|
/**
|
||||||
|
* Default value. Can be a function for dynamic values (e.g. today's date)
|
||||||
|
* evaluated each time the form is reset.
|
||||||
|
*/
|
||||||
|
defaultValue?: string | number | boolean | (() => string | number | boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A feature section like periodic summary, scheduled assets, memory mode. */
|
/** A feature section like periodic summary, scheduled assets, memory mode. */
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export interface Tracker {
|
|||||||
provider_id: number;
|
provider_id: number;
|
||||||
collection_ids: string[];
|
collection_ids: string[];
|
||||||
scan_interval: number;
|
scan_interval: number;
|
||||||
batch_duration: number;
|
adaptive_max_skip: number | null;
|
||||||
default_tracking_config_id: number | null;
|
default_tracking_config_id: number | null;
|
||||||
default_template_config_id: number | null;
|
default_template_config_id: number | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -192,6 +192,9 @@ export interface TrackingConfig {
|
|||||||
memory_favorite_only: boolean;
|
memory_favorite_only: boolean;
|
||||||
memory_asset_type: string;
|
memory_asset_type: string;
|
||||||
memory_min_rating: number;
|
memory_min_rating: number;
|
||||||
|
quiet_hours_enabled: boolean;
|
||||||
|
quiet_hours_start: string | null;
|
||||||
|
quiet_hours_end: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,24 +226,15 @@
|
|||||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// "More" panel items — everything not in the bottom bar
|
// "More" panel mirrors the full desktop sidebar tree so every subnode is
|
||||||
const mobileMoreItems = $derived<NavItem[]>([
|
// reachable on mobile (previously it was a flat hand-picked list that
|
||||||
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
|
// hid all target types, bot channels, and several nested pages).
|
||||||
{ href: '/bots?tab=telegram', key: 'nav.bots', icon: 'mdiRobot' },
|
|
||||||
{ href: '/actions', key: 'nav.actions', icon: 'mdiPlayCircleOutline' },
|
|
||||||
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog' },
|
|
||||||
{ href: '/template-configs', key: 'nav.templates', icon: 'mdiFileDocumentEdit' },
|
|
||||||
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiConsoleLine' },
|
|
||||||
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' },
|
|
||||||
...(auth.isAdmin ? [
|
|
||||||
{ href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' },
|
|
||||||
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
|
|
||||||
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
|
|
||||||
] : []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let mobileMoreOpen = $state(false);
|
let mobileMoreOpen = $state(false);
|
||||||
|
|
||||||
|
function closeMobileMore() {
|
||||||
|
mobileMoreOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
const isAuthPage = $derived(
|
const isAuthPage = $derived(
|
||||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||||
);
|
);
|
||||||
@@ -384,7 +375,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global provider filter -->
|
<!-- Global provider filter -->
|
||||||
{#if allProviders.length > 1}
|
{#if allProviders.length >= 1}
|
||||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<button onclick={() => {
|
<button onclick={() => {
|
||||||
@@ -538,7 +529,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Mobile bottom nav -->
|
<!-- Mobile bottom nav -->
|
||||||
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
|
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 60; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0 calc(0.375rem + env(safe-area-inset-bottom, 0px)); backdrop-filter: blur(12px);">
|
||||||
{#each mobileNavItems as item}
|
{#each mobileNavItems as item}
|
||||||
<a href={item.href} aria-label={t(item.key)}
|
<a href={item.href} aria-label={t(item.key)}
|
||||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||||
@@ -558,40 +549,69 @@
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile "More" panel -->
|
<!-- Mobile "More" panel — mirrors the full desktop nav tree -->
|
||||||
{#if mobileMoreOpen}
|
{#if mobileMoreOpen}
|
||||||
<div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);"
|
<div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);"
|
||||||
onclick={() => mobileMoreOpen = false} role="presentation"></div>
|
onclick={closeMobileMore} role="presentation"></div>
|
||||||
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;"
|
<div class="mobile-more-panel" style="position: fixed; bottom: calc(3rem + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: calc(70vh - env(safe-area-inset-bottom, 0px)); overflow-y: auto;"
|
||||||
transition:slide={{ duration: 200, easing: cubicOut }}>
|
transition:slide={{ duration: 200, easing: cubicOut }}>
|
||||||
{#if allProviders.length > 1}
|
{#if allProviders.length >= 1}
|
||||||
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
|
||||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
|
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="space-y-3">
|
||||||
{#each mobileMoreItems as item}
|
{#each navEntries as entry}
|
||||||
<a href={item.href}
|
{#if isGroup(entry)}
|
||||||
onclick={() => mobileMoreOpen = false}
|
<div>
|
||||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
|
<div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
|
||||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
style="color: var(--color-muted-foreground);">
|
||||||
>
|
<MdiIcon name={entry.icon} size={13} />
|
||||||
<MdiIcon name={item.icon} size={20} />
|
<span>{t(entry.key)}</span>
|
||||||
<span class="text-xs text-center leading-tight">{t(item.key)}</span>
|
</div>
|
||||||
</a>
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{#each entry.children as child}
|
||||||
|
<a href={child.href} onclick={closeMobileMore}
|
||||||
|
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200 relative"
|
||||||
|
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||||
|
>
|
||||||
|
<MdiIcon name={child.icon} size={20} />
|
||||||
|
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
|
||||||
|
{#if child.countKey && navCounts[child.countKey]}
|
||||||
|
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<a href={entry.href} onclick={closeMobileMore}
|
||||||
|
class="flex items-center gap-2 p-3 rounded-lg transition-all duration-200 relative"
|
||||||
|
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
|
||||||
|
>
|
||||||
|
<MdiIcon name={entry.icon} size={18} />
|
||||||
|
<span class="text-sm flex-1">{t(entry.key)}</span>
|
||||||
|
{#if entry.countKey && navCounts[entry.countKey]}
|
||||||
|
<span class="nav-badge">{navCounts[entry.countKey]}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<button onclick={() => { mobileMoreOpen = false; logout(); }}
|
<div class="pt-2" style="border-top: 1px solid var(--color-border);">
|
||||||
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
|
<button onclick={() => { closeMobileMore(); logout(); }}
|
||||||
style="color: var(--color-muted-foreground);">
|
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
|
||||||
<MdiIcon name="mdiLogout" size={20} />
|
style="color: var(--color-muted-foreground);">
|
||||||
<span class="text-xs text-center leading-tight">{t('nav.logout')}</span>
|
<MdiIcon name="mdiLogout" size={18} />
|
||||||
</button>
|
<span class="text-sm">{t('nav.logout')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
<main class="flex-1 overflow-auto md:pb-0"
|
||||||
|
style="padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));">
|
||||||
{#key page.url.pathname}
|
{#key page.url.pathname}
|
||||||
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
|
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -611,19 +631,22 @@
|
|||||||
<!-- Password change modal -->
|
<!-- Password change modal -->
|
||||||
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
|
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
|
||||||
<form onsubmit={changePassword} class="space-y-3">
|
<form onsubmit={changePassword} class="space-y-3">
|
||||||
|
<input type="text" name="username" autocomplete="username" value={auth.user?.username ?? ''}
|
||||||
|
readonly aria-hidden="true" tabindex="-1"
|
||||||
|
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;" />
|
||||||
<div>
|
<div>
|
||||||
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
||||||
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
|
<input id="pwd-current" type="password" autocomplete="current-password" bind:value={pwdCurrent} required
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||||
<input id="pwd-new" type="password" bind:value={pwdNew} required minlength="8"
|
<input id="pwd-new" type="password" autocomplete="new-password" bind:value={pwdNew} required minlength="8"
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
|
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
|
||||||
<input id="pwd-confirm" type="password" bind:value={pwdConfirm} required minlength="8"
|
<input id="pwd-confirm" type="password" autocomplete="new-password" bind:value={pwdConfirm} required minlength="8"
|
||||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
{#if pwdMsg}
|
{#if pwdMsg}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api, parseDate } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { providersCache } from '$lib/stores/caches.svelte';
|
import { providersCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
import EventChart from '$lib/components/EventChart.svelte';
|
import EventChart from '$lib/components/EventChart.svelte';
|
||||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
import { getDescriptor } from '$lib/providers';
|
import { getDescriptor } from '$lib/providers';
|
||||||
@@ -56,6 +58,21 @@
|
|||||||
let eventsLimit = $state(loadEventsPerPage());
|
let eventsLimit = $state(loadEventsPerPage());
|
||||||
let eventsOffset = $state(0);
|
let eventsOffset = $state(0);
|
||||||
let eventsLoading = $state(false);
|
let eventsLoading = $state(false);
|
||||||
|
let confirmClearEvents = $state(false);
|
||||||
|
|
||||||
|
async function clearEvents() {
|
||||||
|
try {
|
||||||
|
const res = await api<{ deleted: number }>('/status/events', { method: 'DELETE' });
|
||||||
|
snackSuccess(t('snack.eventsCleared').replace('{count}', String(res.deleted)));
|
||||||
|
eventsOffset = 0;
|
||||||
|
await loadEvents();
|
||||||
|
await loadChart();
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message || t('common.error'));
|
||||||
|
} finally {
|
||||||
|
confirmClearEvents = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
|
let currentPage = $derived(Math.floor(eventsOffset / eventsLimit) + 1);
|
||||||
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
|
let totalPages = $derived(status ? Math.ceil((status.total_events || 0) / eventsLimit) : 0);
|
||||||
@@ -191,7 +208,7 @@
|
|||||||
] : []);
|
] : []);
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
const diff = Date.now() - parseDate(dateStr).getTime();
|
||||||
const mins = Math.floor(diff / 60000);
|
const mins = Math.floor(diff / 60000);
|
||||||
if (mins < 1) return t('dashboard.justNow');
|
if (mins < 1) return t('dashboard.justNow');
|
||||||
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
|
if (mins < 60) return t('dashboard.minutesAgo').replace('{n}', String(mins));
|
||||||
@@ -206,15 +223,23 @@
|
|||||||
collection_renamed: 'dashboard.collectionRenamed',
|
collection_renamed: 'dashboard.collectionRenamed',
|
||||||
collection_deleted: 'dashboard.collectionDeleted',
|
collection_deleted: 'dashboard.collectionDeleted',
|
||||||
sharing_changed: 'dashboard.sharingChanged',
|
sharing_changed: 'dashboard.sharingChanged',
|
||||||
|
scheduled_message: 'dashboard.scheduledMessage',
|
||||||
|
action_success: 'dashboard.actionSuccess',
|
||||||
|
action_partial: 'dashboard.actionPartial',
|
||||||
|
action_failed: 'dashboard.actionFailed',
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventIcons: Record<string, string> = {
|
const eventIcons: Record<string, string> = {
|
||||||
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
assets_added: 'mdiImagePlus', assets_removed: 'mdiImageMinus',
|
||||||
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
collection_renamed: 'mdiRename', collection_deleted: 'mdiDeleteAlert', sharing_changed: 'mdiShareVariant',
|
||||||
|
scheduled_message: 'mdiCalendarClock',
|
||||||
|
action_success: 'mdiPlayCircle', action_partial: 'mdiAlertCircle', action_failed: 'mdiCloseCircle',
|
||||||
};
|
};
|
||||||
const eventColors: Record<string, string> = {
|
const eventColors: Record<string, string> = {
|
||||||
assets_added: '#059669', assets_removed: '#ef4444',
|
assets_added: '#059669', assets_removed: '#ef4444',
|
||||||
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
collection_renamed: '#6366f1', collection_deleted: '#dc2626', sharing_changed: '#f59e0b',
|
||||||
|
scheduled_message: '#8b5cf6',
|
||||||
|
action_success: '#0d9488', action_partial: '#f59e0b', action_failed: '#dc2626',
|
||||||
};
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -252,13 +277,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Events section -->
|
<!-- Events section -->
|
||||||
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<MdiIcon name="mdiPulse" size={18} />
|
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||||
{t('dashboard.recentEvents')}
|
<MdiIcon name="mdiPulse" size={18} />
|
||||||
|
{t('dashboard.recentEvents')}
|
||||||
|
{#if status.total_events > 0}
|
||||||
|
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
{#if status.total_events > 0}
|
{#if status.total_events > 0}
|
||||||
<span class="text-xs font-normal text-[var(--color-muted-foreground)]">({status.total_events})</span>
|
<button type="button" onclick={() => confirmClearEvents = true}
|
||||||
|
class="clear-events-btn flex items-center gap-1.5 px-2.5 py-1 text-xs border border-[var(--color-border)] rounded-md transition-colors"
|
||||||
|
title={t('dashboard.clearEvents')}>
|
||||||
|
<MdiIcon name="mdiTrashCanOutline" size={14} />
|
||||||
|
<span class="hidden sm:inline">{t('dashboard.clearEvents')}</span>
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</h3>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||||
@@ -370,6 +405,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ConfirmModal open={confirmClearEvents} message={t('dashboard.confirmClearEvents')}
|
||||||
|
onconfirm={clearEvents} oncancel={() => confirmClearEvents = false} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
|
.stat-card { position: relative; border-radius: 0.75rem; padding: 1px; background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border)); transition: all 0.3s ease; }
|
||||||
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
|
.stat-card:hover { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
@@ -385,4 +423,6 @@
|
|||||||
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
|
.event-content { flex: 1; min-width: 0; padding: 0.375rem 0.75rem; border-radius: 0.5rem; background: var(--color-card); border: 1px solid var(--color-border); transition: all 0.2s ease; }
|
||||||
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
|
.event-content:hover { border-color: var(--color-primary); box-shadow: 0 0 12px var(--color-glow); }
|
||||||
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
|
.event-badge { display: inline-block; font-size: 0.65rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.15rem 0.5rem; border-radius: 9999px; background: var(--color-muted); color: var(--color-muted-foreground); white-space: nowrap; font-family: var(--font-mono); }
|
||||||
|
.clear-events-btn { color: var(--color-muted-foreground); background: transparent; }
|
||||||
|
.clear-events-btn:hover { background: color-mix(in srgb, var(--color-error-fg) 10%, transparent); border-color: color-mix(in srgb, var(--color-error-fg) 40%, var(--color-border)); color: var(--color-error-fg); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api, parseDate } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import type { ActionExecution } from '$lib/types';
|
import type { ActionExecution } from '$lib/types';
|
||||||
@@ -47,14 +47,14 @@
|
|||||||
function formatDate(iso: string | null): string {
|
function formatDate(iso: string | null): string {
|
||||||
if (!iso) return '-';
|
if (!iso) return '-';
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleString();
|
return parseDate(iso).toLocaleString();
|
||||||
} catch { return iso; }
|
} catch { return iso; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(start: string, end: string | null): string {
|
function formatDuration(start: string, end: string | null): string {
|
||||||
if (!end) return '-';
|
if (!end) return '-';
|
||||||
try {
|
try {
|
||||||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
const ms = parseDate(end).getTime() - parseDate(start).getTime();
|
||||||
if (ms < 1000) return `${ms}ms`;
|
if (ms < 1000) return `${ms}ms`;
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
} catch { return '-'; }
|
} catch { return '-'; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
import { emailBotsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -58,12 +59,17 @@
|
|||||||
finally { emailSubmitting = false; }
|
finally { emailSubmitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
function removeEmail(id: number) {
|
function removeEmail(id: number) {
|
||||||
confirmDeleteEmail = {
|
confirmDeleteEmail = {
|
||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
|
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
|
||||||
catch (err: any) { error = err.message; snackError(err.message); }
|
catch (err: any) {
|
||||||
|
const bb = getBlockedBy(err);
|
||||||
|
if (bb) { blockedBy = bb; return; }
|
||||||
|
error = err.message; snackError(err.message);
|
||||||
|
}
|
||||||
finally { confirmDeleteEmail = null; }
|
finally { confirmDeleteEmail = null; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -173,3 +179,5 @@
|
|||||||
|
|
||||||
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
|
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
|
||||||
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
|
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />
|
||||||
|
|
||||||
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
import { matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -56,12 +57,17 @@
|
|||||||
finally { matrixSubmitting = false; }
|
finally { matrixSubmitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
function removeMatrix(id: number) {
|
function removeMatrix(id: number) {
|
||||||
confirmDeleteMatrix = {
|
confirmDeleteMatrix = {
|
||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
|
try { await api(`/matrix-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.matrixBotDeleted')); }
|
||||||
catch (err: any) { error = err.message; snackError(err.message); }
|
catch (err: any) {
|
||||||
|
const bb = getBlockedBy(err);
|
||||||
|
if (bb) { blockedBy = bb; return; }
|
||||||
|
error = err.message; snackError(err.message);
|
||||||
|
}
|
||||||
finally { confirmDeleteMatrix = null; }
|
finally { confirmDeleteMatrix = null; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -155,3 +161,5 @@
|
|||||||
|
|
||||||
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
|
<ConfirmModal open={confirmDeleteMatrix !== null} message={t('matrixBot.confirmDelete')}
|
||||||
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
|
onconfirm={() => confirmDeleteMatrix?.onconfirm()} oncancel={() => confirmDeleteMatrix = null} />
|
||||||
|
|
||||||
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { telegramBotsCache } from '$lib/stores/caches.svelte';
|
import { telegramBotsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -64,24 +65,30 @@
|
|||||||
finally { submitting = false; }
|
finally { submitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
function remove(id: number) {
|
function remove(id: number) {
|
||||||
confirmDelete = {
|
confirmDelete = {
|
||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
|
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.botDeleted')); }
|
||||||
catch (err: any) { error = err.message; snackError(err.message); }
|
catch (err: any) {
|
||||||
|
const bb = getBlockedBy(err);
|
||||||
|
if (bb) { blockedBy = bb; return; }
|
||||||
|
error = err.message; snackError(err.message);
|
||||||
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSection(botId: number, section: string) {
|
async function toggleSection(botId: number, section: string) {
|
||||||
if (expandedSection[botId] === section) {
|
if (expandedSection[botId] === section) {
|
||||||
expandedSection = { ...expandedSection, [botId]: '' };
|
expandedSection = { ...expandedSection, [botId]: '' };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (section === 'chats' && !chats[botId]) await loadChats(botId);
|
||||||
|
else if (section === 'listeners' && !botListenerStatus[botId]) await loadListenerStatus(botId);
|
||||||
expandedSection = { ...expandedSection, [botId]: section };
|
expandedSection = { ...expandedSection, [botId]: section };
|
||||||
if (section === 'chats') loadChats(botId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChats(botId: number) {
|
async function loadChats(botId: number) {
|
||||||
@@ -327,10 +334,12 @@
|
|||||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Mode badge -->
|
<!-- Mode badge -->
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
|
<span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
|
||||||
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
|
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
|
||||||
: 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'}">
|
: (bot.update_mode || 'none') === 'polling'
|
||||||
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
|
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
|
||||||
|
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||||
|
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||||
@@ -338,12 +347,14 @@
|
|||||||
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
|
<div class="flex items-center gap-1 flex-shrink-0 flex-wrap">
|
||||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editBot(bot)} />
|
||||||
<button onclick={() => toggleSection(bot.id, 'chats')}
|
<button onclick={() => toggleSection(bot.id, 'chats')}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap">
|
disabled={chatsLoading[bot.id]}
|
||||||
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap disabled:opacity-50">
|
||||||
|
{t('telegramBot.chats')} {chatsLoading[bot.id] ? '…' : expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => { toggleSection(bot.id, 'listeners'); if (expandedSection[bot.id] === 'listeners') loadListenerStatus(bot.id); }}
|
<button onclick={() => toggleSection(bot.id, 'listeners')}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap">
|
disabled={botListenerLoading[bot.id]}
|
||||||
{t('commandTracker.listeners')} {expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1 whitespace-nowrap disabled:opacity-50">
|
||||||
|
{t('commandTracker.listeners')} {botListenerLoading[bot.id] ? '…' : expandedSection[bot.id] === 'listeners' ? '▲' : '▼'}
|
||||||
</button>
|
</button>
|
||||||
<IconButton icon="mdiSync" title={t('telegramBot.syncCommands')} onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]} />
|
<IconButton icon="mdiSync" title={t('telegramBot.syncCommands')} onclick={() => syncCommands(bot.id)} disabled={modeChanging[bot.id]} />
|
||||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
||||||
@@ -447,6 +458,14 @@
|
|||||||
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
|
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
|
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
|
||||||
|
<button onclick={() => switchMode(bot.id, 'none')}
|
||||||
|
disabled={modeChanging[bot.id] || (bot.update_mode || 'none') === 'none'}
|
||||||
|
class="px-3 py-1 text-xs transition-colors {(bot.update_mode || 'none') === 'none'
|
||||||
|
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
|
||||||
|
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
|
||||||
|
<MdiIcon name="mdiBellOff" size={14} />
|
||||||
|
{t('telegramBot.none')}
|
||||||
|
</button>
|
||||||
<button onclick={() => switchMode(bot.id, 'polling')}
|
<button onclick={() => switchMode(bot.id, 'polling')}
|
||||||
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
|
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
|
||||||
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
|
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
|
||||||
@@ -465,6 +484,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if (bot.update_mode || 'none') === 'none'}
|
||||||
|
<span class="text-xs text-[var(--color-muted-foreground)] flex items-center gap-1">
|
||||||
|
<MdiIcon name="mdiBellOff" size={14} />
|
||||||
|
{t('telegramBot.noneActive')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if bot.update_mode === 'polling'}
|
{#if bot.update_mode === 'polling'}
|
||||||
<span class="text-xs text-[var(--color-success-fg)] flex items-center gap-1">
|
<span class="text-xs text-[var(--color-success-fg)] flex items-center gap-1">
|
||||||
<MdiIcon name="mdiCheckCircle" size={14} />
|
<MdiIcon name="mdiCheckCircle" size={14} />
|
||||||
@@ -518,3 +544,5 @@
|
|||||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|
||||||
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -92,7 +93,10 @@
|
|||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
// Auto-select first matching template for the default provider_type
|
// Auto-select first provider type with commands
|
||||||
|
const types = Object.keys(allCapabilities).filter(t => (allCapabilities[t]?.commands?.length || 0) > 0);
|
||||||
|
if (types.length > 0) form.provider_type = types[0];
|
||||||
|
// Auto-select first matching template for the chosen provider_type
|
||||||
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
|
const match = cmdTemplateConfigs.find((c) => c.provider_type === form.provider_type);
|
||||||
if (match) form.command_template_config_id = match.id;
|
if (match) form.command_template_config_id = match.id;
|
||||||
editing = null;
|
editing = null;
|
||||||
@@ -137,6 +141,7 @@
|
|||||||
finally { submitting = false; }
|
finally { submitting = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
function remove(cfg: CommandConfig) {
|
function remove(cfg: CommandConfig) {
|
||||||
confirmDelete = {
|
confirmDelete = {
|
||||||
id: cfg.id,
|
id: cfg.id,
|
||||||
@@ -145,7 +150,11 @@
|
|||||||
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
await api(`/command-configs/${cfg.id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.commandConfigDeleted'));
|
snackSuccess(t('snack.commandConfigDeleted'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) {
|
||||||
|
const bb = getBlockedBy(err);
|
||||||
|
if (bb) { blockedBy = bb; return; }
|
||||||
|
snackError(err.message);
|
||||||
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -296,3 +305,5 @@
|
|||||||
|
|
||||||
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
|
<ConfirmModal open={confirmDelete !== null} message={t('commandConfig.confirmDelete')}
|
||||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|
||||||
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { sanitizePreview } from '$lib/sanitize';
|
import { sanitizePreview } from '$lib/sanitize';
|
||||||
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -53,6 +54,11 @@
|
|||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||||
|
let confirmReset = $state<{
|
||||||
|
kind: 'slot' | 'all';
|
||||||
|
slotKey?: string;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
let slotPreview = $state<Record<string, string>>({});
|
let slotPreview = $state<Record<string, string>>({});
|
||||||
let slotErrors = $state<Record<string, string>>({});
|
let slotErrors = $state<Record<string, string>>({});
|
||||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||||
@@ -116,6 +122,14 @@
|
|||||||
return form.slots[slotName]?.[activeLocale] || '';
|
return form.slots[slotName]?.[activeLocale] || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve variable reference for a slot, preferring provider-specific over shared. */
|
||||||
|
function getVarsFor(slotName: string) {
|
||||||
|
const providerVars = varsRef[form.provider_type];
|
||||||
|
return providerVars?.[slotName] ?? varsRef[slotName];
|
||||||
|
}
|
||||||
|
|
||||||
|
let modalVars = $derived(showVarsFor ? getVarsFor(showVarsFor) : null);
|
||||||
|
|
||||||
/** Set slot template for current locale (immutable update). */
|
/** Set slot template for current locale (immutable update). */
|
||||||
function setSlotValue(slotName: string, value: string) {
|
function setSlotValue(slotName: string, value: string) {
|
||||||
form.slots = {
|
form.slots = {
|
||||||
@@ -189,6 +203,8 @@
|
|||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
|
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
|
||||||
|
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
|
||||||
editing = null;
|
editing = null;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
activeLocale = 'en';
|
activeLocale = 'en';
|
||||||
@@ -242,6 +258,58 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetSlotToDefault(slotKey: string) {
|
||||||
|
if (!form.provider_type) return;
|
||||||
|
confirmReset = {
|
||||||
|
kind: 'slot',
|
||||||
|
slotKey,
|
||||||
|
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAllToDefaults() {
|
||||||
|
if (!form.provider_type) return;
|
||||||
|
confirmReset = {
|
||||||
|
kind: 'all',
|
||||||
|
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performReset() {
|
||||||
|
if (!confirmReset || !form.provider_type) return;
|
||||||
|
const { kind, slotKey } = confirmReset;
|
||||||
|
confirmReset = null;
|
||||||
|
try {
|
||||||
|
if (kind === 'slot' && slotKey) {
|
||||||
|
const res = await api<Record<string, Record<string, string>>>(
|
||||||
|
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||||
|
);
|
||||||
|
const text = res?.[slotKey]?.[activeLocale];
|
||||||
|
if (!text) {
|
||||||
|
snackError(t('templateConfig.resetNoDefault'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSlotValue(slotKey, text);
|
||||||
|
validateSlot(slotKey, text, true);
|
||||||
|
} else {
|
||||||
|
const res = await api<Record<string, Record<string, string>>>(
|
||||||
|
`/command-template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||||
|
);
|
||||||
|
const nextSlots = { ...form.slots };
|
||||||
|
for (const [key, localeMap] of Object.entries(res || {})) {
|
||||||
|
const text = localeMap?.[activeLocale];
|
||||||
|
if (text === undefined) continue;
|
||||||
|
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
|
||||||
|
}
|
||||||
|
form.slots = nextSlots;
|
||||||
|
refreshAllPreviews();
|
||||||
|
}
|
||||||
|
snackSuccess(t('templateConfig.resetApplied'));
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clone(c: CmdTemplateConfig) {
|
function clone(c: CmdTemplateConfig) {
|
||||||
const slotsCopy: Record<string, Record<string, string>> = {};
|
const slotsCopy: Record<string, Record<string, string>> = {};
|
||||||
for (const [k, v] of Object.entries(c.slots)) {
|
for (const [k, v] of Object.entries(c.slots)) {
|
||||||
@@ -265,6 +333,7 @@
|
|||||||
setTimeout(() => refreshAllPreviews(), 100);
|
setTimeout(() => refreshAllPreviews(), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
function remove(id: number) {
|
function remove(id: number) {
|
||||||
confirmDelete = {
|
confirmDelete = {
|
||||||
id,
|
id,
|
||||||
@@ -274,6 +343,8 @@
|
|||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.cmdTemplateDeleted'));
|
snackSuccess(t('snack.cmdTemplateDeleted'));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
const bb = getBlockedBy(err);
|
||||||
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message;
|
error = err.message;
|
||||||
snackError(err.message);
|
snackError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -329,7 +400,7 @@
|
|||||||
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
|
||||||
|
|
||||||
<!-- Locale tabs -->
|
<!-- Locale tabs -->
|
||||||
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||||
{#each LOCALES as loc}
|
{#each LOCALES as loc}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
|
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
|
||||||
@@ -337,6 +408,14 @@
|
|||||||
{loc.toUpperCase()}
|
{loc.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if form.provider_type}
|
||||||
|
<button type="button" onclick={resetAllToDefaults}
|
||||||
|
title={t('templateConfig.resetAllToDefaults')}
|
||||||
|
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
|
||||||
|
<MdiIcon name="mdiRefresh" size={12} />
|
||||||
|
{t('templateConfig.resetAllToDefaults')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slot filter -->
|
<!-- Slot filter -->
|
||||||
@@ -363,10 +442,15 @@
|
|||||||
{t('templateConfig.preview')}
|
{t('templateConfig.preview')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if varsRef[slot.name]}
|
{#if getVarsFor(slot.name)}
|
||||||
<button type="button" onclick={() => showVarsFor = slot.name}
|
<button type="button" onclick={() => showVarsFor = slot.name}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
|
||||||
|
title={t('templateConfig.resetToDefault')}
|
||||||
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||||
|
{t('templateConfig.resetToDefault')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
|
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||||
@@ -379,7 +463,7 @@
|
|||||||
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
||||||
rows={3}
|
rows={3}
|
||||||
errorLine={slotErrorLines[slot.name] || null}
|
errorLine={slotErrorLines[slot.name] || null}
|
||||||
variables={varsRef[slot.name] || undefined}
|
variables={getVarsFor(slot.name) || undefined}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -458,13 +542,23 @@
|
|||||||
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
|
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
|
||||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|
||||||
|
<ConfirmModal open={confirmReset !== null}
|
||||||
|
title={t('templateConfig.resetToDefault')}
|
||||||
|
message={confirmReset?.message || ''}
|
||||||
|
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
|
||||||
|
confirmIcon="mdiRefresh"
|
||||||
|
onconfirm={performReset}
|
||||||
|
oncancel={() => confirmReset = null} />
|
||||||
|
|
||||||
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|
||||||
<!-- Variables reference modal -->
|
<!-- Variables reference modal -->
|
||||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
|
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
|
||||||
{#if showVarsFor && varsRef[showVarsFor]}
|
{#if showVarsFor && modalVars}
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p>
|
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{modalVars.description}</p>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
|
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
|
||||||
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
|
{#each Object.entries(modalVars.variables || {}) as [name, desc]}
|
||||||
<div class="flex items-start gap-2 text-sm">
|
<div class="flex items-start gap-2 text-sm">
|
||||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
|
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
|
||||||
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
||||||
@@ -476,11 +570,19 @@
|
|||||||
['album_fields', 'album', 'Album fields'],
|
['album_fields', 'album', 'Album fields'],
|
||||||
['command_fields', 'cmd', 'Command fields'],
|
['command_fields', 'cmd', 'Command fields'],
|
||||||
['event_fields', 'event', 'Event fields'],
|
['event_fields', 'event', 'Event fields'],
|
||||||
|
['repo_fields', 'repo', 'Repository fields'],
|
||||||
|
['issue_fields', 'issue', 'Issue fields'],
|
||||||
|
['pr_fields', 'pr', 'Pull request fields'],
|
||||||
|
['commit_fields', 'c', 'Commit fields'],
|
||||||
|
['board_fields', 'board', 'Board fields'],
|
||||||
|
['card_fields', 'card', 'Card fields'],
|
||||||
|
['list_fields', 'lst', 'List fields'],
|
||||||
|
['device_fields', 'd', 'Device fields'],
|
||||||
] as [fieldKey, prefix, title]}
|
] as [fieldKey, prefix, title]}
|
||||||
{#if varsRef[showVarsFor][fieldKey]}
|
{#if modalVars[fieldKey]}
|
||||||
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||||
<p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p>
|
<p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p>
|
||||||
{#each Object.entries(varsRef[showVarsFor][fieldKey]) as [name, desc]}
|
{#each Object.entries(modalVars[fieldKey]) as [name, desc]}
|
||||||
<div class="flex items-start gap-2 text-sm">
|
<div class="flex items-start gap-2 text-sm">
|
||||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code>
|
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code>
|
||||||
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import CrossLink from '$lib/components/CrossLink.svelte';
|
import CrossLink from '$lib/components/CrossLink.svelte';
|
||||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||||
@@ -83,7 +84,17 @@
|
|||||||
finally { loaded = true; highlightFromUrl(); }
|
finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
function openNew() {
|
||||||
|
form = defaultForm();
|
||||||
|
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||||
|
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||||
|
if (ptype) {
|
||||||
|
const firstCfg = commandConfigs.find(c => c.provider_type === ptype);
|
||||||
|
if (firstCfg) form.command_config_id = firstCfg.id;
|
||||||
|
}
|
||||||
|
editing = null;
|
||||||
|
showForm = true;
|
||||||
|
}
|
||||||
function editTracker(trk: any) {
|
function editTracker(trk: any) {
|
||||||
form = {
|
form = {
|
||||||
name: trk.name,
|
name: trk.name,
|
||||||
@@ -178,6 +189,35 @@
|
|||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) { snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-listener album scope editing
|
||||||
|
let scopeEditor = $state<{ trkId: number; listener: any; providerId: number; collections: any[]; selectedIds: string[]; inherit: boolean } | null>(null);
|
||||||
|
async function openScopeEditor(trkId: number, listener: any) {
|
||||||
|
const trk = allCmdTrackers.find((t: any) => t.id === trkId);
|
||||||
|
if (!trk) return;
|
||||||
|
let collections: any[] = [];
|
||||||
|
try { collections = await api(`/providers/${trk.provider_id}/collections`); } catch { /* ignore */ }
|
||||||
|
scopeEditor = {
|
||||||
|
trkId,
|
||||||
|
listener,
|
||||||
|
providerId: trk.provider_id,
|
||||||
|
collections,
|
||||||
|
selectedIds: [...(listener.allowed_album_ids || [])],
|
||||||
|
inherit: listener.allowed_album_ids === null || listener.allowed_album_ids === undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function saveScope() {
|
||||||
|
if (!scopeEditor) return;
|
||||||
|
const body = { allowed_album_ids: scopeEditor.inherit ? null : scopeEditor.selectedIds };
|
||||||
|
try {
|
||||||
|
await api(`/command-trackers/${scopeEditor.trkId}/listeners/${scopeEditor.listener.id}`, {
|
||||||
|
method: 'PATCH', body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
snackSuccess(t('snack.listenerScopeSaved'));
|
||||||
|
await loadListeners(scopeEditor.trkId);
|
||||||
|
scopeEditor = null;
|
||||||
|
} catch (err: any) { snackError(err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
function providerName(id: number): string {
|
function providerName(id: number): string {
|
||||||
return providers.find(p => p.id === id)?.name || '?';
|
return providers.find(p => p.id === id)?.name || '?';
|
||||||
}
|
}
|
||||||
@@ -289,10 +329,18 @@
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each listeners[trk.id] as listener}
|
{#each listeners[trk.id] as listener}
|
||||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<MdiIcon name="mdiRobot" size={14} />
|
<MdiIcon name="mdiRobot" size={14} />
|
||||||
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
|
<CrossLink href="/bots?tab=telegram" icon="mdiRobot" label={listener.name || listener.listener_type} entityId={listener.listener_id} />
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-mono">{listener.listener_type}</span>
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] font-mono">{listener.listener_type}</span>
|
||||||
|
<button type="button" onclick={() => openScopeEditor(trk.id, listener)}
|
||||||
|
class="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]"
|
||||||
|
title={t('commandTracker.editScope')}>
|
||||||
|
<MdiIcon name="mdiImageMultiple" size={12} />
|
||||||
|
{listener.allowed_album_ids === null || listener.allowed_album_ids === undefined
|
||||||
|
? t('commandTracker.scopeAll')
|
||||||
|
: `${(listener.allowed_album_ids || []).length} ${t('commandTracker.albumsShort')}`}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
|
<IconButton icon="mdiClose" title={t('commandTracker.removeListener')} size={14}
|
||||||
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
|
onclick={() => removeListener(trk.id, listener.id)} variant="danger" />
|
||||||
@@ -321,3 +369,59 @@
|
|||||||
|
|
||||||
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
|
<ConfirmModal open={confirmDelete !== null} message={t('commandTracker.confirmDelete')}
|
||||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|
||||||
|
<!-- Per-listener album scope editor -->
|
||||||
|
<Modal open={scopeEditor !== null} title={t('commandTracker.scopeTitle')} onclose={() => scopeEditor = null}>
|
||||||
|
{#if scopeEditor}
|
||||||
|
<p class="text-xs text-[var(--color-muted-foreground)] mb-3">{t('commandTracker.scopeDescription')}</p>
|
||||||
|
<label class="flex items-center gap-2 text-sm mb-3">
|
||||||
|
<input type="checkbox" bind:checked={scopeEditor.inherit} />
|
||||||
|
{t('commandTracker.scopeInherit')}
|
||||||
|
</label>
|
||||||
|
{#if !scopeEditor.inherit}
|
||||||
|
{#if scopeEditor.collections.length > 0}
|
||||||
|
<div class="flex items-center justify-between mb-1.5 text-xs" style="color: var(--color-muted-foreground);">
|
||||||
|
<span>{scopeEditor.selectedIds.length} / {scopeEditor.collections.length}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
||||||
|
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = scopeEditor.collections.map((c: any) => c.id); }}>
|
||||||
|
{t('backup.selectAll')}
|
||||||
|
</button>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<button type="button" class="underline hover:text-[var(--color-primary)]"
|
||||||
|
onclick={() => { if (scopeEditor) scopeEditor.selectedIds = []; }}>
|
||||||
|
{t('backup.deselectAll')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="space-y-1 max-h-72 overflow-y-auto border border-[var(--color-border)] rounded-md p-2">
|
||||||
|
{#if scopeEditor.collections.length === 0}
|
||||||
|
<p class="text-xs text-[var(--color-muted-foreground)] py-3 text-center">{t('commandTracker.noCollections')}</p>
|
||||||
|
{:else}
|
||||||
|
{#each scopeEditor.collections as col}
|
||||||
|
{@const cid = col.id}
|
||||||
|
<label class="flex items-center gap-2 text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer">
|
||||||
|
<input type="checkbox" checked={scopeEditor.selectedIds.includes(cid)}
|
||||||
|
onchange={(e) => {
|
||||||
|
if (!scopeEditor) return;
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
scopeEditor.selectedIds = target.checked
|
||||||
|
? [...scopeEditor.selectedIds, cid]
|
||||||
|
: scopeEditor.selectedIds.filter((i) => i !== cid);
|
||||||
|
}} />
|
||||||
|
<span class="truncate min-w-0 flex-1" title={col.albumName || col.name || cid}>{col.albumName || col.name || cid}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex gap-2 justify-end mt-4">
|
||||||
|
<button onclick={() => scopeEditor = null}
|
||||||
|
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<Button size="sm" onclick={saveScope}>{t('common.save')}</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
|
|||||||
@@ -15,13 +15,32 @@
|
|||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
|
|
||||||
|
let backendDown = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
initTheme();
|
initTheme();
|
||||||
mounted = true;
|
mounted = true;
|
||||||
|
// If the user is already signed in (valid access token in storage),
|
||||||
|
// there is no reason to show them the login form. loadUser() runs in
|
||||||
|
// the root layout; we just check the resolved state after a short tick.
|
||||||
|
const { isAuthenticated } = await import('$lib/api');
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
try {
|
||||||
|
await api('/auth/me');
|
||||||
|
goto('/');
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Token was stale; fall through to the login form.
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
||||||
if (res.needs_setup) goto('/setup');
|
if (res.needs_setup) goto('/setup');
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
// The backend is unreachable — surface that distinctly so the user
|
||||||
|
// doesn't blame the login form for a network/backend problem.
|
||||||
|
backendDown = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
@@ -62,7 +81,12 @@
|
|||||||
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p>
|
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if backendDown}
|
||||||
|
<div class="auth-error animate-fade-slide-in">
|
||||||
|
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||||
|
{t('auth.backendUnreachable')}
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
<div class="auth-error animate-fade-slide-in">
|
<div class="auth-error animate-fade-slide-in">
|
||||||
<MdiIcon name="mdiAlertCircle" size={16} />
|
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api, parseDate } from '$lib/api';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -62,7 +62,8 @@
|
|||||||
// Tracker form
|
// Tracker form
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
|
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
|
||||||
scan_interval: 60, batch_duration: 0,
|
scan_interval: 60,
|
||||||
|
adaptive_max_skip: null as number | null,
|
||||||
default_tracking_config_id: 0, default_template_config_id: 0,
|
default_tracking_config_id: 0, default_template_config_id: 0,
|
||||||
filters: {} as Record<string, any>,
|
filters: {} as Record<string, any>,
|
||||||
});
|
});
|
||||||
@@ -84,17 +85,23 @@
|
|||||||
let testMenuStyle = $state('');
|
let testMenuStyle = $state('');
|
||||||
|
|
||||||
// Test types: basic is always available; periodic/scheduled/memory only for providers
|
// Test types: basic is always available; periodic/scheduled/memory only for providers
|
||||||
// that have those notification slots in their capabilities
|
// that have those notification slots in their capabilities AND have the feature
|
||||||
const allTestTypes: Record<string, { key: string; icon: string; labelKey: string; requiredSlot?: string }> = {
|
// enabled on the tracker's default TrackingConfig. A disabled feature on the
|
||||||
|
// default config means cron dispatch won't fire it in production either — so
|
||||||
|
// the test button would just surface a silent skip.
|
||||||
|
const allTestTypes: Record<string, {
|
||||||
|
key: string; icon: string; labelKey: string;
|
||||||
|
requiredSlot?: string; enabledField?: string;
|
||||||
|
}> = {
|
||||||
basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
||||||
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message' },
|
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message', enabledField: 'periodic_enabled' },
|
||||||
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message' },
|
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message', enabledField: 'scheduled_enabled' },
|
||||||
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message' },
|
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message', enabledField: 'memory_enabled' },
|
||||||
};
|
};
|
||||||
|
|
||||||
let testMenuTrackerId = $state<number | null>(null);
|
let testMenuTrackerId = $state<number | null>(null);
|
||||||
let testTypes = $derived.by(() => {
|
let testTypes = $derived.by(() => {
|
||||||
const base = [allTestTypes.basic];
|
const base: { key: string; icon: string; labelKey: string; disabledReason?: string }[] = [allTestTypes.basic];
|
||||||
if (!testMenuTrackerId) return base;
|
if (!testMenuTrackerId) return base;
|
||||||
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
|
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
|
||||||
if (!tracker) return base;
|
if (!tracker) return base;
|
||||||
@@ -103,8 +110,18 @@
|
|||||||
const caps = allCapabilities[provider.type];
|
const caps = allCapabilities[provider.type];
|
||||||
if (!caps) return base;
|
if (!caps) return base;
|
||||||
const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name));
|
const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name));
|
||||||
|
const defaultTc = trackingConfigs.find(c => c.id === tracker.default_tracking_config_id);
|
||||||
for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) {
|
for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) {
|
||||||
if (tt.requiredSlot && slotNames.has(tt.requiredSlot)) base.push(tt);
|
if (!tt.requiredSlot || !slotNames.has(tt.requiredSlot)) continue;
|
||||||
|
const enabled = !!defaultTc && !!tt.enabledField && !!(defaultTc as any)[tt.enabledField];
|
||||||
|
base.push({
|
||||||
|
key: tt.key, icon: tt.icon, labelKey: tt.labelKey,
|
||||||
|
// When surfaced, the button still renders but is disabled and
|
||||||
|
// shows *why* — users who land here via the test menu without
|
||||||
|
// having toggled the feature on Tracking Config see a clear
|
||||||
|
// pointer to the missing setting instead of a silent failure.
|
||||||
|
disabledReason: enabled ? undefined : 'notificationTracker.testDisabledHint',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
});
|
});
|
||||||
@@ -136,16 +153,36 @@
|
|||||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||||
_prevProviderId = form.provider_id;
|
_prevProviderId = form.provider_id;
|
||||||
loadCollections();
|
loadCollections();
|
||||||
|
// Auto-select first available tracking/template config for this provider when creating
|
||||||
|
if (editing === null) {
|
||||||
|
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||||
|
if (ptype) {
|
||||||
|
if (!form.default_tracking_config_id) {
|
||||||
|
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
||||||
|
if (first) form.default_tracking_config_id = first.id;
|
||||||
|
}
|
||||||
|
if (!form.default_template_config_id) {
|
||||||
|
const first = templateConfigs.find(c => c.provider_type === ptype);
|
||||||
|
if (first) form.default_template_config_id = first.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); editing = null; showForm = true; collections = []; previousCollectionIds = []; }
|
function openNew() {
|
||||||
|
form = defaultForm();
|
||||||
|
// Auto-select first provider if any
|
||||||
|
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||||
|
editing = null; showForm = true; collections = []; previousCollectionIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
async function edit(trk: Tracker) {
|
async function edit(trk: Tracker) {
|
||||||
form = {
|
form = {
|
||||||
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
||||||
collection_ids: [...(trk.collection_ids || [])],
|
collection_ids: [...(trk.collection_ids || [])],
|
||||||
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
scan_interval: trk.scan_interval,
|
||||||
|
adaptive_max_skip: trk.adaptive_max_skip ?? null,
|
||||||
default_tracking_config_id: trk.default_tracking_config_id ?? 0,
|
default_tracking_config_id: trk.default_tracking_config_id ?? 0,
|
||||||
default_template_config_id: trk.default_template_config_id ?? 0,
|
default_template_config_id: trk.default_template_config_id ?? 0,
|
||||||
filters: trk.filters || {},
|
filters: trk.filters || {},
|
||||||
@@ -188,6 +225,12 @@
|
|||||||
...form,
|
...form,
|
||||||
default_tracking_config_id: form.default_tracking_config_id || null,
|
default_tracking_config_id: form.default_tracking_config_id || null,
|
||||||
default_template_config_id: form.default_template_config_id || null,
|
default_template_config_id: form.default_template_config_id || null,
|
||||||
|
// Empty string, 0, or null all mean "disable adaptive polling".
|
||||||
|
// Coerce to null so the DB column stays NULL rather than 0.
|
||||||
|
adaptive_max_skip:
|
||||||
|
form.adaptive_max_skip && form.adaptive_max_skip > 1
|
||||||
|
? form.adaptive_max_skip
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(payload) });
|
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
@@ -256,7 +299,7 @@
|
|||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
try {
|
try {
|
||||||
const d = new Date(dateStr);
|
const d = parseDate(dateStr);
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
} catch (e) { console.warn('Date format error:', e); return ''; }
|
} catch (e) { console.warn('Date format error:', e); return ''; }
|
||||||
}
|
}
|
||||||
@@ -339,8 +382,19 @@
|
|||||||
if (ttTesting[key]) return;
|
if (ttTesting[key]) return;
|
||||||
ttTesting = { ...ttTesting, [key]: testType };
|
ttTesting = { ...ttTesting, [key]: testType };
|
||||||
try {
|
try {
|
||||||
await api(`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`, { method: 'POST' });
|
// The endpoint returns 200 OK with ``{success: false, error: "..."}``
|
||||||
snackSuccess(t('snack.targetTestSent'));
|
// on soft failures (missing template slot, no matching assets,
|
||||||
|
// provider unreachable, etc.), so checking for a thrown exception
|
||||||
|
// is not enough. Surface ``error`` as a snackError when present.
|
||||||
|
const res = await api<{ success?: boolean; error?: string; target?: string }>(
|
||||||
|
`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
if (res && res.success === false) {
|
||||||
|
snackError(res.error || t('common.error'));
|
||||||
|
} else {
|
||||||
|
snackSuccess(t('snack.targetTestSent'));
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
snackError(err.message);
|
snackError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -486,6 +540,15 @@
|
|||||||
onclose={() => { linkWarning = null; }}
|
onclose={() => { linkWarning = null; }}
|
||||||
onautoCreate={autoCreateLinks}
|
onautoCreate={autoCreateLinks}
|
||||||
ondismiss={dismissLinkWarning}
|
ondismiss={dismissLinkWarning}
|
||||||
|
onupdate={(remaining) => {
|
||||||
|
if (!linkWarning) return;
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
linkWarning = null;
|
||||||
|
doSave();
|
||||||
|
} else {
|
||||||
|
linkWarning = { ...linkWarning, albums: remaining };
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
|
|||||||
@@ -1,17 +1,50 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { snackError, snackSuccess } from '$lib/stores/snackbar.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
|
|
||||||
|
interface AlbumIssue { id: string; name: string; issue: string }
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
linkWarning: { albums: any[]; providerId: number } | null;
|
linkWarning: { albums: AlbumIssue[]; providerId: number } | null;
|
||||||
linkCreating: boolean;
|
linkCreating: boolean;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
onautoCreate: () => void;
|
onautoCreate: () => void;
|
||||||
ondismiss: () => void;
|
ondismiss: () => void;
|
||||||
|
/** Called with the updated warning list after a per-row replace. */
|
||||||
|
onupdate?: (albums: AlbumIssue[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss }: Props = $props();
|
let { linkWarning, linkCreating, onclose, onautoCreate, ondismiss, onupdate }: Props = $props();
|
||||||
|
|
||||||
|
/** Per-row loading state for the "Replace" button. */
|
||||||
|
let replacing = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expired and password-protected links can't be repaired in place — the
|
||||||
|
* Immich API has no "reset" endpoint. The only remedy is to recreate the
|
||||||
|
* link (which the backend does by POSTing a new one and returning it).
|
||||||
|
* We surface the action per-row so users don't have to leave the form.
|
||||||
|
*/
|
||||||
|
async function replaceOne(album: AlbumIssue) {
|
||||||
|
if (!linkWarning) return;
|
||||||
|
replacing = { ...replacing, [album.id]: true };
|
||||||
|
try {
|
||||||
|
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ replace: true }),
|
||||||
|
});
|
||||||
|
snackSuccess(t('notificationTracker.createdLinks').replace('{count}', '1'));
|
||||||
|
const remaining = linkWarning.albums.filter(a => a.id !== album.id);
|
||||||
|
if (onupdate) onupdate(remaining);
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(t('notificationTracker.linkReplaceFailed').replace('{name}', album.name) + ': ' + err.message);
|
||||||
|
} finally {
|
||||||
|
replacing = { ...replacing, [album.id]: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={onclose}>
|
<Modal open={linkWarning !== null} title={t('notificationTracker.missingLinksTitle')} onclose={onclose}>
|
||||||
@@ -19,13 +52,26 @@
|
|||||||
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
|
<p class="text-sm mb-3" style="color: var(--color-muted-foreground);">
|
||||||
{t('notificationTracker.missingLinksDesc')}
|
{t('notificationTracker.missingLinksDesc')}
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-1.5 mb-4 max-h-40 overflow-y-auto">
|
<div class="space-y-1.5 mb-4 max-h-60 overflow-y-auto">
|
||||||
{#each linkWarning.albums as album}
|
{#each linkWarning.albums as album}
|
||||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
<div class="flex items-center justify-between gap-2 text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||||
<span class="font-medium">{album.name}</span>
|
<div class="flex-1 min-w-0">
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
<span class="font-medium truncate block">{album.name}</span>
|
||||||
|
{#if album.issue === 'password-protected'}
|
||||||
|
<span class="text-[10px] block" style="color: var(--color-muted-foreground);">
|
||||||
|
{t('notificationTracker.linkPasswordProtectedNote')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs px-1.5 py-0.5 rounded shrink-0 {album.issue === 'expired' ? 'bg-[var(--color-error-bg)] text-[var(--color-error-fg)]' : album.issue === 'password-protected' ? 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||||
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
|
{album.issue === 'expired' ? t('notificationTracker.expired') : album.issue === 'password-protected' ? t('notificationTracker.passwordProtected') : t('notificationTracker.noLink')}
|
||||||
</span>
|
</span>
|
||||||
|
{#if album.issue === 'expired' || album.issue === 'password-protected'}
|
||||||
|
<button type="button" onclick={() => replaceOne(album)} disabled={replacing[album.id]}
|
||||||
|
class="text-xs px-2 py-1 rounded border border-[var(--color-border)] hover:bg-[var(--color-muted)] disabled:opacity-50 shrink-0">
|
||||||
|
{replacing[album.id] ? t('notificationTracker.linkReplacing') : t('notificationTracker.linkReplace')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,13 @@
|
|||||||
testMenuOpen: string | null;
|
testMenuOpen: string | null;
|
||||||
testMenuStyle: string;
|
testMenuStyle: string;
|
||||||
ttTesting: Record<string, string>;
|
ttTesting: Record<string, string>;
|
||||||
testTypes: { key: string; icon: string; labelKey: string }[];
|
/**
|
||||||
|
* When `disabledReason` is set, the button is rendered greyed out with a
|
||||||
|
* tooltip pointing the user at the missing setting (e.g. "Enable Periodic
|
||||||
|
* Summary in Tracking Config first"). Clicking is blocked — clicking an
|
||||||
|
* unconfigured test would have surfaced as a silent server-side skip.
|
||||||
|
*/
|
||||||
|
testTypes: { key: string; icon: string; labelKey: string; disabledReason?: string }[];
|
||||||
ontest: (ttId: number, testType: string) => void;
|
ontest: (ttId: number, testType: string) => void;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
}
|
}
|
||||||
@@ -20,18 +26,27 @@
|
|||||||
onclick={onclose}
|
onclick={onclose}
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}>
|
onkeydown={(e) => { if (e.key === 'Escape') onclose(); }}>
|
||||||
</div>
|
</div>
|
||||||
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:10rem;">
|
<div style="{testMenuStyle} background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; box-shadow:0 10px 25px rgba(0,0,0,0.3); padding:0.25rem; min-width:12rem;">
|
||||||
{#each testTypes as tt}
|
{#each testTypes as tt}
|
||||||
|
{@const busy = !!ttTesting[`${testMenuOpen}_${tt.key}`]}
|
||||||
|
{@const blocked = !!tt.disabledReason}
|
||||||
<button
|
<button
|
||||||
onclick={() => ontest(Number(testMenuOpen), tt.key)}
|
onclick={() => { if (!blocked) ontest(Number(testMenuOpen), tt.key); }}
|
||||||
disabled={!!ttTesting[`${testMenuOpen}_${tt.key}`]}
|
disabled={busy || blocked}
|
||||||
|
title={blocked ? t(tt.disabledReason!) : ''}
|
||||||
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
|
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm rounded hover:bg-[var(--color-muted)] transition-colors disabled:opacity-50 text-left">
|
||||||
<MdiIcon name={tt.icon} size={14} />
|
<MdiIcon name={tt.icon} size={14} />
|
||||||
{t(tt.labelKey)}
|
{t(tt.labelKey)}
|
||||||
{#if ttTesting[`${testMenuOpen}_${tt.key}`]}
|
{#if blocked}
|
||||||
|
<MdiIcon name="mdiLock" size={12} />
|
||||||
|
{/if}
|
||||||
|
{#if busy}
|
||||||
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
|
<span class="ml-auto text-xs text-[var(--color-muted-foreground)]">...</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if blocked}
|
||||||
|
<p class="px-3 pb-1 text-[10px]" style="color: var(--color-muted-foreground);">{t(tt.disabledReason!)}</p>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||||
import Hint from '$lib/components/Hint.svelte';
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||||
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
|
||||||
import { getDescriptor } from '$lib/providers';
|
import { getDescriptor } from '$lib/providers';
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
provider_id: number;
|
provider_id: number;
|
||||||
collection_ids: string[];
|
collection_ids: string[];
|
||||||
scan_interval: number;
|
scan_interval: number;
|
||||||
batch_duration: number;
|
adaptive_max_skip: number | null;
|
||||||
default_tracking_config_id: number;
|
default_tracking_config_id: number;
|
||||||
default_template_config_id: number;
|
default_template_config_id: number;
|
||||||
filters: Record<string, any>;
|
filters: Record<string, any>;
|
||||||
@@ -167,19 +168,19 @@
|
|||||||
class="text-xs text-[var(--color-primary)] hover:underline mt-1">+ {t('notificationTracker.addVariable')}</button>
|
class="text-xs text-[var(--color-primary)] hover:underline mt-1">+ {t('notificationTracker.addVariable')}</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if !isWebhook}
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
{#if !isWebhook}
|
|
||||||
<div>
|
<div>
|
||||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
<div>
|
<div>
|
||||||
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
|
<label for="trk-adaptive" class="block text-sm font-medium mb-1">{t('notificationTracker.adaptiveMaxSkip')}<Hint text={t('hints.adaptiveMaxSkip')} /></label>
|
||||||
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
<input id="trk-adaptive" type="number" bind:value={form.adaptive_max_skip} min="0" max="10" placeholder={t('notificationTracker.adaptiveMaxSkipPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Default configs -->
|
<!-- Default configs -->
|
||||||
{#if trackingConfigItems.length > 0 || templateConfigItems.length > 0}
|
{#if trackingConfigItems.length > 0 || templateConfigItems.length > 0}
|
||||||
@@ -199,6 +200,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Feature discovery: the periodic/scheduled/memory/quiet-hours controls
|
||||||
|
live on the tracking config, not on the tracker itself. Surface this
|
||||||
|
here so users don't have to stumble onto the feature by reading docs. -->
|
||||||
|
{#if providerType === 'immich'}
|
||||||
|
<div class="flex items-start gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-muted)]/30 px-3 py-2">
|
||||||
|
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
|
||||||
|
<div class="flex-1 text-xs">
|
||||||
|
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
|
||||||
|
<a href={form.default_tracking_config_id
|
||||||
|
? `/tracking-configs?edit=${form.default_tracking_config_id}`
|
||||||
|
: '/tracking-configs'}
|
||||||
|
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
|
||||||
|
<MdiIcon name="mdiArrowRight" size={12} />
|
||||||
|
{t('notificationTracker.openTrackingConfig')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||||
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { providersCache } from '$lib/stores/caches.svelte';
|
import { providersCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
@@ -131,12 +132,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDelete(provider: any) { confirmDelete = provider; }
|
function startDelete(provider: any) { confirmDelete = provider; }
|
||||||
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
async function doDelete() {
|
async function doDelete() {
|
||||||
if (!confirmDelete) return;
|
if (!confirmDelete) return;
|
||||||
const id = confirmDelete.id;
|
const id = confirmDelete.id;
|
||||||
confirmDelete = null;
|
confirmDelete = null;
|
||||||
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
|
try { await api(`/providers/${id}`, { method: 'DELETE' }); providersCache.invalidate(); await load(); snackSuccess(t('snack.providerDeleted')); }
|
||||||
catch (err: any) { error = err.message; snackError(err.message); }
|
catch (err: any) {
|
||||||
|
const bb = getBlockedBy(err);
|
||||||
|
if (bb) { blockedBy = bb; return; }
|
||||||
|
error = err.message; snackError(err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -280,6 +286,8 @@
|
|||||||
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
|
<ConfirmModal open={!!confirmDelete} message={t('providers.confirmDelete')}
|
||||||
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
||||||
|
|
||||||
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.health-dot {
|
.health-dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { api } from '$lib/api';
|
import { api, parseDate } from '$lib/api';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
import type { WebhookPayloadLog } from '$lib/types';
|
import type { WebhookPayloadLog } from '$lib/types';
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
return new Date(iso).toLocaleString();
|
return parseDate(iso).toLocaleString();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -9,25 +9,69 @@
|
|||||||
import Hint from '$lib/components/Hint.svelte';
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
|
||||||
|
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
|
|
||||||
|
interface CacheBucketStats {
|
||||||
|
count: number;
|
||||||
|
total_size_bytes: number;
|
||||||
|
oldest: string | null;
|
||||||
|
newest: string | null;
|
||||||
|
}
|
||||||
|
interface CacheStats {
|
||||||
|
url: CacheBucketStats;
|
||||||
|
asset: CacheBucketStats;
|
||||||
|
}
|
||||||
|
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let clearingCache = $state(false);
|
||||||
|
let confirmClearCache = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let settings = $state({
|
let settings = $state({
|
||||||
external_url: '',
|
external_url: '',
|
||||||
telegram_webhook_secret: '',
|
telegram_webhook_secret: '',
|
||||||
telegram_cache_ttl_hours: '48',
|
telegram_cache_ttl_hours: '720',
|
||||||
|
telegram_asset_cache_max_entries: '5000',
|
||||||
supported_locales: 'en,ru',
|
supported_locales: 'en,ru',
|
||||||
|
timezone: 'UTC',
|
||||||
|
log_level: 'INFO',
|
||||||
|
log_format: 'text',
|
||||||
|
log_levels: '',
|
||||||
});
|
});
|
||||||
|
let cacheStats = $state<CacheStats | null>(null);
|
||||||
|
|
||||||
|
async function loadCacheStats() {
|
||||||
|
try {
|
||||||
|
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
|
||||||
|
} catch { cacheStats = null; }
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
settings = await api('/settings');
|
settings = await api('/settings');
|
||||||
|
await loadCacheStats();
|
||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||||
finally { loaded = true; }
|
finally { loaded = true; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let i = 0;
|
||||||
|
let v = bytes;
|
||||||
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||||
|
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTs(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||||
|
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
saving = true; error = '';
|
saving = true; error = '';
|
||||||
try {
|
try {
|
||||||
@@ -36,6 +80,17 @@
|
|||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function clearTelegramCache() {
|
||||||
|
confirmClearCache = false;
|
||||||
|
clearingCache = true;
|
||||||
|
try {
|
||||||
|
await api('/settings/telegram-cache/clear', { method: 'POST' });
|
||||||
|
snackSuccess(t('settings.clearCacheDone'));
|
||||||
|
await loadCacheStats();
|
||||||
|
} catch (err: any) { snackError(err.message); }
|
||||||
|
clearingCache = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader title={t('settings.title')} description={t('settings.description')} />
|
<PageHeader title={t('settings.title')} description={t('settings.description')} />
|
||||||
@@ -57,6 +112,10 @@
|
|||||||
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
|
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
|
||||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
||||||
|
<TimezoneSelector bind:value={settings.timezone} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -69,14 +128,68 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
|
||||||
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder={t('providers.optional')}
|
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
|
||||||
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="1" max="720"
|
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
|
||||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
|
||||||
|
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
|
||||||
|
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
|
||||||
|
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
|
||||||
|
{#each [
|
||||||
|
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
|
||||||
|
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
|
||||||
|
] as bucket}
|
||||||
|
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="font-medium">{bucket.label}</span>
|
||||||
|
{#if bucket.data && bucket.data.count > 0}
|
||||||
|
<span>
|
||||||
|
<span class="font-mono">{bucket.data.count}</span>
|
||||||
|
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
|
||||||
|
{#if bucket.data.total_size_bytes > 0}
|
||||||
|
<span style="color: var(--color-muted-foreground);"> · </span>
|
||||||
|
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
|
||||||
|
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
|
||||||
|
{#if bucket.data.oldest}
|
||||||
|
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
|
||||||
|
{/if}
|
||||||
|
{#if bucket.data.newest}
|
||||||
|
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
|
||||||
|
<MdiIcon name="mdiDeleteSweep" size={16} />
|
||||||
|
{clearingCache ? t('common.loading') : t('settings.clearCache')}
|
||||||
|
</button>
|
||||||
|
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -88,9 +201,42 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
|
||||||
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr"
|
<LocaleSelector bind:value={settings.supported_locales} />
|
||||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Logging section -->
|
||||||
|
<Card>
|
||||||
|
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<MdiIcon name="mdiTextBoxOutline" size={18} />
|
||||||
|
{t('settings.logging')}
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1">{t('settings.logLevel')}<Hint text={t('settings.logLevelHint')} /></label>
|
||||||
|
<select bind:value={settings.log_level}
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||||
|
<option value="DEBUG">DEBUG</option>
|
||||||
|
<option value="INFO">INFO</option>
|
||||||
|
<option value="WARNING">WARNING</option>
|
||||||
|
<option value="ERROR">ERROR</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium mb-1">{t('settings.logFormat')}<Hint text={t('settings.logFormatHint')} /></label>
|
||||||
|
<select bind:value={settings.log_format}
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||||
|
<option value="text">text</option>
|
||||||
|
<option value="json">json</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-xs font-medium mb-1">{t('settings.logLevels')}<Hint text={t('settings.logLevelsHint')} /></label>
|
||||||
|
<input bind:value={settings.log_levels}
|
||||||
|
placeholder="sqlalchemy.engine=WARNING,notify_bridge_core.notifications.telegram.client=DEBUG"
|
||||||
|
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -99,4 +245,12 @@
|
|||||||
{saving ? t('common.loading') : t('common.save')}
|
{saving ? t('common.loading') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal open={confirmClearCache}
|
||||||
|
title={t('settings.clearCacheConfirmTitle')}
|
||||||
|
message={t('settings.clearCacheConfirm')}
|
||||||
|
confirmLabel={t('settings.clearCacheConfirmBtn')}
|
||||||
|
confirmIcon="mdiDeleteSweep"
|
||||||
|
onconfirm={clearTelegramCache}
|
||||||
|
oncancel={() => confirmClearCache = false} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api, fetchAuth } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import Card from '$lib/components/Card.svelte';
|
import Card from '$lib/components/Card.svelte';
|
||||||
import Loading from '$lib/components/Loading.svelte';
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import Hint from '$lib/components/Hint.svelte';
|
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
@@ -60,15 +59,23 @@
|
|||||||
let backupFiles = $state<any[]>([]);
|
let backupFiles = $state<any[]>([]);
|
||||||
let loadingFiles = $state(false);
|
let loadingFiles = $state(false);
|
||||||
let confirmDeleteFile = $state('');
|
let confirmDeleteFile = $state('');
|
||||||
|
let creatingBackup = $state(false);
|
||||||
|
|
||||||
|
// --- Pending restore state ---
|
||||||
|
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
|
||||||
|
let postRestoreModalOpen = $state(false);
|
||||||
|
let restartingOverlay = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const [settings, files] = await Promise.all([
|
const [settings, files, p] = await Promise.all([
|
||||||
api('/backup/scheduled'),
|
api('/backup/scheduled'),
|
||||||
api('/backup/files'),
|
api('/backup/files'),
|
||||||
|
api('/backup/pending-restore'),
|
||||||
]);
|
]);
|
||||||
scheduledSettings = settings;
|
scheduledSettings = settings;
|
||||||
backupFiles = files;
|
backupFiles = files;
|
||||||
|
pending = p;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
snackError(err.message);
|
snackError(err.message);
|
||||||
@@ -77,6 +84,53 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function cancelPending() {
|
||||||
|
try {
|
||||||
|
await api('/backup/pending-restore', { method: 'DELETE' });
|
||||||
|
snackSuccess(t('backup.pendingCancelled'));
|
||||||
|
pending = null;
|
||||||
|
} catch (err: any) { snackError(err.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyAndRestart() {
|
||||||
|
try {
|
||||||
|
await api('/backup/apply-restart', { method: 'POST' });
|
||||||
|
restartingOverlay = true;
|
||||||
|
// Poll /health until the new instance is up
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let attempts = 0;
|
||||||
|
const poll = async () => {
|
||||||
|
attempts += 1;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/health');
|
||||||
|
if (res.ok && Date.now() - startedAt > 2000) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* still down */ }
|
||||||
|
if (attempts < 120) setTimeout(poll, 1000);
|
||||||
|
};
|
||||||
|
setTimeout(poll, 1500);
|
||||||
|
} catch (err: any) {
|
||||||
|
restartingOverlay = false;
|
||||||
|
snackError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createManualBackup() {
|
||||||
|
creatingBackup = true;
|
||||||
|
try {
|
||||||
|
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
|
||||||
|
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
|
||||||
|
snackSuccess(t('backup.manualCreated'));
|
||||||
|
await refreshFiles();
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message);
|
||||||
|
} finally {
|
||||||
|
creatingBackup = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Export ---
|
// --- Export ---
|
||||||
async function doExport() {
|
async function doExport() {
|
||||||
if (exportSecrets === 'include') {
|
if (exportSecrets === 'include') {
|
||||||
@@ -120,16 +174,7 @@
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', importFile);
|
formData.append('file', importFile);
|
||||||
const token = localStorage.getItem('access_token');
|
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
|
||||||
const res = await fetch('/api/backup/validate', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
|
||||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
validationResult = await res.json();
|
validationResult = await res.json();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
snackError(err.message);
|
snackError(err.message);
|
||||||
@@ -151,18 +196,15 @@
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', importFile);
|
formData.append('file', importFile);
|
||||||
const token = localStorage.getItem('access_token');
|
const res = await fetchAuth(`/backup/prepare-restore?conflict_mode=${importConflict}`, {
|
||||||
const res = await fetch(`/api/backup/import?conflict_mode=${importConflict}`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
|
||||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
importResult = await res.json();
|
importResult = await res.json();
|
||||||
snackSuccess(t('backup.importSuccess'));
|
pending = importResult;
|
||||||
|
snackSuccess(t('backup.restorePrepared'));
|
||||||
|
postRestoreModalOpen = true;
|
||||||
|
importFile = null;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
snackError(err.message);
|
snackError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -256,6 +298,33 @@
|
|||||||
<Loading />
|
<Loading />
|
||||||
{:else}
|
{:else}
|
||||||
<ErrorBanner message={error} />
|
<ErrorBanner message={error} />
|
||||||
|
|
||||||
|
{#if pending?.pending}
|
||||||
|
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
|
||||||
|
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
|
||||||
|
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
|
||||||
|
<MdiIcon name="mdiClockAlert" size={20} />
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 min-w-[12rem] text-sm">
|
||||||
|
<div class="font-medium">{t('backup.pendingTitle')}</div>
|
||||||
|
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
|
||||||
|
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
{#if pending.supervised}
|
||||||
|
<Button size="sm" onclick={applyAndRestart}>
|
||||||
|
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<button onclick={cancelPending}
|
||||||
|
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
||||||
<!-- Export Section -->
|
<!-- Export Section -->
|
||||||
@@ -502,9 +571,14 @@
|
|||||||
<MdiIcon name="mdiFolder" size={18} />
|
<MdiIcon name="mdiFolder" size={18} />
|
||||||
{t('backup.savedFiles')}
|
{t('backup.savedFiles')}
|
||||||
</h3>
|
</h3>
|
||||||
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
<div class="flex items-center gap-2">
|
||||||
<MdiIcon name="mdiRefresh" size={14} />
|
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
|
||||||
</button>
|
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
|
||||||
|
</Button>
|
||||||
|
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
||||||
|
<MdiIcon name="mdiRefresh" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if backupFiles.length === 0}
|
{#if backupFiles.length === 0}
|
||||||
@@ -568,3 +642,69 @@
|
|||||||
onconfirm={() => deleteFile(confirmDeleteFile)}
|
onconfirm={() => deleteFile(confirmDeleteFile)}
|
||||||
oncancel={() => confirmDeleteFile = ''}
|
oncancel={() => confirmDeleteFile = ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Post-restore modal: Apply now or later -->
|
||||||
|
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
|
||||||
|
{#if postRestoreModalOpen && pending?.pending}
|
||||||
|
<div class="post-restore-backdrop"
|
||||||
|
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
|
||||||
|
onclick={() => postRestoreModalOpen = false}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
|
||||||
|
role="presentation">
|
||||||
|
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
|
||||||
|
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
|
||||||
|
onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="flex items-start gap-3 mb-4">
|
||||||
|
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
|
||||||
|
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
|
||||||
|
<MdiIcon name="mdiClockAlert" size={22} />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
|
||||||
|
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end flex-wrap">
|
||||||
|
<button onclick={() => postRestoreModalOpen = false}
|
||||||
|
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
||||||
|
{t('backup.applyLater')}
|
||||||
|
</button>
|
||||||
|
{#if pending.supervised}
|
||||||
|
<Button size="sm" onclick={() => { postRestoreModalOpen = false; applyAndRestart(); }}>
|
||||||
|
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Restarting overlay -->
|
||||||
|
{#if restartingOverlay}
|
||||||
|
<div role="alert" aria-live="assertive"
|
||||||
|
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
|
||||||
|
<div class="text-center p-6" style="color: var(--color-foreground);">
|
||||||
|
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
|
||||||
|
<MdiIcon name="mdiRestart" size={40} />
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
|
||||||
|
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.restart-spinner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: restart-spin 1.2s linear infinite;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
@keyframes restart-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t, getLocale } from '$lib/i18n';
|
import { t, getLocale } from '$lib/i18n';
|
||||||
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -114,7 +115,7 @@
|
|||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
name: '', icon: '', bot_id: 0, bot_token: '',
|
name: '', icon: '', bot_id: 0, bot_token: '',
|
||||||
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
|
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
|
||||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
|
disable_url_preview: true, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
|
||||||
// Discord/Slack shared settings
|
// Discord/Slack shared settings
|
||||||
username: '',
|
username: '',
|
||||||
// ntfy shared settings
|
// ntfy shared settings
|
||||||
@@ -193,6 +194,10 @@
|
|||||||
function openNew() {
|
function openNew() {
|
||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
formType = activeType || 'telegram';
|
formType = activeType || 'telegram';
|
||||||
|
// Auto-select first available bot of the chosen type
|
||||||
|
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
|
||||||
|
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
|
||||||
|
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
|
||||||
editing = null;
|
editing = null;
|
||||||
showTelegramSettings = false;
|
showTelegramSettings = false;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
@@ -289,12 +294,15 @@
|
|||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) { snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
async function remove(id: number) {
|
async function remove(id: number) {
|
||||||
try {
|
try {
|
||||||
await api(`/targets/${id}`, { method: 'DELETE' });
|
await api(`/targets/${id}`, { method: 'DELETE' });
|
||||||
await load();
|
await load();
|
||||||
snackSuccess(t('snack.targetDeleted'));
|
snackSuccess(t('snack.targetDeleted'));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
const bb = getBlockedBy(err);
|
||||||
|
if (bb) { blockedBy = bb; return; }
|
||||||
error = err.message;
|
error = err.message;
|
||||||
snackError(err.message);
|
snackError(err.message);
|
||||||
}
|
}
|
||||||
@@ -529,3 +537,5 @@
|
|||||||
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
|
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
|
||||||
oncancel={() => confirmDeleteReceiver = null}
|
oncancel={() => confirmDeleteReceiver = null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { sanitizePreview } from '$lib/sanitize';
|
import { sanitizePreview } from '$lib/sanitize';
|
||||||
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||||
@@ -41,6 +42,17 @@
|
|||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||||
|
/**
|
||||||
|
* Reset-to-default confirmation prompt. ``kind: 'slot'`` confirms a
|
||||||
|
* single-slot reset (slotKey populated); ``'all'`` confirms a full
|
||||||
|
* locale-scoped wipe. Split from confirmDelete so the two flows can
|
||||||
|
* coexist without stomping each other's state mid-dialog.
|
||||||
|
*/
|
||||||
|
let confirmReset = $state<{
|
||||||
|
kind: 'slot' | 'all';
|
||||||
|
slotKey?: string;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
let slotPreview = $state<Record<string, string>>({});
|
let slotPreview = $state<Record<string, string>>({});
|
||||||
let slotErrors = $state<Record<string, string>>({});
|
let slotErrors = $state<Record<string, string>>({});
|
||||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||||
@@ -205,10 +217,48 @@
|
|||||||
supportedLocalesCache.fetch(),
|
supportedLocalesCache.fetch(),
|
||||||
]);
|
]);
|
||||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||||
finally { loaded = true; highlightFromUrl(); }
|
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; refreshDateFormatPreview(); }
|
/**
|
||||||
|
* Respond to ``?edit_slot=<slot_name>&provider=<type>`` deep-links from
|
||||||
|
* other pages (currently the tracking-configs Preview-template modal).
|
||||||
|
* Picks the first visible config matching ``provider``, opens it in edit
|
||||||
|
* mode, and pre-expands the target slot. Strips the param from the URL so
|
||||||
|
* a subsequent reload doesn't reopen the form unexpectedly.
|
||||||
|
*/
|
||||||
|
function handleDeepLink() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const slot = params.get('edit_slot');
|
||||||
|
if (!slot) return;
|
||||||
|
const provider = params.get('provider') || '';
|
||||||
|
const target = allTemplateConfigs.find(
|
||||||
|
c => !provider || c.provider_type === provider,
|
||||||
|
);
|
||||||
|
// Strip the deep-link param so reload/back doesn't replay it.
|
||||||
|
params.delete('edit_slot');
|
||||||
|
const qs = params.toString();
|
||||||
|
window.history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''));
|
||||||
|
if (!target) {
|
||||||
|
snackError(t('templateConfig.deepLinkNoConfig'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
edit(target);
|
||||||
|
expandedSlots = new Set([slot]);
|
||||||
|
// Scroll the slot into view once the form has rendered.
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.getElementById(`slot-${slot}`);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
form = defaultForm();
|
||||||
|
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
|
||||||
|
editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
||||||
|
refreshDateFormatPreview();
|
||||||
|
}
|
||||||
function edit(c: TemplateConfig) {
|
function edit(c: TemplateConfig) {
|
||||||
form = {
|
form = {
|
||||||
provider_type: c.provider_type,
|
provider_type: c.provider_type,
|
||||||
@@ -235,6 +285,65 @@
|
|||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the user to confirm a reset. The actual fetch+replace runs in
|
||||||
|
* ``performReset`` after the ConfirmModal's onconfirm fires. Split into
|
||||||
|
* two steps so we can use the app-wide ConfirmModal (consistent look,
|
||||||
|
* keyboard handling) instead of ``window.confirm`` (blocks the page).
|
||||||
|
*/
|
||||||
|
function resetSlotToDefault(slotKey: string) {
|
||||||
|
if (!form.provider_type) return;
|
||||||
|
confirmReset = {
|
||||||
|
kind: 'slot',
|
||||||
|
slotKey,
|
||||||
|
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAllToDefaults() {
|
||||||
|
if (!form.provider_type) return;
|
||||||
|
confirmReset = {
|
||||||
|
kind: 'all',
|
||||||
|
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performReset() {
|
||||||
|
if (!confirmReset || !form.provider_type) return;
|
||||||
|
const { kind, slotKey } = confirmReset;
|
||||||
|
confirmReset = null;
|
||||||
|
try {
|
||||||
|
if (kind === 'slot' && slotKey) {
|
||||||
|
const res = await api<Record<string, Record<string, string>>>(
|
||||||
|
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||||
|
);
|
||||||
|
const text = res?.[slotKey]?.[activeLocale];
|
||||||
|
if (!text) {
|
||||||
|
snackError(t('templateConfig.resetNoDefault'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSlotValue(slotKey, text);
|
||||||
|
validateSlot(slotKey, text, true);
|
||||||
|
} else {
|
||||||
|
const res = await api<Record<string, Record<string, string>>>(
|
||||||
|
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
|
||||||
|
);
|
||||||
|
// Replace current-locale slots; leave other locales' values untouched.
|
||||||
|
const nextSlots = { ...form.slots };
|
||||||
|
for (const [key, localeMap] of Object.entries(res || {})) {
|
||||||
|
const text = localeMap?.[activeLocale];
|
||||||
|
if (text === undefined) continue;
|
||||||
|
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
|
||||||
|
}
|
||||||
|
form.slots = nextSlots;
|
||||||
|
refreshAllPreviews();
|
||||||
|
}
|
||||||
|
snackSuccess(t('templateConfig.resetApplied'));
|
||||||
|
} catch (err: any) {
|
||||||
|
snackError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clone(c: TemplateConfig) {
|
function clone(c: TemplateConfig) {
|
||||||
form = {
|
form = {
|
||||||
provider_type: c.provider_type,
|
provider_type: c.provider_type,
|
||||||
@@ -253,12 +362,17 @@
|
|||||||
setTimeout(() => refreshAllPreviews(), 100);
|
setTimeout(() => refreshAllPreviews(), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
function remove(id: number) {
|
function remove(id: number) {
|
||||||
confirmDelete = {
|
confirmDelete = {
|
||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
||||||
catch (err: any) { error = err.message; snackError(err.message); }
|
catch (err: any) {
|
||||||
|
const bb = getBlockedBy(err);
|
||||||
|
if (bb) { blockedBy = bb; return; }
|
||||||
|
error = err.message; snackError(err.message);
|
||||||
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -310,7 +424,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Locale tabs -->
|
<!-- Locale tabs -->
|
||||||
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
<div class="flex items-center gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||||
{#each LOCALES as loc}
|
{#each LOCALES as loc}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
|
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
|
||||||
@@ -318,6 +432,14 @@
|
|||||||
{loc.toUpperCase()}
|
{loc.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if form.provider_type}
|
||||||
|
<button type="button" onclick={resetAllToDefaults}
|
||||||
|
title={t('templateConfig.resetAllToDefaults')}
|
||||||
|
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
|
||||||
|
<MdiIcon name="mdiRefresh" size={12} />
|
||||||
|
{t('templateConfig.resetAllToDefaults')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slot filter -->
|
<!-- Slot filter -->
|
||||||
@@ -350,6 +472,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div id="slot-{slot.key}">
|
||||||
<CollapsibleSlot
|
<CollapsibleSlot
|
||||||
label={slot.key}
|
label={slot.key}
|
||||||
description={slot.description || t(`templateConfig.${slot.label}`, slot.label)}
|
description={slot.description || t(`templateConfig.${slot.label}`, slot.label)}
|
||||||
@@ -368,6 +491,11 @@
|
|||||||
<button type="button" onclick={() => showVarsFor = slot.key}
|
<button type="button" onclick={() => showVarsFor = slot.key}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button type="button" onclick={() => resetSlotToDefault(slot.key)}
|
||||||
|
title={t('templateConfig.resetToDefault')}
|
||||||
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
|
||||||
|
{t('templateConfig.resetToDefault')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]}
|
{#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]}
|
||||||
@@ -386,6 +514,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</CollapsibleSlot>
|
</CollapsibleSlot>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -455,6 +584,16 @@
|
|||||||
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
|
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
|
||||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|
||||||
|
<ConfirmModal open={confirmReset !== null}
|
||||||
|
title={t('templateConfig.resetToDefault')}
|
||||||
|
message={confirmReset?.message || ''}
|
||||||
|
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
|
||||||
|
confirmIcon="mdiRefresh"
|
||||||
|
onconfirm={performReset}
|
||||||
|
oncancel={() => confirmReset = null} />
|
||||||
|
|
||||||
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|
||||||
<!-- Variables reference modal -->
|
<!-- Variables reference modal -->
|
||||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
|
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
|
||||||
{#if showVarsFor && varsRef[showVarsFor]}
|
{#if showVarsFor && varsRef[showVarsFor]}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { api } from '$lib/api';
|
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
|
||||||
|
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { trackingConfigsCache } from '$lib/stores/caches.svelte';
|
import { trackingConfigsCache } from '$lib/stores/caches.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -11,6 +12,9 @@
|
|||||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||||
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
import { sanitizePreview } from '$lib/sanitize';
|
||||||
|
import { supportedLocalesCache } from '$lib/stores/caches.svelte';
|
||||||
import Hint from '$lib/components/Hint.svelte';
|
import Hint from '$lib/components/Hint.svelte';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||||
@@ -21,13 +25,150 @@
|
|||||||
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
import { getDescriptor, buildTrackingFormDefaults } from '$lib/providers';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import Button from '$lib/components/Button.svelte';
|
import Button from '$lib/components/Button.svelte';
|
||||||
import type { TrackingConfig } from '$lib/types';
|
|
||||||
|
|
||||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||||
const gridItemSources: Record<string, () => any[]> = {
|
const gridItemSources: Record<string, () => any[]> = {
|
||||||
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
|
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
|
||||||
|
* dispatch accepts. Matched on blur for time-list fields; invalid values
|
||||||
|
* are surfaced inline next to the input.
|
||||||
|
*/
|
||||||
|
const TIME_LIST_RE = /^\s*(?:[01]\d|2[0-3]):[0-5]\d(?:\s*,\s*(?:[01]\d|2[0-3]):[0-5]\d)*\s*$/;
|
||||||
|
|
||||||
|
/** Per-field error messages surfaced inline under time-list inputs. */
|
||||||
|
let timeListErrors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
/** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */
|
||||||
|
function normalizeTimeList(key: string) {
|
||||||
|
const raw = String(form[key] ?? '').trim();
|
||||||
|
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
|
||||||
|
if (!TIME_LIST_RE.test(raw)) {
|
||||||
|
// Try a lenient normalization: split on commas, zero-pad each part.
|
||||||
|
const parts = raw.split(',').map(p => p.trim()).filter(Boolean);
|
||||||
|
const fixed: string[] = [];
|
||||||
|
let ok = true;
|
||||||
|
for (const p of parts) {
|
||||||
|
const m = /^(\d{1,2}):(\d{1,2})$/.exec(p);
|
||||||
|
if (!m) { ok = false; break; }
|
||||||
|
const hh = Number(m[1]);
|
||||||
|
const mm = Number(m[2]);
|
||||||
|
if (!Number.isFinite(hh) || !Number.isFinite(mm) || hh < 0 || hh > 23 || mm < 0 || mm > 59) { ok = false; break; }
|
||||||
|
fixed.push(`${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
form[key] = fixed.join(',');
|
||||||
|
timeListErrors = { ...timeListErrors, [key]: '' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timeListErrors = { ...timeListErrors, [key]: t('trackingConfig.invalidTimeList') };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Canonicalise spacing.
|
||||||
|
form[key] = raw.split(',').map(s => s.trim()).join(',');
|
||||||
|
timeListErrors = { ...timeListErrors, [key]: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
|
||||||
|
* minutes — adjust times" when start equals end. Handles overnight ranges
|
||||||
|
* (start > end) correctly.
|
||||||
|
*/
|
||||||
|
function quietHoursPreview(start: string, end: string): string {
|
||||||
|
if (!start || !end) return '';
|
||||||
|
const [sh, sm] = start.split(':').map(Number);
|
||||||
|
const [eh, em] = end.split(':').map(Number);
|
||||||
|
if (![sh, sm, eh, em].every(Number.isFinite)) return '';
|
||||||
|
const sMin = sh * 60 + sm;
|
||||||
|
const eMin = eh * 60 + em;
|
||||||
|
if (sMin === eMin) return t('trackingConfig.quietHoursZero');
|
||||||
|
const overnight = sMin > eMin;
|
||||||
|
const span = overnight ? (24 * 60 - sMin) + eMin : eMin - sMin;
|
||||||
|
const h = Math.floor(span / 60);
|
||||||
|
const m = span % 60;
|
||||||
|
const dur = m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||||
|
const arrow = overnight
|
||||||
|
? `${start} → ${end} ${t('trackingConfig.nextDay')}`
|
||||||
|
: `${start} → ${end}`;
|
||||||
|
return `${arrow} (${dur})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotoTemplateConfig(slotName: string) {
|
||||||
|
// Deep-link to the template configs page: pass the slot as a query
|
||||||
|
// param (``edit_slot``) so the destination can auto-open the first
|
||||||
|
// matching config in edit mode and expand that slot. Plain hashes
|
||||||
|
// like ``#slot-X`` were a no-op because slots don't exist in the DOM
|
||||||
|
// until a config is being edited.
|
||||||
|
const u = new URL('/template-configs', window.location.origin);
|
||||||
|
u.searchParams.set('provider', 'immich');
|
||||||
|
u.searchParams.set('edit_slot', slotName);
|
||||||
|
window.location.href = u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline preview of the shipped default template for a scheduled/periodic/
|
||||||
|
* memory slot. Using the shipped default (not a tracker's current template)
|
||||||
|
* keeps this scoped to the tracking-config page — which has no concept of
|
||||||
|
* which TemplateConfig a given tracker uses. Users who want to edit the
|
||||||
|
* actual config can click "Edit template" in the modal footer.
|
||||||
|
*
|
||||||
|
* ``previewLocale`` is modal-scoped so switching tabs only refetches for
|
||||||
|
* this preview — the user's UI locale (and other previews) are untouched.
|
||||||
|
*/
|
||||||
|
let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null);
|
||||||
|
let previewLoading = $state(false);
|
||||||
|
let previewLocales = $derived(supportedLocalesCache.items);
|
||||||
|
|
||||||
|
async function openTemplatePreview(slotName: string) {
|
||||||
|
await supportedLocalesCache.fetch();
|
||||||
|
const initialLocale = previewLocales.includes('en') ? 'en' : (previewLocales[0] || 'en');
|
||||||
|
await renderPreviewFor(slotName, initialLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPreviewFor(slotName: string, locale: string) {
|
||||||
|
previewLoading = true;
|
||||||
|
try {
|
||||||
|
const defaults = await api<Record<string, Record<string, string>>>(
|
||||||
|
`/template-configs/defaults?provider_type=immich&slot_name=${encodeURIComponent(slotName)}&locale=${encodeURIComponent(locale)}`,
|
||||||
|
);
|
||||||
|
const template = defaults?.[slotName]?.[locale];
|
||||||
|
if (!template) {
|
||||||
|
previewModal = { slotName, rendered: '', error: t('templateConfig.resetNoDefault'), locale };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await api<{ rendered?: string; error?: string }>(
|
||||||
|
'/template-configs/preview-raw',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
template,
|
||||||
|
target_type: 'telegram',
|
||||||
|
date_format: '%d.%m.%Y, %H:%M UTC',
|
||||||
|
date_only_format: '%d.%m.%Y',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
previewModal = {
|
||||||
|
slotName,
|
||||||
|
rendered: res?.rendered || '',
|
||||||
|
error: res?.error || '',
|
||||||
|
locale,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
previewModal = { slotName, rendered: '', error: err.message, locale };
|
||||||
|
} finally {
|
||||||
|
previewLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOT_FOR_SECTION: Record<string, string> = {
|
||||||
|
periodic: 'periodic_summary_message',
|
||||||
|
scheduled: 'scheduled_assets_message',
|
||||||
|
memory: 'memory_mode_message',
|
||||||
|
};
|
||||||
|
|
||||||
let allConfigs = $derived(trackingConfigsCache.items);
|
let allConfigs = $derived(trackingConfigsCache.items);
|
||||||
let filterText = $state('');
|
let filterText = $state('');
|
||||||
let filterType = $state('');
|
let filterType = $state('');
|
||||||
@@ -53,7 +194,25 @@
|
|||||||
async function load() {
|
async function load() {
|
||||||
try { await trackingConfigsCache.fetch(true); }
|
try { await trackingConfigsCache.fetch(true); }
|
||||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||||
finally { loaded = true; highlightFromUrl(); }
|
finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-page deep-link: ``/tracking-configs?edit=<id>`` auto-opens that
|
||||||
|
// config in edit mode. Used by the Notification Tracker form's "Open
|
||||||
|
// Tracking Config" link so users land directly on the right editor
|
||||||
|
// instead of the generic list. Strips the param afterwards so a browser
|
||||||
|
// refresh doesn't re-open the modal.
|
||||||
|
function _openEditFromUrl() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const editId = params.get('edit');
|
||||||
|
if (!editId) return;
|
||||||
|
const match = allConfigs.find(c => String(c.id) === editId);
|
||||||
|
if (match) edit(match);
|
||||||
|
params.delete('edit');
|
||||||
|
const qs = params.toString();
|
||||||
|
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
|
||||||
|
window.history.replaceState(null, '', cleanUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||||
@@ -72,12 +231,17 @@
|
|||||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let blockedBy = $state<BlockedByDetail | null>(null);
|
||||||
function remove(id: number) {
|
function remove(id: number) {
|
||||||
confirmDelete = {
|
confirmDelete = {
|
||||||
id,
|
id,
|
||||||
onconfirm: async () => {
|
onconfirm: async () => {
|
||||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
||||||
catch (err: any) { error = err.message; snackError(err.message); }
|
catch (err: any) {
|
||||||
|
const bb = getBlockedBy(err);
|
||||||
|
if (bb) { blockedBy = bb; return; }
|
||||||
|
error = err.message; snackError(err.message);
|
||||||
|
}
|
||||||
finally { confirmDelete = null; }
|
finally { confirmDelete = null; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -155,10 +319,20 @@
|
|||||||
{t(section.legend)}
|
{t(section.legend)}
|
||||||
{#if section.legendHint}<Hint text={t(section.legendHint)} />{/if}
|
{#if section.legendHint}<Hint text={t(section.legendHint)} />{/if}
|
||||||
</legend>
|
</legend>
|
||||||
<label class="flex items-center gap-2 text-sm mt-1">
|
<div class="flex items-center justify-between mt-1">
|
||||||
<input type="checkbox" bind:checked={form[section.enabledField]} />
|
<label class="flex items-center gap-2 text-sm">
|
||||||
{t('trackingConfig.enabled')}
|
<input type="checkbox" bind:checked={form[section.enabledField]} />
|
||||||
</label>
|
{t('trackingConfig.enabled')}
|
||||||
|
</label>
|
||||||
|
{#if SLOT_FOR_SECTION[section.key]}
|
||||||
|
<button type="button" onclick={() => openTemplatePreview(SLOT_FOR_SECTION[section.key])}
|
||||||
|
class="text-xs text-[var(--color-primary)] hover:underline inline-flex items-center gap-1"
|
||||||
|
disabled={previewLoading}>
|
||||||
|
<MdiIcon name="mdiEyeOutline" size={14} />
|
||||||
|
{t('trackingConfig.previewTemplate')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if form[section.enabledField]}
|
{#if form[section.enabledField]}
|
||||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||||
{#each section.fields as field (field.key)}
|
{#each section.fields as field (field.key)}
|
||||||
@@ -175,14 +349,32 @@
|
|||||||
{:else if field.type === 'grid-select' && field.gridItems}
|
{:else if field.type === 'grid-select' && field.gridItems}
|
||||||
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
||||||
{:else}
|
{:else}
|
||||||
<input type={field.key.includes('date') ? 'date' : field.key.includes('times') ? 'text' : 'number'}
|
{@const inputType = field.type === 'date' ? 'date'
|
||||||
|
: field.type === 'time' ? 'time'
|
||||||
|
: field.type === 'time-list' ? 'text'
|
||||||
|
: 'number'}
|
||||||
|
{@const hasError = field.type === 'time-list' && !!timeListErrors[field.key]}
|
||||||
|
<input type={inputType}
|
||||||
bind:value={form[field.key]} min={field.min} max={field.max}
|
bind:value={form[field.key]} min={field.min} max={field.max}
|
||||||
placeholder={field.key.includes('times') ? String(field.defaultValue ?? '') : ''}
|
onblur={field.type === 'time-list' && field.validateFormat ? () => normalizeTimeList(field.key) : undefined}
|
||||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
placeholder={field.type === 'time-list' || field.type === 'time' ? String(typeof field.defaultValue === 'function' ? field.defaultValue() : (field.defaultValue ?? '')) : ''}
|
||||||
|
class="w-full px-2 py-1 border rounded-md text-sm bg-[var(--color-background)] {hasError ? 'border-[var(--color-error-fg)]' : 'border-[var(--color-border)]'}" />
|
||||||
|
{#if field.inlineHelp}
|
||||||
|
<p class="text-[10px] mt-0.5" style="color: var(--color-muted-foreground);">{t(field.inlineHelp)}</p>
|
||||||
|
{/if}
|
||||||
|
{#if hasError}
|
||||||
|
<p class="text-[10px] mt-0.5" style="color: var(--color-error-fg);">{timeListErrors[field.key]}</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{#if section.key === 'quietHours' && form.quiet_hours_start && form.quiet_hours_end}
|
||||||
|
<p class="text-xs mt-2" style="color: var(--color-muted-foreground);">
|
||||||
|
<MdiIcon name="mdiWeatherNight" size={12} />
|
||||||
|
{quietHoursPreview(String(form.quiet_hours_start), String(form.quiet_hours_end))}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -257,7 +449,65 @@
|
|||||||
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
|
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
|
||||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|
||||||
|
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||||
|
|
||||||
|
<Modal open={previewModal !== null}
|
||||||
|
title={previewModal ? `${t('trackingConfig.previewTemplate')} — ${previewModal.slotName}` : ''}
|
||||||
|
onclose={() => previewModal = null}>
|
||||||
|
{#if previewModal}
|
||||||
|
{#if previewLocales.length > 1}
|
||||||
|
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
|
||||||
|
{#each previewLocales as loc}
|
||||||
|
<button type="button"
|
||||||
|
onclick={() => renderPreviewFor(previewModal!.slotName, loc)}
|
||||||
|
disabled={previewLoading}
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {previewModal.locale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'} disabled:opacity-50">
|
||||||
|
{loc.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p class="text-xs mb-3" style="color: var(--color-muted-foreground);">
|
||||||
|
{t('trackingConfig.previewSampleNote')}
|
||||||
|
</p>
|
||||||
|
<!-- Keep the prior rendered/error box mounted while refetching on locale
|
||||||
|
switch — just dim it. Unmounting and replacing with a small "…"
|
||||||
|
placeholder caused a one-frame layout jump as the modal shrank and
|
||||||
|
then re-expanded. -->
|
||||||
|
<div class="relative mb-3" style="opacity: {previewLoading ? 0.5 : 1}; transition: opacity 0.15s ease;">
|
||||||
|
{#if previewModal.error}
|
||||||
|
<div class="p-3 rounded text-xs" style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||||
|
{previewModal.error}
|
||||||
|
</div>
|
||||||
|
{:else if previewModal.rendered}
|
||||||
|
<div class="p-3 bg-[var(--color-muted)] rounded text-sm preview-html">
|
||||||
|
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(previewModal.rendered)}</pre>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);">…</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end mt-3">
|
||||||
|
<button type="button" onclick={() => { const s = previewModal!.slotName; previewModal = null; gotoTemplateConfig(s); }}
|
||||||
|
class="text-xs px-3 py-1.5 rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)]">
|
||||||
|
{t('trackingConfig.editTemplate')}
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={() => previewModal = null}
|
||||||
|
class="text-xs px-3 py-1.5 rounded-md bg-[var(--color-primary)] text-[var(--color-primary-foreground)]">
|
||||||
|
{t('common.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:global(.preview-html a) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
:global(.preview-html a:hover) {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api, parseDate } from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { getAuth } from '$lib/auth.svelte';
|
import { getAuth } from '$lib/auth.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
@@ -31,6 +31,13 @@
|
|||||||
let resetMsg = $state('');
|
let resetMsg = $state('');
|
||||||
let resetSuccess = $state(false);
|
let resetSuccess = $state(false);
|
||||||
|
|
||||||
|
// Admin edit username/role
|
||||||
|
let editUserId = $state<number | null>(null);
|
||||||
|
let editUsername = $state('');
|
||||||
|
let editRole = $state('user');
|
||||||
|
let editMsg = $state('');
|
||||||
|
let editSuccess = $state(false);
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
async function load() {
|
async function load() {
|
||||||
try { users = await api('/users'); }
|
try { users = await api('/users'); }
|
||||||
@@ -56,6 +63,20 @@
|
|||||||
function openResetPassword(user: any) {
|
function openResetPassword(user: any) {
|
||||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||||
}
|
}
|
||||||
|
function openEditUser(user: any) {
|
||||||
|
editUserId = user.id; editUsername = user.username; editRole = user.role; editMsg = ''; editSuccess = false;
|
||||||
|
}
|
||||||
|
async function saveUserEdit(e: SubmitEvent) {
|
||||||
|
e.preventDefault(); editMsg = ''; editSuccess = false;
|
||||||
|
try {
|
||||||
|
await api(`/users/${editUserId}`, { method: 'PATCH', body: JSON.stringify({ username: editUsername, role: editRole }) });
|
||||||
|
editMsg = t('snack.userUpdated');
|
||||||
|
editSuccess = true;
|
||||||
|
snackSuccess(editMsg);
|
||||||
|
await load();
|
||||||
|
setTimeout(() => { editUserId = null; editMsg = ''; editSuccess = false; }, 1200);
|
||||||
|
} catch (err: any) { editMsg = err.message; editSuccess = false; snackError(err.message); }
|
||||||
|
}
|
||||||
async function resetUserPassword(e: SubmitEvent) {
|
async function resetUserPassword(e: SubmitEvent) {
|
||||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||||
try {
|
try {
|
||||||
@@ -111,9 +132,10 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{user.username}</p>
|
<p class="font-medium">{user.username}</p>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p>
|
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role === 'admin' ? t('users.roleAdmin') : t('users.roleUser')} · {t('users.joined')} {parseDate(user.created_at).toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
<IconButton icon="mdiPencil" title={t('users.edit')} onclick={() => openEditUser(user)} />
|
||||||
{#if user.id !== auth.user?.id}
|
{#if user.id !== auth.user?.id}
|
||||||
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
||||||
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
||||||
@@ -144,5 +166,28 @@
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Admin edit username/role modal -->
|
||||||
|
<Modal open={editUserId !== null} title={t('users.edit')} onclose={() => { editUserId = null; editMsg = ''; editSuccess = false; }}>
|
||||||
|
<form onsubmit={saveUserEdit} class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label for="edit-username" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||||
|
<input id="edit-username" bind:value={editUsername} required
|
||||||
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="edit-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||||
|
<select id="edit-role" bind:value={editRole}
|
||||||
|
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||||
|
<option value="user">{t('users.roleUser')}</option>
|
||||||
|
<option value="admin">{t('users.roleAdmin')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{#if editMsg}
|
||||||
|
<p class="text-sm {editSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{editMsg}</p>
|
||||||
|
{/if}
|
||||||
|
<Button type="submit" class="w-full">{t('common.save')}</Button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-core"
|
name = "notify-bridge-core"
|
||||||
version = "0.1.0"
|
version = "0.5.2"
|
||||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Request-scoped ContextVars that propagate into log records.
|
||||||
|
|
||||||
|
The server sets these at entry points (Telegram webhook, scheduler dispatch,
|
||||||
|
REST call) and they propagate through async calls automatically. A
|
||||||
|
``LogRecordFactory`` installed by ``notify_bridge_server.logging_setup``
|
||||||
|
reads them so every log line is tagged (``request_id``, ``command``,
|
||||||
|
``chat_id``, ``bot_id``, ``dispatch_id``) without each call site having
|
||||||
|
to pass the values explicitly.
|
||||||
|
|
||||||
|
Kept in ``notify_bridge_core`` so core modules (``TelegramClient``,
|
||||||
|
``NotificationDispatcher``) can *set* additional context (e.g. a
|
||||||
|
``dispatch_id``) without depending on the server package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
|
||||||
|
command_var: ContextVar[str | None] = ContextVar("command", default=None)
|
||||||
|
chat_id_var: ContextVar[str | None] = ContextVar("chat_id", default=None)
|
||||||
|
bot_id_var: ContextVar[int | None] = ContextVar("bot_id", default=None)
|
||||||
|
dispatch_id_var: ContextVar[str | None] = ContextVar("dispatch_id", default=None)
|
||||||
|
|
||||||
|
_VAR_MAP: dict[str, ContextVar[Any]] = {
|
||||||
|
"request_id": request_id_var,
|
||||||
|
"command": command_var,
|
||||||
|
"chat_id": chat_id_var,
|
||||||
|
"bot_id": bot_id_var,
|
||||||
|
"dispatch_id": dispatch_id_var,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def bind_log_context(**kwargs: Any) -> Iterator[None]:
|
||||||
|
"""Bind the given context fields for the duration of the ``with`` block.
|
||||||
|
|
||||||
|
Unknown keys are ignored so callers can pass whatever they want without
|
||||||
|
an ``if`` ladder. Values are reset on exit even if the block raises.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
``with bind_log_context(request_id="abc", command="random"): ...``
|
||||||
|
"""
|
||||||
|
tokens: list[tuple[ContextVar[Any], Token]] = []
|
||||||
|
try:
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
var = _VAR_MAP.get(key)
|
||||||
|
if var is None:
|
||||||
|
continue
|
||||||
|
tokens.append((var, var.set(value)))
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
for var, tok in tokens:
|
||||||
|
var.reset(tok)
|
||||||
|
|
||||||
|
|
||||||
|
def current_log_context() -> dict[str, Any]:
|
||||||
|
"""Return a snapshot of the currently-bound context values (non-None)."""
|
||||||
|
snap: dict[str, Any] = {}
|
||||||
|
for key, var in _VAR_MAP.items():
|
||||||
|
val = var.get()
|
||||||
|
if val is not None:
|
||||||
|
snap[key] = val
|
||||||
|
return snap
|
||||||
@@ -52,22 +52,46 @@ class DiscordClient:
|
|||||||
|
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
_MAX_RETRY_AFTER = 60.0
|
||||||
|
|
||||||
async def _post(self, url: str, payload: dict) -> dict[str, Any]:
|
async def _post(self, url: str, payload: dict) -> dict[str, Any]:
|
||||||
try:
|
"""POST with bounded 429 retry.
|
||||||
async with self._session.post(
|
|
||||||
url, json=payload, headers={"Content-Type": "application/json"}
|
We cap retries at _MAX_RETRIES and the ``Retry-After`` header at
|
||||||
) as resp:
|
_MAX_RETRY_AFTER seconds so a hostile or misbehaving upstream cannot
|
||||||
if resp.status == 429:
|
pin the dispatch task indefinitely.
|
||||||
retry_after = float(resp.headers.get("Retry-After", "2"))
|
"""
|
||||||
_LOGGER.warning("Discord rate limited, retrying after %.1fs", retry_after)
|
for attempt in range(self._MAX_RETRIES + 1):
|
||||||
await asyncio.sleep(retry_after)
|
try:
|
||||||
return await self._post(url, payload)
|
async with self._session.post(
|
||||||
if 200 <= resp.status < 300:
|
url,
|
||||||
return {"success": True}
|
json=payload,
|
||||||
body = await resp.text()
|
headers={"Content-Type": "application/json"},
|
||||||
return {"success": False, "error": f"HTTP {resp.status}: {body[:200]}"}
|
allow_redirects=False,
|
||||||
except aiohttp.ClientError as e:
|
) as resp:
|
||||||
return {"success": False, "error": str(e)}
|
if resp.status == 429 and attempt < self._MAX_RETRIES:
|
||||||
|
try:
|
||||||
|
retry_after = float(resp.headers.get("Retry-After", "2"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
retry_after = 2.0
|
||||||
|
retry_after = max(0.0, min(retry_after, self._MAX_RETRY_AFTER))
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Discord rate limited, retrying after %.1fs (attempt %d/%d)",
|
||||||
|
retry_after, attempt + 1, self._MAX_RETRIES,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(retry_after)
|
||||||
|
continue
|
||||||
|
if 200 <= resp.status < 300:
|
||||||
|
return {"success": True}
|
||||||
|
body = await resp.text()
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"HTTP {resp.status}: {body[:200]}",
|
||||||
|
}
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
return {"success": False, "error": "Rate limited (retries exhausted)"}
|
||||||
|
|
||||||
|
|
||||||
def _split_message(text: str, limit: int) -> list[str]:
|
def _split_message(text: str, limit: int) -> list[str]:
|
||||||
|
|||||||
@@ -3,19 +3,29 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from notify_bridge_core.log_context import bind_log_context, dispatch_id_var
|
||||||
from notify_bridge_core.models.events import ServiceEvent
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
from notify_bridge_core.templates.context import build_template_context
|
from notify_bridge_core.templates.context import build_template_context
|
||||||
from notify_bridge_core.templates.renderer import render_template
|
from notify_bridge_core.templates.renderer import render_template
|
||||||
from .ssrf import UnsafeURLError, validate_outbound_url
|
from .ssrf import UnsafeURLError, avalidate_outbound_url
|
||||||
|
|
||||||
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||||
|
|
||||||
|
# Cap on how many asset downloads run concurrently inside
|
||||||
|
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
|
||||||
|
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
|
||||||
|
# max_asset_size``, which matters on small-RAM Docker hosts when a batch
|
||||||
|
# contains many large videos.
|
||||||
|
_PRELOAD_CONCURRENCY = 6
|
||||||
|
|
||||||
|
|
||||||
def _new_session() -> aiohttp.ClientSession:
|
def _new_session() -> aiohttp.ClientSession:
|
||||||
"""Per-dispatch aiohttp session with a sane default timeout.
|
"""Per-dispatch aiohttp session with a sane default timeout.
|
||||||
@@ -38,6 +48,12 @@ from .receiver import (
|
|||||||
)
|
)
|
||||||
from .telegram.cache import TelegramFileCache
|
from .telegram.cache import TelegramFileCache
|
||||||
from .telegram.client import TelegramClient
|
from .telegram.client import TelegramClient
|
||||||
|
from .telegram.media import (
|
||||||
|
build_telegram_asset_entry,
|
||||||
|
extract_asset_id_from_url,
|
||||||
|
is_asset_cache_key,
|
||||||
|
is_asset_id,
|
||||||
|
)
|
||||||
from .webhook.client import WebhookClient
|
from .webhook.client import WebhookClient
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -69,9 +85,28 @@ class NotificationDispatcher:
|
|||||||
*,
|
*,
|
||||||
url_cache: TelegramFileCache | None = None,
|
url_cache: TelegramFileCache | None = None,
|
||||||
asset_cache: TelegramFileCache | None = None,
|
asset_cache: TelegramFileCache | None = None,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._url_cache = url_cache
|
self._url_cache = url_cache
|
||||||
self._asset_cache = asset_cache
|
self._asset_cache = asset_cache
|
||||||
|
# Optional shared session owned by the caller; when supplied we reuse
|
||||||
|
# its connection pool instead of opening a fresh per-dispatch session
|
||||||
|
# (saves a TLS handshake per outbound call).
|
||||||
|
self._shared_session = session
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def _session_ctx(self) -> AsyncIterator[aiohttp.ClientSession]:
|
||||||
|
"""Yield an aiohttp session, reusing the shared one if provided.
|
||||||
|
|
||||||
|
When a shared session was passed in ``__init__`` we yield it without
|
||||||
|
closing (the caller owns its lifetime). Otherwise we open a
|
||||||
|
short-lived session with our default timeout and close it on exit.
|
||||||
|
"""
|
||||||
|
if self._shared_session is not None and not self._shared_session.closed:
|
||||||
|
yield self._shared_session
|
||||||
|
return
|
||||||
|
async with _new_session() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
async def dispatch(
|
async def dispatch(
|
||||||
self,
|
self,
|
||||||
@@ -82,18 +117,40 @@ class NotificationDispatcher:
|
|||||||
|
|
||||||
Returns list of results (one per target).
|
Returns list of results (one per target).
|
||||||
"""
|
"""
|
||||||
raw_results = await asyncio.gather(
|
# Bind a dispatch_id so every log line emitted by the target sends
|
||||||
*[self._send_to_target(event, t) for t in targets],
|
# (including deep in TelegramClient) can be correlated to the same
|
||||||
return_exceptions=True,
|
# upstream event.
|
||||||
)
|
new_id = dispatch_id_var.get() or f"disp:{uuid.uuid4().hex[:12]}"
|
||||||
results = []
|
|
||||||
for raw in raw_results:
|
with bind_log_context(dispatch_id=new_id):
|
||||||
if isinstance(raw, Exception):
|
_LOGGER.info(
|
||||||
_LOGGER.error("Failed to dispatch to target: %s", raw)
|
"Dispatching event %s (collection=%r) to %d target(s)",
|
||||||
results.append({"success": False, "error": str(raw)})
|
event.event_type.value if hasattr(event.event_type, "value") else event.event_type,
|
||||||
else:
|
getattr(event, "collection_name", None), len(targets),
|
||||||
results.append(raw)
|
)
|
||||||
return results
|
raw_results = await asyncio.gather(
|
||||||
|
*[self._send_to_target(event, t) for t in targets],
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
results = []
|
||||||
|
failures = 0
|
||||||
|
for target, raw in zip(targets, raw_results):
|
||||||
|
if isinstance(raw, Exception):
|
||||||
|
failures += 1
|
||||||
|
_LOGGER.error(
|
||||||
|
"Dispatch to target type=%s failed: %s",
|
||||||
|
target.type, raw, exc_info=raw,
|
||||||
|
)
|
||||||
|
results.append({"success": False, "error": str(raw)})
|
||||||
|
else:
|
||||||
|
if isinstance(raw, dict) and not raw.get("success"):
|
||||||
|
failures += 1
|
||||||
|
results.append(raw)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Dispatch finished: %d target(s), %d failure(s)",
|
||||||
|
len(targets), failures,
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
def _resolve_template(
|
def _resolve_template(
|
||||||
self, event: ServiceEvent, target: TargetConfig, locale: str,
|
self, event: ServiceEvent, target: TargetConfig, locale: str,
|
||||||
@@ -146,6 +203,90 @@ class NotificationDispatcher:
|
|||||||
return await send_method(target, default_message, event)
|
return await send_method(target, default_message, event)
|
||||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||||
|
|
||||||
|
async def _preload_asset_data(
|
||||||
|
self,
|
||||||
|
assets: list[dict[str, Any]],
|
||||||
|
media_assets: list[Any],
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
max_size: int | None,
|
||||||
|
) -> None:
|
||||||
|
"""Download each non-cached asset's bytes once and attach to the entry.
|
||||||
|
|
||||||
|
Three benefits:
|
||||||
|
* ``TelegramClient`` sees ``entry["data"]`` and skips its own download,
|
||||||
|
so we don't fetch each URL twice.
|
||||||
|
* We know the exact upload size, which lets the oversize warning in
|
||||||
|
the rendered text compare against real bytes (for Immich videos,
|
||||||
|
the transcoded ``/video/playback``), not the original ``file_size``.
|
||||||
|
* Assets already in the Telegram file_id cache are skipped, and their
|
||||||
|
stored size (if any) is used to populate ``playback_size`` — so
|
||||||
|
templates see consistent sizes for repeat sends without re-download.
|
||||||
|
|
||||||
|
Entries whose download fails or exceeds ``max_size`` are left without
|
||||||
|
``data``; ``TelegramClient`` will then fall back to its own download
|
||||||
|
path and apply the same checks — no regression, just no preload win.
|
||||||
|
|
||||||
|
Concurrency is bounded by ``_PRELOAD_CONCURRENCY`` so peak memory
|
||||||
|
stays predictable: at most N assets worth of bytes held in RAM at
|
||||||
|
once, regardless of ``max_media_to_send``. Total wall-clock is
|
||||||
|
unchanged for small batches and only marginally slower for large
|
||||||
|
ones (most assets fit in a single RTT and SSL negotiation cost
|
||||||
|
dominates, so 6-way parallelism is sufficient).
|
||||||
|
"""
|
||||||
|
if not assets:
|
||||||
|
return
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY)
|
||||||
|
|
||||||
|
async def _fetch(entry: dict[str, Any], media: Any) -> None:
|
||||||
|
# Cache hit → skip download; populate playback_size from stored size.
|
||||||
|
cache, key = self._cache_for_entry(entry)
|
||||||
|
if cache and key:
|
||||||
|
cached = cache.get(key)
|
||||||
|
if cached and cached.get("file_id"):
|
||||||
|
stored_size = cached.get("size")
|
||||||
|
if stored_size is not None:
|
||||||
|
media.extra["playback_size"] = stored_size
|
||||||
|
return
|
||||||
|
|
||||||
|
url = entry["url"]
|
||||||
|
headers = entry.get("headers") or {}
|
||||||
|
async with sem:
|
||||||
|
try:
|
||||||
|
async with session.get(url, headers=headers) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return
|
||||||
|
data = await resp.read()
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
return
|
||||||
|
if max_size is not None and len(data) > max_size:
|
||||||
|
return
|
||||||
|
entry["data"] = data
|
||||||
|
media.extra["playback_size"] = len(data)
|
||||||
|
|
||||||
|
await asyncio.gather(*(_fetch(e, m) for e, m in zip(assets, media_assets)))
|
||||||
|
|
||||||
|
def _cache_for_entry(
|
||||||
|
self, entry: dict[str, Any],
|
||||||
|
) -> tuple[TelegramFileCache | None, str | None]:
|
||||||
|
"""Resolve (cache, key) for an asset entry — mirrors TelegramClient logic.
|
||||||
|
|
||||||
|
Returns (None, None) if no cache is configured or no key can be derived.
|
||||||
|
"""
|
||||||
|
cache_key = entry.get("cache_key")
|
||||||
|
if cache_key:
|
||||||
|
cache = self._asset_cache if is_asset_cache_key(cache_key) else self._url_cache
|
||||||
|
return cache, cache_key
|
||||||
|
url = entry.get("url")
|
||||||
|
if url:
|
||||||
|
if is_asset_id(url):
|
||||||
|
return self._asset_cache, url
|
||||||
|
extracted = extract_asset_id_from_url(url)
|
||||||
|
if extracted:
|
||||||
|
return self._asset_cache, extracted
|
||||||
|
return self._url_cache, url
|
||||||
|
return None, None
|
||||||
|
|
||||||
async def _send_telegram(
|
async def _send_telegram(
|
||||||
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -170,30 +311,48 @@ class NotificationDispatcher:
|
|||||||
# Prefer internal URL for fetching (LAN speed vs public internet)
|
# Prefer internal URL for fetching (LAN speed vs public internet)
|
||||||
internal_url = (target.provider_internal_url or "").rstrip("/")
|
internal_url = (target.provider_internal_url or "").rstrip("/")
|
||||||
external_url = (target.provider_external_url or "").rstrip("/")
|
external_url = (target.provider_external_url or "").rstrip("/")
|
||||||
provider_urls = [u for u in (internal_url, external_url) if u]
|
|
||||||
assets = []
|
assets = []
|
||||||
|
media_assets: list[Any] = [] # aligned with `assets` for preload
|
||||||
for asset in event.added_assets[:max_media]:
|
for asset in event.added_assets[:max_media]:
|
||||||
url = asset.preview_url or asset.thumbnail_url or asset.full_url
|
url = asset.preview_url or asset.thumbnail_url or asset.full_url
|
||||||
if url:
|
asset_entry = build_telegram_asset_entry(
|
||||||
# Rewrite external URL to internal for faster LAN fetching
|
url=url or "",
|
||||||
if internal_url and external_url and url.startswith(external_url):
|
media_type=asset.type.value,
|
||||||
url = internal_url + url[len(external_url):]
|
api_key=target.provider_api_key,
|
||||||
asset_type = "video" if asset.type.value == "video" else "photo"
|
internal_url=internal_url,
|
||||||
asset_headers = {}
|
external_url=external_url,
|
||||||
if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
|
cache_key=asset.extra.get("cache_key"),
|
||||||
asset_headers["x-api-key"] = target.provider_api_key
|
)
|
||||||
asset_entry: dict[str, Any] = {"url": url, "type": asset_type, "headers": asset_headers}
|
if asset_entry is not None:
|
||||||
# Pass explicit cache_key if set by provider (e.g. Google Photos)
|
|
||||||
if asset.extra.get("cache_key"):
|
|
||||||
asset_entry["cache_key"] = asset.extra["cache_key"]
|
|
||||||
assets.append(asset_entry)
|
assets.append(asset_entry)
|
||||||
|
media_assets.append(asset)
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with _new_session() as session:
|
async with self._session_ctx() as session:
|
||||||
|
# Preload all asset bytes once so (a) TelegramClient can skip its
|
||||||
|
# own download and (b) we know exact upload sizes in time for the
|
||||||
|
# oversize warning in the rendered text.
|
||||||
|
await self._preload_asset_data(assets, media_assets, session, max_size)
|
||||||
|
default_message = self._render_message(event, target, target.locale)
|
||||||
|
|
||||||
|
# Asset cache (when in thumbhash mode) invalidates entries when the
|
||||||
|
# asset's visual content changes. The resolver maps asset id → its
|
||||||
|
# current thumbhash. Providers that expose thumbhash put it in
|
||||||
|
# ``asset.extra["thumbhash"]`` (currently Immich).
|
||||||
|
thumbhash_map = {
|
||||||
|
asset.id: asset.extra.get("thumbhash")
|
||||||
|
for asset in event.added_assets
|
||||||
|
if asset.extra.get("thumbhash")
|
||||||
|
}
|
||||||
|
thumbhash_resolver = (
|
||||||
|
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
|
||||||
|
)
|
||||||
|
|
||||||
client = TelegramClient(
|
client = TelegramClient(
|
||||||
session, bot_token,
|
session, bot_token,
|
||||||
url_cache=self._url_cache,
|
url_cache=self._url_cache,
|
||||||
asset_cache=self._asset_cache,
|
asset_cache=self._asset_cache,
|
||||||
|
thumbhash_resolver=thumbhash_resolver,
|
||||||
)
|
)
|
||||||
|
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
@@ -239,13 +398,13 @@ class NotificationDispatcher:
|
|||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with _new_session() as session:
|
async with self._session_ctx() as session:
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
||||||
results.append({"success": False, "error": "Invalid webhook receiver"})
|
results.append({"success": False, "error": "Invalid webhook receiver"})
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
validate_outbound_url(receiver.url)
|
await avalidate_outbound_url(receiver.url)
|
||||||
except UnsafeURLError as err:
|
except UnsafeURLError as err:
|
||||||
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
||||||
continue
|
continue
|
||||||
@@ -313,14 +472,14 @@ class NotificationDispatcher:
|
|||||||
username = target.config.get("username")
|
username = target.config.get("username")
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with _new_session() as session:
|
async with self._session_ctx() as session:
|
||||||
client = DiscordClient(session)
|
client = DiscordClient(session)
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
||||||
results.append({"success": False, "error": "Invalid discord receiver"})
|
results.append({"success": False, "error": "Invalid discord receiver"})
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
validate_outbound_url(receiver.webhook_url)
|
await avalidate_outbound_url(receiver.webhook_url)
|
||||||
except UnsafeURLError as err:
|
except UnsafeURLError as err:
|
||||||
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
||||||
continue
|
continue
|
||||||
@@ -339,14 +498,14 @@ class NotificationDispatcher:
|
|||||||
username = target.config.get("username")
|
username = target.config.get("username")
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with _new_session() as session:
|
async with self._session_ctx() as session:
|
||||||
client = SlackClient(session)
|
client = SlackClient(session)
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
||||||
results.append({"success": False, "error": "Invalid slack receiver"})
|
results.append({"success": False, "error": "Invalid slack receiver"})
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
validate_outbound_url(receiver.webhook_url)
|
await avalidate_outbound_url(receiver.webhook_url)
|
||||||
except UnsafeURLError as err:
|
except UnsafeURLError as err:
|
||||||
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
results.append({"success": False, "error": f"Unsafe URL: {err}"})
|
||||||
continue
|
continue
|
||||||
@@ -365,14 +524,14 @@ class NotificationDispatcher:
|
|||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
try:
|
try:
|
||||||
validate_outbound_url(server_url)
|
await avalidate_outbound_url(server_url)
|
||||||
except UnsafeURLError as err:
|
except UnsafeURLError as err:
|
||||||
return {"success": False, "error": f"Unsafe ntfy server_url: {err}"}
|
return {"success": False, "error": f"Unsafe ntfy server_url: {err}"}
|
||||||
|
|
||||||
title = f"{event.event_type.value}: {event.collection_name}"
|
title = f"{event.event_type.value}: {event.collection_name}"
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with _new_session() as session:
|
async with self._session_ctx() as session:
|
||||||
client = NtfyClient(session)
|
client = NtfyClient(session)
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
||||||
@@ -396,7 +555,7 @@ class NotificationDispatcher:
|
|||||||
if not homeserver or not access_token:
|
if not homeserver or not access_token:
|
||||||
return {"success": False, "error": "Missing Matrix homeserver_url or access_token"}
|
return {"success": False, "error": "Missing Matrix homeserver_url or access_token"}
|
||||||
try:
|
try:
|
||||||
validate_outbound_url(homeserver)
|
await avalidate_outbound_url(homeserver)
|
||||||
except UnsafeURLError as err:
|
except UnsafeURLError as err:
|
||||||
return {"success": False, "error": f"Unsafe matrix homeserver_url: {err}"}
|
return {"success": False, "error": f"Unsafe matrix homeserver_url: {err}"}
|
||||||
|
|
||||||
@@ -404,7 +563,7 @@ class NotificationDispatcher:
|
|||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with _new_session() as session:
|
async with self._session_ctx() as session:
|
||||||
client = MatrixClient(session, homeserver, access_token)
|
client = MatrixClient(session, homeserver, access_token)
|
||||||
for receiver in target.receivers:
|
for receiver in target.receivers:
|
||||||
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ class MatrixClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with self._session.put(url, json=body, headers=headers) as resp:
|
async with self._session.put(
|
||||||
|
url, json=body, headers=headers, allow_redirects=False,
|
||||||
|
) as resp:
|
||||||
if 200 <= resp.status < 300:
|
if 200 <= resp.status < 300:
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
resp_body = await resp.text()
|
resp_body = await resp.text()
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ class NtfyClient:
|
|||||||
headers["Authorization"] = f"Bearer {auth_token}"
|
headers["Authorization"] = f"Bearer {auth_token}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with self._session.post(url, json=payload, headers=headers) as resp:
|
async with self._session.post(
|
||||||
|
url, json=payload, headers=headers, allow_redirects=False,
|
||||||
|
) as resp:
|
||||||
if 200 <= resp.status < 300:
|
if 200 <= resp.status < 300:
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
body = await resp.text()
|
body = await resp.text()
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class SlackClient:
|
|||||||
webhook_url,
|
webhook_url,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
|
allow_redirects=False,
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status == 429:
|
if resp.status == 429:
|
||||||
_LOGGER.warning("Slack rate limited")
|
_LOGGER.warning("Slack rate limited")
|
||||||
|
|||||||
@@ -12,14 +12,25 @@ development against localhost services.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1"
|
_ALLOW_PRIVATE = os.environ.get("NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS") == "1"
|
||||||
_ALLOWED_SCHEMES = {"http", "https"}
|
_ALLOWED_SCHEMES = {"http", "https"}
|
||||||
|
|
||||||
|
if _ALLOW_PRIVATE: # pragma: no cover — operator-visible banner
|
||||||
|
_LOGGER.warning(
|
||||||
|
"SSRF guard: private-URL bypass ENABLED "
|
||||||
|
"(NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1). Requests to RFC1918 / "
|
||||||
|
"loopback / link-local hosts will be permitted."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UnsafeURLError(ValueError):
|
class UnsafeURLError(ValueError):
|
||||||
"""Raised when a URL targets a disallowed network destination."""
|
"""Raised when a URL targets a disallowed network destination."""
|
||||||
@@ -36,13 +47,7 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_outbound_url(url: str) -> str:
|
def _check_scheme_host(url: str) -> tuple[str, str]:
|
||||||
"""Validate ``url`` is safe to fetch; returns the URL on success.
|
|
||||||
|
|
||||||
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP
|
|
||||||
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``)
|
|
||||||
private addresses are permitted but the scheme check still applies.
|
|
||||||
"""
|
|
||||||
if not isinstance(url, str) or not url:
|
if not isinstance(url, str) or not url:
|
||||||
raise UnsafeURLError("URL is empty")
|
raise UnsafeURLError("URL is empty")
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -51,6 +56,31 @@ def validate_outbound_url(url: str) -> str:
|
|||||||
host = parsed.hostname
|
host = parsed.hostname
|
||||||
if not host:
|
if not host:
|
||||||
raise UnsafeURLError("URL has no host")
|
raise UnsafeURLError("URL has no host")
|
||||||
|
return parsed.scheme, host
|
||||||
|
|
||||||
|
|
||||||
|
def _check_resolved_addresses(host: str, infos: list[tuple]) -> None:
|
||||||
|
for info in infos:
|
||||||
|
sockaddr = info[4]
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(sockaddr[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if _is_blocked_ip(ip):
|
||||||
|
raise UnsafeURLError(f"Host {host} resolves to blocked address {ip}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_outbound_url(url: str) -> str:
|
||||||
|
"""Validate ``url`` is safe to fetch; returns the URL on success.
|
||||||
|
|
||||||
|
Raises :class:`UnsafeURLError` when the scheme, host, or resolved IP
|
||||||
|
is not allowed. In development (``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1``)
|
||||||
|
private addresses are permitted but the scheme check still applies.
|
||||||
|
|
||||||
|
Synchronous; uses blocking ``socket.getaddrinfo``. Prefer
|
||||||
|
:func:`avalidate_outbound_url` from async code paths.
|
||||||
|
"""
|
||||||
|
_, host = _check_scheme_host(url)
|
||||||
|
|
||||||
if _ALLOW_PRIVATE:
|
if _ALLOW_PRIVATE:
|
||||||
return url
|
return url
|
||||||
@@ -64,17 +94,37 @@ def validate_outbound_url(url: str) -> str:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Hostname — resolve and reject if any resolution is in a blocked range.
|
|
||||||
try:
|
try:
|
||||||
infos = socket.getaddrinfo(host, None)
|
infos = socket.getaddrinfo(host, None)
|
||||||
except socket.gaierror as exc:
|
except socket.gaierror as exc:
|
||||||
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
|
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
|
||||||
for info in infos:
|
_check_resolved_addresses(host, infos)
|
||||||
sockaddr = info[4]
|
return url
|
||||||
try:
|
|
||||||
ip = ipaddress.ip_address(sockaddr[0])
|
|
||||||
except ValueError:
|
async def avalidate_outbound_url(url: str) -> str:
|
||||||
continue
|
"""Async variant that resolves DNS via the running loop's resolver.
|
||||||
if _is_blocked_ip(ip):
|
|
||||||
raise UnsafeURLError(f"Host {host} resolves to blocked address {ip}")
|
Use this from ``async def`` code paths to avoid blocking the event
|
||||||
|
loop on DNS lookups.
|
||||||
|
"""
|
||||||
|
_, host = _check_scheme_host(url)
|
||||||
|
|
||||||
|
if _ALLOW_PRIVATE:
|
||||||
|
return url
|
||||||
|
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
if _is_blocked_ip(ip):
|
||||||
|
raise UnsafeURLError(f"Host {host} is in a blocked range")
|
||||||
|
return url
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
try:
|
||||||
|
infos = await loop.getaddrinfo(host, None)
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise UnsafeURLError(f"DNS resolution failed for {host}") from exc
|
||||||
|
_check_resolved_addresses(host, infos)
|
||||||
return url
|
return url
|
||||||
|
|||||||
@@ -11,56 +11,69 @@ from notify_bridge_core.storage import StorageBackend
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
|
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
|
||||||
|
DEFAULT_MAX_ENTRIES = 5000
|
||||||
|
|
||||||
|
|
||||||
class TelegramFileCache:
|
class TelegramFileCache:
|
||||||
"""Cache for Telegram file_ids to avoid re-uploading media.
|
"""Cache for Telegram file_ids to avoid re-uploading media.
|
||||||
|
|
||||||
Supports two validation modes:
|
Two complementary invalidation strategies, usable together or separately:
|
||||||
- TTL mode (default): entries expire after a configured time-to-live
|
- TTL: entries expire after ``ttl_seconds``. Set to 0 to disable TTL
|
||||||
- Thumbhash mode: entries validated by comparing stored thumbhash with current
|
(cache essentially forever, subject only to the size cap).
|
||||||
"""
|
- Thumbhash mode: entries are validated on read by comparing the stored
|
||||||
|
thumbhash with the one the caller supplies; a mismatch drops the entry.
|
||||||
|
Intended for content-addressable assets (e.g. Immich) where re-uploads
|
||||||
|
should be triggered by visual change, not elapsed time.
|
||||||
|
|
||||||
THUMBHASH_MAX_ENTRIES = 2000
|
``max_entries`` always applies as an LRU size cap (by ``cached_at``).
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
backend: StorageBackend,
|
backend: StorageBackend,
|
||||||
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
|
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
|
||||||
use_thumbhash: bool = False,
|
use_thumbhash: bool = False,
|
||||||
|
max_entries: int = DEFAULT_MAX_ENTRIES,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._backend = backend
|
self._backend = backend
|
||||||
self._data: dict[str, Any] | None = None
|
self._data: dict[str, Any] | None = None
|
||||||
self._ttl_seconds = ttl_seconds
|
self._ttl_seconds = ttl_seconds
|
||||||
self._use_thumbhash = use_thumbhash
|
self._use_thumbhash = use_thumbhash
|
||||||
|
self._max_entries = max_entries
|
||||||
|
|
||||||
async def async_load(self) -> None:
|
async def async_load(self) -> None:
|
||||||
self._data = await self._backend.load() or {"files": {}}
|
self._data = await self._backend.load() or {"files": {}}
|
||||||
await self._cleanup_expired()
|
await self._cleanup_expired()
|
||||||
|
|
||||||
async def _cleanup_expired(self) -> None:
|
async def _cleanup_expired(self) -> None:
|
||||||
if self._use_thumbhash:
|
|
||||||
files = self._data.get("files", {}) if self._data else {}
|
|
||||||
if len(files) > self.THUMBHASH_MAX_ENTRIES:
|
|
||||||
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
|
|
||||||
for key in sorted_keys[: len(files) - self.THUMBHASH_MAX_ENTRIES]:
|
|
||||||
del files[key]
|
|
||||||
await self._backend.save(self._data)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self._data or "files" not in self._data:
|
if not self._data or "files" not in self._data:
|
||||||
return
|
return
|
||||||
|
files = self._data["files"]
|
||||||
|
changed = False
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
# TTL sweep — only when TTL validation is active (i.e. no thumbhash
|
||||||
expired = [
|
# mode and a positive TTL). In thumbhash mode we rely entirely on
|
||||||
url for url, entry in self._data["files"].items()
|
# content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
|
||||||
if entry.get("cached_at") and
|
# cache forever, subject only to the size cap.
|
||||||
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
|
if not self._use_thumbhash and self._ttl_seconds > 0:
|
||||||
]
|
now = datetime.now(timezone.utc)
|
||||||
|
expired = [
|
||||||
if expired:
|
url for url, entry in files.items()
|
||||||
|
if entry.get("cached_at") and
|
||||||
|
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
|
||||||
|
]
|
||||||
for key in expired:
|
for key in expired:
|
||||||
del self._data["files"][key]
|
del files[key]
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# LRU cap — always enforced. Evicts oldest-cached entries first.
|
||||||
|
if self._max_entries > 0 and len(files) > self._max_entries:
|
||||||
|
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
|
||||||
|
for key in sorted_keys[: len(files) - self._max_entries]:
|
||||||
|
del files[key]
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
await self._backend.save(self._data)
|
await self._backend.save(self._data)
|
||||||
|
|
||||||
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
|
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
|
||||||
@@ -77,17 +90,26 @@ class TelegramFileCache:
|
|||||||
if stored and stored != thumbhash:
|
if stored and stored != thumbhash:
|
||||||
del self._data["files"][key]
|
del self._data["files"][key]
|
||||||
return None
|
return None
|
||||||
else:
|
elif self._ttl_seconds > 0:
|
||||||
cached_at_str = entry.get("cached_at")
|
cached_at_str = entry.get("cached_at")
|
||||||
if cached_at_str:
|
if cached_at_str:
|
||||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
|
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
|
||||||
if age > self._ttl_seconds:
|
if age > self._ttl_seconds:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return {"file_id": entry.get("file_id"), "type": entry.get("type")}
|
return {
|
||||||
|
"file_id": entry.get("file_id"),
|
||||||
|
"type": entry.get("type"),
|
||||||
|
"size": entry.get("size"), # bytes of what was uploaded; None for legacy entries
|
||||||
|
}
|
||||||
|
|
||||||
async def async_set(
|
async def async_set(
|
||||||
self, key: str, file_id: str, media_type: str, thumbhash: str | None = None
|
self,
|
||||||
|
key: str,
|
||||||
|
file_id: str,
|
||||||
|
media_type: str,
|
||||||
|
thumbhash: str | None = None,
|
||||||
|
size: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self._data is None:
|
if self._data is None:
|
||||||
self._data = {"files": {}}
|
self._data = {"files": {}}
|
||||||
@@ -99,20 +121,34 @@ class TelegramFileCache:
|
|||||||
}
|
}
|
||||||
if thumbhash is not None:
|
if thumbhash is not None:
|
||||||
entry["thumbhash"] = thumbhash
|
entry["thumbhash"] = thumbhash
|
||||||
|
if size is not None:
|
||||||
|
entry["size"] = size
|
||||||
|
|
||||||
self._data["files"][key] = entry
|
self._data["files"][key] = entry
|
||||||
await self._backend.save(self._data)
|
await self._backend.save(self._data)
|
||||||
|
|
||||||
async def async_set_many(
|
async def async_set_many(
|
||||||
self, entries: list[tuple[str, str, str, str | None]]
|
self,
|
||||||
|
entries: list[tuple[str, str, str, str | None] | tuple[str, str, str, str | None, int | None]],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Bulk-store file_id cache entries.
|
||||||
|
|
||||||
|
Each entry is a tuple ``(key, file_id, media_type, thumbhash[, size])``.
|
||||||
|
The size element is optional for backward compatibility with callers
|
||||||
|
that don't yet track upload sizes.
|
||||||
|
"""
|
||||||
if not entries:
|
if not entries:
|
||||||
return
|
return
|
||||||
if self._data is None:
|
if self._data is None:
|
||||||
self._data = {"files": {}}
|
self._data = {"files": {}}
|
||||||
|
|
||||||
now_iso = datetime.now(timezone.utc).isoformat()
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
for key, file_id, media_type, thumbhash in entries:
|
for item in entries:
|
||||||
|
if len(item) == 5:
|
||||||
|
key, file_id, media_type, thumbhash, size = item
|
||||||
|
else:
|
||||||
|
key, file_id, media_type, thumbhash = item
|
||||||
|
size = None
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
"file_id": file_id,
|
"file_id": file_id,
|
||||||
"type": media_type,
|
"type": media_type,
|
||||||
@@ -120,6 +156,8 @@ class TelegramFileCache:
|
|||||||
}
|
}
|
||||||
if thumbhash is not None:
|
if thumbhash is not None:
|
||||||
entry["thumbhash"] = thumbhash
|
entry["thumbhash"] = thumbhash
|
||||||
|
if size is not None:
|
||||||
|
entry["size"] = size
|
||||||
self._data["files"][key] = entry
|
self._data["files"][key] = entry
|
||||||
|
|
||||||
await self._backend.save(self._data)
|
await self._backend.save(self._data)
|
||||||
@@ -127,3 +165,32 @@ class TelegramFileCache:
|
|||||||
async def async_remove(self) -> None:
|
async def async_remove(self) -> None:
|
||||||
await self._backend.remove()
|
await self._backend.remove()
|
||||||
self._data = None
|
self._data = None
|
||||||
|
|
||||||
|
def stats(self) -> dict[str, Any]:
|
||||||
|
"""Return summary stats about the current cache contents.
|
||||||
|
|
||||||
|
Includes the number of cached entries, total tracked size in bytes
|
||||||
|
(only counts entries with a recorded ``size``), and the oldest /
|
||||||
|
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
|
||||||
|
"""
|
||||||
|
files = self._data.get("files", {}) if self._data else {}
|
||||||
|
count = len(files)
|
||||||
|
total_size = 0
|
||||||
|
oldest: str | None = None
|
||||||
|
newest: str | None = None
|
||||||
|
for entry in files.values():
|
||||||
|
size = entry.get("size")
|
||||||
|
if isinstance(size, int):
|
||||||
|
total_size += size
|
||||||
|
cached_at = entry.get("cached_at")
|
||||||
|
if cached_at:
|
||||||
|
if oldest is None or cached_at < oldest:
|
||||||
|
oldest = cached_at
|
||||||
|
if newest is None or cached_at > newest:
|
||||||
|
newest = cached_at
|
||||||
|
return {
|
||||||
|
"count": count,
|
||||||
|
"total_size_bytes": total_size,
|
||||||
|
"oldest": oldest,
|
||||||
|
"newest": newest,
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -29,6 +30,36 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
NotificationResult = dict[str, Any]
|
NotificationResult = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _MediaKind:
|
||||||
|
"""Describes one Telegram media kind (photo / video / document).
|
||||||
|
|
||||||
|
Used by the generic _send_from_cache / _upload_media helpers so the three
|
||||||
|
send paths don't have to duplicate endpoint, field-name, or response-shape
|
||||||
|
boilerplate.
|
||||||
|
"""
|
||||||
|
api_method: str # "sendPhoto" / "sendVideo" / "sendDocument"
|
||||||
|
form_field: str # "photo" / "video" / "document"
|
||||||
|
cache_type: str # same string stored in cache entries
|
||||||
|
default_filename: str # "photo.jpg" / "video.mp4" / "file"
|
||||||
|
default_content_type: str
|
||||||
|
|
||||||
|
def file_id_from_result(self, result: dict[str, Any]) -> str | None:
|
||||||
|
obj = result.get(self.form_field)
|
||||||
|
if isinstance(obj, list) and obj:
|
||||||
|
# sendPhoto returns a list of resolutions; the largest is last.
|
||||||
|
last = obj[-1]
|
||||||
|
return last.get("file_id") if isinstance(last, dict) else None
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get("file_id")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_PHOTO_KIND = _MediaKind("sendPhoto", "photo", "photo", "photo.jpg", "image/jpeg")
|
||||||
|
_VIDEO_KIND = _MediaKind("sendVideo", "video", "video", "video.mp4", "video/mp4")
|
||||||
|
_DOCUMENT_KIND = _MediaKind("sendDocument", "document", "document", "file", "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
class TelegramClient:
|
class TelegramClient:
|
||||||
"""Async Telegram Bot API client for sending notifications with media."""
|
"""Async Telegram Bot API client for sending notifications with media."""
|
||||||
|
|
||||||
@@ -58,6 +89,18 @@ class TelegramClient:
|
|||||||
self, url: str | None, cache_key: str | None = None,
|
self, url: str | None, cache_key: str | None = None,
|
||||||
) -> tuple[TelegramFileCache | None, str | None, str | None]:
|
) -> tuple[TelegramFileCache | None, str | None, str | None]:
|
||||||
if cache_key:
|
if cache_key:
|
||||||
|
# Route asset-UUID cache keys to the asset cache so single-item
|
||||||
|
# sends hit the same cache the media-group path uses. Without
|
||||||
|
# this, a command returning one photo stored file_ids in the
|
||||||
|
# URL cache and a command returning multiple stored them in
|
||||||
|
# the asset cache — repeated sends never hit.
|
||||||
|
if is_asset_cache_key(cache_key):
|
||||||
|
bare_id = asset_id_from_cache_key(cache_key)
|
||||||
|
thumbhash = (
|
||||||
|
self._thumbhash_resolver(bare_id)
|
||||||
|
if self._thumbhash_resolver else None
|
||||||
|
)
|
||||||
|
return self._asset_cache, cache_key, thumbhash
|
||||||
return self._url_cache, cache_key, None
|
return self._url_cache, cache_key, None
|
||||||
if url:
|
if url:
|
||||||
if is_asset_id(url):
|
if is_asset_id(url):
|
||||||
@@ -76,6 +119,115 @@ class TelegramClient:
|
|||||||
is_asset = is_asset_cache_key(key)
|
is_asset = is_asset_cache_key(key)
|
||||||
return self._asset_cache if is_asset else self._url_cache
|
return self._asset_cache if is_asset else self._url_cache
|
||||||
|
|
||||||
|
async def _fetch_bytes(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
headers: dict[str, str] | None,
|
||||||
|
preloaded: bytes | None,
|
||||||
|
) -> tuple[bytes | None, str | None]:
|
||||||
|
"""Return ``(data, error_msg)``. Uses ``preloaded`` bytes if provided."""
|
||||||
|
if preloaded is not None:
|
||||||
|
return preloaded, None
|
||||||
|
try:
|
||||||
|
async with self._session.get(self._resolve_url(url), headers=headers or {}) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return None, f"HTTP {resp.status}"
|
||||||
|
return await resp.read(), None
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
return None, str(err)
|
||||||
|
|
||||||
|
async def _send_from_cache(
|
||||||
|
self,
|
||||||
|
kind: _MediaKind,
|
||||||
|
chat_id: str,
|
||||||
|
file_id: str,
|
||||||
|
caption: str | None,
|
||||||
|
reply_to_message_id: int | None,
|
||||||
|
parse_mode: str,
|
||||||
|
) -> NotificationResult | None:
|
||||||
|
"""POST a file_id reference. Return None on transient error so the
|
||||||
|
caller can fall through to a fresh upload."""
|
||||||
|
payload: dict[str, Any] = {"chat_id": chat_id, kind.form_field: file_id, "parse_mode": parse_mode}
|
||||||
|
if caption:
|
||||||
|
payload["caption"] = caption
|
||||||
|
if reply_to_message_id:
|
||||||
|
payload["reply_parameters"] = {"message_id": reply_to_message_id}
|
||||||
|
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
|
||||||
|
try:
|
||||||
|
async with self._session.post(telegram_url, json=payload) as response:
|
||||||
|
result = await response.json()
|
||||||
|
if response.status == 200 and result.get("ok"):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message_id": result.get("result", {}).get("message_id"),
|
||||||
|
"cached": True,
|
||||||
|
}
|
||||||
|
# Non-ok from a cached send — file_id stale or file deleted on
|
||||||
|
# Telegram's side. Log at DEBUG so operators who are hunting
|
||||||
|
# "why didn't the cached send work?" can see it, but the
|
||||||
|
# caller will fall through to a fresh upload.
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Telegram %s (cached) returned non-ok: status=%s code=%s desc=%r — falling back to fresh upload",
|
||||||
|
kind.api_method, response.status, result.get("error_code"),
|
||||||
|
result.get("description"),
|
||||||
|
)
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Telegram %s (cached) transport error — falling back to fresh upload: %s",
|
||||||
|
kind.api_method, err,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _upload_media(
|
||||||
|
self,
|
||||||
|
kind: _MediaKind,
|
||||||
|
chat_id: str,
|
||||||
|
data: bytes,
|
||||||
|
filename: str,
|
||||||
|
content_type: str,
|
||||||
|
caption: str | None,
|
||||||
|
reply_to_message_id: int | None,
|
||||||
|
parse_mode: str,
|
||||||
|
cache: TelegramFileCache | None,
|
||||||
|
cache_key: str | None,
|
||||||
|
thumbhash: str | None,
|
||||||
|
) -> NotificationResult:
|
||||||
|
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
|
||||||
|
form = FormData()
|
||||||
|
form.add_field("chat_id", chat_id)
|
||||||
|
form.add_field(kind.form_field, data, filename=filename, content_type=content_type)
|
||||||
|
form.add_field("parse_mode", parse_mode)
|
||||||
|
if caption:
|
||||||
|
form.add_field("caption", caption)
|
||||||
|
if reply_to_message_id:
|
||||||
|
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
||||||
|
|
||||||
|
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
|
||||||
|
try:
|
||||||
|
async with self._session.post(telegram_url, data=form) as response:
|
||||||
|
result = await response.json()
|
||||||
|
if response.status == 200 and result.get("ok"):
|
||||||
|
res = result.get("result", {})
|
||||||
|
file_id = kind.file_id_from_result(res)
|
||||||
|
if file_id and cache and cache_key:
|
||||||
|
await cache.async_set(
|
||||||
|
cache_key, file_id, kind.cache_type,
|
||||||
|
thumbhash=thumbhash, size=len(data),
|
||||||
|
)
|
||||||
|
return {"success": True, "message_id": res.get("message_id")}
|
||||||
|
_LOGGER.error(
|
||||||
|
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
|
||||||
|
kind.api_method, response.status, result.get("error_code"),
|
||||||
|
result.get("description", "Unknown"), len(data),
|
||||||
|
)
|
||||||
|
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Telegram %s transport error (bytes=%d): %s",
|
||||||
|
kind.api_method, len(data), err, exc_info=True,
|
||||||
|
)
|
||||||
|
return {"success": False, "error": str(err)}
|
||||||
|
|
||||||
async def send_notification(
|
async def send_notification(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@@ -98,7 +250,7 @@ class TelegramClient:
|
|||||||
|
|
||||||
typing_task = None
|
typing_task = None
|
||||||
if chat_action:
|
if chat_action:
|
||||||
typing_task = self._start_typing_indicator(chat_id, chat_action)
|
typing_task = self.start_chat_action_keepalive(chat_id, chat_action)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if len(assets) == 1 and assets[0].get("type") == "photo":
|
if len(assets) == 1 and assets[0].get("type") == "photo":
|
||||||
@@ -107,6 +259,7 @@ class TelegramClient:
|
|||||||
parse_mode, max_asset_data_size, send_large_photos_as_documents,
|
parse_mode, max_asset_data_size, send_large_photos_as_documents,
|
||||||
assets[0].get("content_type"), assets[0].get("cache_key"),
|
assets[0].get("content_type"), assets[0].get("cache_key"),
|
||||||
download_headers=assets[0].get("headers"),
|
download_headers=assets[0].get("headers"),
|
||||||
|
preloaded_data=assets[0].get("data"),
|
||||||
)
|
)
|
||||||
if len(assets) == 1 and assets[0].get("type") == "video":
|
if len(assets) == 1 and assets[0].get("type") == "video":
|
||||||
return await self._send_video(
|
return await self._send_video(
|
||||||
@@ -114,28 +267,31 @@ class TelegramClient:
|
|||||||
parse_mode, max_asset_data_size,
|
parse_mode, max_asset_data_size,
|
||||||
assets[0].get("content_type"), assets[0].get("cache_key"),
|
assets[0].get("content_type"), assets[0].get("cache_key"),
|
||||||
download_headers=assets[0].get("headers"),
|
download_headers=assets[0].get("headers"),
|
||||||
|
preloaded_data=assets[0].get("data"),
|
||||||
)
|
)
|
||||||
if len(assets) == 1 and assets[0].get("type", "document") == "document":
|
if len(assets) == 1 and assets[0].get("type", "document") == "document":
|
||||||
url = assets[0].get("url")
|
url = assets[0].get("url")
|
||||||
if not url:
|
if not url:
|
||||||
return {"success": False, "error": "Missing 'url' for document"}
|
return {"success": False, "error": "Missing 'url' for document"}
|
||||||
try:
|
data = assets[0].get("data")
|
||||||
download_url = self._resolve_url(url)
|
if data is None:
|
||||||
dl_headers = assets[0].get("headers") or {}
|
try:
|
||||||
async with self._session.get(download_url, headers=dl_headers) as resp:
|
download_url = self._resolve_url(url)
|
||||||
if resp.status != 200:
|
dl_headers = assets[0].get("headers") or {}
|
||||||
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
|
async with self._session.get(download_url, headers=dl_headers) as resp:
|
||||||
data = await resp.read()
|
if resp.status != 200:
|
||||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
|
||||||
return {"success": False, "error": f"Media size exceeds limit"}
|
data = await resp.read()
|
||||||
filename = url.split("/")[-1].split("?")[0] or "file"
|
except aiohttp.ClientError as err:
|
||||||
return await self._send_document(
|
return {"success": False, "error": f"Failed to download media: {err}"}
|
||||||
chat_id, data, filename, caption, reply_to_message_id,
|
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||||
parse_mode, url, assets[0].get("content_type"),
|
return {"success": False, "error": f"Media size exceeds limit"}
|
||||||
assets[0].get("cache_key"),
|
filename = url.split("/")[-1].split("?")[0] or "file"
|
||||||
)
|
return await self._send_document(
|
||||||
except aiohttp.ClientError as err:
|
chat_id, data, filename, caption, reply_to_message_id,
|
||||||
return {"success": False, "error": f"Failed to download media: {err}"}
|
parse_mode, url, assets[0].get("content_type"),
|
||||||
|
assets[0].get("cache_key"),
|
||||||
|
)
|
||||||
|
|
||||||
return await self._send_media_group(
|
return await self._send_media_group(
|
||||||
chat_id, assets, caption, reply_to_message_id, max_group_size,
|
chat_id, assets, caption, reply_to_message_id, max_group_size,
|
||||||
@@ -177,13 +333,29 @@ class TelegramClient:
|
|||||||
# Retry without parse_mode on parse errors
|
# Retry without parse_mode on parse errors
|
||||||
desc = str(result.get("description", ""))
|
desc = str(result.get("description", ""))
|
||||||
if "parse" in desc.lower():
|
if "parse" in desc.lower():
|
||||||
|
# Log loudly: a parse failure means the template author (or
|
||||||
|
# an asset field) is producing malformed HTML. Silent
|
||||||
|
# fallback hides bugs and makes XSS-via-unescaped-field
|
||||||
|
# harder to spot. Do not log the full payload — it may
|
||||||
|
# contain secrets.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Telegram rejected parse_mode=%s (%r); retrying as plain text. "
|
||||||
|
"Check template output for unescaped characters.",
|
||||||
|
payload.get("parse_mode"), desc,
|
||||||
|
)
|
||||||
payload.pop("parse_mode", None)
|
payload.pop("parse_mode", None)
|
||||||
async with self._session.post(telegram_url, json=payload) as retry_resp:
|
async with self._session.post(telegram_url, json=payload) as retry_resp:
|
||||||
retry_result = await retry_resp.json()
|
retry_result = await retry_resp.json()
|
||||||
if retry_resp.status == 200 and retry_result.get("ok"):
|
if retry_resp.status == 200 and retry_result.get("ok"):
|
||||||
return {"success": True, "message_id": retry_result.get("result", {}).get("message_id")}
|
return {"success": True, "message_id": retry_result.get("result", {}).get("message_id")}
|
||||||
|
_LOGGER.error(
|
||||||
|
"Telegram sendMessage failed: status=%s code=%s desc=%r",
|
||||||
|
response.status, result.get("error_code"),
|
||||||
|
result.get("description", "Unknown"),
|
||||||
|
)
|
||||||
return {"success": False, "error": result.get("description", "Unknown Telegram error"), "error_code": result.get("error_code")}
|
return {"success": False, "error": result.get("description", "Unknown Telegram error"), "error_code": result.get("error_code")}
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.error("Telegram sendMessage transport error: %s", err, exc_info=True)
|
||||||
return {"success": False, "error": str(err)}
|
return {"success": False, "error": str(err)}
|
||||||
|
|
||||||
async def send_chat_action(self, chat_id: str, action: str = "typing") -> bool:
|
async def send_chat_action(self, chat_id: str, action: str = "typing") -> bool:
|
||||||
@@ -195,7 +367,13 @@ class TelegramClient:
|
|||||||
except aiohttp.ClientError:
|
except aiohttp.ClientError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _start_typing_indicator(self, chat_id: str, action: str = "typing") -> asyncio.Task:
|
def start_chat_action_keepalive(self, chat_id: str, action: str = "typing") -> asyncio.Task:
|
||||||
|
"""Repeatedly post ``action`` every 4s until the returned task is cancelled.
|
||||||
|
|
||||||
|
Telegram chat actions expire after ~5s, so callers that want the hint
|
||||||
|
to persist through longer work (fetching assets, multi-chunk uploads)
|
||||||
|
need a keep-alive. Cancel the task in a ``finally`` to stop it.
|
||||||
|
"""
|
||||||
async def action_loop() -> None:
|
async def action_loop() -> None:
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -211,133 +389,85 @@ class TelegramClient:
|
|||||||
max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
|
max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
|
||||||
content_type: str | None = None, cache_key: str | None = None,
|
content_type: str | None = None, cache_key: str | None = None,
|
||||||
download_headers: dict[str, str] | None = None,
|
download_headers: dict[str, str] | None = None,
|
||||||
|
preloaded_data: bytes | None = None,
|
||||||
) -> NotificationResult:
|
) -> NotificationResult:
|
||||||
if not content_type:
|
|
||||||
content_type = "image/jpeg"
|
|
||||||
if not url:
|
if not url:
|
||||||
return {"success": False, "error": "Missing 'url' for photo"}
|
return {"success": False, "error": "Missing 'url' for photo"}
|
||||||
|
|
||||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key)
|
cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
|
||||||
|
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
|
||||||
# Check cache
|
|
||||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
|
|
||||||
if cached and cached.get("file_id"):
|
if cached and cached.get("file_id"):
|
||||||
payload = {"chat_id": chat_id, "photo": cached["file_id"], "parse_mode": parse_mode}
|
cached_result = await self._send_from_cache(
|
||||||
if caption:
|
_PHOTO_KIND, chat_id, cached["file_id"],
|
||||||
payload["caption"] = caption
|
caption, reply_to_message_id, parse_mode,
|
||||||
if reply_to_message_id:
|
)
|
||||||
payload["reply_parameters"] = {"message_id": reply_to_message_id}
|
if cached_result is not None:
|
||||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
|
return cached_result
|
||||||
try:
|
|
||||||
async with self._session.post(telegram_url, json=payload) as response:
|
|
||||||
result = await response.json()
|
|
||||||
if response.status == 200 and result.get("ok"):
|
|
||||||
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
|
|
||||||
except aiohttp.ClientError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
|
||||||
download_url = self._resolve_url(url)
|
if data is None:
|
||||||
async with self._session.get(download_url, headers=download_headers or {}) as resp:
|
return {"success": False, "error": f"Failed to download photo: {err}"}
|
||||||
if resp.status != 200:
|
|
||||||
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
|
|
||||||
data = await resp.read()
|
|
||||||
|
|
||||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||||
return {"success": False, "error": "Photo exceeds size limit", "skipped": True}
|
return {"success": False, "error": "Photo exceeds size limit", "skipped": True}
|
||||||
|
|
||||||
exceeds_limits, reason, _, _ = check_photo_limits(data)
|
exceeds_limits, reason, _, _ = check_photo_limits(data)
|
||||||
if exceeds_limits:
|
if exceeds_limits:
|
||||||
if send_large_photos_as_documents:
|
if send_large_photos_as_documents:
|
||||||
return await self._send_document(chat_id, data, "photo.jpg", caption, reply_to_message_id, parse_mode, url, None, cache_key)
|
return await self._send_document(
|
||||||
return {"success": False, "error": f"Photo {reason}", "skipped": True}
|
chat_id, data, "photo.jpg", caption, reply_to_message_id,
|
||||||
|
parse_mode, url, None, cache_key,
|
||||||
|
)
|
||||||
|
return {"success": False, "error": f"Photo {reason}", "skipped": True}
|
||||||
|
|
||||||
form = FormData()
|
return await self._upload_media(
|
||||||
form.add_field("chat_id", chat_id)
|
_PHOTO_KIND, chat_id, data,
|
||||||
form.add_field("photo", data, filename="photo.jpg", content_type=content_type)
|
_PHOTO_KIND.default_filename,
|
||||||
form.add_field("parse_mode", parse_mode)
|
content_type or _PHOTO_KIND.default_content_type,
|
||||||
if caption:
|
caption, reply_to_message_id, parse_mode,
|
||||||
form.add_field("caption", caption)
|
cache, key, thumbhash,
|
||||||
if reply_to_message_id:
|
)
|
||||||
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
|
||||||
|
|
||||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
|
|
||||||
async with self._session.post(telegram_url, data=form) as response:
|
|
||||||
result = await response.json()
|
|
||||||
if response.status == 200 and result.get("ok"):
|
|
||||||
photos = result.get("result", {}).get("photo", [])
|
|
||||||
if photos and effective_cache and effective_cache_key:
|
|
||||||
file_id = photos[-1].get("file_id")
|
|
||||||
if file_id:
|
|
||||||
await effective_cache.async_set(effective_cache_key, file_id, "photo", thumbhash=effective_thumbhash)
|
|
||||||
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
|
|
||||||
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
|
|
||||||
except aiohttp.ClientError as err:
|
|
||||||
return {"success": False, "error": str(err)}
|
|
||||||
|
|
||||||
async def _send_video(
|
async def _send_video(
|
||||||
self, chat_id: str, url: str | None, caption: str | None = None,
|
self, chat_id: str, url: str | None, caption: str | None = None,
|
||||||
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
|
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
|
||||||
max_asset_data_size: int | None = None, content_type: str | None = None,
|
max_asset_data_size: int | None = None, content_type: str | None = None,
|
||||||
cache_key: str | None = None, download_headers: dict[str, str] | None = None,
|
cache_key: str | None = None, download_headers: dict[str, str] | None = None,
|
||||||
|
preloaded_data: bytes | None = None,
|
||||||
) -> NotificationResult:
|
) -> NotificationResult:
|
||||||
if not content_type:
|
|
||||||
content_type = "video/mp4"
|
|
||||||
if not url:
|
if not url:
|
||||||
return {"success": False, "error": "Missing 'url' for video"}
|
return {"success": False, "error": "Missing 'url' for video"}
|
||||||
|
|
||||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key)
|
cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
|
||||||
|
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
|
||||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
|
|
||||||
if cached and cached.get("file_id"):
|
if cached and cached.get("file_id"):
|
||||||
payload = {"chat_id": chat_id, "video": cached["file_id"], "parse_mode": parse_mode}
|
cached_result = await self._send_from_cache(
|
||||||
if caption:
|
_VIDEO_KIND, chat_id, cached["file_id"],
|
||||||
payload["caption"] = caption
|
caption, reply_to_message_id, parse_mode,
|
||||||
if reply_to_message_id:
|
)
|
||||||
payload["reply_parameters"] = {"message_id": reply_to_message_id}
|
if cached_result is not None:
|
||||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
|
return cached_result
|
||||||
try:
|
|
||||||
async with self._session.post(telegram_url, json=payload) as response:
|
|
||||||
result = await response.json()
|
|
||||||
if response.status == 200 and result.get("ok"):
|
|
||||||
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
|
|
||||||
except aiohttp.ClientError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
|
||||||
download_url = self._resolve_url(url)
|
if data is None:
|
||||||
async with self._session.get(download_url, headers=download_headers or {}) as resp:
|
return {"success": False, "error": f"Failed to download video: {err}"}
|
||||||
if resp.status != 200:
|
|
||||||
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
|
|
||||||
data = await resp.read()
|
|
||||||
|
|
||||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||||
return {"success": False, "error": "Video exceeds size limit", "skipped": True}
|
return {"success": False, "error": "Video exceeds size limit", "skipped": True}
|
||||||
if len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
if len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
||||||
return {"success": False, "error": f"Video exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE // (1024*1024)} MB limit", "skipped": True}
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Video exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE // (1024*1024)} MB limit",
|
||||||
|
"skipped": True,
|
||||||
|
}
|
||||||
|
|
||||||
form = FormData()
|
return await self._upload_media(
|
||||||
form.add_field("chat_id", chat_id)
|
_VIDEO_KIND, chat_id, data,
|
||||||
form.add_field("video", data, filename="video.mp4", content_type=content_type)
|
_VIDEO_KIND.default_filename,
|
||||||
form.add_field("parse_mode", parse_mode)
|
content_type or _VIDEO_KIND.default_content_type,
|
||||||
if caption:
|
caption, reply_to_message_id, parse_mode,
|
||||||
form.add_field("caption", caption)
|
cache, key, thumbhash,
|
||||||
if reply_to_message_id:
|
)
|
||||||
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
|
||||||
|
|
||||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
|
|
||||||
async with self._session.post(telegram_url, data=form) as response:
|
|
||||||
result = await response.json()
|
|
||||||
if response.status == 200 and result.get("ok"):
|
|
||||||
video = result.get("result", {}).get("video", {})
|
|
||||||
if video and effective_cache and effective_cache_key:
|
|
||||||
file_id = video.get("file_id")
|
|
||||||
if file_id:
|
|
||||||
await effective_cache.async_set(effective_cache_key, file_id, "video", thumbhash=effective_thumbhash)
|
|
||||||
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
|
|
||||||
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
|
|
||||||
except aiohttp.ClientError as err:
|
|
||||||
return {"success": False, "error": str(err)}
|
|
||||||
|
|
||||||
async def _send_document(
|
async def _send_document(
|
||||||
self, chat_id: str, data: bytes, filename: str = "file",
|
self, chat_id: str, data: bytes, filename: str = "file",
|
||||||
@@ -348,50 +478,24 @@ class TelegramClient:
|
|||||||
if not content_type:
|
if not content_type:
|
||||||
content_type, _ = mimetypes.guess_type(filename)
|
content_type, _ = mimetypes.guess_type(filename)
|
||||||
if not content_type:
|
if not content_type:
|
||||||
content_type = "application/octet-stream"
|
content_type = _DOCUMENT_KIND.default_content_type
|
||||||
|
|
||||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(source_url, cache_key)
|
cache, key, thumbhash = self._get_cache_and_key(source_url, cache_key)
|
||||||
|
if cache and key:
|
||||||
|
cached = cache.get(key, thumbhash=thumbhash)
|
||||||
|
if cached and cached.get("file_id") and cached.get("type") == _DOCUMENT_KIND.cache_type:
|
||||||
|
cached_result = await self._send_from_cache(
|
||||||
|
_DOCUMENT_KIND, chat_id, cached["file_id"],
|
||||||
|
caption, reply_to_message_id, parse_mode,
|
||||||
|
)
|
||||||
|
if cached_result is not None:
|
||||||
|
return cached_result
|
||||||
|
|
||||||
if effective_cache and effective_cache_key:
|
return await self._upload_media(
|
||||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash)
|
_DOCUMENT_KIND, chat_id, data, filename, content_type,
|
||||||
if cached and cached.get("file_id") and cached.get("type") == "document":
|
caption, reply_to_message_id, parse_mode,
|
||||||
payload = {"chat_id": chat_id, "document": cached["file_id"], "parse_mode": parse_mode}
|
cache, key, thumbhash,
|
||||||
if caption:
|
)
|
||||||
payload["caption"] = caption
|
|
||||||
if reply_to_message_id:
|
|
||||||
payload["reply_parameters"] = {"message_id": reply_to_message_id}
|
|
||||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
|
|
||||||
try:
|
|
||||||
async with self._session.post(telegram_url, json=payload) as response:
|
|
||||||
result = await response.json()
|
|
||||||
if response.status == 200 and result.get("ok"):
|
|
||||||
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
|
|
||||||
except aiohttp.ClientError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
form = FormData()
|
|
||||||
form.add_field("chat_id", chat_id)
|
|
||||||
form.add_field("document", data, filename=filename, content_type=content_type)
|
|
||||||
form.add_field("parse_mode", parse_mode)
|
|
||||||
if caption:
|
|
||||||
form.add_field("caption", caption)
|
|
||||||
if reply_to_message_id:
|
|
||||||
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
|
|
||||||
|
|
||||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
|
|
||||||
async with self._session.post(telegram_url, data=form) as response:
|
|
||||||
result = await response.json()
|
|
||||||
if response.status == 200 and result.get("ok"):
|
|
||||||
if effective_cache_key and effective_cache:
|
|
||||||
document = result.get("result", {}).get("document", {})
|
|
||||||
file_id = document.get("file_id")
|
|
||||||
if file_id:
|
|
||||||
await effective_cache.async_set(effective_cache_key, file_id, "document", thumbhash=effective_thumbhash)
|
|
||||||
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
|
|
||||||
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
|
|
||||||
except aiohttp.ClientError as err:
|
|
||||||
return {"success": False, "error": str(err)}
|
|
||||||
|
|
||||||
async def _send_media_group(
|
async def _send_media_group(
|
||||||
self, chat_id: str, assets: list[dict[str, str]],
|
self, chat_id: str, assets: list[dict[str, str]],
|
||||||
@@ -411,9 +515,9 @@ class TelegramClient:
|
|||||||
chunk_caption = caption if chunk_idx == 0 else None
|
chunk_caption = caption if chunk_idx == 0 else None
|
||||||
chunk_reply = reply_to_message_id if chunk_idx == 0 else None
|
chunk_reply = reply_to_message_id if chunk_idx == 0 else None
|
||||||
if item.get("type") == "photo":
|
if item.get("type") == "photo":
|
||||||
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"))
|
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"), preloaded_data=item.get("data"))
|
||||||
elif item.get("type") == "video":
|
elif item.get("type") == "video":
|
||||||
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"))
|
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"), preloaded_data=item.get("data"))
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
@@ -433,13 +537,17 @@ class TelegramClient:
|
|||||||
# Track cache info per media_json entry (in order) so we can map
|
# Track cache info per media_json entry (in order) so we can map
|
||||||
# Telegram response items back to cache keys for newly uploaded items.
|
# Telegram response items back to cache keys for newly uploaded items.
|
||||||
# None = already cached (no need to store), tuple = needs caching.
|
# None = already cached (no need to store), tuple = needs caching.
|
||||||
media_cache_info: list[tuple[str, str, str | None] | None] = []
|
# Tuple is (cache_key, media_type, thumbhash, uploaded_size).
|
||||||
|
media_cache_info: list[tuple[str, str, str | None, int] | None] = []
|
||||||
|
|
||||||
# Resolve cache hits and collect download tasks in parallel
|
# Resolve cache hits and collect download tasks in parallel.
|
||||||
|
# Each drop site logs the reason — otherwise a filtered asset
|
||||||
|
# disappears silently and the media group silently shrinks.
|
||||||
async def _fetch_asset(idx: int, item: dict) -> tuple[int, dict | None, bytes | None]:
|
async def _fetch_asset(idx: int, item: dict) -> tuple[int, dict | None, bytes | None]:
|
||||||
"""Return (index, cache_entry_or_None, downloaded_bytes_or_None)."""
|
"""Return (index, cache_entry_or_None, downloaded_bytes_or_None)."""
|
||||||
url = item.get("url")
|
url = item.get("url")
|
||||||
if not url:
|
if not url:
|
||||||
|
_LOGGER.warning("Media skipped: missing url (idx=%d type=%s)", idx, item.get("type"))
|
||||||
return idx, None, None
|
return idx, None, None
|
||||||
media_type = item.get("type", "photo")
|
media_type = item.get("type", "photo")
|
||||||
custom_cache_key = item.get("cache_key")
|
custom_cache_key = item.get("cache_key")
|
||||||
@@ -454,23 +562,69 @@ class TelegramClient:
|
|||||||
if cached and cached.get("file_id"):
|
if cached and cached.get("file_id"):
|
||||||
return idx, cached, None
|
return idx, cached, None
|
||||||
|
|
||||||
|
# Use preloaded bytes if the dispatcher already fetched them
|
||||||
|
preloaded = item.get("data")
|
||||||
|
if preloaded is not None:
|
||||||
|
data = preloaded
|
||||||
|
if max_asset_data_size and len(data) > max_asset_data_size:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Media skipped: preloaded size %d exceeds max_asset_data_size %d (idx=%d type=%s url=%s)",
|
||||||
|
len(data), max_asset_data_size, idx, media_type, url,
|
||||||
|
)
|
||||||
|
return idx, None, None
|
||||||
|
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Media skipped: preloaded video %d bytes exceeds Telegram limit %d (idx=%d url=%s)",
|
||||||
|
len(data), TELEGRAM_MAX_VIDEO_SIZE, idx, url,
|
||||||
|
)
|
||||||
|
return idx, None, None
|
||||||
|
if media_type == "photo":
|
||||||
|
exceeds, reason, _, _ = check_photo_limits(data)
|
||||||
|
if exceeds:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Media skipped: preloaded photo %s (idx=%d url=%s)",
|
||||||
|
reason, idx, url,
|
||||||
|
)
|
||||||
|
return idx, None, None
|
||||||
|
return idx, None, data
|
||||||
|
|
||||||
try:
|
try:
|
||||||
download_url = self._resolve_url(url)
|
download_url = self._resolve_url(url)
|
||||||
dl_headers = item.get("headers") or {}
|
dl_headers = item.get("headers") or {}
|
||||||
async with self._session.get(download_url, headers=dl_headers) as resp:
|
async with self._session.get(download_url, headers=dl_headers) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Media skipped: download HTTP %d (idx=%d type=%s url=%s)",
|
||||||
|
resp.status, idx, media_type, url,
|
||||||
|
)
|
||||||
return idx, None, None
|
return idx, None, None
|
||||||
data = await resp.read()
|
data = await resp.read()
|
||||||
if max_asset_data_size and len(data) > max_asset_data_size:
|
if max_asset_data_size and len(data) > max_asset_data_size:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Media skipped: downloaded size %d exceeds max_asset_data_size %d (idx=%d type=%s url=%s)",
|
||||||
|
len(data), max_asset_data_size, idx, media_type, url,
|
||||||
|
)
|
||||||
return idx, None, None
|
return idx, None, None
|
||||||
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Media skipped: video %d bytes exceeds Telegram %d-byte limit (idx=%d url=%s)",
|
||||||
|
len(data), TELEGRAM_MAX_VIDEO_SIZE, idx, url,
|
||||||
|
)
|
||||||
return idx, None, None
|
return idx, None, None
|
||||||
if media_type == "photo":
|
if media_type == "photo":
|
||||||
exceeds, _, _, _ = check_photo_limits(data)
|
exceeds, reason, _, _ = check_photo_limits(data)
|
||||||
if exceeds:
|
if exceeds:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Media skipped: photo %s (idx=%d url=%s)",
|
||||||
|
reason, idx, url,
|
||||||
|
)
|
||||||
return idx, None, None
|
return idx, None, None
|
||||||
return idx, None, data
|
return idx, None, data
|
||||||
except aiohttp.ClientError:
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Media skipped: download failed (idx=%d type=%s url=%s): %s",
|
||||||
|
idx, media_type, url, err,
|
||||||
|
)
|
||||||
return idx, None, None
|
return idx, None, None
|
||||||
|
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
@@ -500,7 +654,7 @@ class TelegramClient:
|
|||||||
ck_is_asset = is_asset_cache_key(ck)
|
ck_is_asset = is_asset_cache_key(ck)
|
||||||
bare_ck = asset_id_from_cache_key(ck) if ck_is_asset else ck
|
bare_ck = asset_id_from_cache_key(ck) if ck_is_asset else ck
|
||||||
th = self._thumbhash_resolver(bare_ck) if ck_is_asset and self._thumbhash_resolver else None
|
th = self._thumbhash_resolver(bare_ck) if ck_is_asset and self._thumbhash_resolver else None
|
||||||
media_cache_info.append((ck, media_type, th))
|
media_cache_info.append((ck, media_type, th, len(data)))
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -510,6 +664,14 @@ class TelegramClient:
|
|||||||
media_json.append(mij)
|
media_json.append(mij)
|
||||||
|
|
||||||
if not media_json:
|
if not media_json:
|
||||||
|
# Every asset in this chunk was filtered out (size, download
|
||||||
|
# failure, etc.). Without this log, sendMediaGroup returns
|
||||||
|
# success=True with zero message_ids and nobody knows why
|
||||||
|
# the user sees only the text reply and no media.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"sendMediaGroup skipped — chunk %d/%d had %d input items but 0 usable (all filtered/failed)",
|
||||||
|
chunk_idx + 1, len(chunks), len(chunk),
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
form.add_field("media", json.dumps(media_json))
|
form.add_field("media", json.dumps(media_json))
|
||||||
@@ -523,14 +685,14 @@ class TelegramClient:
|
|||||||
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
|
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
|
||||||
|
|
||||||
# Cache file_ids from response — map by position
|
# Cache file_ids from response — map by position
|
||||||
cache_entries: list[tuple[str, str, str, str | None]] = []
|
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
|
||||||
for i, msg in enumerate(result_msgs):
|
for i, msg in enumerate(result_msgs):
|
||||||
if i >= len(media_cache_info):
|
if i >= len(media_cache_info):
|
||||||
break
|
break
|
||||||
info = media_cache_info[i]
|
info = media_cache_info[i]
|
||||||
if info is None:
|
if info is None:
|
||||||
continue # was a cache hit, skip
|
continue # was a cache hit, skip
|
||||||
ck, mt, th = info
|
ck, mt, th, sz = info
|
||||||
file_id = None
|
file_id = None
|
||||||
if msg.get("photo"):
|
if msg.get("photo"):
|
||||||
file_id = msg["photo"][-1].get("file_id")
|
file_id = msg["photo"][-1].get("file_id")
|
||||||
@@ -539,17 +701,42 @@ class TelegramClient:
|
|||||||
elif msg.get("document"):
|
elif msg.get("document"):
|
||||||
file_id = msg["document"].get("file_id")
|
file_id = msg["document"].get("file_id")
|
||||||
if file_id:
|
if file_id:
|
||||||
cache_entries.append((ck, file_id, mt, th))
|
cache_entries.append((ck, file_id, mt, th, sz))
|
||||||
if cache_entries:
|
if cache_entries:
|
||||||
# All entries in a chunk share the same cache backend
|
# All entries in a chunk share the same cache backend
|
||||||
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
|
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
|
||||||
if eff_cache:
|
if eff_cache:
|
||||||
await eff_cache.async_set_many(cache_entries)
|
await eff_cache.async_set_many(cache_entries)
|
||||||
else:
|
else:
|
||||||
return {"success": False, "error": result.get("description", "Unknown"), "failed_at_chunk": chunk_idx + 1}
|
_LOGGER.error(
|
||||||
|
"Telegram sendMediaGroup failed: status=%s code=%s desc=%r chunk=%d/%d items=%d",
|
||||||
|
response.status, result.get("error_code"),
|
||||||
|
result.get("description", "Unknown"),
|
||||||
|
chunk_idx + 1, len(chunks), len(media_json),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.get("description", "Unknown"),
|
||||||
|
"error_code": result.get("error_code"),
|
||||||
|
"failed_at_chunk": chunk_idx + 1,
|
||||||
|
}
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
|
||||||
|
chunk_idx + 1, len(chunks), len(media_json), err,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
|
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
|
||||||
|
|
||||||
|
# Distinguish "posted something" from "posted nothing" so the caller
|
||||||
|
# can surface an ERROR when a command produced a caption reply but no
|
||||||
|
# media ever reached Telegram.
|
||||||
|
if not all_message_ids:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"sendMediaGroup completed with 0 message_ids across %d chunk(s) — nothing was delivered",
|
||||||
|
len(chunks),
|
||||||
|
)
|
||||||
|
return {"success": False, "error": "no_items_delivered", "chunks_sent": len(chunks)}
|
||||||
return {"success": True, "message_ids": all_message_ids, "chunks_sent": len(chunks)}
|
return {"success": True, "message_ids": all_message_ids, "chunks_sent": len(chunks)}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -568,6 +755,18 @@ class TelegramClient:
|
|||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
return {"success": False, "error": str(err)}
|
return {"success": False, "error": str(err)}
|
||||||
|
|
||||||
|
async def get_chat(self, chat_id: str) -> dict[str, Any]:
|
||||||
|
"""Call getChat to fetch up-to-date chat metadata (title, username, type, etc.)."""
|
||||||
|
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getChat"
|
||||||
|
try:
|
||||||
|
async with self._session.post(url, json={"chat_id": chat_id}) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
if data.get("ok"):
|
||||||
|
return {"success": True, "result": data.get("result", {})}
|
||||||
|
return {"success": False, "error": data.get("description", "Unknown error")}
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
return {"success": False, "error": str(err)}
|
||||||
|
|
||||||
async def get_webhook_info(self) -> dict[str, Any]:
|
async def get_webhook_info(self) -> dict[str, Any]:
|
||||||
"""Call getWebhookInfo to check current webhook status."""
|
"""Call getWebhookInfo to check current webhook status."""
|
||||||
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo"
|
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Final
|
from typing import Any, Final
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
# Telegram constants
|
# Telegram constants
|
||||||
@@ -52,6 +52,65 @@ def extract_asset_id_from_url(url: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_telegram_asset_entry(
|
||||||
|
*,
|
||||||
|
url: str,
|
||||||
|
media_type: str,
|
||||||
|
api_key: str | None = None,
|
||||||
|
internal_url: str = "",
|
||||||
|
external_url: str = "",
|
||||||
|
cache_key: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Build a ``TelegramClient.send_notification`` asset dict from raw fields.
|
||||||
|
|
||||||
|
Shared by the notification dispatcher and provider command handlers so
|
||||||
|
both paths agree on media typing, URL rewriting, and auth headers. In
|
||||||
|
particular: video assets MUST be typed ``"video"`` and point at a real
|
||||||
|
video endpoint (e.g. Immich ``/video/playback``) — if they are sent as
|
||||||
|
``"photo"`` pointing at a thumbnail URL, Telegram delivers a still image
|
||||||
|
for every video in a media group and the user sees a dead poster frame
|
||||||
|
instead of a playable clip.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Source URL for the asset bytes. Prefer a transcoded/preview
|
||||||
|
URL for videos (``/video/playback``) and a preview-sized
|
||||||
|
thumbnail for photos.
|
||||||
|
media_type: Case-insensitive type token. Accepts ``"video"``/
|
||||||
|
``"VIDEO"``/``MediaType.VIDEO`` or any photo-like string.
|
||||||
|
api_key: Optional API key. Attached as ``x-api-key`` iff the URL is
|
||||||
|
served by one of the provider hosts in ``internal_url`` /
|
||||||
|
``external_url`` (prevents leaking the key to unrelated hosts).
|
||||||
|
internal_url: LAN-facing provider URL. Used to rewrite
|
||||||
|
``external_url`` prefixes so Docker-host downloads stay on the
|
||||||
|
LAN instead of egressing to the public domain.
|
||||||
|
external_url: Public provider URL the notification URL was built
|
||||||
|
from. Only used for the LAN rewrite and the api-key scope check.
|
||||||
|
cache_key: Optional explicit cache key. Providers whose URLs don't
|
||||||
|
embed a stable asset id (Google Photos) pass one through so the
|
||||||
|
file_id cache still works.
|
||||||
|
|
||||||
|
Returns ``None`` iff ``url`` is empty.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if internal_url and external_url and url.startswith(external_url):
|
||||||
|
url = internal_url + url[len(external_url):]
|
||||||
|
|
||||||
|
normalized_type = str(media_type or "").lower()
|
||||||
|
entry_type = "video" if normalized_type == "video" else "photo"
|
||||||
|
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
provider_urls = [u for u in (internal_url, external_url) if u]
|
||||||
|
if api_key and (not provider_urls or any(url.startswith(u) for u in provider_urls)):
|
||||||
|
headers["x-api-key"] = api_key
|
||||||
|
|
||||||
|
entry: dict[str, Any] = {"url": url, "type": entry_type, "headers": headers}
|
||||||
|
if cache_key:
|
||||||
|
entry["cache_key"] = cache_key
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def split_media_by_upload_size(
|
def split_media_by_upload_size(
|
||||||
media_items: list[tuple], max_upload_size: int
|
media_items: list[tuple], max_upload_size: int
|
||||||
) -> list[list[tuple]]:
|
) -> list[list[tuple]]:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ..ssrf import UnsafeURLError, validate_outbound_url
|
from ..ssrf import UnsafeURLError, avalidate_outbound_url
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ class WebhookClient:
|
|||||||
|
|
||||||
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
async def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
validate_outbound_url(self._url)
|
await avalidate_outbound_url(self._url)
|
||||||
except UnsafeURLError as err:
|
except UnsafeURLError as err:
|
||||||
return {"success": False, "error": f"Unsafe URL: {err}"}
|
return {"success": False, "error": f"Unsafe URL: {err}"}
|
||||||
try:
|
try:
|
||||||
@@ -33,6 +33,7 @@ class WebhookClient:
|
|||||||
json=payload,
|
json=payload,
|
||||||
headers={"Content-Type": "application/json", **self._headers},
|
headers={"Content-Type": "application/json", **self._headers},
|
||||||
timeout=_DEFAULT_TIMEOUT,
|
timeout=_DEFAULT_TIMEOUT,
|
||||||
|
allow_redirects=False,
|
||||||
) as response:
|
) as response:
|
||||||
if 200 <= response.status < 300:
|
if 200 <= response.status < 300:
|
||||||
return {"success": True, "status_code": response.status}
|
return {"success": True, "status_code": response.status}
|
||||||
|
|||||||
@@ -132,8 +132,10 @@ class ImmichActionExecutor(ActionExecutor):
|
|||||||
target_album_ids = [single]
|
target_album_ids = [single]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Gather candidate assets from criteria
|
# Step 1: Gather candidate assets from criteria. Asset type is
|
||||||
candidate_ids = await self._gather_candidates(criteria)
|
# kept alongside the id so we can pick the first *photo* (not a
|
||||||
|
# video) as an album thumbnail when one is missing.
|
||||||
|
candidate_ids, types_by_id = await self._gather_candidates(criteria)
|
||||||
|
|
||||||
if not candidate_ids:
|
if not candidate_ids:
|
||||||
return RuleResult(
|
return RuleResult(
|
||||||
@@ -146,6 +148,7 @@ class ImmichActionExecutor(ActionExecutor):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# If no target albums and create_if_missing, create one
|
# If no target albums and create_if_missing, create one
|
||||||
|
album_created_now: set[str] = set()
|
||||||
if not target_album_ids and create_if_missing and create_album_name:
|
if not target_album_ids and create_if_missing and create_album_name:
|
||||||
if dry_run:
|
if dry_run:
|
||||||
_LOGGER.info("[DRY RUN] Would create album '%s'", create_album_name)
|
_LOGGER.info("[DRY RUN] Would create album '%s'", create_album_name)
|
||||||
@@ -153,6 +156,8 @@ class ImmichActionExecutor(ActionExecutor):
|
|||||||
else:
|
else:
|
||||||
created = await self._client.create_album(create_album_name)
|
created = await self._client.create_album(create_album_name)
|
||||||
target_album_ids = [created.get("id", "")]
|
target_album_ids = [created.get("id", "")]
|
||||||
|
if target_album_ids[0]:
|
||||||
|
album_created_now.add(target_album_ids[0])
|
||||||
_LOGGER.info("Created album '%s' with id %s", create_album_name, target_album_ids[0])
|
_LOGGER.info("Created album '%s' with id %s", create_album_name, target_album_ids[0])
|
||||||
|
|
||||||
if not target_album_ids:
|
if not target_album_ids:
|
||||||
@@ -169,34 +174,66 @@ class ImmichActionExecutor(ActionExecutor):
|
|||||||
|
|
||||||
for album_id in target_album_ids:
|
for album_id in target_album_ids:
|
||||||
album_asset_ids: set[str] = set()
|
album_asset_ids: set[str] = set()
|
||||||
|
needs_thumbnail = album_id in album_created_now
|
||||||
|
|
||||||
if album_id and album_id != "__dry_run_new__":
|
if album_id and album_id != "__dry_run_new__":
|
||||||
album = await self._client.get_album(album_id)
|
# Actions diff the current album state to decide what to
|
||||||
|
# add — must observe fresh data, not a cached view.
|
||||||
|
album = await self._client.get_album(album_id, use_cache=False)
|
||||||
if album is None and create_if_missing and create_album_name:
|
if album is None and create_if_missing and create_album_name:
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
created = await self._client.create_album(create_album_name)
|
created = await self._client.create_album(create_album_name)
|
||||||
album_id = created.get("id", album_id)
|
album_id = created.get("id", album_id)
|
||||||
|
album_created_now.add(album_id)
|
||||||
|
needs_thumbnail = True
|
||||||
_LOGGER.info("Created album '%s' with id %s", create_album_name, album_id)
|
_LOGGER.info("Created album '%s' with id %s", create_album_name, album_id)
|
||||||
elif album is None:
|
elif album is None:
|
||||||
album_details.append({"album_id": album_id, "error": "not found"})
|
album_details.append({"album_id": album_id, "error": "not found"})
|
||||||
continue
|
continue
|
||||||
elif album is not None:
|
elif album is not None:
|
||||||
album_asset_ids = set(album.asset_ids)
|
album_asset_ids = set(album.asset_ids)
|
||||||
|
if not album.thumbnail_asset_id:
|
||||||
|
needs_thumbnail = True
|
||||||
|
|
||||||
new_asset_ids = [aid for aid in candidate_ids if aid not in album_asset_ids]
|
new_asset_ids = [aid for aid in candidate_ids if aid not in album_asset_ids]
|
||||||
skipped = len(candidate_ids) - len(new_asset_ids)
|
skipped = len(candidate_ids) - len(new_asset_ids)
|
||||||
|
|
||||||
|
thumbnail_set_id: str | None = None
|
||||||
if new_asset_ids and not dry_run and album_id:
|
if new_asset_ids and not dry_run and album_id:
|
||||||
for i in range(0, len(new_asset_ids), 500):
|
for i in range(0, len(new_asset_ids), 500):
|
||||||
batch = new_asset_ids[i : i + 500]
|
batch = new_asset_ids[i : i + 500]
|
||||||
await self._client.add_assets_to_album(album_id, batch)
|
await self._client.add_assets_to_album(album_id, batch)
|
||||||
_LOGGER.info("Added %d assets to album %s", len(new_asset_ids), album_id)
|
_LOGGER.info("Added %d assets to album %s", len(new_asset_ids), album_id)
|
||||||
|
|
||||||
|
# Best-effort: give newly-created/empty-thumbnail albums a
|
||||||
|
# cover. Prefer the first image; fall back to the first
|
||||||
|
# added asset of any type if none are images (Immich renders
|
||||||
|
# a video poster, which still looks fine). Failures here
|
||||||
|
# must not fail the rule — the add already succeeded.
|
||||||
|
if needs_thumbnail:
|
||||||
|
pick = next(
|
||||||
|
(aid for aid in new_asset_ids if (types_by_id.get(aid) or "").lower() == "image"),
|
||||||
|
None,
|
||||||
|
) or new_asset_ids[0]
|
||||||
|
try:
|
||||||
|
await self._client.set_album_thumbnail(album_id, pick)
|
||||||
|
thumbnail_set_id = pick
|
||||||
|
_LOGGER.info("Set thumbnail of album %s to %s", album_id, pick)
|
||||||
|
except ImmichApiError as err:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Could not set thumbnail for album %s: %s", album_id, err
|
||||||
|
)
|
||||||
elif dry_run and new_asset_ids:
|
elif dry_run and new_asset_ids:
|
||||||
_LOGGER.info("[DRY RUN] Would add %d assets to album %s", len(new_asset_ids), album_id)
|
_LOGGER.info("[DRY RUN] Would add %d assets to album %s", len(new_asset_ids), album_id)
|
||||||
|
if needs_thumbnail:
|
||||||
|
_LOGGER.info("[DRY RUN] Would set album %s thumbnail to first added asset", album_id)
|
||||||
|
|
||||||
total_affected += len(new_asset_ids)
|
total_affected += len(new_asset_ids)
|
||||||
total_skipped += skipped
|
total_skipped += skipped
|
||||||
album_details.append({"album_id": album_id, "added": len(new_asset_ids), "skipped": skipped})
|
detail = {"album_id": album_id, "added": len(new_asset_ids), "skipped": skipped}
|
||||||
|
if thumbnail_set_id:
|
||||||
|
detail["thumbnail_set_to"] = thumbnail_set_id
|
||||||
|
album_details.append(detail)
|
||||||
|
|
||||||
return RuleResult(
|
return RuleResult(
|
||||||
rule_name=rule_name,
|
rule_name=rule_name,
|
||||||
@@ -228,10 +265,16 @@ class ImmichActionExecutor(ActionExecutor):
|
|||||||
|
|
||||||
async def _gather_candidates(
|
async def _gather_candidates(
|
||||||
self, criteria: dict[str, Any]
|
self, criteria: dict[str, Any]
|
||||||
) -> list[str]:
|
) -> tuple[list[str], dict[str, str]]:
|
||||||
"""Gather asset IDs matching the criteria (union of all sources)."""
|
"""Gather asset IDs matching the criteria (union of all sources).
|
||||||
|
|
||||||
|
Returns ``(ordered_ids, types_by_id)`` so callers that need asset
|
||||||
|
type — e.g. picking a photo for an album thumbnail — don't have to
|
||||||
|
re-fetch each asset.
|
||||||
|
"""
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
result: list[str] = []
|
result: list[str] = []
|
||||||
|
types_by_id: dict[str, str] = {}
|
||||||
|
|
||||||
# Source 1: Person assets
|
# Source 1: Person assets
|
||||||
person_ids = criteria.get("person_ids", [])
|
person_ids = criteria.get("person_ids", [])
|
||||||
@@ -243,6 +286,7 @@ class ImmichActionExecutor(ActionExecutor):
|
|||||||
if self._matches_filters(asset, criteria):
|
if self._matches_filters(asset, criteria):
|
||||||
seen.add(aid)
|
seen.add(aid)
|
||||||
result.append(aid)
|
result.append(aid)
|
||||||
|
types_by_id[aid] = asset.get("type", "") or ""
|
||||||
|
|
||||||
# Source 2: Smart search
|
# Source 2: Smart search
|
||||||
query = criteria.get("query", "")
|
query = criteria.get("query", "")
|
||||||
@@ -254,6 +298,7 @@ class ImmichActionExecutor(ActionExecutor):
|
|||||||
if self._matches_filters(asset, criteria):
|
if self._matches_filters(asset, criteria):
|
||||||
seen.add(aid)
|
seen.add(aid)
|
||||||
result.append(aid)
|
result.append(aid)
|
||||||
|
types_by_id[aid] = asset.get("type", "") or ""
|
||||||
|
|
||||||
# Exclude assets belonging to excluded persons
|
# Exclude assets belonging to excluded persons
|
||||||
exclude_person_ids = criteria.get("exclude_person_ids", [])
|
exclude_person_ids = criteria.get("exclude_person_ids", [])
|
||||||
@@ -266,8 +311,12 @@ class ImmichActionExecutor(ActionExecutor):
|
|||||||
if aid:
|
if aid:
|
||||||
excluded_asset_ids.add(aid)
|
excluded_asset_ids.add(aid)
|
||||||
result = [aid for aid in result if aid not in excluded_asset_ids]
|
result = [aid for aid in result if aid not in excluded_asset_ids]
|
||||||
|
for aid in list(types_by_id):
|
||||||
|
if aid not in excluded_asset_ids:
|
||||||
|
continue
|
||||||
|
types_by_id.pop(aid, None)
|
||||||
|
|
||||||
return result
|
return result, types_by_id
|
||||||
|
|
||||||
def _matches_filters(
|
def _matches_filters(
|
||||||
self, asset: dict[str, Any], criteria: dict[str, Any]
|
self, asset: dict[str, Any], criteria: dict[str, Any]
|
||||||
|
|||||||
@@ -193,6 +193,27 @@ def get_asset_video_url(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_asset_media_urls(
|
||||||
|
external_url: str, asset_id: str, asset_type: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Return ``(preview_url, full_url)`` for an Immich asset.
|
||||||
|
|
||||||
|
Single source of truth for the photo-vs-video endpoint rule. Used by
|
||||||
|
``asset_to_media`` (notification path) and the bot command handlers
|
||||||
|
(command path) so both always pick the transcoded ``/video/playback``
|
||||||
|
for videos and the preview-sized thumbnail for photos — if they
|
||||||
|
diverge, Telegram ends up delivering a still JPEG for videos in a
|
||||||
|
media group.
|
||||||
|
"""
|
||||||
|
is_video = asset_type == ASSET_TYPE_VIDEO
|
||||||
|
if is_video:
|
||||||
|
preview_url = f"{external_url}/api/assets/{asset_id}/video/playback"
|
||||||
|
else:
|
||||||
|
preview_url = f"{external_url}/api/assets/{asset_id}/thumbnail?size=preview"
|
||||||
|
full_url = f"{external_url}/api/assets/{asset_id}/original"
|
||||||
|
return preview_url, full_url
|
||||||
|
|
||||||
|
|
||||||
def build_asset_detail(
|
def build_asset_detail(
|
||||||
asset: ImmichAssetInfo,
|
asset: ImmichAssetInfo,
|
||||||
external_url: str,
|
external_url: str,
|
||||||
@@ -243,6 +264,11 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
|
|||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
created_at = datetime.now(timezone.utc)
|
created_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# preview_url is what the notification dispatcher feeds to Telegram as the
|
||||||
|
# actual media bytes — for videos it must be the transcoded playback (mp4),
|
||||||
|
# not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4.
|
||||||
|
preview_url, full_url = build_asset_media_urls(external_url, asset.id, asset.type)
|
||||||
|
|
||||||
return MediaAsset(
|
return MediaAsset(
|
||||||
id=asset.id,
|
id=asset.id,
|
||||||
type=media_type,
|
type=media_type,
|
||||||
@@ -252,8 +278,8 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
|
|||||||
description=asset.description or None,
|
description=asset.description or None,
|
||||||
tags=list(asset.people),
|
tags=list(asset.people),
|
||||||
thumbnail_url=f"{external_url}/api/assets/{asset.id}/thumbnail",
|
thumbnail_url=f"{external_url}/api/assets/{asset.id}/thumbnail",
|
||||||
preview_url=f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview",
|
preview_url=preview_url,
|
||||||
full_url=f"{external_url}/api/assets/{asset.id}/original",
|
full_url=full_url,
|
||||||
extra={
|
extra={
|
||||||
"owner_id": asset.owner_id,
|
"owner_id": asset.owner_id,
|
||||||
"is_favorite": asset.is_favorite,
|
"is_favorite": asset.is_favorite,
|
||||||
@@ -264,7 +290,11 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
|
|||||||
"state": asset.state,
|
"state": asset.state,
|
||||||
"country": asset.country,
|
"country": asset.country,
|
||||||
"thumbhash": asset.thumbhash,
|
"thumbhash": asset.thumbhash,
|
||||||
|
# file_size = original asset bytes (from exifInfo.fileSizeInByte).
|
||||||
|
# playback_size = bytes we will actually upload (videos: transcoded
|
||||||
|
# /video/playback). Populated lazily at dispatch time via HEAD.
|
||||||
"file_size": asset.file_size,
|
"file_size": asset.file_size,
|
||||||
|
"playback_size": None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -303,17 +333,27 @@ def collect_scheduled_assets(
|
|||||||
memory_date = now.isoformat() if is_memory else None
|
memory_date = now.isoformat() if is_memory else None
|
||||||
|
|
||||||
all_eligible: list[ImmichAssetInfo] = []
|
all_eligible: list[ImmichAssetInfo] = []
|
||||||
# Track which album each asset belongs to for public URL construction
|
# Track which album each asset belongs to. Public URL is used to construct
|
||||||
asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url)
|
# a per-asset share link; name/internal-url are surfaced to templates so
|
||||||
|
# combined-mode sends can attribute each row to its source album.
|
||||||
|
asset_album_map: dict[str, tuple[str, str, str, str]] = {}
|
||||||
|
# asset_id → (album_id, album_public_url, album_name, album_internal_url)
|
||||||
collections_extra: list[dict[str, Any]] = []
|
collections_extra: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# limit=0 is the periodic-summary test path — the caller only needs
|
||||||
|
# per-album stats (name/url/counts), not a sample of assets. Skip the
|
||||||
|
# expensive ``filter_assets`` + sampling loop entirely; on a 50k-asset
|
||||||
|
# album the serial scan-then-discard pattern wasted seconds per test.
|
||||||
|
stats_only = limit <= 0
|
||||||
|
|
||||||
for album_id, album in albums.items():
|
for album_id, album in albums.items():
|
||||||
links = shared_links.get(album_id, [])
|
links = shared_links.get(album_id, [])
|
||||||
album_public_url = get_public_url(external_url, links) or ""
|
album_public_url = get_public_url(external_url, links) or ""
|
||||||
|
album_internal_url = f"{external_url}/albums/{album_id}"
|
||||||
|
|
||||||
collections_extra.append({
|
collections_extra.append({
|
||||||
"name": album.name,
|
"name": album.name,
|
||||||
"url": album_public_url or f"{external_url}/albums/{album_id}",
|
"url": album_public_url or album_internal_url,
|
||||||
"public_url": album_public_url,
|
"public_url": album_public_url,
|
||||||
"asset_count": album.asset_count,
|
"asset_count": album.asset_count,
|
||||||
"shared": album.shared,
|
"shared": album.shared,
|
||||||
@@ -322,6 +362,9 @@ def collect_scheduled_assets(
|
|||||||
"owner": album.owner,
|
"owner": album.owner,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if stats_only:
|
||||||
|
continue
|
||||||
|
|
||||||
filtered = filter_assets(
|
filtered = filter_assets(
|
||||||
list(album.assets.values()),
|
list(album.assets.values()),
|
||||||
favorite_only=favorite_only,
|
favorite_only=favorite_only,
|
||||||
@@ -331,9 +374,14 @@ def collect_scheduled_assets(
|
|||||||
)
|
)
|
||||||
for asset in filtered:
|
for asset in filtered:
|
||||||
if asset.id not in asset_album_map:
|
if asset.id not in asset_album_map:
|
||||||
asset_album_map[asset.id] = (album_id, album_public_url)
|
asset_album_map[asset.id] = (
|
||||||
|
album_id, album_public_url, album.name, album_internal_url,
|
||||||
|
)
|
||||||
all_eligible.append(asset)
|
all_eligible.append(asset)
|
||||||
|
|
||||||
|
if stats_only:
|
||||||
|
return [], collections_extra
|
||||||
|
|
||||||
# Random sample
|
# Random sample
|
||||||
if len(all_eligible) > limit:
|
if len(all_eligible) > limit:
|
||||||
selected = random.sample(all_eligible, limit)
|
selected = random.sample(all_eligible, limit)
|
||||||
@@ -341,15 +389,25 @@ def collect_scheduled_assets(
|
|||||||
random.shuffle(all_eligible)
|
random.shuffle(all_eligible)
|
||||||
selected = all_eligible
|
selected = all_eligible
|
||||||
|
|
||||||
# Convert to MediaAsset with public URLs
|
# Convert to MediaAsset with public URLs. Per-asset album_name/album_url
|
||||||
|
# let combined-mode templates attribute each row to its source album —
|
||||||
|
# critical when a tracker spans multiple albums, where the event-level
|
||||||
|
# ``album_name`` (first album only) would be misleading.
|
||||||
result: list[MediaAsset] = []
|
result: list[MediaAsset] = []
|
||||||
for asset in selected:
|
for asset in selected:
|
||||||
media = asset_to_media(asset, external_url)
|
media = asset_to_media(asset, external_url)
|
||||||
_, album_pub_url = asset_album_map.get(asset.id, ("", ""))
|
mapped = asset_album_map.get(asset.id)
|
||||||
|
if mapped:
|
||||||
|
_, album_pub_url, album_name, album_internal_url = mapped
|
||||||
|
else:
|
||||||
|
album_pub_url = album_name = album_internal_url = ""
|
||||||
if album_pub_url:
|
if album_pub_url:
|
||||||
media.extra["public_url"] = f"{album_pub_url}/photos/{asset.id}"
|
media.extra["public_url"] = f"{album_pub_url}/photos/{asset.id}"
|
||||||
else:
|
else:
|
||||||
media.extra.setdefault("public_url", "")
|
media.extra.setdefault("public_url", "")
|
||||||
|
media.extra["album_name"] = album_name
|
||||||
|
media.extra["album_url"] = album_pub_url or album_internal_url
|
||||||
|
media.extra["album_public_url"] = album_pub_url
|
||||||
result.append(media)
|
result.append(media)
|
||||||
|
|
||||||
return result, collections_extra
|
return result, collections_extra
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ from .models import ImmichAlbumData, ImmichAssetInfo
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Guard against runaway payloads when a bulk import lands in one poll tick.
|
||||||
|
# Templates iterate every entry in ``added_assets`` / ``removed_asset_ids``
|
||||||
|
# in Jinja for-loops (see defaults/*/assets_added.jinja2), and Telegram's
|
||||||
|
# media group has a hard cap of its own — sending 200k entries would both
|
||||||
|
# crash rendering and produce a message that no transport can deliver.
|
||||||
|
#
|
||||||
|
# ``added_count`` / ``removed_count`` on the event always carry the true
|
||||||
|
# totals so templates can show an accurate "N added" number even when the
|
||||||
|
# per-asset list is truncated.
|
||||||
|
_MAX_ASSETS_PER_EVENT = 50
|
||||||
|
_MAX_REMOVALS_PER_EVENT = 200
|
||||||
|
|
||||||
|
|
||||||
def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict:
|
def _make_base_extra(new_album: ImmichAlbumData, external_url: str) -> dict:
|
||||||
"""Build the common extra dict for album events."""
|
"""Build the common extra dict for album events."""
|
||||||
@@ -85,7 +97,17 @@ def detect_album_changes(
|
|||||||
|
|
||||||
# Emit one event per change type detected
|
# Emit one event per change type detected
|
||||||
if added_assets:
|
if added_assets:
|
||||||
media_assets = [asset_to_media(a, external_url) for a in added_assets]
|
total_added = len(added_assets)
|
||||||
|
truncated_added = added_assets[:_MAX_ASSETS_PER_EVENT]
|
||||||
|
media_assets = [asset_to_media(a, external_url) for a in truncated_added]
|
||||||
|
event_extra = dict(extra)
|
||||||
|
if total_added > _MAX_ASSETS_PER_EVENT:
|
||||||
|
event_extra["truncated"] = True
|
||||||
|
event_extra["shown_count"] = _MAX_ASSETS_PER_EVENT
|
||||||
|
_LOGGER.info(
|
||||||
|
"Truncated assets_added event for album %s: %d → %d",
|
||||||
|
new_album.id, total_added, _MAX_ASSETS_PER_EVENT,
|
||||||
|
)
|
||||||
events.append(ServiceEvent(
|
events.append(ServiceEvent(
|
||||||
event_type=EventType.ASSETS_ADDED,
|
event_type=EventType.ASSETS_ADDED,
|
||||||
provider_type=ServiceProviderType.IMMICH,
|
provider_type=ServiceProviderType.IMMICH,
|
||||||
@@ -95,12 +117,22 @@ def detect_album_changes(
|
|||||||
timestamp=now,
|
timestamp=now,
|
||||||
added_assets=media_assets,
|
added_assets=media_assets,
|
||||||
removed_asset_ids=[],
|
removed_asset_ids=[],
|
||||||
added_count=len(added_assets),
|
added_count=total_added,
|
||||||
removed_count=0,
|
removed_count=0,
|
||||||
extra=dict(extra),
|
extra=event_extra,
|
||||||
))
|
))
|
||||||
|
|
||||||
if removed_ids:
|
if removed_ids:
|
||||||
|
total_removed = len(removed_ids)
|
||||||
|
truncated_removed = list(removed_ids)[:_MAX_REMOVALS_PER_EVENT]
|
||||||
|
event_extra = dict(extra)
|
||||||
|
if total_removed > _MAX_REMOVALS_PER_EVENT:
|
||||||
|
event_extra["truncated"] = True
|
||||||
|
event_extra["shown_count"] = _MAX_REMOVALS_PER_EVENT
|
||||||
|
_LOGGER.info(
|
||||||
|
"Truncated assets_removed event for album %s: %d → %d",
|
||||||
|
new_album.id, total_removed, _MAX_REMOVALS_PER_EVENT,
|
||||||
|
)
|
||||||
events.append(ServiceEvent(
|
events.append(ServiceEvent(
|
||||||
event_type=EventType.ASSETS_REMOVED,
|
event_type=EventType.ASSETS_REMOVED,
|
||||||
provider_type=ServiceProviderType.IMMICH,
|
provider_type=ServiceProviderType.IMMICH,
|
||||||
@@ -109,10 +141,10 @@ def detect_album_changes(
|
|||||||
collection_name=new_album.name,
|
collection_name=new_album.name,
|
||||||
timestamp=now,
|
timestamp=now,
|
||||||
added_assets=[],
|
added_assets=[],
|
||||||
removed_asset_ids=list(removed_ids),
|
removed_asset_ids=truncated_removed,
|
||||||
added_count=0,
|
added_count=0,
|
||||||
removed_count=len(removed_ids),
|
removed_count=total_removed,
|
||||||
extra=dict(extra),
|
extra=event_extra,
|
||||||
))
|
))
|
||||||
|
|
||||||
if name_changed:
|
if name_changed:
|
||||||
|
|||||||
@@ -2,15 +2,96 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from .models import ImmichAlbumData, SharedLinkInfo
|
from ...notifications.ssrf import UnsafeURLError, validate_outbound_url
|
||||||
|
from .models import ImmichAlbumData, ImmichAlbumMeta, SharedLinkInfo
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cap user-controlled Immich search parameters so a low-privileged command
|
||||||
|
# listener (e.g. an Immich ``/search`` command) cannot DoS the upstream.
|
||||||
|
MAX_SEARCH_QUERY_LEN = 256
|
||||||
|
MAX_SEARCH_PERSON_IDS = 50
|
||||||
|
|
||||||
|
# Module-level TTL caches for album bodies and shared-link listings. The
|
||||||
|
# Immich ``GET /api/albums/{id}`` response can be tens or hundreds of MB on a
|
||||||
|
# large album, and bot commands like /random, /latest, /memory all refetch
|
||||||
|
# the same album in quick succession. A short TTL makes repeat runs nearly
|
||||||
|
# instant and deduplicates concurrent fetches so a burst of commands issues
|
||||||
|
# one HTTP call instead of N.
|
||||||
|
#
|
||||||
|
# Caches are module-scoped (not instance-scoped) because ``ImmichClient`` is
|
||||||
|
# constructed fresh per request in several places (api/providers.py,
|
||||||
|
# services/action_runner.py, command handlers), so an instance cache would
|
||||||
|
# never survive to serve a second caller. This mirrors ``_users_cache`` in
|
||||||
|
# ``provider.py``.
|
||||||
|
_ALBUM_CACHE_TTL_SECONDS = 60
|
||||||
|
_SHARED_LINKS_CACHE_TTL_SECONDS = 60
|
||||||
|
# Guard rail against runaway memory — a 200k-asset album response can be
|
||||||
|
# ~150 MB, so even modest caps bound the worst case.
|
||||||
|
_ALBUM_CACHE_MAX_ENTRIES = 32
|
||||||
|
_album_cache_lock = asyncio.Lock()
|
||||||
|
# key = (server_digest, album_id); value = (monotonic_ts, raw_api_dict)
|
||||||
|
# Store the raw dict rather than the parsed ``ImmichAlbumData`` so callers
|
||||||
|
# that pass a ``users_cache`` still get owner-name enrichment on cache hits.
|
||||||
|
_album_cache: dict[tuple[str, str], tuple[float, dict[str, Any]]] = {}
|
||||||
|
_shared_links_cache_lock = asyncio.Lock()
|
||||||
|
# key = server_digest; value = (monotonic_ts, {album_id: [SharedLinkInfo, ...]})
|
||||||
|
# The underlying ``/api/shared-links`` endpoint has no per-album filter, so
|
||||||
|
# every call was already paying for the full server-wide list. Caching the
|
||||||
|
# bucketed result once per server turns N per-album calls into one fetch.
|
||||||
|
_shared_links_cache: dict[str, tuple[float, dict[str, list[SharedLinkInfo]]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _server_digest(url: str, api_key: str) -> str:
|
||||||
|
"""Hashed key that avoids putting raw api_key into cache dict keys."""
|
||||||
|
return hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_album_cache() -> None:
|
||||||
|
"""Drop every cached album body. Call after mutations that invalidate
|
||||||
|
the cached view (e.g. integration tests, manual /refresh commands)."""
|
||||||
|
_album_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_shared_links_cache() -> None:
|
||||||
|
"""Drop every cached shared-link listing."""
|
||||||
|
_shared_links_cache.clear()
|
||||||
|
|
||||||
|
# User-facing error bodies — Immich responses may leak internal paths,
|
||||||
|
# hostnames, or headers injected by intermediary proxies. These helpers keep
|
||||||
|
# only a short, scrubbed summary; full bodies are logged server-side only.
|
||||||
|
_REDACTED_BODY_MAX = 120
|
||||||
|
_SECRET_PATTERN = re.compile(
|
||||||
|
r"(?i)(bearer\s+\S+|x-api-key[:=]\s*\S+|authorization[:=]\s*\S+|cookie[:=]\s*\S+|"
|
||||||
|
r"password[:=]?\s*\S+|token[:=]?\s*[A-Za-z0-9._\-]+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_body(text: str) -> str:
|
||||||
|
"""Return a short, credential-scrubbed snippet safe to surface to UI callers.
|
||||||
|
|
||||||
|
Immich error responses are admin-configurable (via reverse proxies, custom
|
||||||
|
error pages) and may echo request headers or environment leak. Stripping
|
||||||
|
anything that looks like a credential + capping length keeps us from
|
||||||
|
persisting secrets into ``ActionExecution.error`` / ``EventLog.details``
|
||||||
|
(both of which are returned through the dashboard API).
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
cleaned = _SECRET_PATTERN.sub("[redacted]", text)
|
||||||
|
if len(cleaned) > _REDACTED_BODY_MAX:
|
||||||
|
return cleaned[:_REDACTED_BODY_MAX] + "..."
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
class ImmichClient:
|
class ImmichClient:
|
||||||
"""Async client for the Immich API."""
|
"""Async client for the Immich API."""
|
||||||
@@ -25,6 +106,19 @@ class ImmichClient:
|
|||||||
self._url = url.rstrip("/")
|
self._url = url.rstrip("/")
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._external_domain: str | None = None
|
self._external_domain: str | None = None
|
||||||
|
# SSRF guard — admin-set Immich URLs are loaded from provider config
|
||||||
|
# which can be mutated via PATCH /api/providers or imported via
|
||||||
|
# prepare-restore, so we revalidate at construction time rather than
|
||||||
|
# trusting DB state. Homelab deployments pointing at RFC1918 targets
|
||||||
|
# must set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the runtime env.
|
||||||
|
if self._url:
|
||||||
|
try:
|
||||||
|
validate_outbound_url(self._url)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
raise UnsafeURLError(
|
||||||
|
f"Refusing to build ImmichClient for unsafe URL {self._url!r}: {err}. "
|
||||||
|
"If this is a LAN/homelab Immich, set NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1."
|
||||||
|
) from err
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
@@ -36,6 +130,14 @@ class ImmichClient:
|
|||||||
|
|
||||||
@external_domain.setter
|
@external_domain.setter
|
||||||
def external_domain(self, value: str | None) -> None:
|
def external_domain(self, value: str | None) -> None:
|
||||||
|
# Mirror the constructor's SSRF guard. Set
|
||||||
|
# ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` for LAN/homelab targets.
|
||||||
|
if value:
|
||||||
|
try:
|
||||||
|
validate_outbound_url(value)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
_LOGGER.warning("Ignoring unsafe external_domain %r: %s", value, err)
|
||||||
|
return
|
||||||
self._external_domain = value
|
self._external_domain = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -130,28 +232,100 @@ class ImmichClient:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]:
|
async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]:
|
||||||
links: list[SharedLinkInfo] = []
|
bucketed = await self._get_shared_links_bucketed()
|
||||||
|
return list(bucketed.get(album_id, []))
|
||||||
|
|
||||||
|
async def _get_shared_links_bucketed(self) -> dict[str, list[SharedLinkInfo]]:
|
||||||
|
"""Return ``{album_id: [SharedLinkInfo, ...]}`` for the server, hitting
|
||||||
|
the module-level TTL cache first. Underlying Immich endpoint has no
|
||||||
|
per-album filter, so one server-wide fetch serves every caller until
|
||||||
|
the TTL elapses.
|
||||||
|
"""
|
||||||
|
digest = _server_digest(self._url, self._api_key)
|
||||||
|
now = time.monotonic()
|
||||||
|
entry = _shared_links_cache.get(digest)
|
||||||
|
if entry is not None and (now - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
|
||||||
|
return entry[1]
|
||||||
|
|
||||||
|
async with _shared_links_cache_lock:
|
||||||
|
# Re-check under the lock — another coroutine may have refreshed
|
||||||
|
# while we waited.
|
||||||
|
entry = _shared_links_cache.get(digest)
|
||||||
|
if entry is not None and (time.monotonic() - entry[0]) < _SHARED_LINKS_CACHE_TTL_SECONDS:
|
||||||
|
return entry[1]
|
||||||
|
fresh = await self.get_all_shared_links_by_album()
|
||||||
|
_shared_links_cache[digest] = (time.monotonic(), fresh)
|
||||||
|
return fresh
|
||||||
|
|
||||||
|
async def get_all_shared_links_by_album(self) -> dict[str, list[SharedLinkInfo]]:
|
||||||
|
"""Fetch every shared link on the server, bucketed by album id.
|
||||||
|
|
||||||
|
Immich's ``/api/shared-links`` endpoint is server-wide — there's no
|
||||||
|
per-album filter server-side — so every call that wanted the links
|
||||||
|
for a single album was already paying the cost of the full listing
|
||||||
|
and then discarding most of the response. Callers that need links
|
||||||
|
for multiple albums in one tick should use this method and index
|
||||||
|
into the returned dict instead of hitting ``get_shared_links`` in
|
||||||
|
a loop.
|
||||||
|
|
||||||
|
Returns an empty dict on any error (matches the silent-failure
|
||||||
|
contract of ``get_shared_links`` so callers don't need to branch
|
||||||
|
on transient outages).
|
||||||
|
"""
|
||||||
|
result: dict[str, list[SharedLinkInfo]] = {}
|
||||||
try:
|
try:
|
||||||
async with self._session.get(
|
async with self._session.get(
|
||||||
f"{self._url}/api/shared-links",
|
f"{self._url}/api/shared-links",
|
||||||
headers=self._headers,
|
headers=self._headers,
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
if response.status != 200:
|
||||||
data = await response.json()
|
_LOGGER.warning(
|
||||||
for link in data:
|
"get_all_shared_links non-200: HTTP %s", response.status
|
||||||
album = link.get("album")
|
)
|
||||||
key = link.get("key")
|
return result
|
||||||
if album and key and album.get("id") == album_id:
|
data = await response.json()
|
||||||
links.append(SharedLinkInfo.from_api_response(link))
|
for link in data:
|
||||||
|
album = link.get("album")
|
||||||
|
key = link.get("key")
|
||||||
|
if not (album and key):
|
||||||
|
continue
|
||||||
|
aid = album.get("id")
|
||||||
|
if not aid:
|
||||||
|
continue
|
||||||
|
result.setdefault(aid, []).append(
|
||||||
|
SharedLinkInfo.from_api_response(link)
|
||||||
|
)
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
_LOGGER.warning("Failed to fetch shared links: %s", err)
|
_LOGGER.warning("Failed to fetch all shared links: %s", err)
|
||||||
return links
|
return result
|
||||||
|
|
||||||
async def get_album(
|
async def get_album(
|
||||||
self,
|
self,
|
||||||
album_id: str,
|
album_id: str,
|
||||||
users_cache: dict[str, str] | None = None,
|
users_cache: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
use_cache: bool = True,
|
||||||
) -> ImmichAlbumData | None:
|
) -> ImmichAlbumData | None:
|
||||||
|
"""Fetch an album by id, optionally serving from the module-level
|
||||||
|
TTL cache. Pass ``use_cache=False`` from paths that must observe the
|
||||||
|
current server state (e.g. the notification poll loop's full-fetch
|
||||||
|
path, where a stale cached entry would delay asset-removal events).
|
||||||
|
Non-cached fetches still populate the cache for subsequent readers.
|
||||||
|
"""
|
||||||
|
cache_key = (_server_digest(self._url, self._api_key), album_id)
|
||||||
|
if use_cache:
|
||||||
|
entry = _album_cache.get(cache_key)
|
||||||
|
if entry is not None and (time.monotonic() - entry[0]) < _ALBUM_CACHE_TTL_SECONDS:
|
||||||
|
# Rehydrate per-call so ``users_cache`` enrichment is applied
|
||||||
|
# with the caller's dict, not whichever one was live when the
|
||||||
|
# cache was populated.
|
||||||
|
return ImmichAlbumData.from_api_response(entry[1], users_cache)
|
||||||
|
|
||||||
|
# Deliberately fetch without holding a lock so concurrent calls for
|
||||||
|
# *different* album_ids (the common case from asyncio.gather in
|
||||||
|
# fetch_albums_with_links) stay parallel. The worst case is a small
|
||||||
|
# duplicate-fetch stampede when two requests miss the same album at
|
||||||
|
# the same instant — acceptable for our scale.
|
||||||
try:
|
try:
|
||||||
async with self._session.get(
|
async with self._session.get(
|
||||||
f"{self._url}/api/albums/{album_id}",
|
f"{self._url}/api/albums/{album_id}",
|
||||||
@@ -164,10 +338,132 @@ class ImmichClient:
|
|||||||
f"Error fetching album {album_id}: HTTP {response.status}"
|
f"Error fetching album {album_id}: HTTP {response.status}"
|
||||||
)
|
)
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
return ImmichAlbumData.from_api_response(data, users_cache)
|
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
|
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
|
||||||
|
|
||||||
|
async with _album_cache_lock:
|
||||||
|
# Evict the oldest entry if we're at the cap — simple FIFO is fine
|
||||||
|
# for our access pattern (commands touch a small working set).
|
||||||
|
if len(_album_cache) >= _ALBUM_CACHE_MAX_ENTRIES and cache_key not in _album_cache:
|
||||||
|
oldest = min(_album_cache.items(), key=lambda kv: kv[1][0])[0]
|
||||||
|
_album_cache.pop(oldest, None)
|
||||||
|
_album_cache[cache_key] = (time.monotonic(), data)
|
||||||
|
return ImmichAlbumData.from_api_response(data, users_cache)
|
||||||
|
|
||||||
|
async def get_album_meta(self, album_id: str) -> ImmichAlbumMeta | None:
|
||||||
|
"""Fetch album metadata without the assets array.
|
||||||
|
|
||||||
|
Uses Immich's ``?withoutAssets=true`` query param, which skips the
|
||||||
|
(potentially huge) ``assets`` field. A 200k-asset album response
|
||||||
|
drops from ~150 MB to a few hundred bytes, so this is cheap enough
|
||||||
|
to run on every poll as a change-detection probe.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._session.get(
|
||||||
|
f"{self._url}/api/albums/{album_id}",
|
||||||
|
params={"withoutAssets": "true"},
|
||||||
|
headers=self._headers,
|
||||||
|
) as response:
|
||||||
|
if response.status == 404:
|
||||||
|
return None
|
||||||
|
if response.status != 200:
|
||||||
|
raise ImmichApiError(
|
||||||
|
f"Error fetching album meta {album_id}: HTTP {response.status}"
|
||||||
|
)
|
||||||
|
data = await response.json()
|
||||||
|
return ImmichAlbumMeta.from_api_response(data)
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
|
||||||
|
|
||||||
|
async def search_album_assets_updated_after(
|
||||||
|
self,
|
||||||
|
album_id: str,
|
||||||
|
updated_after: str,
|
||||||
|
*,
|
||||||
|
page_size: int = 1000,
|
||||||
|
max_pages: int = 50,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch assets in ``album_id`` whose ``updatedAt`` is after ``updated_after``.
|
||||||
|
|
||||||
|
Uses ``POST /api/search/metadata`` with ``albumIds=[album_id]`` and
|
||||||
|
``updatedAfter=<iso>``. Paginates through up to ``max_pages`` pages —
|
||||||
|
the cap exists so a clock-skew or upstream bug cannot produce an
|
||||||
|
infinite loop that exhausts memory on a 200k-asset album. In practice
|
||||||
|
an active album sees a few hundred updated assets per tick and
|
||||||
|
terminates after one page.
|
||||||
|
|
||||||
|
Returns raw Immich asset dicts (same shape as ``album.assets[*]``
|
||||||
|
from ``get_album``), so callers can feed them into
|
||||||
|
``ImmichAssetInfo.from_api_response`` directly.
|
||||||
|
"""
|
||||||
|
if not updated_after:
|
||||||
|
return []
|
||||||
|
|
||||||
|
page_size = max(1, min(page_size, 1000))
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for page in range(1, max_pages + 1):
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"albumIds": [album_id],
|
||||||
|
"updatedAfter": updated_after,
|
||||||
|
"page": page,
|
||||||
|
"size": page_size,
|
||||||
|
# ``withExif`` keeps location/description parity with
|
||||||
|
# ``get_album`` so downstream ``ImmichAssetInfo.from_api_response``
|
||||||
|
# populates city/country/rating on the delta path too.
|
||||||
|
"withExif": True,
|
||||||
|
"withPeople": True,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with self._session.post(
|
||||||
|
f"{self._url}/api/search/metadata",
|
||||||
|
headers=self._json_headers,
|
||||||
|
json=payload,
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
body_snip = await response.text()
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Immich delta search non-200: HTTP %s body=%s",
|
||||||
|
response.status, _redact_body(body_snip),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
data = await response.json()
|
||||||
|
assets_block = data.get("assets")
|
||||||
|
if isinstance(assets_block, dict):
|
||||||
|
items = assets_block.get("items", []) or []
|
||||||
|
next_page = assets_block.get("nextPage")
|
||||||
|
elif isinstance(assets_block, list):
|
||||||
|
items = assets_block
|
||||||
|
next_page = None
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Immich delta search returned unexpected shape: keys=%s",
|
||||||
|
list(data.keys())[:5],
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
results.extend(items)
|
||||||
|
|
||||||
|
# Stop early on the last page. Immich returns nextPage as
|
||||||
|
# the next page number (string or int) or None/empty when
|
||||||
|
# exhausted. Fall back to page-fullness heuristic if the
|
||||||
|
# server omits the pagination hint.
|
||||||
|
if next_page is None or next_page == "" or next_page == 0:
|
||||||
|
break
|
||||||
|
if len(items) < page_size:
|
||||||
|
break
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.warning("Immich delta search transport error: %s", err)
|
||||||
|
break
|
||||||
|
except Exception as err: # noqa: BLE001 — resilience over correctness
|
||||||
|
_LOGGER.warning("Immich delta search parse error: %s", err)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Immich delta search for album %s hit max_pages=%d cap",
|
||||||
|
album_id, max_pages,
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
async def get_albums(self) -> list[dict[str, Any]]:
|
async def get_albums(self) -> list[dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
async with self._session.get(
|
async with self._session.get(
|
||||||
@@ -235,33 +531,97 @@ class ImmichClient:
|
|||||||
query: str,
|
query: str,
|
||||||
album_ids: list[str] | None = None,
|
album_ids: list[str] | None = None,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
|
page: int = 1,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
payload: dict[str, Any] = {"query": query, "page": 1, "size": limit}
|
# Cap user-controlled inputs — a low-privileged Telegram listener can
|
||||||
|
# craft arbitrarily long queries to DoS the upstream Immich.
|
||||||
|
query = (query or "")[:MAX_SEARCH_QUERY_LEN]
|
||||||
|
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": min(max(1, limit), 100)}
|
||||||
if album_ids:
|
if album_ids:
|
||||||
payload["albumIds"] = album_ids
|
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
|
||||||
try:
|
return await self._search_items(
|
||||||
async with self._session.post(
|
f"{self._url}/api/search/smart", payload, limit, "smart",
|
||||||
f"{self._url}/api/search/smart",
|
)
|
||||||
headers=self._json_headers,
|
|
||||||
json=payload,
|
|
||||||
) as response:
|
|
||||||
if response.status == 200:
|
|
||||||
data = await response.json()
|
|
||||||
items = data.get("assets", {}).get("items", [])
|
|
||||||
return items[:limit]
|
|
||||||
except aiohttp.ClientError:
|
|
||||||
pass
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def search_metadata(
|
async def search_metadata(
|
||||||
self,
|
self,
|
||||||
query: str,
|
query: str,
|
||||||
album_ids: list[str] | None = None,
|
album_ids: list[str] | None = None,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
|
page: int = 1,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
payload: dict[str, Any] = {"originalFileName": query, "page": 1, "size": limit}
|
query = (query or "")[:MAX_SEARCH_QUERY_LEN]
|
||||||
|
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": min(max(1, limit), 100)}
|
||||||
if album_ids:
|
if album_ids:
|
||||||
payload["albumIds"] = album_ids
|
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
|
||||||
|
return await self._search_items(
|
||||||
|
f"{self._url}/api/search/metadata", payload, limit, "metadata",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _search_items(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
limit: int,
|
||||||
|
kind: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Shared POST-and-extract-items helper with error logging.
|
||||||
|
|
||||||
|
Returns an empty list on any error; previously these paths swallowed
|
||||||
|
non-200s silently, making "/search always returns no results" on
|
||||||
|
misbehaving Immich deployments impossible to diagnose without a
|
||||||
|
network trace. Logging keeps the empty-list contract but tells the
|
||||||
|
operator *why* it's empty.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with self._session.post(
|
||||||
|
url,
|
||||||
|
headers=self._json_headers,
|
||||||
|
json=payload,
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
body_snip = await response.text()
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Immich %s search non-200: HTTP %s body=%s",
|
||||||
|
kind, response.status, _redact_body(body_snip),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
data = await response.json()
|
||||||
|
# Modern Immich: {"assets": {"items": [...], ...}}
|
||||||
|
assets_block = data.get("assets")
|
||||||
|
if isinstance(assets_block, dict):
|
||||||
|
items = assets_block.get("items", []) or []
|
||||||
|
elif isinstance(assets_block, list):
|
||||||
|
# Older/alternate shape — flat list of assets.
|
||||||
|
items = assets_block
|
||||||
|
else:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Immich %s search returned unexpected shape: keys=%s",
|
||||||
|
kind, list(data.keys())[:5],
|
||||||
|
)
|
||||||
|
items = []
|
||||||
|
return items[:limit]
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.warning("Immich %s search transport error: %s", kind, err)
|
||||||
|
except Exception as err: # noqa: BLE001 — don't crash caller on unexpected JSON
|
||||||
|
_LOGGER.warning("Immich %s search parse error: %s", kind, err)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def search_by_person(
|
||||||
|
self, person_id: str, limit: int = 10
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch up to ``limit`` assets tagged with ``person_id``.
|
||||||
|
|
||||||
|
Uses ``POST /api/search/metadata`` with ``personIds`` — the public
|
||||||
|
``GET /api/people/{id}/assets`` endpoint was removed from Immich
|
||||||
|
around v1.106 and now silently 404s, which is why this method used
|
||||||
|
to return an empty list on current servers.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"personIds": [person_id][:MAX_SEARCH_PERSON_IDS],
|
||||||
|
"page": 1,
|
||||||
|
"size": max(1, min(limit, 100)),
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
async with self._session.post(
|
async with self._session.post(
|
||||||
f"{self._url}/api/search/metadata",
|
f"{self._url}/api/search/metadata",
|
||||||
@@ -276,21 +636,6 @@ class ImmichClient:
|
|||||||
pass
|
pass
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def search_by_person(
|
|
||||||
self, person_id: str, limit: int = 10
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
try:
|
|
||||||
async with self._session.get(
|
|
||||||
f"{self._url}/api/people/{person_id}/assets",
|
|
||||||
headers=self._headers,
|
|
||||||
) as response:
|
|
||||||
if response.status == 200:
|
|
||||||
data = await response.json()
|
|
||||||
return data[:limit] if isinstance(data, list) else []
|
|
||||||
except aiohttp.ClientError:
|
|
||||||
pass
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def get_memories(
|
async def get_memories(
|
||||||
self,
|
self,
|
||||||
date: str | None = None,
|
date: str | None = None,
|
||||||
@@ -329,7 +674,15 @@ class ImmichClient:
|
|||||||
async def add_assets_to_album(
|
async def add_assets_to_album(
|
||||||
self, album_id: str, asset_ids: list[str]
|
self, album_id: str, asset_ids: list[str]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Add assets to an album. Returns API response with success/error arrays."""
|
"""Add assets to an album. Returns API response with success/error arrays.
|
||||||
|
|
||||||
|
Immich returns 200 with a per-asset array even when some IDs fail
|
||||||
|
individually (already in album, not found, etc). Partial failures
|
||||||
|
are data, not errors — surface them as the normal return value.
|
||||||
|
Non-2xx responses include Immich's error body in the raised message
|
||||||
|
so callers and logs see the real reason (bad UUIDs, stale album,
|
||||||
|
permission, etc.) instead of just the HTTP status code.
|
||||||
|
"""
|
||||||
payload = {"ids": asset_ids}
|
payload = {"ids": asset_ids}
|
||||||
try:
|
try:
|
||||||
async with self._session.put(
|
async with self._session.put(
|
||||||
@@ -337,14 +690,63 @@ class ImmichClient:
|
|||||||
headers=self._json_headers,
|
headers=self._json_headers,
|
||||||
json=payload,
|
json=payload,
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
body_text = await response.text()
|
||||||
return await response.json()
|
if response.status in (200, 201):
|
||||||
|
try:
|
||||||
|
parsed = await response.json(content_type=None)
|
||||||
|
except Exception: # noqa: BLE001 — malformed body, still 200
|
||||||
|
return {"raw": body_text}
|
||||||
|
# Per-asset array is the typical shape; wrap for consistency.
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return {"results": parsed}
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
return {"raw": body_text}
|
||||||
|
# Log full body server-side (for operators), surface only a
|
||||||
|
# redacted snippet to the caller — this string ends up in
|
||||||
|
# ActionExecution.error / EventLog.details which are returned
|
||||||
|
# through the dashboard API.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"add_assets_to_album failed: HTTP %s body=%s",
|
||||||
|
response.status, body_text[:512],
|
||||||
|
)
|
||||||
raise ImmichApiError(
|
raise ImmichApiError(
|
||||||
f"Failed to add assets to album {album_id}: HTTP {response.status}"
|
f"Failed to add assets to album {album_id}: "
|
||||||
|
f"HTTP {response.status} {_redact_body(body_text)}"
|
||||||
)
|
)
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
raise ImmichApiError(f"Error adding assets to album: {err}") from err
|
raise ImmichApiError(f"Error adding assets to album: {err}") from err
|
||||||
|
|
||||||
|
async def set_album_thumbnail(
|
||||||
|
self, album_id: str, asset_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Set an album's cover/thumbnail to the given asset.
|
||||||
|
|
||||||
|
Uses ``PATCH /api/albums/{id}`` with ``albumThumbnailAssetId``.
|
||||||
|
Raises ``ImmichApiError`` on non-2xx so callers can treat it as
|
||||||
|
best-effort and log.
|
||||||
|
"""
|
||||||
|
payload = {"albumThumbnailAssetId": asset_id}
|
||||||
|
try:
|
||||||
|
async with self._session.patch(
|
||||||
|
f"{self._url}/api/albums/{album_id}",
|
||||||
|
headers=self._json_headers,
|
||||||
|
json=payload,
|
||||||
|
) as response:
|
||||||
|
if response.status in (200, 201, 204):
|
||||||
|
return
|
||||||
|
body_text = await response.text()
|
||||||
|
_LOGGER.warning(
|
||||||
|
"set_album_thumbnail failed: HTTP %s body=%s",
|
||||||
|
response.status, body_text[:512],
|
||||||
|
)
|
||||||
|
raise ImmichApiError(
|
||||||
|
f"Failed to set album thumbnail for {album_id}: "
|
||||||
|
f"HTTP {response.status} {_redact_body(body_text)}"
|
||||||
|
)
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
raise ImmichApiError(f"Error setting album thumbnail: {err}") from err
|
||||||
|
|
||||||
async def remove_assets_from_album(
|
async def remove_assets_from_album(
|
||||||
self, album_id: str, asset_ids: list[str]
|
self, album_id: str, asset_ids: list[str]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -386,22 +788,49 @@ class ImmichClient:
|
|||||||
raise ImmichApiError(f"Error creating album: {err}") from err
|
raise ImmichApiError(f"Error creating album: {err}") from err
|
||||||
|
|
||||||
async def get_person_assets_all(self, person_id: str) -> list[dict[str, Any]]:
|
async def get_person_assets_all(self, person_id: str) -> list[dict[str, Any]]:
|
||||||
"""Fetch ALL assets for a person (no limit)."""
|
"""Fetch ALL assets tagged with a person (paginated, no soft cap).
|
||||||
try:
|
|
||||||
async with self._session.get(
|
Uses ``POST /api/search/metadata`` with ``personIds``. The legacy
|
||||||
f"{self._url}/api/people/{person_id}/assets",
|
``GET /api/people/{id}/assets`` endpoint was removed from Immich
|
||||||
headers=self._headers,
|
around v1.106 and returns 404 on current servers — switching to
|
||||||
) as response:
|
the search endpoint is the only way to get person-filtered assets
|
||||||
if response.status == 200:
|
from modern Immich.
|
||||||
data = await response.json()
|
"""
|
||||||
return data if isinstance(data, list) else []
|
all_items: list[dict[str, Any]] = []
|
||||||
if response.status == 404:
|
page = 1
|
||||||
return []
|
page_size = 100
|
||||||
raise ImmichApiError(
|
max_pages = 1000 # hard cap to avoid runaway loops if server misbehaves
|
||||||
f"Failed to fetch person {person_id} assets: HTTP {response.status}"
|
while page <= max_pages:
|
||||||
)
|
payload: dict[str, Any] = {
|
||||||
except aiohttp.ClientError as err:
|
"personIds": [person_id],
|
||||||
raise ImmichApiError(f"Error fetching person assets: {err}") from err
|
"page": page,
|
||||||
|
"size": page_size,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with self._session.post(
|
||||||
|
f"{self._url}/api/search/metadata",
|
||||||
|
headers=self._json_headers,
|
||||||
|
json=payload,
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
data = await response.json()
|
||||||
|
items = data.get("assets", {}).get("items", [])
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
all_items.extend(items)
|
||||||
|
if len(items) < page_size:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
continue
|
||||||
|
if response.status == 404:
|
||||||
|
# Person doesn't exist — return empty rather than raising
|
||||||
|
return all_items
|
||||||
|
raise ImmichApiError(
|
||||||
|
f"Failed to fetch person {person_id} assets: HTTP {response.status}"
|
||||||
|
)
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
raise ImmichApiError(f"Error fetching person assets: {err}") from err
|
||||||
|
return all_items
|
||||||
|
|
||||||
async def search_smart_all(
|
async def search_smart_all(
|
||||||
self, query: str, limit: int = 1000
|
self, query: str, limit: int = 1000
|
||||||
|
|||||||
@@ -146,6 +146,49 @@ class ImmichAssetInfo:
|
|||||||
return bool(thumbhash)
|
return bool(thumbhash)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImmichAlbumMeta:
|
||||||
|
"""Lightweight album metadata from ``GET /api/albums/{id}?withoutAssets=true``.
|
||||||
|
|
||||||
|
Used as a cheap change-detection probe so we can skip the multi-MB
|
||||||
|
full-asset fetch when nothing interesting has changed. Large albums
|
||||||
|
(tens to hundreds of thousands of assets) would otherwise re-serialize
|
||||||
|
the entire asset list on every poll interval.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
asset_count: int
|
||||||
|
updated_at: str
|
||||||
|
shared: bool
|
||||||
|
thumbnail_asset_id: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(cls, data: dict[str, Any]) -> ImmichAlbumMeta:
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
name=data.get("albumName", "Unnamed"),
|
||||||
|
asset_count=int(data.get("assetCount", 0) or 0),
|
||||||
|
updated_at=data.get("updatedAt", "") or "",
|
||||||
|
shared=bool(data.get("shared", False)),
|
||||||
|
thumbnail_asset_id=data.get("albumThumbnailAssetId"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def fingerprint(self) -> dict[str, Any]:
|
||||||
|
"""Return a minimal serializable dict for persistence + equality checks.
|
||||||
|
|
||||||
|
We purposefully exclude ``id`` (known from the state row) and keep the
|
||||||
|
dict flat so JSON round-trips are cheap and stable for equality.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"asset_count": self.asset_count,
|
||||||
|
"shared": self.shared,
|
||||||
|
"name": self.name,
|
||||||
|
"thumbnail_asset_id": self.thumbnail_asset_id or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ImmichAlbumData:
|
class ImmichAlbumData:
|
||||||
"""Full album data from Immich API."""
|
"""Full album data from Immich API."""
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -11,13 +14,62 @@ from notify_bridge_core.models.events import ServiceEvent
|
|||||||
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
||||||
from notify_bridge_core.templates.variables import TemplateVariableDefinition
|
from notify_bridge_core.templates.variables import TemplateVariableDefinition
|
||||||
|
|
||||||
from .change_detector import detect_album_changes
|
from .asset_utils import asset_to_media
|
||||||
|
from .change_detector import _MAX_ASSETS_PER_EVENT, detect_album_changes
|
||||||
from .client import ImmichClient
|
from .client import ImmichClient
|
||||||
from .models import ImmichAlbumData
|
from .models import ImmichAlbumData, ImmichAlbumMeta, ImmichAssetInfo
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level users cache shared across ImmichServiceProvider instances.
|
||||||
|
# Users change rarely (new people joining the server, display-name edits), so
|
||||||
|
# refetching on every tracker's ``connect()`` is wasteful — a fleet of 10
|
||||||
|
# trackers on the same Immich server otherwise issues 10 ``GET /api/users``
|
||||||
|
# calls per poll cycle. TTL is conservative (1h) and a hashed key keeps the
|
||||||
|
# raw api_key out of dict keys in case of a memory dump.
|
||||||
|
_USERS_CACHE_TTL_SECONDS = 3600
|
||||||
|
_users_cache_lock = asyncio.Lock()
|
||||||
|
_users_cache: dict[str, tuple[float, dict[str, str]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _users_cache_key(url: str, api_key: str) -> str:
|
||||||
|
digest = hashlib.sha256(f"{url}|{api_key}".encode("utf-8")).hexdigest()
|
||||||
|
return digest[:32]
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cached_users(
|
||||||
|
client: ImmichClient, url: str, api_key: str
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Return ``{user_id: display_name}`` for the server, reusing cache entries
|
||||||
|
whose TTL has not elapsed. Misses and stale hits fall through to a real
|
||||||
|
fetch under a single lock so concurrent polls don't stampede the server.
|
||||||
|
"""
|
||||||
|
key = _users_cache_key(url, api_key)
|
||||||
|
now = time.monotonic()
|
||||||
|
entry = _users_cache.get(key)
|
||||||
|
if entry is not None and (now - entry[0]) < _USERS_CACHE_TTL_SECONDS:
|
||||||
|
return entry[1]
|
||||||
|
|
||||||
|
async with _users_cache_lock:
|
||||||
|
# Re-check after acquiring the lock — another coroutine may have
|
||||||
|
# refreshed the entry while we waited.
|
||||||
|
entry = _users_cache.get(key)
|
||||||
|
if entry is not None and (time.monotonic() - entry[0]) < _USERS_CACHE_TTL_SECONDS:
|
||||||
|
return entry[1]
|
||||||
|
fresh = await client.get_users()
|
||||||
|
_users_cache[key] = (time.monotonic(), fresh)
|
||||||
|
return fresh
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_users_cache() -> None:
|
||||||
|
"""Drop every cached users dict. Exposed for callers that mutate users
|
||||||
|
(e.g. provider config changes, integration tests) and need the next
|
||||||
|
``connect()`` to re-fetch.
|
||||||
|
"""
|
||||||
|
_users_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
# Immich-specific template variables
|
# Immich-specific template variables
|
||||||
IMMICH_VARIABLES: list[TemplateVariableDefinition] = [
|
IMMICH_VARIABLES: list[TemplateVariableDefinition] = [
|
||||||
TemplateVariableDefinition(
|
TemplateVariableDefinition(
|
||||||
@@ -135,7 +187,9 @@ class ImmichServiceProvider(ServiceProvider):
|
|||||||
await self._client.get_server_config()
|
await self._client.get_server_config()
|
||||||
if self._external_domain:
|
if self._external_domain:
|
||||||
self._client.external_domain = self._external_domain
|
self._client.external_domain = self._external_domain
|
||||||
self._users_cache = await self._client.get_users()
|
self._users_cache = await _get_cached_users(
|
||||||
|
self._client, self._client.url, self._client.api_key,
|
||||||
|
)
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
@@ -150,9 +204,32 @@ class ImmichServiceProvider(ServiceProvider):
|
|||||||
new_state = dict(tracker_state)
|
new_state = dict(tracker_state)
|
||||||
external_url = self._client.external_url
|
external_url = self._client.external_url
|
||||||
|
|
||||||
for album_id in collection_ids:
|
# Tick-scoped share-link cache. Populated lazily on first enrichment;
|
||||||
album = await self._client.get_album(album_id, self._users_cache)
|
# a tracker watching 5 albums with changes now issues 1 ``/api/shared-links``
|
||||||
if album is None:
|
# request per tick instead of 5 (and the endpoint is server-wide — each
|
||||||
|
# call was already fetching all links and discarding most of them).
|
||||||
|
self._tick_shared_links: dict[str, list] | None = None
|
||||||
|
|
||||||
|
# Fan out the cheap meta probes in parallel. For a tracker that
|
||||||
|
# watches 20 albums on the same Immich server this turns a 20-hop
|
||||||
|
# serial wait into ~1 round-trip's worth of latency. aiohttp's
|
||||||
|
# connection pool caps concurrency per host, so this can't stampede.
|
||||||
|
meta_results = await asyncio.gather(
|
||||||
|
*(self._client.get_album_meta(aid) for aid in collection_ids),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for album_id, meta_or_exc in zip(collection_ids, meta_results):
|
||||||
|
if isinstance(meta_or_exc, BaseException):
|
||||||
|
# Transient failure on this album — preserve existing state
|
||||||
|
# and move on. Logging at warning so flaky albums surface in
|
||||||
|
# the log without flooding on hard outages.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Meta probe failed for album %s: %s", album_id, meta_or_exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
meta = meta_or_exc
|
||||||
|
if meta is None:
|
||||||
# Album deleted
|
# Album deleted
|
||||||
if album_id in new_state:
|
if album_id in new_state:
|
||||||
from notify_bridge_core.models.events import EventType
|
from notify_bridge_core.models.events import EventType
|
||||||
@@ -168,11 +245,80 @@ class ImmichServiceProvider(ServiceProvider):
|
|||||||
del new_state[album_id]
|
del new_state[album_id]
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Get previous state
|
|
||||||
prev = new_state.get(album_id)
|
prev = new_state.get(album_id)
|
||||||
|
prev_fingerprint = prev.get("meta_fingerprint") if prev else None
|
||||||
|
has_pending = bool(prev and prev.get("pending_asset_ids"))
|
||||||
|
|
||||||
|
# 2) Fast-path: fingerprint match and no pending assets → no work.
|
||||||
|
# We still refresh the fingerprint slot (no-op if identical) and
|
||||||
|
# leave asset_ids untouched on disk.
|
||||||
|
if (
|
||||||
|
prev is not None
|
||||||
|
and prev_fingerprint == meta.fingerprint()
|
||||||
|
and not has_pending
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3) Decide: delta fetch (cheap, active-album case) or full
|
||||||
|
# fetch (first tick + reconciliation for removals).
|
||||||
|
old_fp = prev.get("meta_fingerprint") if prev else None
|
||||||
|
old_asset_count = (old_fp or {}).get("asset_count", 0)
|
||||||
|
old_updated_at = (old_fp or {}).get("updated_at", "")
|
||||||
|
|
||||||
|
# Gate for the delta path:
|
||||||
|
# - must be tracked already (prev exists, has asset_ids)
|
||||||
|
# - must have a prior timestamp (empty ⇒ migrated DB row)
|
||||||
|
# - asset_count must not have decreased (removals need full fetch)
|
||||||
|
can_delta = (
|
||||||
|
prev is not None
|
||||||
|
and bool(prev.get("asset_ids"))
|
||||||
|
and bool(old_updated_at)
|
||||||
|
and meta.asset_count >= old_asset_count
|
||||||
|
)
|
||||||
|
|
||||||
|
if can_delta:
|
||||||
|
delta_events = await self._poll_delta(
|
||||||
|
album_id=album_id,
|
||||||
|
prev=prev,
|
||||||
|
new_meta=meta,
|
||||||
|
old_updated_at=old_updated_at,
|
||||||
|
)
|
||||||
|
if delta_events is not None:
|
||||||
|
events.extend(delta_events["events"])
|
||||||
|
new_state[album_id] = delta_events["new_state"]
|
||||||
|
continue
|
||||||
|
# delta_events is None ⇒ delta saw more additions than the
|
||||||
|
# net count increase (mixed add+remove) ⇒ fall through to
|
||||||
|
# the full-fetch path so removals get detected.
|
||||||
|
|
||||||
|
# Full fetch: first tick, or count-decreased, or delta-unsafe.
|
||||||
|
# Bypass the module-level album cache — this path runs when we
|
||||||
|
# specifically need the current server state (e.g. to detect
|
||||||
|
# asset removals), so a stale cached entry would silently delay
|
||||||
|
# the event.
|
||||||
|
album = await self._client.get_album(
|
||||||
|
album_id, self._users_cache, use_cache=False,
|
||||||
|
)
|
||||||
|
if album is None:
|
||||||
|
# Album was deleted between meta probe and full fetch — handle
|
||||||
|
# the deletion the same way as above.
|
||||||
|
if album_id in new_state:
|
||||||
|
from notify_bridge_core.models.events import EventType
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
events.append(ServiceEvent(
|
||||||
|
event_type=EventType.COLLECTION_DELETED,
|
||||||
|
provider_type=ServiceProviderType.IMMICH,
|
||||||
|
provider_name=self._name,
|
||||||
|
collection_id=album_id,
|
||||||
|
collection_name=new_state.get(album_id, {}).get("name", "Unknown"),
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
))
|
||||||
|
del new_state[album_id]
|
||||||
|
continue
|
||||||
|
|
||||||
if prev is None:
|
if prev is None:
|
||||||
# First time seeing this album — store state, no event
|
# First time seeing this album — store state, no event
|
||||||
new_state[album_id] = _serialize_album_state(album)
|
new_state[album_id] = _serialize_album_state(album, meta)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Reconstruct previous album data for comparison
|
# Reconstruct previous album data for comparison
|
||||||
@@ -184,34 +330,233 @@ class ImmichServiceProvider(ServiceProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if detected_events:
|
if detected_events:
|
||||||
# Fetch shared links to enrich events with public_url
|
await self._enrich_with_shared_links(album_id, detected_events)
|
||||||
shared_links = await self._client.get_shared_links(album_id)
|
|
||||||
public_link = None
|
|
||||||
protected_link = None
|
|
||||||
for link in shared_links:
|
|
||||||
if link.is_accessible and not link.is_expired:
|
|
||||||
if link.has_password:
|
|
||||||
protected_link = link
|
|
||||||
else:
|
|
||||||
public_link = link
|
|
||||||
break # prefer non-password link
|
|
||||||
|
|
||||||
ext_domain = self._external_domain or self._client.external_url
|
|
||||||
for evt in detected_events:
|
|
||||||
if public_link:
|
|
||||||
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
|
||||||
elif protected_link:
|
|
||||||
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
|
||||||
|
|
||||||
events.extend(detected_events)
|
events.extend(detected_events)
|
||||||
|
|
||||||
# Update state
|
# Update state
|
||||||
state = _serialize_album_state(album)
|
state = _serialize_album_state(album, meta)
|
||||||
state["pending_asset_ids"] = list(updated_pending)
|
state["pending_asset_ids"] = list(updated_pending)
|
||||||
new_state[album_id] = state
|
new_state[album_id] = state
|
||||||
|
|
||||||
return events, new_state
|
return events, new_state
|
||||||
|
|
||||||
|
async def _poll_delta(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
album_id: str,
|
||||||
|
prev: dict[str, Any],
|
||||||
|
new_meta: ImmichAlbumMeta,
|
||||||
|
old_updated_at: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Delta-fetch path for an active album.
|
||||||
|
|
||||||
|
Calls ``search/metadata`` with ``updatedAfter`` instead of pulling
|
||||||
|
the full asset list. Returns a dict with ``events`` and ``new_state``
|
||||||
|
on success, or ``None`` to signal the caller to retry via full fetch
|
||||||
|
(used when a mixed add+remove is detected — the delta endpoint can't
|
||||||
|
tell us *what* was removed, only that additions alone don't account
|
||||||
|
for the net count change).
|
||||||
|
|
||||||
|
Trades strict detection of removals-during-mixed-changes for a
|
||||||
|
drastic reduction in bytes fetched per tick. On a 200k-asset album
|
||||||
|
where 50 were just added, we fetch ~50 asset records instead of
|
||||||
|
200 000.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from notify_bridge_core.models.events import EventType
|
||||||
|
|
||||||
|
prev_asset_ids: set[str] = set(prev.get("asset_ids", []))
|
||||||
|
prev_pending: set[str] = set(prev.get("pending_asset_ids", []))
|
||||||
|
|
||||||
|
raw_assets = await self._client.search_album_assets_updated_after(
|
||||||
|
album_id, old_updated_at
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse everything that came back. We need unprocessed entries too
|
||||||
|
# (they feed the ``pending_asset_ids`` list used by the original
|
||||||
|
# change detector's processed-later logic).
|
||||||
|
delta_assets: list[ImmichAssetInfo] = []
|
||||||
|
for raw in raw_assets:
|
||||||
|
try:
|
||||||
|
delta_assets.append(
|
||||||
|
ImmichAssetInfo.from_api_response(raw, self._users_cache)
|
||||||
|
)
|
||||||
|
except Exception as err: # noqa: BLE001 — one bad record ≠ abort tick
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Skipping malformed asset record in delta response: %s", err
|
||||||
|
)
|
||||||
|
|
||||||
|
newly_added: list[ImmichAssetInfo] = []
|
||||||
|
still_pending: set[str] = set()
|
||||||
|
for asset in delta_assets:
|
||||||
|
if asset.is_processed:
|
||||||
|
if asset.id not in prev_asset_ids:
|
||||||
|
newly_added.append(asset)
|
||||||
|
else:
|
||||||
|
still_pending.add(asset.id)
|
||||||
|
|
||||||
|
old_asset_count = int((prev.get("meta_fingerprint") or {}).get("asset_count", 0))
|
||||||
|
net_change = new_meta.asset_count - old_asset_count
|
||||||
|
|
||||||
|
# If delta found more "added" assets than the net count change,
|
||||||
|
# a concurrent removal happened. Full fetch is the only way to
|
||||||
|
# know what was removed — bail out so the caller retries.
|
||||||
|
if net_change >= 0 and len(newly_added) > net_change:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Delta for album %s found %d additions but net change is %d "
|
||||||
|
"— falling back to full fetch for removal reconciliation",
|
||||||
|
album_id, len(newly_added), net_change,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Mirror case: positive net change we couldn't account for with the
|
||||||
|
# delta results (possibly clock skew on ``updated_at``, or an asset
|
||||||
|
# whose timestamp is before ``old_updated_at`` yet the album's
|
||||||
|
# ``updatedAt`` bumped). Full fetch to avoid silently missing adds.
|
||||||
|
if net_change > 0 and len(newly_added) < net_change:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Delta for album %s found %d additions but net change is %d "
|
||||||
|
"— falling back to full fetch to avoid missing assets",
|
||||||
|
album_id, len(newly_added), net_change,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
events: list[ServiceEvent] = []
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
external_url = self._external_domain or self._client.external_url
|
||||||
|
album_url = f"{external_url}/albums/{album_id}"
|
||||||
|
|
||||||
|
# Carry album-level attributes we know from the cheap meta probe.
|
||||||
|
# Shared-link enrichment happens further down only if we emitted
|
||||||
|
# any asset events.
|
||||||
|
base_extra = {
|
||||||
|
"album_url": album_url,
|
||||||
|
"shared": new_meta.shared,
|
||||||
|
"asset_count": new_meta.asset_count,
|
||||||
|
"photo_count": 0, # unknown without per-asset scan; templates tolerate 0
|
||||||
|
"video_count": 0,
|
||||||
|
"people": [],
|
||||||
|
"owner": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Metadata-only events (no asset fetch needed)
|
||||||
|
old_fp = prev.get("meta_fingerprint") or {}
|
||||||
|
if old_fp.get("name") and old_fp["name"] != new_meta.name:
|
||||||
|
events.append(ServiceEvent(
|
||||||
|
event_type=EventType.COLLECTION_RENAMED,
|
||||||
|
provider_type=ServiceProviderType.IMMICH,
|
||||||
|
provider_name=self._name,
|
||||||
|
collection_id=album_id,
|
||||||
|
collection_name=new_meta.name,
|
||||||
|
timestamp=now,
|
||||||
|
added_assets=[],
|
||||||
|
removed_asset_ids=[],
|
||||||
|
added_count=0,
|
||||||
|
removed_count=0,
|
||||||
|
old_name=old_fp["name"],
|
||||||
|
new_name=new_meta.name,
|
||||||
|
extra=dict(base_extra),
|
||||||
|
))
|
||||||
|
|
||||||
|
if "shared" in old_fp and bool(old_fp["shared"]) != bool(new_meta.shared):
|
||||||
|
events.append(ServiceEvent(
|
||||||
|
event_type=EventType.SHARING_CHANGED,
|
||||||
|
provider_type=ServiceProviderType.IMMICH,
|
||||||
|
provider_name=self._name,
|
||||||
|
collection_id=album_id,
|
||||||
|
collection_name=new_meta.name,
|
||||||
|
timestamp=now,
|
||||||
|
added_assets=[],
|
||||||
|
removed_asset_ids=[],
|
||||||
|
added_count=0,
|
||||||
|
removed_count=0,
|
||||||
|
old_shared=bool(old_fp["shared"]),
|
||||||
|
new_shared=bool(new_meta.shared),
|
||||||
|
extra=dict(base_extra),
|
||||||
|
))
|
||||||
|
|
||||||
|
if newly_added:
|
||||||
|
total_added = len(newly_added)
|
||||||
|
truncated = newly_added[:_MAX_ASSETS_PER_EVENT]
|
||||||
|
media_assets = [
|
||||||
|
asset_to_media(a, self._client.external_url) for a in truncated
|
||||||
|
]
|
||||||
|
extra = dict(base_extra)
|
||||||
|
if total_added > _MAX_ASSETS_PER_EVENT:
|
||||||
|
extra["truncated"] = True
|
||||||
|
extra["shown_count"] = _MAX_ASSETS_PER_EVENT
|
||||||
|
_LOGGER.info(
|
||||||
|
"Delta-path truncated assets_added event for album %s: %d → %d",
|
||||||
|
album_id, total_added, _MAX_ASSETS_PER_EVENT,
|
||||||
|
)
|
||||||
|
events.append(ServiceEvent(
|
||||||
|
event_type=EventType.ASSETS_ADDED,
|
||||||
|
provider_type=ServiceProviderType.IMMICH,
|
||||||
|
provider_name=self._name,
|
||||||
|
collection_id=album_id,
|
||||||
|
collection_name=new_meta.name,
|
||||||
|
timestamp=now,
|
||||||
|
added_assets=media_assets,
|
||||||
|
removed_asset_ids=[],
|
||||||
|
added_count=total_added,
|
||||||
|
removed_count=0,
|
||||||
|
extra=extra,
|
||||||
|
))
|
||||||
|
|
||||||
|
if events:
|
||||||
|
await self._enrich_with_shared_links(album_id, events)
|
||||||
|
|
||||||
|
# Rebuild state. asset_ids grows by the newly-added processed set.
|
||||||
|
# pending is the union of the prior pending list (things still in
|
||||||
|
# flight) and anything the delta confirmed as not-yet-processed.
|
||||||
|
# When net_change is 0 or negative we trust the meta count over
|
||||||
|
# our bookkeeping — skip-path will fix drift on the next full fetch.
|
||||||
|
new_asset_ids = prev_asset_ids | {a.id for a in newly_added}
|
||||||
|
# Discard any previously-pending IDs that just landed as processed.
|
||||||
|
new_pending = (prev_pending | still_pending) - {a.id for a in newly_added}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"events": events,
|
||||||
|
"new_state": {
|
||||||
|
"name": new_meta.name,
|
||||||
|
"asset_ids": list(new_asset_ids),
|
||||||
|
"shared": new_meta.shared,
|
||||||
|
"pending_asset_ids": list(new_pending),
|
||||||
|
"meta_fingerprint": new_meta.fingerprint(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _enrich_with_shared_links(
|
||||||
|
self, album_id: str, events_to_enrich: list[ServiceEvent]
|
||||||
|
) -> None:
|
||||||
|
"""Attach public/protected share link URLs to events for this album.
|
||||||
|
|
||||||
|
Uses the tick-scoped bulk cache populated lazily on first call, so a
|
||||||
|
tracker with changes across N albums makes one ``/api/shared-links``
|
||||||
|
request per tick instead of N.
|
||||||
|
"""
|
||||||
|
if self._tick_shared_links is None:
|
||||||
|
self._tick_shared_links = await self._client.get_all_shared_links_by_album()
|
||||||
|
|
||||||
|
shared_links = self._tick_shared_links.get(album_id, [])
|
||||||
|
public_link = None
|
||||||
|
protected_link = None
|
||||||
|
for link in shared_links:
|
||||||
|
if link.is_accessible and not link.is_expired:
|
||||||
|
if link.has_password:
|
||||||
|
protected_link = link
|
||||||
|
else:
|
||||||
|
public_link = link
|
||||||
|
break # prefer non-password link
|
||||||
|
|
||||||
|
ext_domain = self._external_domain or self._client.external_url
|
||||||
|
for evt in events_to_enrich:
|
||||||
|
if public_link:
|
||||||
|
evt.extra["public_url"] = f"{ext_domain}/share/{public_link.key}"
|
||||||
|
elif protected_link:
|
||||||
|
evt.extra["protected_url"] = f"{ext_domain}/share/{protected_link.key}"
|
||||||
|
|
||||||
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
def get_available_variables(self) -> list[TemplateVariableDefinition]:
|
||||||
return list(IMMICH_VARIABLES)
|
return list(IMMICH_VARIABLES)
|
||||||
|
|
||||||
@@ -262,13 +607,33 @@ class ImmichServiceProvider(ServiceProvider):
|
|||||||
return {"ok": False, "message": "Failed to connect to Immich"}
|
return {"ok": False, "message": "Failed to connect to Immich"}
|
||||||
|
|
||||||
|
|
||||||
def _serialize_album_state(album: ImmichAlbumData) -> dict[str, Any]:
|
def _serialize_album_state(
|
||||||
"""Serialize album state for persistence."""
|
album: ImmichAlbumData,
|
||||||
|
meta: ImmichAlbumMeta | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Serialize album state for persistence.
|
||||||
|
|
||||||
|
``meta`` carries the fingerprint used for cheap no-change detection on
|
||||||
|
subsequent polls. When omitted (legacy callers, tests) we synthesize a
|
||||||
|
best-effort fingerprint from the full album — it will still match on the
|
||||||
|
next tick if nothing changed, which is what matters.
|
||||||
|
"""
|
||||||
|
if meta is None:
|
||||||
|
fingerprint = {
|
||||||
|
"updated_at": album.updated_at,
|
||||||
|
"asset_count": len(album.asset_ids),
|
||||||
|
"shared": album.shared,
|
||||||
|
"name": album.name,
|
||||||
|
"thumbnail_asset_id": album.thumbnail_asset_id or "",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
fingerprint = meta.fingerprint()
|
||||||
return {
|
return {
|
||||||
"name": album.name,
|
"name": album.name,
|
||||||
"asset_ids": list(album.asset_ids),
|
"asset_ids": list(album.asset_ids),
|
||||||
"shared": album.shared,
|
"shared": album.shared,
|
||||||
"pending_asset_ids": [],
|
"pending_asset_ids": [],
|
||||||
|
"meta_fingerprint": fingerprint,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_DEFAULT_PORT = 3493
|
_DEFAULT_PORT = 3493
|
||||||
_READ_TIMEOUT = 10.0
|
_READ_TIMEOUT = 10.0
|
||||||
|
_WRITE_TIMEOUT = 10.0
|
||||||
_CONNECT_TIMEOUT = 5.0
|
_CONNECT_TIMEOUT = 5.0
|
||||||
|
|
||||||
# Allowed characters for NUT protocol identifiers (UPS names, variable names).
|
# Allowed characters for NUT protocol identifiers (UPS names, variable names).
|
||||||
@@ -84,14 +85,26 @@ class NutClient:
|
|||||||
await self._command(f"PASSWORD {self._password}")
|
await self._command(f"PASSWORD {self._password}")
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
"""Send LOGOUT and close the TCP connection."""
|
"""Send LOGOUT and close the TCP connection.
|
||||||
|
|
||||||
|
``drain`` is bounded by ``_WRITE_TIMEOUT`` so a half-closed peer
|
||||||
|
cannot hold the disconnect indefinitely — a tracker tick would
|
||||||
|
otherwise be pinned by a stuck NUT server and block the scheduler
|
||||||
|
slot (``max_instances=1``).
|
||||||
|
"""
|
||||||
if self._writer is not None:
|
if self._writer is not None:
|
||||||
try:
|
try:
|
||||||
self._writer.write(b"LOGOUT\n")
|
self._writer.write(b"LOGOUT\n")
|
||||||
await self._writer.drain()
|
await asyncio.wait_for(self._writer.drain(), timeout=_WRITE_TIMEOUT)
|
||||||
except OSError:
|
except (OSError, asyncio.TimeoutError):
|
||||||
pass
|
pass
|
||||||
self._writer.close()
|
self._writer.close()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
self._writer.wait_closed(), timeout=_WRITE_TIMEOUT,
|
||||||
|
)
|
||||||
|
except (OSError, asyncio.TimeoutError):
|
||||||
|
pass
|
||||||
self._reader = None
|
self._reader = None
|
||||||
self._writer = None
|
self._writer = None
|
||||||
|
|
||||||
@@ -135,7 +148,10 @@ class NutClient:
|
|||||||
if self._writer is None:
|
if self._writer is None:
|
||||||
raise NutClientError("Not connected")
|
raise NutClientError("Not connected")
|
||||||
self._writer.write(f"{cmd}\n".encode())
|
self._writer.write(f"{cmd}\n".encode())
|
||||||
await self._writer.drain()
|
try:
|
||||||
|
await asyncio.wait_for(self._writer.drain(), timeout=_WRITE_TIMEOUT)
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise NutClientError("Write timeout") from exc
|
||||||
|
|
||||||
async def _readline(self) -> str:
|
async def _readline(self) -> str:
|
||||||
"""Read one line from upsd, stripping trailing newline."""
|
"""Read one line from upsd, stripping trailing newline."""
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from notify_bridge_core.models.events import EventType, ServiceEvent
|
from notify_bridge_core.models.events import EventType, ServiceEvent
|
||||||
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
from notify_bridge_core.providers.base import ServiceProvider, ServiceProviderType
|
||||||
@@ -57,6 +58,13 @@ SCHEDULER_VARIABLES: list[TemplateVariableDefinition] = [
|
|||||||
example="Monday",
|
example="Monday",
|
||||||
provider_type=ServiceProviderType.SCHEDULER,
|
provider_type=ServiceProviderType.SCHEDULER,
|
||||||
),
|
),
|
||||||
|
TemplateVariableDefinition(
|
||||||
|
name="timezone",
|
||||||
|
type="string",
|
||||||
|
description="IANA timezone used to compute current_date/time",
|
||||||
|
example="Europe/Warsaw",
|
||||||
|
provider_type=ServiceProviderType.SCHEDULER,
|
||||||
|
),
|
||||||
TemplateVariableDefinition(
|
TemplateVariableDefinition(
|
||||||
name="custom_vars",
|
name="custom_vars",
|
||||||
type="dict",
|
type="dict",
|
||||||
@@ -83,7 +91,8 @@ class SchedulerServiceProvider(ServiceProvider):
|
|||||||
custom_variables: dict[str, str] | None = None,
|
custom_variables: dict[str, str] | None = None,
|
||||||
date_format: str = "%d.%m.%Y",
|
date_format: str = "%d.%m.%Y",
|
||||||
time_format: str = "%H:%M",
|
time_format: str = "%H:%M",
|
||||||
datetime_format: str = "%d.%m.%Y, %H:%M UTC",
|
datetime_format: str = "%d.%m.%Y, %H:%M %Z",
|
||||||
|
timezone_name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._name = name
|
self._name = name
|
||||||
self._tracker_name = tracker_name
|
self._tracker_name = tracker_name
|
||||||
@@ -91,6 +100,18 @@ class SchedulerServiceProvider(ServiceProvider):
|
|||||||
self._date_format = date_format
|
self._date_format = date_format
|
||||||
self._time_format = time_format
|
self._time_format = time_format
|
||||||
self._datetime_format = datetime_format
|
self._datetime_format = datetime_format
|
||||||
|
# Resolve a timezone for date/time rendering. Falls back to UTC on
|
||||||
|
# invalid IANA names so a typo in app settings doesn't break polls.
|
||||||
|
tz: ZoneInfo
|
||||||
|
if timezone_name:
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(timezone_name)
|
||||||
|
except (ZoneInfoNotFoundError, ValueError):
|
||||||
|
_LOGGER.warning("Unknown timezone %r; falling back to UTC", timezone_name)
|
||||||
|
tz = ZoneInfo("UTC")
|
||||||
|
else:
|
||||||
|
tz = ZoneInfo("UTC")
|
||||||
|
self._tz = tz
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
return True # virtual provider — always connected
|
return True # virtual provider — always connected
|
||||||
@@ -103,7 +124,8 @@ class SchedulerServiceProvider(ServiceProvider):
|
|||||||
collection_ids: list[str],
|
collection_ids: list[str],
|
||||||
tracker_state: dict[str, Any],
|
tracker_state: dict[str, Any],
|
||||||
) -> tuple[list[ServiceEvent], dict[str, Any]]:
|
) -> tuple[list[ServiceEvent], dict[str, Any]]:
|
||||||
now = datetime.now(timezone.utc)
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
now = now_utc.astimezone(self._tz)
|
||||||
# State uses {collection_id: {dict}} convention like other providers
|
# State uses {collection_id: {dict}} convention like other providers
|
||||||
sched_state = tracker_state.get("scheduler", {})
|
sched_state = tracker_state.get("scheduler", {})
|
||||||
fire_count = sched_state.get("fire_count", 0) + 1
|
fire_count = sched_state.get("fire_count", 0) + 1
|
||||||
@@ -115,6 +137,7 @@ class SchedulerServiceProvider(ServiceProvider):
|
|||||||
"current_time": now.strftime(self._time_format),
|
"current_time": now.strftime(self._time_format),
|
||||||
"current_datetime": now.strftime(self._datetime_format),
|
"current_datetime": now.strftime(self._datetime_format),
|
||||||
"weekday": _WEEKDAYS[now.weekday()],
|
"weekday": _WEEKDAYS[now.weekday()],
|
||||||
|
"timezone": self._tz.key,
|
||||||
"custom_vars": dict(self._custom_variables),
|
"custom_vars": dict(self._custom_variables),
|
||||||
}
|
}
|
||||||
# Flatten custom variables at top level for easy template access
|
# Flatten custom variables at top level for easy template access
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Protocol, runtime_checkable
|
from typing import Any, Protocol, runtime_checkable
|
||||||
|
|
||||||
@@ -19,34 +21,58 @@ class StorageBackend(Protocol):
|
|||||||
async def remove(self) -> None: ...
|
async def remove(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _read_file(path: Path) -> str | None:
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(path: Path, payload: str) -> None:
|
||||||
|
"""Write atomically: tmp file + rename. Prevents half-written files on crash."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(payload, encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_file(path: Path) -> None:
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
class JsonFileBackend:
|
class JsonFileBackend:
|
||||||
"""Simple JSON file storage backend."""
|
"""Simple JSON file storage backend.
|
||||||
|
|
||||||
|
All blocking I/O is wrapped in ``asyncio.to_thread`` so callers can
|
||||||
|
``await load() / save() / remove()`` without stalling the event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, path: Path) -> None:
|
def __init__(self, path: Path) -> None:
|
||||||
self._path = path
|
self._path = path
|
||||||
|
|
||||||
async def load(self) -> dict[str, Any] | None:
|
async def load(self) -> dict[str, Any] | None:
|
||||||
if not self._path.exists():
|
try:
|
||||||
|
text = await asyncio.to_thread(_read_file, self._path)
|
||||||
|
except OSError as err:
|
||||||
|
_LOGGER.warning("Failed to load %s: %s", self._path, err)
|
||||||
|
return None
|
||||||
|
if text is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
text = self._path.read_text(encoding="utf-8")
|
|
||||||
return json.loads(text)
|
return json.loads(text)
|
||||||
except (json.JSONDecodeError, OSError) as err:
|
except json.JSONDecodeError as err:
|
||||||
_LOGGER.warning("Failed to load %s: %s", self._path, err)
|
_LOGGER.warning("Failed to parse %s: %s", self._path, err)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def save(self, data: dict[str, Any]) -> None:
|
async def save(self, data: dict[str, Any]) -> None:
|
||||||
|
payload = json.dumps(data, default=str)
|
||||||
try:
|
try:
|
||||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
await asyncio.to_thread(_atomic_write, self._path, payload)
|
||||||
self._path.write_text(
|
|
||||||
json.dumps(data, default=str), encoding="utf-8"
|
|
||||||
)
|
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
_LOGGER.error("Failed to save %s: %s", self._path, err)
|
_LOGGER.error("Failed to save %s: %s", self._path, err)
|
||||||
|
|
||||||
async def remove(self) -> None:
|
async def remove(self) -> None:
|
||||||
try:
|
try:
|
||||||
if self._path.exists():
|
await asyncio.to_thread(_remove_file, self._path)
|
||||||
self._path.unlink()
|
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
_LOGGER.error("Failed to remove %s: %s", self._path, err)
|
_LOGGER.error("Failed to remove %s: %s", self._path, err)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
⭐ Favorites:
|
⭐ Favorites:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %} ❤️
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ❤️
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
📸 Latest:
|
📸 Latest:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
📅 On this day:
|
📅 On this day:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
🎲 Random:
|
🎲 Random:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
{%- else %}🔍 Results for "{{ query }}":
|
{%- else %}🔍 Results for "{{ query }}":
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
📊 Status
|
📊 Status
|
||||||
Trackers: {{ trackers_active }}/{{ trackers_total }} active
|
|
||||||
Albums: {{ total_albums }}
|
Albums: {{ total_albums }}
|
||||||
Last event: {{ last_event }}
|
Last event: {{ last_event }}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
📋 Album summary ({{ albums | length }}):
|
📋 Album summary ({{ albums | length }}):
|
||||||
{%- for album in albums %}
|
{%- for album in albums %}
|
||||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
|
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
|
||||||
{%- endfor %}
|
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
|
||||||
|
{%- if album.shared %} 🔗{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
⭐ Избранное:
|
⭐ Избранное:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %} ❤️
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ❤️
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
📸 Последние:
|
📸 Последние:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
📅 В этот день:
|
📅 В этот день:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
🎲 Случайные:
|
🎲 Случайные:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
{%- else %}🔍 Результаты по "{{ query }}":
|
{%- else %}🔍 Результаты по "{{ query }}":
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" or asset.type == "video" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.originalFileName }}</a>{% else %}{{ asset.originalFileName }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}{% if asset.year %} ({{ asset.year }}){% endif %}
|
||||||
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
{%- if asset.is_favorite %} ❤️{% endif %}
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
📊 Статус
|
📊 Статус
|
||||||
Трекеры: {{ trackers_active }}/{{ trackers_total }} активных
|
|
||||||
Альбомы: {{ total_albums }}
|
Альбомы: {{ total_albums }}
|
||||||
Последнее событие: {{ last_event }}
|
Последнее событие: {{ last_event }}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
📋 Сводка альбомов ({{ albums | length }}):
|
📋 Сводка альбомов ({{ albums | length }}):
|
||||||
{%- for album in albums %}
|
{%- for album in albums %}
|
||||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
|
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
|
||||||
{%- endfor %}
|
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
|
||||||
|
{%- if album.shared %} 🔗{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
|||||||
@@ -2,16 +2,67 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from notify_bridge_core.models.events import ServiceEvent
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Per-target maximum video size (bytes). None = no limit.
|
# Per-target maximum video size (bytes). None = no limit.
|
||||||
_MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = {
|
_MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = {
|
||||||
"telegram": 50 * 1024 * 1024, # 50 MB — Telegram Bot API hard limit
|
"telegram": 50 * 1024 * 1024, # 50 MB — Telegram Bot API hard limit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Keys that must NEVER flow into the Jinja2 template context, even if a
|
||||||
|
# provider stuffs them into ``event.extra`` (webhooks, Immich metadata, etc.).
|
||||||
|
# Templates that could reach a Telegram/Discord/etc. chat would otherwise
|
||||||
|
# expose operator credentials if a template author simply did ``{{ api_key }}``.
|
||||||
|
# Case-insensitive substring match — any ``extra`` key containing one of these
|
||||||
|
# tokens is dropped before the merge.
|
||||||
|
_SENSITIVE_EXTRA_TOKENS: tuple[str, ...] = (
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"hashed_",
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"session_id",
|
||||||
|
"bearer",
|
||||||
|
"private_key",
|
||||||
|
"access_key",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sensitive_key(key: str) -> bool:
|
||||||
|
lowered = str(key).lower()
|
||||||
|
return any(tok in lowered for tok in _SENSITIVE_EXTRA_TOKENS)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_merge_extras(ctx: dict[str, Any], extras: dict[str, Any]) -> None:
|
||||||
|
"""Merge provider ``extras`` into ``ctx``, dropping sensitive keys.
|
||||||
|
|
||||||
|
Dropped keys are logged once per event (DEBUG) so operators can spot
|
||||||
|
leaking providers without flooding the log.
|
||||||
|
"""
|
||||||
|
if not extras:
|
||||||
|
return
|
||||||
|
dropped: list[str] = []
|
||||||
|
for key, value in extras.items():
|
||||||
|
if _is_sensitive_key(key):
|
||||||
|
dropped.append(key)
|
||||||
|
continue
|
||||||
|
ctx[key] = value
|
||||||
|
if dropped:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Dropped %d sensitive key(s) from template context: %s",
|
||||||
|
len(dropped), ", ".join(sorted(dropped)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_template_context(
|
def build_template_context(
|
||||||
event: ServiceEvent,
|
event: ServiceEvent,
|
||||||
@@ -61,9 +112,12 @@ def build_template_context(
|
|||||||
"preview_url": asset.preview_url or "",
|
"preview_url": asset.preview_url or "",
|
||||||
"full_url": asset.full_url or "",
|
"full_url": asset.full_url or "",
|
||||||
}
|
}
|
||||||
# Flatten extras into asset dict for template access
|
# Flatten extras into asset dict for template access — same
|
||||||
asset_dict.update(asset.extra)
|
# sensitive-key filtering applied as the top-level merge.
|
||||||
|
_safe_merge_extras(asset_dict, asset.extra)
|
||||||
asset_dict.setdefault("oversized", False)
|
asset_dict.setdefault("oversized", False)
|
||||||
|
asset_dict.setdefault("file_size", None)
|
||||||
|
asset_dict.setdefault("playback_size", None)
|
||||||
assets.append(asset_dict)
|
assets.append(asset_dict)
|
||||||
|
|
||||||
# Enrich assets with per-asset public URLs if album has a public share link
|
# Enrich assets with per-asset public URLs if album has a public share link
|
||||||
@@ -87,12 +141,16 @@ def build_template_context(
|
|||||||
ctx["max_video_size"] = max_video_bytes # bytes or None
|
ctx["max_video_size"] = max_video_bytes # bytes or None
|
||||||
ctx["max_video_size_mb"] = max_video_bytes // (1024 * 1024) if max_video_bytes else None
|
ctx["max_video_size_mb"] = max_video_bytes // (1024 * 1024) if max_video_bytes else None
|
||||||
|
|
||||||
|
# Oversize check uses playback_size (bytes we actually upload). file_size
|
||||||
|
# (original asset size) is informational only — for providers that transcode
|
||||||
|
# before sending (e.g. Immich /video/playback), original can be much larger
|
||||||
|
# than what reaches Telegram, so it would false-positive the warning.
|
||||||
has_oversized = False
|
has_oversized = False
|
||||||
if max_video_bytes:
|
if max_video_bytes:
|
||||||
for a in assets:
|
for a in assets:
|
||||||
if a.get("type") == "VIDEO":
|
if a.get("type") == "VIDEO":
|
||||||
fs = a.get("file_size")
|
size = a.get("playback_size")
|
||||||
oversized = fs is not None and fs > max_video_bytes
|
oversized = size is not None and size > max_video_bytes
|
||||||
a["oversized"] = oversized
|
a["oversized"] = oversized
|
||||||
if oversized:
|
if oversized:
|
||||||
has_oversized = True
|
has_oversized = True
|
||||||
@@ -132,8 +190,11 @@ def build_template_context(
|
|||||||
if len(locations) == 1 and "" not in locations:
|
if len(locations) == 1 and "" not in locations:
|
||||||
ctx["common_location"] = locations.pop()
|
ctx["common_location"] = locations.pop()
|
||||||
|
|
||||||
# Provider-specific extras merged at top level
|
# Provider-specific extras merged at top level. Sensitive keys (tokens,
|
||||||
ctx.update(event.extra)
|
# secrets, auth headers) are dropped — see ``_SENSITIVE_EXTRA_TOKENS``.
|
||||||
|
# Without this, a template author could exfiltrate provider credentials
|
||||||
|
# via ``{{ api_key }}`` in an outgoing notification body.
|
||||||
|
_safe_merge_extras(ctx, event.extra)
|
||||||
|
|
||||||
# Ensure URL variables always exist (avoid Jinja2 undefined errors)
|
# Ensure URL variables always exist (avoid Jinja2 undefined errors)
|
||||||
ctx.setdefault("public_url", "")
|
ctx.setdefault("public_url", "")
|
||||||
@@ -163,6 +224,7 @@ def build_template_context(
|
|||||||
ctx.setdefault("current_time", event.extra.get("current_time", ""))
|
ctx.setdefault("current_time", event.extra.get("current_time", ""))
|
||||||
ctx.setdefault("current_datetime", event.extra.get("current_datetime", ""))
|
ctx.setdefault("current_datetime", event.extra.get("current_datetime", ""))
|
||||||
ctx.setdefault("weekday", event.extra.get("weekday", ""))
|
ctx.setdefault("weekday", event.extra.get("weekday", ""))
|
||||||
|
ctx.setdefault("timezone", event.extra.get("timezone", "UTC"))
|
||||||
ctx.setdefault("custom_vars", event.extra.get("custom_vars", {}))
|
ctx.setdefault("custom_vars", event.extra.get("custom_vars", {}))
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
📅 On this day:
|
📅 On this day:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
|
||||||
{%- endfor %}
|
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
|
||||||
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
📋 Tracked Albums Summary ({{ albums | length }} albums):
|
📋 Tracked Albums Summary ({{ albums | length }} albums):
|
||||||
{%- for album in albums %}
|
{%- for album in albums %}
|
||||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
|
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} assets
|
||||||
{%- endfor %}
|
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
|
||||||
|
{%- if album.shared %} 🔗{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
📸 Photos from {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
{%- if albums and albums|length > 1 -%}
|
||||||
|
🗓️ Scheduled delivery — random photos from {% for album in albums %}{% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}:
|
||||||
|
{%- else -%}
|
||||||
|
🗓️ Scheduled delivery — random photos from {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
||||||
|
{%- endif %}
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||||
{%- endfor %}
|
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
|
||||||
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
📅 В этот день:
|
📅 В этот день:
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %} ({{ asset.created_at[:4] }})
|
||||||
{%- endfor %}
|
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
|
||||||
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
📋 Сводка альбомов ({{ albums | length }}):
|
📋 Сводка альбомов ({{ albums | length }}):
|
||||||
{%- for album in albums %}
|
{%- for album in albums %}
|
||||||
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
|
• {% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}: {{ album.asset_count }} файлов
|
||||||
{%- endfor %}
|
{%- if album.photo_count is defined or album.video_count is defined %} ({% if album.photo_count %}🖼️ {{ album.photo_count }}{% endif %}{% if album.photo_count and album.video_count %} · {% endif %}{% if album.video_count %}🎬 {{ album.video_count }}{% endif %}){% endif %}
|
||||||
|
{%- if album.shared %} 🔗{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
📸 Фото из {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
{%- if albums and albums|length > 1 -%}
|
||||||
|
🗓️ Доставка по расписанию — случайные фото из {% for album in albums %}{% if album.public_url %}<a href="{{ album.public_url }}">{{ album.name }}</a>{% else %}{{ album.name }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}:
|
||||||
|
{%- else -%}
|
||||||
|
🗓️ Доставка по расписанию — случайные фото из {% if public_url %}<a href="{{ public_url }}">{{ album_name }}</a>{% else %}"{{ album_name }}"{% endif %}:
|
||||||
|
{%- endif %}
|
||||||
{%- for asset in assets %}
|
{%- for asset in assets %}
|
||||||
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
• {%- if asset.type == "VIDEO" %} 🎬{% else %} 🖼️{% endif %} {% if asset.public_url %}<a href="{{ asset.public_url }}">{{ asset.filename }}</a>{% else %}{{ asset.filename }}{% endif %}
|
||||||
{%- endfor %}
|
{%- if albums and albums|length > 1 and asset.album_name %} — {% if asset.album_url %}<a href="{{ asset.album_url }}">{{ asset.album_name }}</a>{% else %}{{ asset.album_name }}{% endif %}{% endif %}
|
||||||
|
{%- if asset.city %} 📍 {{ asset.city }}{% if asset.country %}, {{ asset.country }}{% endif %}{% endif %}
|
||||||
|
{%- if asset.is_favorite %} ❤️{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-server"
|
name = "notify-bridge-server"
|
||||||
version = "0.1.0"
|
version = "0.5.2"
|
||||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -28,6 +28,7 @@ dev = [
|
|||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-asyncio>=0.23",
|
"pytest-asyncio>=0.23",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"aioresponses>=0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -35,3 +36,14 @@ notify-bridge = "notify_bridge_server.main:run"
|
|||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src/notify_bridge_server"]
|
packages = ["src/notify_bridge_server"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
# The default filter doesn't let SQLAlchemy warnings fail the suite, which
|
||||||
|
# matters because our migrations emit a handful of deprecation warnings we
|
||||||
|
# don't want to suppress at source.
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::DeprecationWarning:passlib",
|
||||||
|
"ignore::DeprecationWarning:bcrypt",
|
||||||
|
]
|
||||||
|
|||||||
@@ -20,17 +20,39 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|||||||
_SETTING_KEYS = {
|
_SETTING_KEYS = {
|
||||||
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
|
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
|
||||||
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
||||||
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
"telegram_cache_ttl_hours": None, # URL cache TTL; 0 disables TTL
|
||||||
|
"telegram_asset_cache_max_entries": None, # LRU cap for both caches
|
||||||
"supported_locales": None, # comma-separated locale codes
|
"supported_locales": None, # comma-separated locale codes
|
||||||
|
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
|
||||||
|
# Logging — applied live via apply_log_levels() when changed.
|
||||||
|
"log_level": "NOTIFY_BRIDGE_LOG_LEVEL", # DEBUG/INFO/WARNING/ERROR
|
||||||
|
"log_format": "NOTIFY_BRIDGE_LOG_FORMAT", # text|json (requires restart to switch)
|
||||||
|
"log_levels": "NOTIFY_BRIDGE_LOG_LEVELS", # module=LEVEL,module2=LEVEL
|
||||||
}
|
}
|
||||||
|
|
||||||
_DEFAULTS = {
|
_DEFAULTS = {
|
||||||
"external_url": "",
|
"external_url": "",
|
||||||
"telegram_webhook_secret": "",
|
"telegram_webhook_secret": "",
|
||||||
"telegram_cache_ttl_hours": "48",
|
# 720h = 30d. URL cache only; asset cache uses thumbhash validation
|
||||||
|
# (content-addressable) and ignores TTL entirely.
|
||||||
|
"telegram_cache_ttl_hours": "720",
|
||||||
|
"telegram_asset_cache_max_entries": "5000",
|
||||||
"supported_locales": "en,ru",
|
"supported_locales": "en,ru",
|
||||||
|
"timezone": "UTC",
|
||||||
|
"log_level": "INFO",
|
||||||
|
"log_format": "text",
|
||||||
|
"log_levels": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Settings whose changes require dropping in-memory Telegram caches so the
|
||||||
|
# next dispatch rebuilds them with the new parameters. Files are preserved.
|
||||||
|
_CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_entries"}
|
||||||
|
|
||||||
|
# Settings that change logging behaviour. ``log_level`` + ``log_levels`` apply
|
||||||
|
# live via apply_log_levels(); ``log_format`` requires a restart because
|
||||||
|
# changing it means swapping the handler formatter entirely.
|
||||||
|
_LOG_SETTING_KEYS = {"log_level", "log_levels", "log_format"}
|
||||||
|
|
||||||
|
|
||||||
async def get_setting(session: AsyncSession, key: str) -> str:
|
async def get_setting(session: AsyncSession, key: str) -> str:
|
||||||
"""Read a setting from DB, falling back to env var then default."""
|
"""Read a setting from DB, falling back to env var then default."""
|
||||||
@@ -46,10 +68,19 @@ async def get_setting(session: AsyncSession, key: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class SettingsUpdate(BaseModel):
|
class SettingsUpdate(BaseModel):
|
||||||
|
# Numeric fields declared as int|str so clients can send either form.
|
||||||
|
# Svelte's bind:value on <input type="number"> coerces to a JS number,
|
||||||
|
# so the frontend sends ints for these; older/manual clients may send
|
||||||
|
# strings. We normalize to str before persisting.
|
||||||
external_url: str | None = None
|
external_url: str | None = None
|
||||||
telegram_webhook_secret: str | None = None
|
telegram_webhook_secret: str | None = None
|
||||||
telegram_cache_ttl_hours: str | None = None
|
telegram_cache_ttl_hours: int | str | None = None
|
||||||
|
telegram_asset_cache_max_entries: int | str | None = None
|
||||||
supported_locales: str | None = None
|
supported_locales: str | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
log_level: str | None = None
|
||||||
|
log_format: str | None = None
|
||||||
|
log_levels: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -77,21 +108,70 @@ async def update_settings(
|
|||||||
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
|
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
|
||||||
old_base_url = await get_setting(session, "external_url")
|
old_base_url = await get_setting(session, "external_url")
|
||||||
old_secret = await get_setting(session, "telegram_webhook_secret")
|
old_secret = await get_setting(session, "telegram_webhook_secret")
|
||||||
|
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
|
||||||
|
old_timezone = await get_setting(session, "timezone")
|
||||||
|
old_log_values = {k: await get_setting(session, k) for k in _LOG_SETTING_KEYS}
|
||||||
|
|
||||||
for key in _SETTING_KEYS:
|
for key in _SETTING_KEYS:
|
||||||
value = getattr(body, key, None)
|
value = getattr(body, key, None)
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
|
value_str = str(value)
|
||||||
|
# GET masks the webhook secret as "***<last4>" so the real value is
|
||||||
|
# never exposed to the frontend. If the client sends the mask back
|
||||||
|
# (which happens on every save, since bind:value holds whatever GET
|
||||||
|
# returned), treat it as "unchanged" — otherwise we'd overwrite the
|
||||||
|
# real secret with its mask, silently breaking webhook HMAC.
|
||||||
|
if key == "telegram_webhook_secret" and value_str.startswith("***"):
|
||||||
|
continue
|
||||||
row = await session.get(AppSetting, key)
|
row = await session.get(AppSetting, key)
|
||||||
if row:
|
if row:
|
||||||
row.value = value
|
row.value = value_str
|
||||||
else:
|
else:
|
||||||
row = AppSetting(key=key, value=value)
|
row = AppSetting(key=key, value=value_str)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
# Drop in-memory caches if any cache-tuning setting actually changed, so
|
||||||
|
# the next dispatch rebuilds them with the new parameters. Files survive.
|
||||||
|
cache_changed = False
|
||||||
|
for key in _CACHE_SETTING_KEYS:
|
||||||
|
if await get_setting(session, key) != old_cache_values[key]:
|
||||||
|
cache_changed = True
|
||||||
|
break
|
||||||
|
if cache_changed:
|
||||||
|
from ..services.watcher import reset_telegram_caches_in_memory
|
||||||
|
await reset_telegram_caches_in_memory()
|
||||||
|
|
||||||
new_base_url = await get_setting(session, "external_url")
|
new_base_url = await get_setting(session, "external_url")
|
||||||
new_secret = await get_setting(session, "telegram_webhook_secret")
|
new_secret = await get_setting(session, "telegram_webhook_secret")
|
||||||
|
new_timezone = await get_setting(session, "timezone")
|
||||||
|
new_log_values = {k: await get_setting(session, k) for k in _LOG_SETTING_KEYS}
|
||||||
|
|
||||||
|
# Apply live log-level changes (log_format still needs a restart).
|
||||||
|
if (new_log_values["log_level"] != old_log_values["log_level"]
|
||||||
|
or new_log_values["log_levels"] != old_log_values["log_levels"]):
|
||||||
|
from ..logging_setup import apply_log_levels
|
||||||
|
apply_log_levels(
|
||||||
|
level=new_log_values["log_level"] or None,
|
||||||
|
per_module_levels=new_log_values["log_levels"],
|
||||||
|
)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Log levels updated: root=%s overrides=%r",
|
||||||
|
new_log_values["log_level"], new_log_values["log_levels"],
|
||||||
|
)
|
||||||
|
if new_log_values["log_format"] != old_log_values["log_format"]:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"log_format changed from %r to %r — restart the server for it to take effect",
|
||||||
|
old_log_values["log_format"], new_log_values["log_format"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cron triggers freeze their timezone at construction time, so a tz change
|
||||||
|
# has no effect until the jobs are rebuilt — do that here, before we
|
||||||
|
# return success, so the UI reflects the actual schedule immediately.
|
||||||
|
if new_timezone != old_timezone:
|
||||||
|
from ..services.scheduler import reschedule_cron_jobs_for_timezone_change
|
||||||
|
await reschedule_cron_jobs_for_timezone_change()
|
||||||
|
|
||||||
# Update webhook secret in the webhook handler module
|
# Update webhook secret in the webhook handler module
|
||||||
if new_secret != old_secret:
|
if new_secret != old_secret:
|
||||||
@@ -108,6 +188,25 @@ async def update_settings(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/telegram-cache/stats")
|
||||||
|
async def telegram_cache_stats(
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Return counts and sizes for the Telegram file_id caches."""
|
||||||
|
from ..services.watcher import get_telegram_cache_stats
|
||||||
|
return await get_telegram_cache_stats()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telegram-cache/clear")
|
||||||
|
async def clear_telegram_cache(
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Clear the Telegram file_id cache (URL and asset) from disk and memory."""
|
||||||
|
from ..services.watcher import clear_telegram_caches
|
||||||
|
result = await clear_telegram_caches()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/locales")
|
@router.get("/locales")
|
||||||
async def get_supported_locales(
|
async def get_supported_locales(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
@@ -135,7 +234,10 @@ async def _reregister_webhooks(
|
|||||||
if res.get("success"):
|
if res.get("success"):
|
||||||
_LOGGER.info("Re-registered webhook for bot %d (%s)", bot.id, bot.name)
|
_LOGGER.info("Re-registered webhook for bot %d (%s)", bot.id, bot.name)
|
||||||
else:
|
else:
|
||||||
_LOGGER.warning(
|
# Webhook re-register failure means the bot silently stops
|
||||||
"Failed to re-register webhook for bot %d: %s",
|
# delivering updates — this is operational visibility for an
|
||||||
bot.id, res.get("error"),
|
# admin, ERROR is appropriate.
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to re-register webhook for bot %d (%s): %s",
|
||||||
|
bot.id, bot.name, res.get("error"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
"""Configuration backup/restore API (admin only)."""
|
"""Configuration backup/restore API (admin only)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile, File, Query
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
@@ -16,10 +21,29 @@ from ..services.backup_schema import (
|
|||||||
ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode,
|
ALL_CATEGORIES, BackupCategory, BackupFile, ConflictMode, SecretsMode,
|
||||||
)
|
)
|
||||||
from ..services.backup_service import (
|
from ..services.backup_service import (
|
||||||
cleanup_old_backups, export_backup, import_backup, list_backup_files,
|
cleanup_old_backups, export_backup, export_backup_to_file, import_backup,
|
||||||
validate_backup,
|
list_backup_files, validate_backup,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Pending-restore marker keys (single source of truth consumed at startup)
|
||||||
|
PENDING_RESTORE_PATH_KEY = "pending_restore_path"
|
||||||
|
PENDING_RESTORE_CONFLICT_KEY = "pending_restore_conflict_mode"
|
||||||
|
PENDING_RESTORE_UPLOADED_AT_KEY = "pending_restore_uploaded_at"
|
||||||
|
PENDING_RESTORE_UPLOADED_BY_KEY = "pending_restore_uploaded_by"
|
||||||
|
# SHA256 of the staged pending_restore.json, written atomically with the file.
|
||||||
|
# The startup hook refuses to apply if the on-disk file's hash does not match —
|
||||||
|
# defends against anyone dropping a tampered file into data/ between prepare
|
||||||
|
# and restart.
|
||||||
|
PENDING_RESTORE_SHA256_KEY = "pending_restore_sha256"
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_restore_path():
|
||||||
|
return app_config.data_dir / "pending_restore.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _applied_restores_dir():
|
||||||
|
return app_config.data_dir / "applied_restores"
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/backup", tags=["backup"])
|
router = APIRouter(prefix="/api/backup", tags=["backup"])
|
||||||
@@ -27,6 +51,69 @@ router = APIRouter(prefix="/api/backup", tags=["backup"])
|
|||||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_upload_bounded(file: UploadFile, max_bytes: int = MAX_UPLOAD_SIZE) -> bytes:
|
||||||
|
"""Read an UploadFile into memory, failing fast if it exceeds ``max_bytes``.
|
||||||
|
|
||||||
|
Rejects on ``content_length`` header up-front when available; always
|
||||||
|
stream-reads with a running byte counter so we never allocate more than
|
||||||
|
the limit even when the header is missing or lies.
|
||||||
|
"""
|
||||||
|
# Fast path: reject on header before we allocate anything.
|
||||||
|
cl = file.headers.get("content-length") if hasattr(file, "headers") else None
|
||||||
|
if cl:
|
||||||
|
try:
|
||||||
|
if int(cl) > max_bytes:
|
||||||
|
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(64 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
total += len(chunk)
|
||||||
|
if total > max_bytes:
|
||||||
|
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_same_origin(request: Request) -> None:
|
||||||
|
"""Reject cross-origin admin-write POSTs (CSRF defense).
|
||||||
|
|
||||||
|
Bearer tokens in ``localStorage`` plus cookie-less CORS mean a malicious
|
||||||
|
page cannot technically submit our Authorization header from a victim's
|
||||||
|
session, BUT browser extensions and misconfigured CORS policies routinely
|
||||||
|
break this assumption. For endpoints whose blast radius is restart/RCE-
|
||||||
|
equivalent (restore apply), we additionally require the request to come
|
||||||
|
from our own origin.
|
||||||
|
"""
|
||||||
|
host = request.headers.get("host", "").lower()
|
||||||
|
if not host:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing Host header")
|
||||||
|
|
||||||
|
def _host_of(u: str | None) -> str:
|
||||||
|
if not u:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return (urlparse(u).netloc or "").lower()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
|
||||||
|
origin_host = _host_of(request.headers.get("origin"))
|
||||||
|
referer_host = _host_of(request.headers.get("referer"))
|
||||||
|
# At least one of Origin/Referer must be present and match Host.
|
||||||
|
# Legitimate browser requests to this endpoint always ship Origin.
|
||||||
|
same = (origin_host and origin_host == host) or (referer_host and referer_host == host)
|
||||||
|
if not same:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Cross-origin request rejected",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _backup_dir():
|
def _backup_dir():
|
||||||
return app_config.data_dir / "backups"
|
return app_config.data_dir / "backups"
|
||||||
|
|
||||||
@@ -87,9 +174,7 @@ async def validate_config(
|
|||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
"""Validate a backup file without importing."""
|
"""Validate a backup file without importing."""
|
||||||
content = await file.read()
|
content = await _read_upload_bounded(file)
|
||||||
if len(content) > MAX_UPLOAD_SIZE:
|
|
||||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = json.loads(content)
|
raw = json.loads(content)
|
||||||
@@ -112,9 +197,7 @@ async def import_config(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Import configuration from a backup file."""
|
"""Import configuration from a backup file."""
|
||||||
content = await file.read()
|
content = await _read_upload_bounded(file)
|
||||||
if len(content) > MAX_UPLOAD_SIZE:
|
|
||||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = json.loads(content)
|
raw = json.loads(content)
|
||||||
@@ -131,6 +214,202 @@ async def import_config(
|
|||||||
return result.model_dump()
|
return result.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pending restore (prepare → apply on next restart)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _set_app_setting(session: AsyncSession, key: str, value: str) -> None:
|
||||||
|
row = await session.get(AppSetting, key)
|
||||||
|
if row:
|
||||||
|
row.value = value
|
||||||
|
else:
|
||||||
|
row = AppSetting(key=key, value=value)
|
||||||
|
session.add(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def _clear_pending_restore_markers(session: AsyncSession) -> None:
|
||||||
|
for key in (
|
||||||
|
PENDING_RESTORE_PATH_KEY,
|
||||||
|
PENDING_RESTORE_CONFLICT_KEY,
|
||||||
|
PENDING_RESTORE_UPLOADED_AT_KEY,
|
||||||
|
PENDING_RESTORE_UPLOADED_BY_KEY,
|
||||||
|
PENDING_RESTORE_SHA256_KEY,
|
||||||
|
):
|
||||||
|
row = await session.get(AppSetting, key)
|
||||||
|
if row:
|
||||||
|
await session.delete(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/prepare-restore")
|
||||||
|
async def prepare_restore(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
conflict_mode: ConflictMode = Query(default=ConflictMode.SKIP),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Stage a backup for restore on next backend restart.
|
||||||
|
|
||||||
|
Validates the uploaded file, writes it to ``data/pending_restore.json``,
|
||||||
|
and persists marker settings so startup will apply it atomically.
|
||||||
|
"""
|
||||||
|
content = await _read_upload_bounded(file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.loads(content)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}")
|
||||||
|
|
||||||
|
validation = validate_backup(raw)
|
||||||
|
if not validation.valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid backup: {'; '.join(validation.errors)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
pending_path = _pending_restore_path()
|
||||||
|
pending_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Atomic write: write to tmp then rename, so a crash mid-write never
|
||||||
|
# leaves a truncated pending_restore.json that would break startup apply.
|
||||||
|
payload = json.dumps(raw).encode("utf-8")
|
||||||
|
digest = hashlib.sha256(payload).hexdigest()
|
||||||
|
tmp_path = pending_path.with_suffix(pending_path.suffix + ".tmp")
|
||||||
|
tmp_path.write_bytes(payload)
|
||||||
|
os.replace(tmp_path, pending_path)
|
||||||
|
# Best-effort tighten perms so a non-root local user cannot swap the file
|
||||||
|
# for one they control between prepare and restart. On Windows this is a
|
||||||
|
# no-op; on POSIX we restrict to owner-only rw.
|
||||||
|
try:
|
||||||
|
os.chmod(pending_path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
await _set_app_setting(session, PENDING_RESTORE_PATH_KEY, str(pending_path))
|
||||||
|
await _set_app_setting(session, PENDING_RESTORE_CONFLICT_KEY, conflict_mode.value)
|
||||||
|
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_AT_KEY, now_iso)
|
||||||
|
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_BY_KEY, user.username)
|
||||||
|
await _set_app_setting(session, PENDING_RESTORE_SHA256_KEY, digest)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pending": True,
|
||||||
|
"uploaded_at": now_iso,
|
||||||
|
"uploaded_by": user.username,
|
||||||
|
"conflict_mode": conflict_mode.value,
|
||||||
|
"validation": validation.model_dump(),
|
||||||
|
"supervised": _is_supervised(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pending-restore")
|
||||||
|
async def get_pending_restore(
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Return current pending-restore state, or null if none."""
|
||||||
|
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
|
||||||
|
if not path_row or not path_row.value:
|
||||||
|
return {"pending": False, "supervised": _is_supervised()}
|
||||||
|
|
||||||
|
conflict_row = await session.get(AppSetting, PENDING_RESTORE_CONFLICT_KEY)
|
||||||
|
uploaded_at_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_AT_KEY)
|
||||||
|
uploaded_by_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
|
||||||
|
return {
|
||||||
|
"pending": True,
|
||||||
|
"uploaded_at": uploaded_at_row.value if uploaded_at_row else None,
|
||||||
|
"uploaded_by": uploaded_by_row.value if uploaded_by_row else None,
|
||||||
|
"conflict_mode": (conflict_row.value if conflict_row else ConflictMode.SKIP.value),
|
||||||
|
"supervised": _is_supervised(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/pending-restore")
|
||||||
|
async def cancel_pending_restore(
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Cancel a pending restore."""
|
||||||
|
pending_path = _pending_restore_path()
|
||||||
|
if pending_path.exists():
|
||||||
|
pending_path.unlink()
|
||||||
|
await _clear_pending_restore_markers(session)
|
||||||
|
await session.commit()
|
||||||
|
return {"cancelled": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_supervised() -> bool:
|
||||||
|
"""Heuristic: is this process managed by something that will respawn it?
|
||||||
|
|
||||||
|
Priority order:
|
||||||
|
1. Explicit operator override: ``NOTIFY_BRIDGE_SUPERVISED`` env var or
|
||||||
|
the ``supervised`` AppSetting (values: ``true``/``false``/``auto``).
|
||||||
|
``auto`` (or unset) falls through to the detection heuristic.
|
||||||
|
2. Heuristic: look at common container/service-manager env vars.
|
||||||
|
|
||||||
|
Used by the frontend to decide whether to offer "Restart now" — a bad
|
||||||
|
guess here is a foot-gun (process exits, stays dead), so err on the side
|
||||||
|
of false when unsure.
|
||||||
|
"""
|
||||||
|
override = os.environ.get("NOTIFY_BRIDGE_SUPERVISED", "").strip().lower()
|
||||||
|
if override in ("true", "1", "yes", "on"):
|
||||||
|
return True
|
||||||
|
if override in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
for var in ("CONTAINER", "DOCKER_CONTAINER", "KUBERNETES_SERVICE_HOST",
|
||||||
|
"INVOCATION_ID", "PM2_HOME"):
|
||||||
|
if os.environ.get(var):
|
||||||
|
return True
|
||||||
|
if os.path.exists("/.dockerenv"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/apply-restart")
|
||||||
|
async def apply_and_restart(
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Trigger a graceful exit so the supervisor respawns and applies the pending restore.
|
||||||
|
|
||||||
|
Only allowed when a pending restore is staged AND the process is supervised.
|
||||||
|
Requires same-origin Origin/Referer — this endpoint's blast radius is a
|
||||||
|
full config replace + restart, so an admin token alone (vulnerable to
|
||||||
|
XSS-driven CSRF) is not enough.
|
||||||
|
"""
|
||||||
|
_check_same_origin(request)
|
||||||
|
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
|
||||||
|
if not path_row or not path_row.value:
|
||||||
|
raise HTTPException(status_code=409, detail="No pending restore to apply")
|
||||||
|
if not _is_supervised():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"This process is not supervised. Restart the backend manually to apply "
|
||||||
|
"the pending restore, or use the Cancel button."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _shutdown_soon() -> None:
|
||||||
|
# Small delay so the HTTP response flushes before the signal fires.
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
_LOGGER.warning("Admin triggered restart to apply pending restore")
|
||||||
|
# SIGTERM lets uvicorn run its normal graceful shutdown:
|
||||||
|
# drain in-flight requests, fire the lifespan shutdown hooks
|
||||||
|
# (close_http_session, scheduler.shutdown), then exit. The
|
||||||
|
# supervisor respawns, and startup applies the pending restore.
|
||||||
|
try:
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
|
except Exception: # noqa: BLE001 — last-resort fallback on platforms that reject SIGTERM
|
||||||
|
_LOGGER.exception("SIGTERM delivery failed; falling back to os._exit")
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
background_tasks.add_task(_shutdown_soon)
|
||||||
|
return {"restart_requested": True}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Scheduled backup settings
|
# Scheduled backup settings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -205,6 +484,37 @@ async def get_backup_files(
|
|||||||
return list_backup_files(_backup_dir())
|
return list_backup_files(_backup_dir())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/files")
|
||||||
|
async def create_manual_backup(
|
||||||
|
secrets_mode: SecretsMode = Query(default=SecretsMode.EXCLUDE),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Create a backup file in the backups directory (manual checkpoint).
|
||||||
|
|
||||||
|
Produces the same JSON format as scheduled backups, saved under
|
||||||
|
``data/backups/backup-<timestamp>.json``. Retention is managed by the
|
||||||
|
existing scheduled-backup settings (``backup_retention_count``).
|
||||||
|
"""
|
||||||
|
backup_dir = _backup_dir()
|
||||||
|
filepath = await export_backup_to_file(session, user.id, backup_dir, secrets_mode)
|
||||||
|
# Apply the same retention as scheduled backups if configured.
|
||||||
|
retention_row = await session.get(AppSetting, "backup_retention_count")
|
||||||
|
if retention_row and retention_row.value:
|
||||||
|
try:
|
||||||
|
retention = int(retention_row.value)
|
||||||
|
if retention > 0:
|
||||||
|
cleanup_old_backups(backup_dir, keep=retention)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
stat = filepath.stat()
|
||||||
|
return {
|
||||||
|
"filename": filepath.name,
|
||||||
|
"size": stat.st_size,
|
||||||
|
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files/{filename}")
|
@router.get("/files/{filename}")
|
||||||
async def download_backup_file(
|
async def download_backup_file(
|
||||||
filename: str,
|
filename: str,
|
||||||
|
|||||||
@@ -74,6 +74,36 @@ async def _get(session: AsyncSession, config_id: int, user_id: int) -> CommandTe
|
|||||||
# Routes
|
# Routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/defaults")
|
||||||
|
async def get_default_command_templates(
|
||||||
|
provider_type: str,
|
||||||
|
slot_name: str | None = None,
|
||||||
|
locale: str | None = None,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return the shipped Jinja2 default command templates for a provider type.
|
||||||
|
|
||||||
|
Used by the UI's "Reset to default" actions. Filtering works the same way
|
||||||
|
as the notification-template equivalent: omit ``slot_name`` for the whole
|
||||||
|
set, omit ``locale`` for every locale.
|
||||||
|
|
||||||
|
Response shape: ``{slot_name: {locale: template_text}}``
|
||||||
|
"""
|
||||||
|
from notify_bridge_core.templates.command_defaults.loader import (
|
||||||
|
load_default_command_templates,
|
||||||
|
)
|
||||||
|
from notify_bridge_core.templates.defaults.loader import get_available_locales
|
||||||
|
locales = [locale] if locale else get_available_locales()
|
||||||
|
result: dict[str, dict[str, str]] = {}
|
||||||
|
for loc in locales:
|
||||||
|
defaults = load_default_command_templates(loc, provider_type)
|
||||||
|
for name, text in defaults.items():
|
||||||
|
if slot_name and name != slot_name:
|
||||||
|
continue
|
||||||
|
result.setdefault(name, {})[loc] = text
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/variables")
|
@router.get("/variables")
|
||||||
async def get_command_variables(
|
async def get_command_variables(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
@@ -84,15 +114,26 @@ async def get_command_variables(
|
|||||||
}
|
}
|
||||||
asset_fields = {
|
asset_fields = {
|
||||||
"id": "Asset ID (UUID)",
|
"id": "Asset ID (UUID)",
|
||||||
"originalFileName": "Original filename",
|
"filename": "Original filename (preferred; same as originalFileName)",
|
||||||
|
"originalFileName": "Original filename (alias of filename, kept for backward-compat with older templates)",
|
||||||
"type": "IMAGE or VIDEO",
|
"type": "IMAGE or VIDEO",
|
||||||
"createdAt": "Creation date/time (ISO 8601)",
|
"created_at": "Creation date/time (ISO 8601)",
|
||||||
|
"createdAt": "Creation date/time (alias of created_at)",
|
||||||
"year": "Year of the memory (memory command only)",
|
"year": "Year of the memory (memory command only)",
|
||||||
|
"public_url": "Per-asset public share URL (empty if no album link)",
|
||||||
|
"city": "City name (empty if unknown)",
|
||||||
|
"country": "Country name (empty if unknown)",
|
||||||
|
"is_favorite": "Whether asset is favorited (boolean)",
|
||||||
}
|
}
|
||||||
album_fields = {
|
album_fields = {
|
||||||
"name": "Album name",
|
"name": "Album name",
|
||||||
"asset_count": "Number of assets in the album",
|
|
||||||
"id": "Album ID (UUID)",
|
"id": "Album ID (UUID)",
|
||||||
|
"public_url": "Public share link URL (empty if none)",
|
||||||
|
"asset_count": "Number of assets in the album",
|
||||||
|
"photo_count": "Number of photos in the album",
|
||||||
|
"video_count": "Number of videos in the album",
|
||||||
|
"shared": "Whether the album is shared (boolean)",
|
||||||
|
"owner": "Album owner display name",
|
||||||
}
|
}
|
||||||
command_fields = {
|
command_fields = {
|
||||||
"name": "Command name (e.g. status, albums)",
|
"name": "Command name (e.g. status, albums)",
|
||||||
@@ -138,13 +179,11 @@ async def get_command_variables(
|
|||||||
# --- Immich-specific ---
|
# --- Immich-specific ---
|
||||||
immich = {
|
immich = {
|
||||||
"status": {
|
"status": {
|
||||||
"description": "/status tracker summary",
|
"description": "/status tracker summary (scoped to this chat)",
|
||||||
"variables": {
|
"variables": {
|
||||||
**common_vars,
|
**common_vars,
|
||||||
"trackers_active": "Number of active trackers",
|
"total_albums": "Tracked albums visible to this chat",
|
||||||
"trackers_total": "Total tracker count",
|
"last_event": "Last event timestamp string (scoped to this chat's albums)",
|
||||||
"total_albums": "Total tracked albums",
|
|
||||||
"last_event": "Last event timestamp string",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"albums": {
|
"albums": {
|
||||||
@@ -494,10 +533,11 @@ async def preview_raw(
|
|||||||
{"name": "latest", "description": "Show latest photos", "usage": "/latest 10"},
|
{"name": "latest", "description": "Show latest photos", "usage": "/latest 10"},
|
||||||
{"name": "search", "description": "Smart search (AI)", "usage": "/search sunset at the beach"},
|
{"name": "search", "description": "Smart search (AI)", "usage": "/search sunset at the beach"},
|
||||||
],
|
],
|
||||||
# /albums, /summary
|
# /albums, /summary — provide photo/video split, sharing, owner so the
|
||||||
|
# enriched summary template previews fully.
|
||||||
"albums": [
|
"albums": [
|
||||||
{"name": "Family Photos", "asset_count": 142, "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"},
|
{"name": "Family Photos", "asset_count": 142, "photo_count": 120, "video_count": 22, "shared": True, "owner": "Alice", "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"},
|
||||||
{"name": "Vacation 2025", "asset_count": 87, "id": "def-456", "public_url": ""},
|
{"name": "Vacation 2025", "asset_count": 87, "photo_count": 80, "video_count": 7, "shared": False, "owner": "Bob", "id": "def-456", "public_url": ""},
|
||||||
],
|
],
|
||||||
# /events
|
# /events
|
||||||
"events": [
|
"events": [
|
||||||
@@ -507,9 +547,12 @@ async def preview_raw(
|
|||||||
# /people
|
# /people
|
||||||
"people": ["Alice", "Bob", "Charlie"],
|
"people": ["Alice", "Bob", "Charlie"],
|
||||||
# /search, /find, /person, /place, /latest, /favorites, /random, /memory
|
# /search, /find, /person, /place, /latest, /favorites, /random, /memory
|
||||||
|
# ``filename`` is the canonical key (matches notification context and
|
||||||
|
# build_asset_dict output); ``originalFileName`` is kept as an alias
|
||||||
|
# so templates still using the old key render in preview.
|
||||||
"assets": [
|
"assets": [
|
||||||
{"id": "a1", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True},
|
{"id": "a1", "filename": "IMG_001.jpg", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "created_at": "2026-03-19T14:30:00", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True},
|
||||||
{"id": "a2", "originalFileName": "VID_002.mp4", "type": "VIDEO", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False},
|
{"id": "a2", "filename": "VID_002.mp4", "originalFileName": "VID_002.mp4", "type": "VIDEO", "created_at": "2026-03-19T15:00:00", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False},
|
||||||
],
|
],
|
||||||
"query": "sunset",
|
"query": "sunset",
|
||||||
"command": "search",
|
"command": "search",
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ class CommandTrackerUpdate(BaseModel):
|
|||||||
class ListenerCreate(BaseModel):
|
class ListenerCreate(BaseModel):
|
||||||
listener_type: str
|
listener_type: str
|
||||||
listener_id: int
|
listener_id: int
|
||||||
|
allowed_album_ids: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ListenerUpdate(BaseModel):
|
||||||
|
allowed_album_ids: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Command Tracker CRUD ---
|
# --- Command Tracker CRUD ---
|
||||||
@@ -299,6 +304,7 @@ async def add_listener(
|
|||||||
command_tracker_id=tracker_id,
|
command_tracker_id=tracker_id,
|
||||||
listener_type=body.listener_type,
|
listener_type=body.listener_type,
|
||||||
listener_id=body.listener_id,
|
listener_id=body.listener_id,
|
||||||
|
allowed_album_ids=body.allowed_album_ids,
|
||||||
)
|
)
|
||||||
session.add(listener)
|
session.add(listener)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -316,6 +322,30 @@ async def add_listener(
|
|||||||
return await _listener_response(session, listener)
|
return await _listener_response(session, listener)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tracker_id}/listeners/{listener_id}")
|
||||||
|
async def update_listener(
|
||||||
|
tracker_id: int,
|
||||||
|
listener_id: int,
|
||||||
|
body: ListenerUpdate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Update a listener's per-chat settings (currently just allowed_album_ids)."""
|
||||||
|
await _get_user_tracker(session, tracker_id, user.id)
|
||||||
|
listener = await session.get(CommandTrackerListener, listener_id)
|
||||||
|
if not listener or listener.command_tracker_id != tracker_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Listener not found")
|
||||||
|
# Empty list means "no albums" which is rarely useful; treat as null (inherit).
|
||||||
|
if body.allowed_album_ids is not None and len(body.allowed_album_ids) == 0:
|
||||||
|
listener.allowed_album_ids = None
|
||||||
|
else:
|
||||||
|
listener.allowed_album_ids = body.allowed_album_ids
|
||||||
|
session.add(listener)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(listener)
|
||||||
|
return await _listener_response(session, listener)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def remove_listener(
|
async def remove_listener(
|
||||||
tracker_id: int,
|
tracker_id: int,
|
||||||
@@ -394,6 +424,7 @@ async def _listener_response(session: AsyncSession, l: CommandTrackerListener) -
|
|||||||
"command_tracker_id": l.command_tracker_id,
|
"command_tracker_id": l.command_tracker_id,
|
||||||
"listener_type": l.listener_type,
|
"listener_type": l.listener_type,
|
||||||
"listener_id": l.listener_id,
|
"listener_id": l.listener_id,
|
||||||
|
"allowed_album_ids": l.allowed_album_ids,
|
||||||
"name": name,
|
"name": name,
|
||||||
"created_at": l.created_at.isoformat(),
|
"created_at": l.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,22 @@ from ..database.models import (
|
|||||||
|
|
||||||
|
|
||||||
def raise_if_used(consumers: list[str], entity_name: str) -> None:
|
def raise_if_used(consumers: list[str], entity_name: str) -> None:
|
||||||
"""Raise 409 Conflict if the entity has consumers."""
|
"""Raise 409 Conflict if the entity has consumers.
|
||||||
|
|
||||||
|
Produces a human-readable summary string (kept as the primary ``detail``)
|
||||||
|
plus a structured ``blocked_by`` list so the frontend can render a
|
||||||
|
clickable warning modal.
|
||||||
|
"""
|
||||||
if consumers:
|
if consumers:
|
||||||
detail = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s). " + "; ".join(consumers)
|
summary = f"Cannot delete {entity_name}: used by {len(consumers)} consumer(s)."
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": summary,
|
||||||
|
"entity": entity_name,
|
||||||
|
"blocked_by": consumers,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def check_service_provider(session: AsyncSession, provider_id: int) -> list[str]:
|
async def check_service_provider(session: AsyncSession, provider_id: int) -> list[str]:
|
||||||
|
|||||||
@@ -93,7 +93,14 @@ async def update_email_bot(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
bot = await _get_user_bot(session, bot_id, user.id)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
for field, value in body.model_dump(exclude_unset=True).items():
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
# Reject the masked value the GET response returns so the stored password
|
||||||
|
# is preserved if the user saves without retyping it.
|
||||||
|
if "smtp_password" in updates:
|
||||||
|
pw = updates["smtp_password"]
|
||||||
|
if isinstance(pw, str) and pw.startswith("***"):
|
||||||
|
updates.pop("smtp_password")
|
||||||
|
for field, value in updates.items():
|
||||||
setattr(bot, field, value)
|
setattr(bot, field, value)
|
||||||
session.add(bot)
|
session.add(bot)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ from pydantic import BaseModel
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from notify_bridge_core.notifications.ssrf import (
|
||||||
|
UnsafeURLError,
|
||||||
|
avalidate_outbound_url,
|
||||||
|
)
|
||||||
|
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import MatrixBot, User
|
from ..database.models import MatrixBot, User
|
||||||
@@ -33,6 +38,21 @@ class MatrixBotUpdate(BaseModel):
|
|||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_masked_secret(value: str | None) -> bool:
|
||||||
|
"""True when a field still carries our masked placeholder."""
|
||||||
|
return bool(value) and (value.startswith("***") or "..." in value)
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_homeserver_url(url: str) -> None:
|
||||||
|
"""Reject homeserver URLs that point to blocked networks."""
|
||||||
|
try:
|
||||||
|
await avalidate_outbound_url(url)
|
||||||
|
except UnsafeURLError as err:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Invalid homeserver_url: {err}"
|
||||||
|
) from err
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_matrix_bots(
|
async def list_matrix_bots(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
@@ -50,6 +70,7 @@ async def create_matrix_bot(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
await _validate_homeserver_url(body.homeserver_url)
|
||||||
bot = MatrixBot(user_id=user.id, **body.model_dump())
|
bot = MatrixBot(user_id=user.id, **body.model_dump())
|
||||||
session.add(bot)
|
session.add(bot)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -74,7 +95,19 @@ async def update_matrix_bot(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
bot = await _get_user_bot(session, bot_id, user.id)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
for field, value in body.model_dump(exclude_unset=True).items():
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# Re-validate homeserver_url whenever the client supplies a new one so
|
||||||
|
# no private/loopback target can ever be saved, even via update.
|
||||||
|
if "homeserver_url" in updates and updates["homeserver_url"]:
|
||||||
|
await _validate_homeserver_url(updates["homeserver_url"])
|
||||||
|
|
||||||
|
# Never accept the masked placeholder the GET response returns. If the
|
||||||
|
# client echoes it back, keep the stored secret.
|
||||||
|
if "access_token" in updates and _is_masked_secret(updates["access_token"]):
|
||||||
|
updates.pop("access_token")
|
||||||
|
|
||||||
|
for field, value in updates.items():
|
||||||
setattr(bot, field, value)
|
setattr(bot, field, value)
|
||||||
session.add(bot)
|
session.add(bot)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -108,15 +141,17 @@ async def test_matrix_bot(
|
|||||||
If room_id is not provided, just verifies the access token by calling /whoami.
|
If room_id is not provided, just verifies the access token by calling /whoami.
|
||||||
"""
|
"""
|
||||||
bot = await _get_user_bot(session, bot_id, user.id)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
|
# Defense-in-depth: even though create/update validate the URL, a bot row
|
||||||
|
# written before this guard was added could still point at a blocked host.
|
||||||
|
await _validate_homeserver_url(bot.homeserver_url)
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from ..services.http_session import get_http_session
|
from ..services.http_session import get_http_session
|
||||||
http = await get_http_session()
|
http = await get_http_session()
|
||||||
# Verify token with /whoami
|
|
||||||
whoami_url = f"{bot.homeserver_url.rstrip('/')}/_matrix/client/v3/account/whoami"
|
whoami_url = f"{bot.homeserver_url.rstrip('/')}/_matrix/client/v3/account/whoami"
|
||||||
headers = {"Authorization": f"Bearer {bot.access_token}"}
|
headers = {"Authorization": f"Bearer {bot.access_token}"}
|
||||||
try:
|
try:
|
||||||
async with http.get(whoami_url, headers=headers) as resp:
|
async with http.get(whoami_url, headers=headers, allow_redirects=False) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
body = await resp.text()
|
body = await resp.text()
|
||||||
return {"success": False, "error": f"Auth failed: HTTP {resp.status} — {body[:200]}"}
|
return {"success": False, "error": f"Auth failed: HTTP {resp.status} — {body[:200]}"}
|
||||||
@@ -126,7 +161,6 @@ async def test_matrix_bot(
|
|||||||
|
|
||||||
result = {"success": True, "user_id": whoami.get("user_id", "")}
|
result = {"success": True, "user_id": whoami.get("user_id", "")}
|
||||||
|
|
||||||
# Optionally send a test message
|
|
||||||
if room_id:
|
if room_id:
|
||||||
from ..services.notifier import _get_test_message
|
from ..services.notifier import _get_test_message
|
||||||
from notify_bridge_core.notifications.matrix.client import MatrixClient
|
from notify_bridge_core.notifications.matrix.client import MatrixClient
|
||||||
@@ -148,7 +182,7 @@ def _response(bot: MatrixBot) -> dict:
|
|||||||
"name": bot.name,
|
"name": bot.name,
|
||||||
"icon": bot.icon,
|
"icon": bot.icon,
|
||||||
"homeserver_url": bot.homeserver_url,
|
"homeserver_url": bot.homeserver_url,
|
||||||
"access_token": f"{bot.access_token[:8]}...{bot.access_token[-4:]}" if len(bot.access_token) > 12 else "***",
|
"access_token": f"***{bot.access_token[-4:]}" if len(bot.access_token) > 4 else "***",
|
||||||
"display_name": bot.display_name,
|
"display_name": bot.display_name,
|
||||||
"created_at": bot.created_at.isoformat(),
|
"created_at": bot.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ from ..database.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from ..services.notifier import send_test_notification
|
from ..services.notifier import send_test_notification
|
||||||
from ..services.test_dispatch import dispatch_test_notification
|
from ..services.manual_dispatch import dispatch_test_notification
|
||||||
|
from ..services.scheduler import reschedule_immich_dispatch_jobs
|
||||||
from .helpers import get_owned_entity
|
from .helpers import get_owned_entity
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -118,6 +119,7 @@ async def create_notification_tracker_target(
|
|||||||
session.add(tt)
|
session.add(tt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(tt)
|
await session.refresh(tt)
|
||||||
|
await reschedule_immich_dispatch_jobs()
|
||||||
return await _tt_response(session, tt)
|
return await _tt_response(session, tt)
|
||||||
|
|
||||||
|
|
||||||
@@ -164,6 +166,7 @@ async def update_notification_tracker_target(
|
|||||||
session.add(tt)
|
session.add(tt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(tt)
|
await session.refresh(tt)
|
||||||
|
await reschedule_immich_dispatch_jobs()
|
||||||
return await _tt_response(session, tt)
|
return await _tt_response(session, tt)
|
||||||
|
|
||||||
|
|
||||||
@@ -181,6 +184,7 @@ async def delete_notification_tracker_target(
|
|||||||
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
raise HTTPException(status_code=404, detail="Tracker-target link not found")
|
||||||
await session.delete(tt)
|
await session.delete(tt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
await reschedule_immich_dispatch_jobs()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{tracker_target_id}/test/{test_type}")
|
@router.post("/{tracker_target_id}/test/{test_type}")
|
||||||
|
|||||||
@@ -11,13 +11,18 @@ from ..auth.dependencies import get_current_user
|
|||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import (
|
from ..database.models import (
|
||||||
EventLog,
|
EventLog,
|
||||||
|
NotificationTarget,
|
||||||
NotificationTracker,
|
NotificationTracker,
|
||||||
NotificationTrackerState,
|
NotificationTrackerState,
|
||||||
NotificationTrackerTarget,
|
NotificationTrackerTarget,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from ..services.scheduler import schedule_tracker, unschedule_tracker
|
from ..services.scheduler import (
|
||||||
|
reschedule_immich_dispatch_jobs,
|
||||||
|
schedule_tracker,
|
||||||
|
unschedule_tracker,
|
||||||
|
)
|
||||||
from .helpers import get_owned_entity
|
from .helpers import get_owned_entity
|
||||||
from .notification_tracker_targets import _tt_response
|
from .notification_tracker_targets import _tt_response
|
||||||
|
|
||||||
@@ -32,7 +37,7 @@ class NotificationTrackerCreate(BaseModel):
|
|||||||
icon: str = ""
|
icon: str = ""
|
||||||
collection_ids: list[str] = []
|
collection_ids: list[str] = []
|
||||||
scan_interval: int = 60
|
scan_interval: int = 60
|
||||||
batch_duration: int = 0
|
adaptive_max_skip: int | None = None
|
||||||
default_tracking_config_id: int | None = None
|
default_tracking_config_id: int | None = None
|
||||||
default_template_config_id: int | None = None
|
default_template_config_id: int | None = None
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
@@ -43,7 +48,11 @@ class NotificationTrackerUpdate(BaseModel):
|
|||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
collection_ids: list[str] | None = None
|
collection_ids: list[str] | None = None
|
||||||
scan_interval: int | None = None
|
scan_interval: int | None = None
|
||||||
batch_duration: int | None = None
|
# int | None is ambiguous for partial updates — we can't distinguish
|
||||||
|
# "clear the field" from "don't touch". Callers send this via
|
||||||
|
# model_dump(exclude_unset=True), so an omitted key leaves the value
|
||||||
|
# alone and an explicit null clears it back to the adaptive-off default.
|
||||||
|
adaptive_max_skip: int | None = None
|
||||||
default_tracking_config_id: int | None = None
|
default_tracking_config_id: int | None = None
|
||||||
default_template_config_id: int | None = None
|
default_template_config_id: int | None = None
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
@@ -54,11 +63,79 @@ async def list_notification_trackers(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
# Batched loader: pull trackers, then all their tracker-target links in
|
||||||
|
# a single query, then the referenced targets in a single query. Avoids
|
||||||
|
# the old 1 + N + N*M pattern that ran ~60 round-trips for 10 trackers.
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(NotificationTracker).where(NotificationTracker.user_id == user.id)
|
select(NotificationTracker).where(NotificationTracker.user_id == user.id)
|
||||||
)
|
)
|
||||||
trackers = result.all()
|
trackers = list(result.all())
|
||||||
return [await _tracker_response(session, t) for t in trackers]
|
if not trackers:
|
||||||
|
return []
|
||||||
|
|
||||||
|
tracker_ids = [t.id for t in trackers]
|
||||||
|
tt_result = await session.exec(
|
||||||
|
select(NotificationTrackerTarget).where(
|
||||||
|
NotificationTrackerTarget.tracker_id.in_(tracker_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tt_rows = list(tt_result.all())
|
||||||
|
|
||||||
|
target_ids = {tt.target_id for tt in tt_rows}
|
||||||
|
targets_by_id: dict[int, NotificationTarget] = {}
|
||||||
|
if target_ids:
|
||||||
|
tgt_result = await session.exec(
|
||||||
|
select(NotificationTarget).where(NotificationTarget.id.in_(target_ids))
|
||||||
|
)
|
||||||
|
targets_by_id = {t.id: t for t in tgt_result.all()}
|
||||||
|
|
||||||
|
tts_by_tracker: dict[int, list[NotificationTrackerTarget]] = {}
|
||||||
|
for tt in tt_rows:
|
||||||
|
tts_by_tracker.setdefault(tt.tracker_id, []).append(tt)
|
||||||
|
|
||||||
|
return [
|
||||||
|
_build_tracker_response(t, tts_by_tracker.get(t.id, []), targets_by_id)
|
||||||
|
for t in trackers
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tracker_response(
|
||||||
|
t: NotificationTracker,
|
||||||
|
tts: list[NotificationTrackerTarget],
|
||||||
|
targets_by_id: dict[int, NotificationTarget],
|
||||||
|
) -> dict:
|
||||||
|
"""In-memory assembler for a tracker + its pre-loaded links/targets."""
|
||||||
|
tracker_targets = []
|
||||||
|
for tt in tts:
|
||||||
|
target = targets_by_id.get(tt.target_id)
|
||||||
|
tracker_targets.append({
|
||||||
|
"id": tt.id,
|
||||||
|
"tracker_id": tt.tracker_id,
|
||||||
|
"target_id": tt.target_id,
|
||||||
|
"target_name": target.name if target else None,
|
||||||
|
"target_type": target.type if target else None,
|
||||||
|
"target_icon": target.icon if target else None,
|
||||||
|
"tracking_config_id": tt.tracking_config_id,
|
||||||
|
"template_config_id": tt.template_config_id,
|
||||||
|
"enabled": tt.enabled,
|
||||||
|
"quiet_hours_start": tt.quiet_hours_start,
|
||||||
|
"quiet_hours_end": tt.quiet_hours_end,
|
||||||
|
"created_at": tt.created_at.isoformat(),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"id": t.id,
|
||||||
|
"name": t.name,
|
||||||
|
"icon": t.icon,
|
||||||
|
"provider_id": t.provider_id,
|
||||||
|
"collection_ids": t.collection_ids,
|
||||||
|
"scan_interval": t.scan_interval,
|
||||||
|
"adaptive_max_skip": t.adaptive_max_skip,
|
||||||
|
"default_tracking_config_id": t.default_tracking_config_id,
|
||||||
|
"default_template_config_id": t.default_template_config_id,
|
||||||
|
"enabled": t.enabled,
|
||||||
|
"tracker_targets": tracker_targets,
|
||||||
|
"created_at": t.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
@@ -76,7 +153,11 @@ async def create_notification_tracker(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(tracker)
|
await session.refresh(tracker)
|
||||||
if tracker.enabled:
|
if tracker.enabled:
|
||||||
await schedule_tracker(tracker.id, tracker.scan_interval)
|
await schedule_tracker(
|
||||||
|
tracker.id, tracker.scan_interval,
|
||||||
|
adaptive_max_skip=tracker.adaptive_max_skip,
|
||||||
|
)
|
||||||
|
await reschedule_immich_dispatch_jobs()
|
||||||
return await _tracker_response(session, tracker)
|
return await _tracker_response(session, tracker)
|
||||||
|
|
||||||
|
|
||||||
@@ -104,9 +185,13 @@ async def update_notification_tracker(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(tracker)
|
await session.refresh(tracker)
|
||||||
if tracker.enabled:
|
if tracker.enabled:
|
||||||
await schedule_tracker(tracker.id, tracker.scan_interval)
|
await schedule_tracker(
|
||||||
|
tracker.id, tracker.scan_interval,
|
||||||
|
adaptive_max_skip=tracker.adaptive_max_skip,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await unschedule_tracker(tracker.id)
|
await unschedule_tracker(tracker.id)
|
||||||
|
await reschedule_immich_dispatch_jobs()
|
||||||
return await _tracker_response(session, tracker)
|
return await _tracker_response(session, tracker)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,6 +224,7 @@ async def delete_notification_tracker(
|
|||||||
await session.delete(tracker)
|
await session.delete(tracker)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await unschedule_tracker(tracker_id)
|
await unschedule_tracker(tracker_id)
|
||||||
|
await reschedule_immich_dispatch_jobs()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{tracker_id}/trigger")
|
@router.post("/{tracker_id}/trigger")
|
||||||
@@ -194,7 +280,7 @@ async def _tracker_response(session: AsyncSession, t: NotificationTracker) -> di
|
|||||||
"provider_id": t.provider_id,
|
"provider_id": t.provider_id,
|
||||||
"collection_ids": t.collection_ids,
|
"collection_ids": t.collection_ids,
|
||||||
"scan_interval": t.scan_interval,
|
"scan_interval": t.scan_interval,
|
||||||
"batch_duration": t.batch_duration,
|
"adaptive_max_skip": t.adaptive_max_skip,
|
||||||
"default_tracking_config_id": t.default_tracking_config_id,
|
"default_tracking_config_id": t.default_tracking_config_id,
|
||||||
"default_template_config_id": t.default_template_config_id,
|
"default_template_config_id": t.default_template_config_id,
|
||||||
"enabled": t.enabled,
|
"enabled": t.enabled,
|
||||||
|
|||||||
@@ -306,16 +306,31 @@ async def update_provider(
|
|||||||
if body.icon is not None:
|
if body.icon is not None:
|
||||||
provider.icon = body.icon
|
provider.icon = body.icon
|
||||||
|
|
||||||
config_changed = body.config is not None and body.config != provider.config
|
|
||||||
if body.config is not None:
|
if body.config is not None:
|
||||||
_validate_provider_config(provider.type, body.config)
|
# Merge rather than replace so the masked secrets the frontend
|
||||||
provider.config = body.config
|
# receives on GET cannot silently nuke the stored values when the
|
||||||
|
# user saves without re-entering them. Any field that still carries
|
||||||
|
# our mask placeholder ("***…") is dropped from the incoming body.
|
||||||
|
incoming = dict(body.config)
|
||||||
|
for secret_field in (
|
||||||
|
"api_key", "api_token", "webhook_secret", "password",
|
||||||
|
"client_secret", "refresh_token",
|
||||||
|
):
|
||||||
|
value = incoming.get(secret_field)
|
||||||
|
if isinstance(value, str) and value.startswith("***"):
|
||||||
|
incoming.pop(secret_field, None)
|
||||||
|
new_config = {**provider.config, **incoming}
|
||||||
|
_validate_provider_config(provider.type, new_config)
|
||||||
|
config_changed = new_config != provider.config
|
||||||
|
provider.config = new_config
|
||||||
|
|
||||||
# Re-validate connection when config changes for known provider types
|
if config_changed:
|
||||||
if config_changed:
|
test_result = await _validate_provider_connection(provider)
|
||||||
test_result = await _validate_provider_connection(provider)
|
if test_result.get("external_domain"):
|
||||||
if test_result.get("external_domain"):
|
provider.config = {
|
||||||
provider.config = {**provider.config, "external_domain": test_result["external_domain"]}
|
**provider.config,
|
||||||
|
"external_domain": test_result["external_domain"],
|
||||||
|
}
|
||||||
|
|
||||||
session.add(provider)
|
session.add(provider)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -411,19 +426,45 @@ async def get_album_shared_links(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSharedLinkRequest(BaseModel):
|
||||||
|
"""Options for POST /shared-links.
|
||||||
|
|
||||||
|
``replace=True`` deletes every existing link for the album before creating
|
||||||
|
the new one, which is the only way to repair an expired or password-
|
||||||
|
protected link in the Immich API (there is no in-place "reset" endpoint).
|
||||||
|
Default ``False`` preserves the original additive behaviour used by the
|
||||||
|
"auto-create missing links" flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
replace: bool = False
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{provider_id}/albums/{album_id}/shared-links")
|
@router.post("/{provider_id}/albums/{album_id}/shared-links")
|
||||||
async def create_album_shared_link(
|
async def create_album_shared_link(
|
||||||
provider_id: int,
|
provider_id: int,
|
||||||
album_id: str,
|
album_id: str,
|
||||||
|
body: CreateSharedLinkRequest | None = None,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Auto-create a public shared link for an album."""
|
"""Auto-create a public shared link for an album.
|
||||||
|
|
||||||
|
With ``replace=True`` existing links for the album are deleted first, so
|
||||||
|
expired/password-protected links are effectively recycled into a fresh
|
||||||
|
public one.
|
||||||
|
"""
|
||||||
provider = await _get_user_provider(session, provider_id, user.id)
|
provider = await _get_user_provider(session, provider_id, user.id)
|
||||||
|
|
||||||
if provider.type == "immich":
|
if provider.type == "immich":
|
||||||
http_session = await get_http_session()
|
http_session = await get_http_session()
|
||||||
immich = make_immich_provider(http_session, provider)
|
immich = make_immich_provider(http_session, provider)
|
||||||
|
if body and body.replace:
|
||||||
|
# Best-effort delete; if any delete fails we still try to create —
|
||||||
|
# the user will see the new link co-exist alongside the old one,
|
||||||
|
# which is better than a hard failure that leaves them stuck.
|
||||||
|
existing = await immich.client.get_shared_links(album_id)
|
||||||
|
for link in existing:
|
||||||
|
await immich.client.delete_shared_link(link.id)
|
||||||
success = await immich.client.create_shared_link(album_id)
|
success = await immich.client.create_shared_link(album_id)
|
||||||
if success:
|
if success:
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
from sqlmodel import func, select
|
from sqlmodel import func, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import (
|
from ..database.models import (
|
||||||
|
Action,
|
||||||
CommandConfig,
|
CommandConfig,
|
||||||
CommandTemplateConfig,
|
CommandTemplateConfig,
|
||||||
CommandTracker,
|
CommandTracker,
|
||||||
@@ -54,12 +56,10 @@ async def get_status(
|
|||||||
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
select(func.count()).select_from(NotificationTarget).where(NotificationTarget.user_id == user.id)
|
||||||
)).one()
|
)).one()
|
||||||
|
|
||||||
# Build events query with filters
|
# Build events query with filters. EventLog.user_id is the owner column;
|
||||||
events_query = (
|
# action events (event_type starts with "action_") have tracker_id NULL but
|
||||||
select(EventLog)
|
# user_id set, so we filter by user_id directly.
|
||||||
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
events_query = select(EventLog).where(EventLog.user_id == user.id)
|
||||||
.where(NotificationTracker.user_id == user.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if event_type:
|
if event_type:
|
||||||
events_query = events_query.where(EventLog.event_type == event_type)
|
events_query = events_query.where(EventLog.event_type == event_type)
|
||||||
@@ -69,6 +69,7 @@ async def get_status(
|
|||||||
events_query = events_query.where(
|
events_query = events_query.where(
|
||||||
EventLog.collection_name.contains(search)
|
EventLog.collection_name.contains(search)
|
||||||
| EventLog.tracker_name.contains(search)
|
| EventLog.tracker_name.contains(search)
|
||||||
|
| EventLog.action_name.contains(search)
|
||||||
| EventLog.provider_name.contains(search)
|
| EventLog.provider_name.contains(search)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,6 +85,65 @@ async def get_status(
|
|||||||
|
|
||||||
events_query = events_query.offset(offset).limit(limit)
|
events_query = events_query.offset(offset).limit(limit)
|
||||||
recent_events = await session.exec(events_query)
|
recent_events = await session.exec(events_query)
|
||||||
|
event_rows = recent_events.all()
|
||||||
|
|
||||||
|
# Resolve live tracker names from FK (fall back to stored snapshot when deleted)
|
||||||
|
tracker_ids = {e.tracker_id for e in event_rows if e.tracker_id is not None}
|
||||||
|
tracker_name_map: dict[int, str] = {}
|
||||||
|
if tracker_ids:
|
||||||
|
tracker_rows = (await session.exec(
|
||||||
|
select(NotificationTracker.id, NotificationTracker.name).where(
|
||||||
|
NotificationTracker.id.in_(tracker_ids)
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
tracker_name_map = {tid: tname for tid, tname in tracker_rows}
|
||||||
|
|
||||||
|
# Resolve live provider names similarly
|
||||||
|
provider_ids = {e.provider_id for e in event_rows if e.provider_id is not None}
|
||||||
|
provider_name_map: dict[int, str] = {}
|
||||||
|
if provider_ids:
|
||||||
|
provider_rows = (await session.exec(
|
||||||
|
select(ServiceProvider.id, ServiceProvider.name).where(
|
||||||
|
ServiceProvider.id.in_(provider_ids)
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
provider_name_map = {pid: pname for pid, pname in provider_rows}
|
||||||
|
|
||||||
|
# Resolve live action names so renames are reflected; fall back to snapshot.
|
||||||
|
action_ids = {e.action_id for e in event_rows if e.action_id is not None}
|
||||||
|
action_name_map: dict[int, str] = {}
|
||||||
|
if action_ids:
|
||||||
|
action_rows = (await session.exec(
|
||||||
|
select(Action.id, Action.name).where(Action.id.in_(action_ids))
|
||||||
|
)).all()
|
||||||
|
action_name_map = {aid: aname for aid, aname in action_rows}
|
||||||
|
|
||||||
|
def _display_tracker_name(e: EventLog) -> str:
|
||||||
|
if e.tracker_id is not None and e.tracker_id in tracker_name_map:
|
||||||
|
return tracker_name_map[e.tracker_id]
|
||||||
|
return f"(deleted) {e.tracker_name}" if e.tracker_name else "(deleted)"
|
||||||
|
|
||||||
|
def _display_provider_name(e: EventLog) -> str:
|
||||||
|
if e.provider_id is not None and e.provider_id in provider_name_map:
|
||||||
|
return provider_name_map[e.provider_id]
|
||||||
|
return e.provider_name or ""
|
||||||
|
|
||||||
|
def _display_action_name(e: EventLog) -> str:
|
||||||
|
if e.action_id is not None and e.action_id in action_name_map:
|
||||||
|
return action_name_map[e.action_id]
|
||||||
|
if e.action_name:
|
||||||
|
return f"(deleted) {e.action_name}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _display_subject(e: EventLog) -> str:
|
||||||
|
"""The primary label shown on the event row.
|
||||||
|
|
||||||
|
For action events the ``collection_name`` stores the action name;
|
||||||
|
use the live-resolved action name when available so renames show.
|
||||||
|
"""
|
||||||
|
if e.action_id is not None or (e.event_type or "").startswith("action_"):
|
||||||
|
return _display_action_name(e) or e.collection_name
|
||||||
|
return e.collection_name
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"providers": providers_count,
|
"providers": providers_count,
|
||||||
@@ -94,19 +154,43 @@ async def get_status(
|
|||||||
{
|
{
|
||||||
"id": e.id,
|
"id": e.id,
|
||||||
"event_type": e.event_type,
|
"event_type": e.event_type,
|
||||||
"collection_name": e.collection_name,
|
"collection_name": _display_subject(e),
|
||||||
"tracker_name": e.tracker_name or "",
|
"tracker_name": _display_tracker_name(e),
|
||||||
"provider_name": e.provider_name or "",
|
"action_id": e.action_id,
|
||||||
|
"action_name": _display_action_name(e),
|
||||||
|
"provider_name": _display_provider_name(e),
|
||||||
"provider_id": e.provider_id,
|
"provider_id": e.provider_id,
|
||||||
"assets_count": e.assets_count or 0,
|
"assets_count": e.assets_count or 0,
|
||||||
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
|
"created_at": e.created_at.isoformat() + ("Z" if not e.created_at.tzinfo else ""),
|
||||||
"details": e.details or {},
|
"details": e.details or {},
|
||||||
}
|
}
|
||||||
for e in recent_events.all()
|
for e in event_rows
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/events")
|
||||||
|
async def clear_events(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
older_than_days: int | None = Query(None, ge=0),
|
||||||
|
):
|
||||||
|
"""Delete all event log entries for the current user.
|
||||||
|
|
||||||
|
Optionally keep events newer than `older_than_days` days.
|
||||||
|
"""
|
||||||
|
stmt = sa_delete(EventLog).where(EventLog.user_id == user.id)
|
||||||
|
if older_than_days is not None:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
||||||
|
stmt = stmt.where(EventLog.created_at < cutoff)
|
||||||
|
|
||||||
|
# Use session.execute() for DELETE (consistent with other endpoints and
|
||||||
|
# avoids sqlmodel wrapping a CursorResult that may drop rowcount).
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
return {"deleted": result.rowcount or 0}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/counts")
|
@router.get("/counts")
|
||||||
async def get_nav_counts(
|
async def get_nav_counts(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
@@ -192,8 +276,7 @@ async def get_event_chart(
|
|||||||
EventLog.event_type,
|
EventLog.event_type,
|
||||||
func.count().label("total"),
|
func.count().label("total"),
|
||||||
)
|
)
|
||||||
.join(NotificationTracker, EventLog.tracker_id == NotificationTracker.id)
|
.where(EventLog.user_id == user.id, EventLog.created_at >= cutoff)
|
||||||
.where(NotificationTracker.user_id == user.id, EventLog.created_at >= cutoff)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if event_type:
|
if event_type:
|
||||||
@@ -204,6 +287,7 @@ async def get_event_chart(
|
|||||||
query = query.where(
|
query = query.where(
|
||||||
EventLog.collection_name.contains(search)
|
EventLog.collection_name.contains(search)
|
||||||
| EventLog.tracker_name.contains(search)
|
| EventLog.tracker_name.contains(search)
|
||||||
|
| EventLog.action_name.contains(search)
|
||||||
| EventLog.provider_name.contains(search)
|
| EventLog.provider_name.contains(search)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ async def update_bot(
|
|||||||
bot.icon = body.icon
|
bot.icon = body.icon
|
||||||
# Handle mode switching
|
# Handle mode switching
|
||||||
if body.update_mode is not None and body.update_mode != bot.update_mode:
|
if body.update_mode is not None and body.update_mode != bot.update_mode:
|
||||||
|
if body.update_mode not in ("none", "polling", "webhook"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid update_mode: {body.update_mode!r}. Must be 'none', 'polling', or 'webhook'.",
|
||||||
|
)
|
||||||
if body.update_mode == "webhook":
|
if body.update_mode == "webhook":
|
||||||
# Validate and register webhook BEFORE stopping polling
|
# Validate and register webhook BEFORE stopping polling
|
||||||
base_url = await get_setting(session, "external_url")
|
base_url = await get_setting(session, "external_url")
|
||||||
@@ -108,6 +113,12 @@ async def update_bot(
|
|||||||
# Switching to polling: unregister webhook, start polling
|
# Switching to polling: unregister webhook, start polling
|
||||||
await unregister_webhook(bot.token)
|
await unregister_webhook(bot.token)
|
||||||
schedule_bot_polling(bot.id)
|
schedule_bot_polling(bot.id)
|
||||||
|
elif body.update_mode == "none":
|
||||||
|
# Disable listener: stop polling and clear any webhook so Telegram
|
||||||
|
# stops delivering updates. This makes the bot send-only, which is
|
||||||
|
# safe when another instance owns the listener.
|
||||||
|
unschedule_bot_polling(bot.id)
|
||||||
|
await unregister_webhook(bot.token)
|
||||||
bot.update_mode = body.update_mode
|
bot.update_mode = body.update_mode
|
||||||
|
|
||||||
session.add(bot)
|
session.add(bot)
|
||||||
@@ -287,10 +298,30 @@ async def test_chat(
|
|||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Send a test message to a chat via the bot."""
|
"""Send a test message to a chat via the bot.
|
||||||
|
|
||||||
|
Locale resolution: prefer the chat row's ``language_override`` (explicit
|
||||||
|
user choice in the UI), fall back to Telegram's ``language_code`` sent
|
||||||
|
with the chat, and only use the ``?locale=`` query param if neither is
|
||||||
|
set. Otherwise users who set RU on a chat would still see an EN test.
|
||||||
|
"""
|
||||||
bot = await _get_user_bot(session, bot_id, user.id)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
|
chat_row = (await session.exec(
|
||||||
|
select(TelegramChat).where(
|
||||||
|
TelegramChat.bot_id == bot_id,
|
||||||
|
TelegramChat.chat_id == chat_id,
|
||||||
|
)
|
||||||
|
)).first()
|
||||||
|
effective_locale = locale
|
||||||
|
if chat_row:
|
||||||
|
chat_locale = (
|
||||||
|
getattr(chat_row, 'language_override', '') or
|
||||||
|
getattr(chat_row, 'language_code', '') or ''
|
||||||
|
)
|
||||||
|
if chat_locale:
|
||||||
|
effective_locale = chat_locale[:2].lower()
|
||||||
from ..services.http_session import get_http_session
|
from ..services.http_session import get_http_session
|
||||||
message = _get_test_message(locale, "telegram")
|
message = _get_test_message(effective_locale, "telegram")
|
||||||
http = await get_http_session()
|
http = await get_http_session()
|
||||||
client = TelegramClient(http, bot.token)
|
client = TelegramClient(http, bot.token)
|
||||||
return await client.send_message(chat_id, message)
|
return await client.send_message(chat_id, message)
|
||||||
@@ -406,7 +437,7 @@ def _bot_response(b: TelegramBot) -> dict:
|
|||||||
"bot_username": b.bot_username,
|
"bot_username": b.bot_username,
|
||||||
"bot_id": b.bot_id,
|
"bot_id": b.bot_id,
|
||||||
"webhook_path_id": b.webhook_path_id,
|
"webhook_path_id": b.webhook_path_id,
|
||||||
"update_mode": b.update_mode or "polling",
|
"update_mode": b.update_mode or "none",
|
||||||
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
|
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
|
||||||
"created_at": b.created_at.isoformat(),
|
"created_at": b.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,37 @@ async def list_configs(
|
|||||||
return [await _response(session, c) for c in result.all()]
|
return [await _response(session, c) for c in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults")
|
||||||
|
async def get_default_slot_templates(
|
||||||
|
provider_type: str,
|
||||||
|
slot_name: str | None = None,
|
||||||
|
locale: str | None = None,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Return the shipped Jinja2 default templates for a provider type.
|
||||||
|
|
||||||
|
Used by the UI's "Reset to default" actions. Filtering is optional —
|
||||||
|
omit ``slot_name`` to get every slot, omit ``locale`` to get every locale.
|
||||||
|
Registered before ``/{config_id}`` so the literal path wins over the
|
||||||
|
path-parameter route in FastAPI's matcher.
|
||||||
|
|
||||||
|
Response shape: ``{slot_name: {locale: template_text}}``
|
||||||
|
"""
|
||||||
|
from notify_bridge_core.templates.defaults.loader import (
|
||||||
|
get_available_locales,
|
||||||
|
load_default_templates,
|
||||||
|
)
|
||||||
|
locales = [locale] if locale else get_available_locales()
|
||||||
|
result: dict[str, dict[str, str]] = {}
|
||||||
|
for loc in locales:
|
||||||
|
defaults = load_default_templates(loc, provider_type)
|
||||||
|
for name, text in defaults.items():
|
||||||
|
if slot_name and name != slot_name:
|
||||||
|
continue
|
||||||
|
result.setdefault(name, {})[loc] = text
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/variables")
|
@router.get("/variables")
|
||||||
async def get_template_variables(
|
async def get_template_variables(
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
@@ -162,20 +193,28 @@ async def get_template_variables(
|
|||||||
"city": "City name",
|
"city": "City name",
|
||||||
"state": "State/region name",
|
"state": "State/region name",
|
||||||
"country": "Country name",
|
"country": "Country name",
|
||||||
"file_size": "File size in bytes (null if unknown)",
|
"file_size": "Original asset size in bytes (null if unknown)",
|
||||||
"oversized": "Whether video exceeds the target's size limit (boolean, videos only)",
|
"playback_size": "Size in bytes of the media we actually upload — for Immich videos this is the transcoded /video/playback (null for photos or when unknown)",
|
||||||
|
"oversized": "Whether the asset's playback_size exceeds the target's size limit (boolean, videos only)",
|
||||||
"public_url": "Per-asset public share URL (empty if no album link)",
|
"public_url": "Per-asset public share URL (empty if no album link)",
|
||||||
"url": "Public viewer URL (if shared)",
|
"url": "Public viewer URL (if shared)",
|
||||||
"download_url": "Direct download URL (if shared)",
|
"download_url": "Direct download URL (if shared)",
|
||||||
"photo_url": "Preview image URL (images only, if shared)",
|
"photo_url": "Preview image URL (images only, if shared)",
|
||||||
"playback_url": "Video playback URL (videos only, if shared)",
|
"playback_url": "Video playback URL (videos only, if shared)",
|
||||||
|
# Per-asset album attribution (scheduled/memory templates in combined mode).
|
||||||
|
"album_name": "Source album name (combined-mode scheduled/memory only)",
|
||||||
|
"album_url": "Source album URL — public share link if available, else internal album URL",
|
||||||
|
"album_public_url": "Source album public share URL (empty if no public link)",
|
||||||
}
|
}
|
||||||
album_fields = {
|
album_fields = {
|
||||||
"name": "Collection/album name",
|
"name": "Collection/album name",
|
||||||
"url": "Share URL",
|
"url": "Share URL",
|
||||||
"public_url": "Public share link URL",
|
"public_url": "Public share link URL",
|
||||||
"asset_count": "Total assets in collection",
|
"asset_count": "Total assets in collection",
|
||||||
"shared": "Whether collection is shared",
|
"photo_count": "Number of photos in the album",
|
||||||
|
"video_count": "Number of videos in the album",
|
||||||
|
"shared": "Whether collection is shared (boolean)",
|
||||||
|
"owner": "Album owner display name",
|
||||||
}
|
}
|
||||||
scheduled_vars = {
|
scheduled_vars = {
|
||||||
"date": "Current date string",
|
"date": "Current date string",
|
||||||
@@ -216,12 +255,26 @@ async def get_template_variables(
|
|||||||
},
|
},
|
||||||
"scheduled_assets_message": {
|
"scheduled_assets_message": {
|
||||||
"description": "Scheduled asset delivery (daily photo picks)",
|
"description": "Scheduled asset delivery (daily photo picks)",
|
||||||
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
"variables": {
|
||||||
|
**scheduled_vars,
|
||||||
|
"assets": "List of asset dicts (use {% for asset in assets %})",
|
||||||
|
"album_name": "Source album name",
|
||||||
|
"public_url": "Public share link URL for the source album (empty if none)",
|
||||||
|
"asset_count": "Total assets in the source album",
|
||||||
|
"photo_count": "Photos in the source album",
|
||||||
|
"video_count": "Videos in the source album",
|
||||||
|
"owner": "Source album owner",
|
||||||
|
},
|
||||||
"asset_fields": asset_fields,
|
"asset_fields": asset_fields,
|
||||||
},
|
},
|
||||||
"memory_mode_message": {
|
"memory_mode_message": {
|
||||||
"description": "\"On This Day\" memories from previous years",
|
"description": "\"On This Day\" memories from previous years",
|
||||||
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
|
"variables": {
|
||||||
|
**scheduled_vars,
|
||||||
|
"assets": "List of asset dicts (use {% for asset in assets %})",
|
||||||
|
"album_name": "Source album name (when rendered per-album)",
|
||||||
|
"public_url": "Public share link URL for the source album (empty if none)",
|
||||||
|
},
|
||||||
"asset_fields": asset_fields,
|
"asset_fields": asset_fields,
|
||||||
},
|
},
|
||||||
# --- Generic Webhook slots ---
|
# --- Generic Webhook slots ---
|
||||||
@@ -241,6 +294,8 @@ async def get_template_variables(
|
|||||||
"current_date": "Current date (formatted)",
|
"current_date": "Current date (formatted)",
|
||||||
"current_time": "Current time (formatted)",
|
"current_time": "Current time (formatted)",
|
||||||
"current_datetime": "Current date and time (formatted)",
|
"current_datetime": "Current date and time (formatted)",
|
||||||
|
"weekday": "Day of the week (Monday..Sunday)",
|
||||||
|
"timezone": "IANA timezone used for current_date/time",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import TrackingConfig, User
|
from ..database.models import TrackingConfig, User
|
||||||
|
from ..services.scheduler import reschedule_immich_dispatch_jobs
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class TrackingConfigCreate(BaseModel):
|
|||||||
notify_favorites_only: bool = False
|
notify_favorites_only: bool = False
|
||||||
include_tags: bool = True
|
include_tags: bool = True
|
||||||
include_asset_details: bool = False
|
include_asset_details: bool = False
|
||||||
max_assets_to_show: int = 5
|
max_assets_to_show: int = 10
|
||||||
assets_order_by: str = "none"
|
assets_order_by: str = "none"
|
||||||
assets_order: str = "descending"
|
assets_order: str = "descending"
|
||||||
periodic_enabled: bool = False
|
periodic_enabled: bool = False
|
||||||
@@ -54,6 +55,9 @@ class TrackingConfigCreate(BaseModel):
|
|||||||
memory_favorite_only: bool = False
|
memory_favorite_only: bool = False
|
||||||
memory_asset_type: str = "all"
|
memory_asset_type: str = "all"
|
||||||
memory_min_rating: int = 0
|
memory_min_rating: int = 0
|
||||||
|
quiet_hours_enabled: bool = False
|
||||||
|
quiet_hours_start: str | None = None
|
||||||
|
quiet_hours_end: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TrackingConfigUpdate(BaseModel):
|
class TrackingConfigUpdate(BaseModel):
|
||||||
@@ -93,6 +97,9 @@ class TrackingConfigUpdate(BaseModel):
|
|||||||
memory_favorite_only: bool | None = None
|
memory_favorite_only: bool | None = None
|
||||||
memory_asset_type: str | None = None
|
memory_asset_type: str | None = None
|
||||||
memory_min_rating: int | None = None
|
memory_min_rating: int | None = None
|
||||||
|
quiet_hours_enabled: bool | None = None
|
||||||
|
quiet_hours_start: str | None = None
|
||||||
|
quiet_hours_end: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -121,6 +128,8 @@ async def create_config(
|
|||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
|
if config.provider_type == "immich":
|
||||||
|
await reschedule_immich_dispatch_jobs()
|
||||||
return _response(config)
|
return _response(config)
|
||||||
|
|
||||||
|
|
||||||
@@ -146,6 +155,8 @@ async def update_config(
|
|||||||
session.add(config)
|
session.add(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(config)
|
await session.refresh(config)
|
||||||
|
if config.provider_type == "immich":
|
||||||
|
await reschedule_immich_dispatch_jobs()
|
||||||
return _response(config)
|
return _response(config)
|
||||||
|
|
||||||
|
|
||||||
@@ -158,8 +169,11 @@ async def delete_config(
|
|||||||
from .delete_protection import check_tracking_config, raise_if_used
|
from .delete_protection import check_tracking_config, raise_if_used
|
||||||
config = await _get(session, config_id, user.id)
|
config = await _get(session, config_id, user.id)
|
||||||
raise_if_used(await check_tracking_config(session, config.id), config.name)
|
raise_if_used(await check_tracking_config(session, config.id), config.name)
|
||||||
|
provider_type = config.provider_type
|
||||||
await session.delete(config)
|
await session.delete(config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
if provider_type == "immich":
|
||||||
|
await reschedule_immich_dispatch_jobs()
|
||||||
|
|
||||||
|
|
||||||
def _response(c: TrackingConfig) -> dict:
|
def _response(c: TrackingConfig) -> dict:
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""User management API routes (admin only)."""
|
"""User management API routes (admin only)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
@@ -13,6 +15,15 @@ from ..auth.dependencies import require_admin
|
|||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import User
|
from ..database.models import User
|
||||||
|
|
||||||
|
|
||||||
|
async def _hash_password(password: str) -> str:
|
||||||
|
"""Run bcrypt off the event loop. Matches the helper in auth/routes.py."""
|
||||||
|
|
||||||
|
def _work() -> str:
|
||||||
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_work)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
@@ -35,8 +46,12 @@ async def list_users(
|
|||||||
admin: User = Depends(require_admin),
|
admin: User = Depends(require_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""List all users (admin only)."""
|
"""List all users (admin only).
|
||||||
result = await session.exec(select(User))
|
|
||||||
|
Excludes the internal ``__system__`` placeholder (id=0) used as the
|
||||||
|
owner of default templates/configs — it is never a real account.
|
||||||
|
"""
|
||||||
|
result = await session.exec(select(User).where(User.id != 0))
|
||||||
return [
|
return [
|
||||||
{"id": u.id, "username": u.username, "role": u.role, "created_at": u.created_at.isoformat()}
|
{"id": u.id, "username": u.username, "role": u.role, "created_at": u.created_at.isoformat()}
|
||||||
for u in result.all()
|
for u in result.all()
|
||||||
@@ -60,7 +75,7 @@ async def create_user(
|
|||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
username=body.username,
|
username=body.username,
|
||||||
hashed_password=bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode(),
|
hashed_password=await _hash_password(body.password),
|
||||||
role=body.role if body.role in ("admin", "user") else "user",
|
role=body.role if body.role in ("admin", "user") else "user",
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
@@ -69,6 +84,81 @@ async def create_user(
|
|||||||
return {"id": user.id, "username": user.username, "role": user.role}
|
return {"id": user.id, "username": user.username, "role": user.role}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}")
|
||||||
|
async def update_user(
|
||||||
|
user_id: int,
|
||||||
|
body: UserUpdate,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Update username and/or role for a user (admin only)."""
|
||||||
|
user = await session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Track whether the identity that JWTs encode has changed. Any such change
|
||||||
|
# must bump ``token_version`` so already-issued tokens are rejected — a
|
||||||
|
# user demoted admin→user must not keep admin in their cached JWT until
|
||||||
|
# expiry, and a rename should invalidate prior sessions too.
|
||||||
|
identity_changed = False
|
||||||
|
|
||||||
|
if body.username is not None and body.username != user.username:
|
||||||
|
new_username = body.username.strip()
|
||||||
|
if not new_username:
|
||||||
|
raise HTTPException(status_code=400, detail="Username cannot be empty")
|
||||||
|
dup = await session.exec(select(User).where(User.username == new_username))
|
||||||
|
if dup.first():
|
||||||
|
raise HTTPException(status_code=409, detail="Username already exists")
|
||||||
|
user.username = new_username
|
||||||
|
identity_changed = True
|
||||||
|
|
||||||
|
if body.role is not None and body.role != user.role:
|
||||||
|
if body.role not in ("admin", "user"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid role")
|
||||||
|
# Prevent demoting the last admin. Done via a COUNT to avoid loading
|
||||||
|
# every admin row; more importantly, re-checked *after* the role
|
||||||
|
# change is staged (TOCTOU guard — two concurrent demotes can each
|
||||||
|
# see admin_count=2 and both proceed, dropping to 0).
|
||||||
|
if user.role == "admin" and body.role != "admin":
|
||||||
|
admin_count = (await session.exec(
|
||||||
|
select(func.count(User.id)).where(User.role == "admin")
|
||||||
|
)).one()
|
||||||
|
if isinstance(admin_count, tuple):
|
||||||
|
admin_count = admin_count[0]
|
||||||
|
if (admin_count or 0) <= 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
|
||||||
|
user.role = body.role
|
||||||
|
identity_changed = True
|
||||||
|
|
||||||
|
if identity_changed:
|
||||||
|
user.token_version = (user.token_version or 1) + 1
|
||||||
|
|
||||||
|
session.add(user)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Final defense against admin-count race: if we just demoted the last admin
|
||||||
|
# due to a concurrent demote landing between our check and commit, undo.
|
||||||
|
if body.role is not None and body.role != "admin":
|
||||||
|
admin_count_after = (await session.exec(
|
||||||
|
select(func.count(User.id)).where(User.role == "admin")
|
||||||
|
)).one()
|
||||||
|
if isinstance(admin_count_after, tuple):
|
||||||
|
admin_count_after = admin_count_after[0]
|
||||||
|
if (admin_count_after or 0) < 1:
|
||||||
|
# Roll the user back to admin and re-commit.
|
||||||
|
user.role = "admin"
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
raise HTTPException(status_code=409, detail="Refused: would remove the last admin")
|
||||||
|
|
||||||
|
await session.refresh(user)
|
||||||
|
return {"id": user.id, "username": user.username, "role": user.role}
|
||||||
|
|
||||||
|
|
||||||
class ResetPasswordRequest(BaseModel):
|
class ResetPasswordRequest(BaseModel):
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
@@ -86,7 +176,10 @@ async def reset_user_password(
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
if len(body.new_password) < 8:
|
if len(body.new_password) < 8:
|
||||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||||
user.hashed_password = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
|
user.hashed_password = await _hash_password(body.new_password)
|
||||||
|
# Invalidate all prior JWTs issued for this user — matches the self-serve
|
||||||
|
# password-change path in auth/routes.py.
|
||||||
|
user.token_version = (user.token_version or 1) + 1
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|||||||
@@ -27,12 +27,53 @@ from ..database.models import (
|
|||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
WebhookPayloadLog,
|
WebhookPayloadLog,
|
||||||
)
|
)
|
||||||
from ..services.dispatch_helpers import event_allowed_by_config, load_link_data
|
from ..services.dispatch_helpers import (
|
||||||
|
apply_tracking_display_filters,
|
||||||
|
event_allowed_by_config,
|
||||||
|
get_app_timezone,
|
||||||
|
load_link_data,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||||
|
|
||||||
|
# Hard cap on inbound webhook body size (1 MiB is far larger than anything
|
||||||
|
# legitimate providers send and keeps the worst-case memory footprint bounded
|
||||||
|
# when a malicious peer lies about Content-Length or streams slowly).
|
||||||
|
_MAX_WEBHOOK_BODY_BYTES = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_bounded_body(request: Request, limit: int = _MAX_WEBHOOK_BODY_BYTES) -> bytes:
|
||||||
|
"""Reject oversized inbound bodies before they exhaust memory.
|
||||||
|
|
||||||
|
First checks ``Content-Length`` (fast-path for honest peers), then
|
||||||
|
streams the body in chunks enforcing the same cap on actual bytes
|
||||||
|
received so a peer that lies about Content-Length cannot slip through.
|
||||||
|
"""
|
||||||
|
declared = request.headers.get("content-length")
|
||||||
|
if declared:
|
||||||
|
try:
|
||||||
|
if int(declared) > limit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"Payload too large (max {limit} bytes)",
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid Content-Length")
|
||||||
|
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
size = 0
|
||||||
|
async for chunk in request.stream():
|
||||||
|
size += len(chunk)
|
||||||
|
if size > limit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"Payload too large (max {limit} bytes)",
|
||||||
|
)
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
async def _get_provider_by_token(
|
async def _get_provider_by_token(
|
||||||
session: AsyncSession, token: str, expected_type: str,
|
session: AsyncSession, token: str, expected_type: str,
|
||||||
@@ -144,9 +185,12 @@ async def _dispatch_webhook_event(
|
|||||||
if not link_data:
|
if not link_data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
app_tz = await get_app_timezone(session)
|
||||||
|
|
||||||
# Log event
|
# Log event
|
||||||
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
|
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
|
||||||
session.add(EventLog(
|
session.add(EventLog(
|
||||||
|
user_id=tracker.user_id,
|
||||||
tracker_id=tracker.id,
|
tracker_id=tracker.id,
|
||||||
tracker_name=tracker.name,
|
tracker_name=tracker.name,
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
@@ -162,10 +206,15 @@ async def _dispatch_webhook_event(
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Dispatch to targets
|
# Dispatch to targets
|
||||||
dispatcher = NotificationDispatcher()
|
from ..services.http_session import get_http_session
|
||||||
target_configs = _build_target_configs(event, link_data, provider_config)
|
dispatcher = NotificationDispatcher(session=await get_http_session())
|
||||||
if target_configs:
|
for tc, target_configs in _build_target_groups(event, link_data, provider_config, app_tz):
|
||||||
results = await dispatcher.dispatch(event, target_configs)
|
if not target_configs:
|
||||||
|
continue
|
||||||
|
shaped_event = apply_tracking_display_filters(event, tc)
|
||||||
|
if shaped_event is None:
|
||||||
|
continue
|
||||||
|
results = await dispatcher.dispatch(shaped_event, target_configs)
|
||||||
for r in results:
|
for r in results:
|
||||||
if r.get("success"):
|
if r.get("success"):
|
||||||
dispatched += 1
|
dispatched += 1
|
||||||
@@ -196,7 +245,7 @@ async def gitea_webhook(token: str, request: Request):
|
|||||||
webhook_secret = (provider.config or {}).get("webhook_secret", "")
|
webhook_secret = (provider.config or {}).get("webhook_secret", "")
|
||||||
|
|
||||||
# Read raw body for HMAC check
|
# Read raw body for HMAC check
|
||||||
raw_body = await request.body()
|
raw_body = await _read_bounded_body(request)
|
||||||
|
|
||||||
if not webhook_secret:
|
if not webhook_secret:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -214,8 +263,8 @@ async def gitea_webhook(token: str, request: Request):
|
|||||||
return {"ok": True, "skipped": "no event header"}
|
return {"ok": True, "skipped": "no event header"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = json.loads(raw_body.decode("utf-8"))
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||||
|
|
||||||
event = parse_gitea_webhook(event_header, payload, provider.name)
|
event = parse_gitea_webhook(event_header, payload, provider.name)
|
||||||
@@ -273,10 +322,10 @@ async def planka_webhook(token: str, request: Request):
|
|||||||
if not _verify_planka_token(webhook_secret, request):
|
if not _verify_planka_token(webhook_secret, request):
|
||||||
raise HTTPException(status_code=403, detail="Invalid token")
|
raise HTTPException(status_code=403, detail="Invalid token")
|
||||||
|
|
||||||
# Parse payload
|
# Parse payload from the bounded raw_body we already read.
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = json.loads(raw_body.decode("utf-8"))
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON")
|
raise HTTPException(status_code=400, detail="Invalid JSON")
|
||||||
|
|
||||||
event_type = payload.get("type", "")
|
event_type = payload.get("type", "")
|
||||||
@@ -439,23 +488,22 @@ async def generic_webhook(token: str, request: Request):
|
|||||||
store_payloads = provider_config.get("store_payloads", True)
|
store_payloads = provider_config.get("store_payloads", True)
|
||||||
max_stored = min(max(int(provider_config.get("max_stored_payloads", 20)), 1), 100)
|
max_stored = min(max(int(provider_config.get("max_stored_payloads", 20)), 1), 100)
|
||||||
|
|
||||||
raw_body = await request.body()
|
raw_body = await _read_bounded_body(request)
|
||||||
|
|
||||||
# Enforce payload size limit BEFORE parsing JSON
|
# Bounded read above already enforces the size cap; no need to re-check.
|
||||||
if len(raw_body) > 1_000_000:
|
|
||||||
raise HTTPException(status_code=413, detail="Payload too large (max 1 MB)")
|
|
||||||
|
|
||||||
if not _verify_generic_webhook_auth(provider_config, request, raw_body):
|
if not _verify_generic_webhook_auth(provider_config, request, raw_body):
|
||||||
raise HTTPException(status_code=403, detail="Authentication failed")
|
raise HTTPException(status_code=403, detail="Authentication failed")
|
||||||
|
|
||||||
safe_headers = _filter_headers(dict(request.headers))
|
safe_headers = _filter_headers(dict(request.headers))
|
||||||
|
|
||||||
# Parse JSON payload
|
# Parse JSON payload from the already-bounded raw_body (request.body()
|
||||||
|
# has been consumed, so request.json() is no longer usable here).
|
||||||
try:
|
try:
|
||||||
payload = await request.json()
|
payload = json.loads(raw_body.decode("utf-8"))
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ValueError("Payload must be a JSON object")
|
raise ValueError("Payload must be a JSON object")
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
||||||
if store_payloads:
|
if store_payloads:
|
||||||
async with AsyncSession(get_engine()) as log_session:
|
async with AsyncSession(get_engine()) as log_session:
|
||||||
await _save_webhook_log(
|
await _save_webhook_log(
|
||||||
@@ -508,20 +556,27 @@ async def generic_webhook(token: str, request: Request):
|
|||||||
return {"ok": True, "dispatched": dispatched}
|
return {"ok": True, "dispatched": dispatched}
|
||||||
|
|
||||||
|
|
||||||
def _build_target_configs(
|
def _build_target_groups(
|
||||||
event: ServiceEvent,
|
event: ServiceEvent,
|
||||||
link_data: list[dict[str, Any]],
|
link_data: list[dict[str, Any]],
|
||||||
provider_config: dict[str, Any],
|
provider_config: dict[str, Any],
|
||||||
) -> list[TargetConfig]:
|
app_tz: str = "UTC",
|
||||||
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
|
) -> list[tuple[Any, list[TargetConfig]]]:
|
||||||
target_configs: list[TargetConfig] = []
|
"""Build TargetConfigs for dispatch, grouped by their TrackingConfig.
|
||||||
|
|
||||||
|
Targets sharing a TrackingConfig dispatch together so a single
|
||||||
|
``apply_tracking_display_filters`` pass can shape one event for the
|
||||||
|
whole group; targets with different TCs may see differently-shaped
|
||||||
|
events (e.g. one with favorites_only, one without).
|
||||||
|
"""
|
||||||
|
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
|
||||||
for ld in link_data:
|
for ld in link_data:
|
||||||
tc = ld["tracking_config"]
|
tc = ld["tracking_config"]
|
||||||
if tc and not event_allowed_by_config(event, tc):
|
if tc and not event_allowed_by_config(event, tc, app_tz):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tmpl = ld["template_config"]
|
tmpl = ld["template_config"]
|
||||||
target_configs.append(TargetConfig(
|
target_cfg = TargetConfig(
|
||||||
type=ld["target_type"],
|
type=ld["target_type"],
|
||||||
config=ld["target_config"],
|
config=ld["target_config"],
|
||||||
template_slots=ld["template_slots"],
|
template_slots=ld["template_slots"],
|
||||||
@@ -531,5 +586,9 @@ def _build_target_configs(
|
|||||||
provider_internal_url=provider_config.get("url", ""),
|
provider_internal_url=provider_config.get("url", ""),
|
||||||
provider_external_url=provider_config.get("url", ""),
|
provider_external_url=provider_config.get("url", ""),
|
||||||
receivers=ld["receivers"],
|
receivers=ld["receivers"],
|
||||||
))
|
)
|
||||||
return target_configs
|
key = id(tc) if tc is not None else 0
|
||||||
|
if key not in groups:
|
||||||
|
groups[key] = (tc, [])
|
||||||
|
groups[key][1].append(target_cfg)
|
||||||
|
return list(groups.values())
|
||||||
|
|||||||
@@ -7,30 +7,51 @@ import jwt
|
|||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
_LEEWAY_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(user_id: int, role: str, token_version: int = 1) -> str:
|
def create_access_token(user_id: int, role: str, token_version: int = 1) -> str:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
now = _now_utc()
|
||||||
|
expire = now + timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
payload = {
|
payload = {
|
||||||
|
"iss": settings.jwt_issuer,
|
||||||
|
"aud": settings.jwt_audience,
|
||||||
"sub": str(user_id),
|
"sub": str(user_id),
|
||||||
"role": role,
|
"role": role,
|
||||||
"type": "access",
|
"type": "access",
|
||||||
"ver": token_version,
|
"ver": token_version,
|
||||||
|
"iat": now,
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(user_id: int, token_version: int = 1) -> str:
|
def create_refresh_token(user_id: int, token_version: int = 1) -> str:
|
||||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days)
|
now = _now_utc()
|
||||||
|
expire = now + timedelta(days=settings.refresh_token_expire_days)
|
||||||
payload = {
|
payload = {
|
||||||
|
"iss": settings.jwt_issuer,
|
||||||
|
"aud": settings.jwt_audience,
|
||||||
"sub": str(user_id),
|
"sub": str(user_id),
|
||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
"ver": token_version,
|
"ver": token_version,
|
||||||
|
"iat": now,
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
return jwt.encode(payload, settings.secret_key, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str) -> dict:
|
def decode_token(token: str) -> dict:
|
||||||
return jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.secret_key,
|
||||||
|
algorithms=[ALGORITHM],
|
||||||
|
audience=settings.jwt_audience,
|
||||||
|
issuer=settings.jwt_issuer,
|
||||||
|
leeway=_LEEWAY_SECONDS,
|
||||||
|
options={"require": ["exp", "sub", "iss", "aud", "type"]},
|
||||||
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user