mirror of
https://github.com/immich-app/immich.git
synced 2026-05-26 17:42:32 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dfa202fd7 | |||
| 53a24783f5 | |||
| 0546bc900c | |||
| 7c25bcc0a7 | |||
| 7905853639 | |||
| 073dcc1fbe | |||
| ccdaa4223c | |||
| 5386b62dc4 | |||
| 9733fa4872 | |||
| 3b34c53092 | |||
| fd7ddfef54 | |||
| 0975b1599c | |||
| 78ac0ade01 |
@@ -231,7 +231,7 @@ jobs:
|
||||
run: mise //mobile:codegen:pigeon
|
||||
|
||||
- name: Setup Ruby
|
||||
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
|
||||
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
|
||||
with:
|
||||
ruby-version: '3.3'
|
||||
bundler-cache: true
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -154,33 +154,33 @@ Redis (Sentinel) URL example JSON before encoding:
|
||||
|
||||
## Machine Learning
|
||||
|
||||
| Variable | Description | Default | Containers |
|
||||
| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------------------: | :--------------- |
|
||||
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
|
||||
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
|
||||
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
|
||||
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
|
||||
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `120` (`300` if using OpenVINO) | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
||||
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
|
||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
|
||||
| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
|
||||
| Variable | Description | Default | Containers |
|
||||
| :---------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :--------------- |
|
||||
| `MACHINE_LEARNING_MODEL_TTL` | Inactivity time (s) before a model is unloaded (disabled if \<= 0) | `300` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_TTL_POLL_S` | Interval (s) between checks for the model TTL (disabled if \<= 0) | `10` | machine learning |
|
||||
| `MACHINE_LEARNING_CACHE_FOLDER` | Directory where models are downloaded | `/cache` | machine learning |
|
||||
| `MACHINE_LEARNING_REQUEST_THREADS`<sup>\*1</sup> | Thread count of the request thread pool (disabled if \<= 0) | number of CPU cores | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_INTER_OP_THREADS` | Number of parallel model operations | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_INTRA_OP_THREADS` | Number of threads for each model operation | `2` | machine learning |
|
||||
| `MACHINE_LEARNING_WORKERS`<sup>\*2</sup> | Number of worker processes to spawn | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_HTTP_KEEPALIVE_TIMEOUT_S`<sup>\*3</sup> | HTTP Keep-alive time in seconds | `2` | machine learning |
|
||||
| `MACHINE_LEARNING_WORKER_TIMEOUT` | Maximum time (s) of unresponsiveness before a worker is killed | `300` (`900` if using ROCm) | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__CLIP__TEXTUAL` | Comma-separated list of (textual) CLIP model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__CLIP__VISUAL` | Comma-separated list of (visual) CLIP model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__RECOGNITION` | Comma-separated list of (recognition) facial recognition model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__FACIAL_RECOGNITION__DETECTION` | Comma-separated list of (detection) facial recognition model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__OCR__RECOGNITION` | Comma-separated list of (recognition) OCR model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_PRELOAD__OCR__DETECTION` | Comma-separated list of (detection) OCR model(s) to preload and cache | | machine learning |
|
||||
| `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning |
|
||||
| `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning |
|
||||
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
|
||||
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
|
||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
|
||||
| `MACHINE_LEARNING_MAX_BATCH_SIZE__OCR` | Set the maximum number of boxes that will be processed at once by the OCR model | `6` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
|
||||
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spun up while inferencing. | `1` | machine learning |
|
||||
| `MACHINE_LEARNING_MODEL_ARENA` | Pre-allocates CPU memory to avoid memory fragmentation | true | machine learning |
|
||||
| `MACHINE_LEARNING_OPENVINO_PRECISION` | If set to FP16, uses half-precision floating-point operations for faster inference with reduced accuracy (one of [`FP16`, `FP32`], applies only to OpenVINO) | `FP32` | machine learning |
|
||||
|
||||
\*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones.
|
||||
|
||||
|
||||
@@ -536,7 +536,7 @@ test.describe('Timeline', () => {
|
||||
force: false,
|
||||
ids: [assetToTrash.id],
|
||||
});
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.locator('#control-bar').getByLabel('Close').click();
|
||||
await page.getByText('Trash', { exact: true }).click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||
@@ -676,7 +676,7 @@ test.describe('Timeline', () => {
|
||||
ids: [assetToArchive.id],
|
||||
});
|
||||
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.locator('#control-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Archive').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||
@@ -823,7 +823,7 @@ test.describe('Timeline', () => {
|
||||
});
|
||||
// ensure thumbnail still exists and has favorite icon
|
||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
||||
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
|
||||
await page.locator('#control-bar').getByLabel('Close').click();
|
||||
await page.getByRole('link').getByText('Favorites').click();
|
||||
await timelineUtils.waitForTimelineLoad(page);
|
||||
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
|
||||
|
||||
@@ -976,6 +976,7 @@
|
||||
"downloading_asset_filename": "Downloading asset {filename}",
|
||||
"downloading_from_icloud": "Downloading from iCloud",
|
||||
"downloading_media": "Downloading media",
|
||||
"drag_to_reorder": "Drag to reorder",
|
||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||
"duplicates": "Duplicates",
|
||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
|
||||
@@ -2254,6 +2255,7 @@
|
||||
"step_delete_confirm": "Are you sure you want to delete this step?",
|
||||
"step_details": "Step details",
|
||||
"steps": "Steps",
|
||||
"steps_count": "{count, plural, one {# step} other {# steps}}",
|
||||
"stop_casting": "Stop casting",
|
||||
"stop_motion_photo": "Stop Motion Photo",
|
||||
"stop_photo_sharing": "Stop sharing your photos?",
|
||||
@@ -2476,6 +2478,7 @@
|
||||
"week": "Week",
|
||||
"welcome": "Welcome",
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"when": "When",
|
||||
"width": "Width",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
"workflow": "Workflow",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
|
||||
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
|
||||
|
||||
FROM builder-cpu AS builder-cuda
|
||||
|
||||
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
|
||||
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
from socket import socket
|
||||
|
||||
from gunicorn.arbiter import Arbiter
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
@@ -42,6 +42,10 @@ class MaxBatchSize(BaseModel):
|
||||
ocr: int | None = None
|
||||
|
||||
|
||||
def default_worker_timeout() -> int:
|
||||
return 900 if os.environ.get("DEVICE") == "rocm" else 300
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="MACHINE_LEARNING_",
|
||||
@@ -54,7 +58,7 @@ class Settings(BaseSettings):
|
||||
model_ttl: int = 300
|
||||
model_ttl_poll_s: int = 10
|
||||
workers: int = 1
|
||||
worker_timeout: int = 300
|
||||
worker_timeout: int = Field(default_factory=default_worker_timeout)
|
||||
http_keepalive_timeout_s: int = 2
|
||||
test_full: bool = False
|
||||
request_threads: int = os.cpu_count() or 4
|
||||
|
||||
@@ -89,4 +89,10 @@ class FaceRecognizer(InferenceModel):
|
||||
@property
|
||||
def _batch_size_default(self) -> int | None:
|
||||
providers = ort.get_available_providers()
|
||||
return None if self.model_format == ModelFormat.ONNX and "OpenVINOExecutionProvider" not in providers else 1
|
||||
if (
|
||||
self.model_format == ModelFormat.ONNX
|
||||
and "MIGraphXExecutionProvider" not in providers
|
||||
and "OpenVINOExecutionProvider" not in providers
|
||||
):
|
||||
return None
|
||||
return 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
@@ -12,6 +13,37 @@ from immich_ml.schemas import ModelPrecision, SessionNode
|
||||
|
||||
from ..config import log, settings
|
||||
|
||||
MigraphxInputSignature = tuple[tuple[str, str, tuple[int, ...]], ...]
|
||||
|
||||
_migraphx_registry_lock = Lock()
|
||||
_migraphx_model_locks: dict[str, Lock] = {}
|
||||
_migraphx_compiled_inputs: set[tuple[str, MigraphxInputSignature]] = set()
|
||||
|
||||
|
||||
def _migraphx_get_model_lock(model_key: str) -> Lock:
|
||||
with _migraphx_registry_lock:
|
||||
lock = _migraphx_model_locks.get(model_key)
|
||||
if lock is None:
|
||||
lock = Lock()
|
||||
_migraphx_model_locks[model_key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def _migraphx_has_compiled_input(key: tuple[str, MigraphxInputSignature]) -> bool:
|
||||
with _migraphx_registry_lock:
|
||||
return key in _migraphx_compiled_inputs
|
||||
|
||||
|
||||
def _migraphx_mark_compiled_input(key: tuple[str, MigraphxInputSignature]) -> None:
|
||||
with _migraphx_registry_lock:
|
||||
_migraphx_compiled_inputs.add(key)
|
||||
|
||||
|
||||
def _migraphx_input_signature(
|
||||
input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]],
|
||||
) -> MigraphxInputSignature:
|
||||
return tuple((name, str(value.dtype), tuple(value.shape)) for name, value in sorted(input_feed.items()))
|
||||
|
||||
|
||||
class OrtSession:
|
||||
session: ort.InferenceSession
|
||||
@@ -48,7 +80,21 @@ class OrtSession:
|
||||
input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]],
|
||||
run_options: Any = None,
|
||||
) -> list[NDArray[np.float32]]:
|
||||
outputs: list[NDArray[np.float32]] = self.session.run(output_names, input_feed, run_options)
|
||||
if "MIGraphXExecutionProvider" in self.providers:
|
||||
model_key = self.model_path.resolve().as_posix()
|
||||
input_key = (model_key, _migraphx_input_signature(input_feed))
|
||||
if not _migraphx_has_compiled_input(input_key):
|
||||
model_lock = _migraphx_get_model_lock(model_key)
|
||||
with model_lock:
|
||||
if not _migraphx_has_compiled_input(input_key):
|
||||
outputs: list[NDArray[np.float32]] = self.session.run(output_names, input_feed, run_options)
|
||||
_migraphx_mark_compiled_input(input_key)
|
||||
return outputs
|
||||
|
||||
outputs = self.session.run(output_names, input_feed, run_options)
|
||||
return outputs
|
||||
|
||||
outputs = self.session.run(output_names, input_feed, run_options)
|
||||
return outputs
|
||||
|
||||
@property
|
||||
|
||||
@@ -10,7 +10,7 @@ dependencies = [
|
||||
"fastapi>=0.95.2,<1.0",
|
||||
"gunicorn>=21.1.0",
|
||||
"huggingface-hub>=1.0,<2.0",
|
||||
"insightface>=0.7.3,<1.0",
|
||||
"insightface>=0.7.3,<2.0",
|
||||
"numpy>=2.4.0,<3.0",
|
||||
"opencv-python-headless>=4.7.0.72,<5.0",
|
||||
"orjson>=3.9.5",
|
||||
|
||||
@@ -35,7 +35,37 @@ from immich_ml.sessions.ort import OrtSession
|
||||
from immich_ml.sessions.rknn import RknnSession, run_inference
|
||||
|
||||
|
||||
class FakeLock:
|
||||
def __init__(self) -> None:
|
||||
self.enter = mock.Mock()
|
||||
self.exit = mock.Mock()
|
||||
|
||||
def __enter__(self) -> None:
|
||||
self.enter()
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.exit(*args)
|
||||
|
||||
|
||||
class TestBase:
|
||||
def test_sets_default_worker_timeout(self, monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("DEVICE", raising=False)
|
||||
monkeypatch.delenv("MACHINE_LEARNING_WORKER_TIMEOUT", raising=False)
|
||||
|
||||
assert Settings().worker_timeout == 300
|
||||
|
||||
def test_sets_rocm_default_worker_timeout(self, monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DEVICE", "rocm")
|
||||
monkeypatch.delenv("MACHINE_LEARNING_WORKER_TIMEOUT", raising=False)
|
||||
|
||||
assert Settings().worker_timeout == 900
|
||||
|
||||
def test_worker_timeout_env_override(self, monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DEVICE", "rocm")
|
||||
monkeypatch.setenv("MACHINE_LEARNING_WORKER_TIMEOUT", "1200")
|
||||
|
||||
assert Settings().worker_timeout == 1200
|
||||
|
||||
def test_sets_default_cache_dir(self) -> None:
|
||||
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
|
||||
|
||||
@@ -413,6 +443,52 @@ class TestOrtSession:
|
||||
|
||||
assert sess_options is session.sess_options
|
||||
|
||||
def test_serializes_rocm_first_run_for_new_input_signature(self, mocker: MockerFixture) -> None:
|
||||
lock = FakeLock()
|
||||
get_model_lock = mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
|
||||
mocker.patch("immich_ml.sessions.ort._migraphx_compiled_inputs", set())
|
||||
mocker.patch("immich_ml.sessions.ort.Path.mkdir")
|
||||
session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["MIGraphXExecutionProvider"])
|
||||
input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
|
||||
|
||||
session.run(None, input_feed)
|
||||
session.run(None, input_feed)
|
||||
|
||||
lock.enter.assert_called_once()
|
||||
lock.exit.assert_called_once()
|
||||
get_model_lock.assert_called_once()
|
||||
session.session.run.assert_has_calls([mock.call(None, input_feed, None), mock.call(None, input_feed, None)])
|
||||
|
||||
def test_serializes_rocm_run_for_each_new_input_signature(self, mocker: MockerFixture) -> None:
|
||||
lock = FakeLock()
|
||||
mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
|
||||
mocker.patch("immich_ml.sessions.ort._migraphx_compiled_inputs", set())
|
||||
mocker.patch("immich_ml.sessions.ort.Path.mkdir")
|
||||
session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["MIGraphXExecutionProvider"])
|
||||
input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
|
||||
new_shape_input_feed = {"input": np.random.rand(2, 3, 224, 224).astype(np.float32)}
|
||||
|
||||
session.run(None, input_feed)
|
||||
session.run(None, new_shape_input_feed)
|
||||
|
||||
assert lock.enter.call_count == 2
|
||||
assert lock.exit.call_count == 2
|
||||
session.session.run.assert_has_calls(
|
||||
[mock.call(None, input_feed, None), mock.call(None, new_shape_input_feed, None)]
|
||||
)
|
||||
|
||||
def test_does_not_serialize_non_rocm_run(self, mocker: MockerFixture) -> None:
|
||||
lock = FakeLock()
|
||||
get_model_lock = mocker.patch("immich_ml.sessions.ort._migraphx_get_model_lock", return_value=lock)
|
||||
session = OrtSession("/cache/ViT-B-32__openai/model.onnx", providers=["CPUExecutionProvider"])
|
||||
input_feed = {"input": np.random.rand(1, 3, 224, 224).astype(np.float32)}
|
||||
|
||||
session.run(None, input_feed)
|
||||
|
||||
get_model_lock.assert_not_called()
|
||||
lock.enter.assert_not_called()
|
||||
session.session.run.assert_called_once_with(None, input_feed, None)
|
||||
|
||||
|
||||
class TestAnnSession:
|
||||
def test_creates_ann_session(self, ann_session: mock.Mock, info: mock.Mock) -> None:
|
||||
@@ -883,6 +959,34 @@ class TestFaceRecognition:
|
||||
onnx.load.assert_not_called()
|
||||
onnx.save.assert_not_called()
|
||||
|
||||
def test_recognition_does_not_add_batch_axis_for_migraphx(
|
||||
self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture
|
||||
) -> None:
|
||||
onnx = mocker.patch("immich_ml.models.facial_recognition.recognition.onnx", autospec=True)
|
||||
update_dims = mocker.patch(
|
||||
"immich_ml.models.facial_recognition.recognition.update_inputs_outputs_dims", autospec=True
|
||||
)
|
||||
mocker.patch("immich_ml.models.base.InferenceModel.download")
|
||||
mocker.patch("immich_ml.models.facial_recognition.recognition.ArcFaceONNX")
|
||||
mocker.patch(
|
||||
"immich_ml.models.facial_recognition.recognition.ort.get_available_providers",
|
||||
return_value=["MIGraphXExecutionProvider", "CPUExecutionProvider"],
|
||||
)
|
||||
path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
|
||||
|
||||
inputs = [SimpleNamespace(name="input.1", shape=(1, 3, 224, 224))]
|
||||
outputs = [SimpleNamespace(name="output.1", shape=(1, 800))]
|
||||
ort_session.return_value.get_inputs.return_value = inputs
|
||||
ort_session.return_value.get_outputs.return_value = outputs
|
||||
|
||||
face_recognizer = FaceRecognizer("buffalo_s", cache_dir=path)
|
||||
face_recognizer.load()
|
||||
|
||||
assert face_recognizer.batch_size == 1
|
||||
update_dims.assert_not_called()
|
||||
onnx.load.assert_not_called()
|
||||
onnx.save.assert_not_called()
|
||||
|
||||
def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2))
|
||||
|
||||
|
||||
Generated
+496
-448
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,6 @@ run = "bash ./bin/generate-dart-sdk.sh"
|
||||
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
|
||||
run = [
|
||||
{ task = "//:plugins" },
|
||||
{ task = "//server:build" },
|
||||
{ task = "//server:install" },
|
||||
{ task = "//server:build" },
|
||||
{ task = "//server:sync-open-api" },
|
||||
|
||||
@@ -17,6 +17,8 @@ import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
import app.alextran.immich.images.RemoteImagesImpl
|
||||
import app.alextran.immich.permission.PermissionApi
|
||||
import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
@@ -44,7 +46,9 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
} else {
|
||||
NativeSyncApiImpl30(ctx)
|
||||
}
|
||||
val permissionApiImpl = PermissionApiImpl(ctx)
|
||||
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
||||
PermissionApi.setUp(messenger, permissionApiImpl)
|
||||
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
|
||||
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
|
||||
|
||||
@@ -53,6 +57,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||
flutterEngine.plugins.add(permissionApiImpl)
|
||||
}
|
||||
|
||||
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
||||
@@ -60,6 +65,8 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
||||
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
||||
nativeApi?.detachFromEngine()
|
||||
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
|
||||
permissionApi?.detachFromEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import androidx.core.net.toUri
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
class ManageMediaPermissionDelegate(
|
||||
context: Context,
|
||||
private val requestCode: Int = 1003,
|
||||
) : PluginRegistry.ActivityResultListener {
|
||||
private val ctx = context.applicationContext
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
|
||||
|
||||
fun hasManageMediaPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaStore.canManageMedia(ctx)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
if (hasManageMediaPermission()) {
|
||||
callback(Result.success(true))
|
||||
return
|
||||
}
|
||||
|
||||
openManageMediaPermissionSettings(callback)
|
||||
}
|
||||
|
||||
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
openManageMediaPermissionSettings(callback)
|
||||
}
|
||||
|
||||
private fun openManageMediaPermissionSettings(callback: (Result<Boolean>) -> Unit) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
callback(Result.success(false))
|
||||
return
|
||||
}
|
||||
|
||||
val activity = activityBinding?.activity
|
||||
if (activity == null) {
|
||||
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
|
||||
return
|
||||
}
|
||||
|
||||
pendingResult = callback
|
||||
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA).apply {
|
||||
data = "package:${activity.packageName}".toUri()
|
||||
}
|
||||
try {
|
||||
activity.startActivityForResult(intent, requestCode)
|
||||
} catch (e: Exception) {
|
||||
pendingResult = null
|
||||
callback(
|
||||
Result.failure(
|
||||
FlutterError("ACTIVITY_LAUNCH_FAILED", "Failed to launch MANAGE_MEDIA settings", e.toString())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
binding.addActivityResultListener(this)
|
||||
}
|
||||
|
||||
fun onDetachedFromActivity() {
|
||||
failPending("ACTIVITY_DETACHED", "Activity detached before MANAGE_MEDIA result")
|
||||
activityBinding?.removeActivityResultListener(this)
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == this.requestCode) {
|
||||
val callback = pendingResult
|
||||
pendingResult = null
|
||||
callback?.invoke(Result.success(hasManageMediaPermission()))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun failPending(code: String, message: String) {
|
||||
val callback = pendingResult ?: return
|
||||
pendingResult = null
|
||||
callback(Result.failure(FlutterError(code, message, null)))
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object PermissionApiPigeonUtils {
|
||||
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : RuntimeException()
|
||||
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return super.readValueOfType(type, buffer)
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface PermissionApi {
|
||||
fun hasManageMediaPermission(): Boolean
|
||||
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by PermissionApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
PermissionApiPigeonCodec()
|
||||
}
|
||||
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
|
||||
@JvmOverloads
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.hasManageMediaPermission())
|
||||
} catch (exception: Throwable) {
|
||||
PermissionApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.requestManageMediaPermission{ result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(PermissionApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
api.manageMediaPermission{ result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(PermissionApiPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package app.alextran.immich.permission
|
||||
|
||||
import android.content.Context
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
|
||||
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
|
||||
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
|
||||
|
||||
override fun hasManageMediaPermission(): Boolean =
|
||||
manageMediaPermissionDelegate.hasManageMediaPermission()
|
||||
|
||||
override fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
manageMediaPermissionDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
override fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||
manageMediaPermissionDelegate.manageMediaPermission { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
manageMediaPermissionDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
manageMediaPermissionDelegate.onDetachedFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
manageMediaPermissionDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
manageMediaPermissionDelegate.onDetachedFromActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package app.alextran.immich.sync
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
|
||||
class MediaTrashDelegate(
|
||||
context: Context,
|
||||
private val trashRequestCode: Int = 1002,
|
||||
) : PluginRegistry.ActivityResultListener {
|
||||
private val ctx = context.applicationContext
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
|
||||
|
||||
private fun hasManageMediaPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaStore.canManageMedia(ctx)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || !hasManageMediaPermission()) {
|
||||
callback(Result.failure(FlutterError("PERMISSION_DENIED", "Media permission required", null)))
|
||||
return
|
||||
}
|
||||
|
||||
val id = mediaId.toLongOrNull()
|
||||
if (id == null) {
|
||||
callback(Result.failure(FlutterError("INVALID_ID", "The file id is not a valid number: $mediaId", null)))
|
||||
return
|
||||
}
|
||||
|
||||
if (!isInTrash(id)) {
|
||||
callback(Result.failure(FlutterError("TRASH_NOT_FOUND", "Item with id=$id not found in trash", null)))
|
||||
return
|
||||
}
|
||||
|
||||
restoreUri(ContentUris.withAppendedId(contentUriForType(type.toInt()), id), callback)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun restoreUri(
|
||||
contentUri: Uri,
|
||||
callback: (Result<Boolean>) -> Unit,
|
||||
) {
|
||||
val activity = activityBinding?.activity
|
||||
if (activity == null) {
|
||||
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val pendingIntent = MediaStore.createTrashRequest(ctx.contentResolver, listOf(contentUri), false)
|
||||
pendingResult = callback
|
||||
activity.startIntentSenderForResult(
|
||||
pendingIntent.intentSender,
|
||||
trashRequestCode,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
pendingResult = null
|
||||
callback(
|
||||
Result.failure(
|
||||
FlutterError("TRASH_ERROR", "Error creating or starting trash request", e.toString())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun isInTrash(id: Long): Boolean {
|
||||
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
val args = Bundle().apply {
|
||||
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
|
||||
}
|
||||
return ctx.contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
|
||||
?.use { it.moveToFirst() } == true
|
||||
}
|
||||
|
||||
private fun contentUriForType(type: Int): Uri =
|
||||
when (type) {
|
||||
// Same order as AssetType from Dart.
|
||||
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
}
|
||||
|
||||
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
binding.addActivityResultListener(this)
|
||||
}
|
||||
|
||||
fun onDetachedFromActivity() {
|
||||
failPending("ACTIVITY_DETACHED", "Activity detached before trash result")
|
||||
activityBinding?.removeActivityResultListener(this)
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == trashRequestCode) {
|
||||
val callback = pendingResult
|
||||
pendingResult = null
|
||||
callback?.invoke(Result.success(resultCode == Activity.RESULT_OK))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun failPending(code: String, message: String) {
|
||||
val callback = pendingResult ?: return
|
||||
pendingResult = null
|
||||
callback(Result.failure(FlutterError(code, message, null)))
|
||||
}
|
||||
}
|
||||
@@ -553,6 +553,7 @@ interface NativeSyncApi {
|
||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||
fun cancelHashing()
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
|
||||
companion object {
|
||||
@@ -747,6 +748,27 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val mediaIdArg = args[0] as String
|
||||
val typeArg = args[1] as Long
|
||||
api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result<Boolean> ->
|
||||
val error = result.exceptionOrNull()
|
||||
if (error != null) {
|
||||
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||
} else {
|
||||
val data = result.getOrNull()
|
||||
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
|
||||
@@ -17,6 +17,8 @@ import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -39,10 +41,11 @@ sealed class AssetResult {
|
||||
private const val TAG = "NativeSyncApiImplBase"
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
|
||||
private val ctx: Context = context.applicationContext
|
||||
|
||||
private var hashTask: Job? = null
|
||||
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
|
||||
|
||||
companion object {
|
||||
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||
@@ -448,6 +451,26 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
hashTask = null
|
||||
}
|
||||
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
mediaTrashDelegate.onDetachedFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
mediaTrashDelegate.onDetachedFromActivity()
|
||||
}
|
||||
|
||||
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
|
||||
@Suppress("unused", "UNUSED_PARAMETER")
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
|
||||
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
|
||||
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
|
||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
|
||||
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
|
||||
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
@@ -105,6 +107,8 @@
|
||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
||||
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
|
||||
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
|
||||
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
|
||||
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
|
||||
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
|
||||
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -283,6 +287,7 @@
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
B2EE00052E72CA15008B6CA7 /* Permission */,
|
||||
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
||||
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
@@ -317,6 +322,15 @@
|
||||
path = Connectivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2EE00052E72CA15008B6CA7 /* Permission */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */,
|
||||
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */,
|
||||
);
|
||||
path = Permission;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -619,6 +633,8 @@
|
||||
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
||||
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
||||
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
|
||||
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
|
||||
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
|
||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
||||
|
||||
@@ -26,6 +26,7 @@ import native_video_player
|
||||
|
||||
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
|
||||
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||
PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
|
||||
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
|
||||
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
|
||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import Flutter
|
||||
#elseif os(macOS)
|
||||
import FlutterMacOS
|
||||
#else
|
||||
#error("Unsupported platform.")
|
||||
#endif
|
||||
|
||||
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||
return [result]
|
||||
}
|
||||
|
||||
private func wrapError(_ error: Any) -> [Any?] {
|
||||
if let pigeonError = error as? PigeonError {
|
||||
return [
|
||||
pigeonError.code,
|
||||
pigeonError.message,
|
||||
pigeonError.details,
|
||||
]
|
||||
}
|
||||
if let flutterError = error as? FlutterError {
|
||||
return [
|
||||
flutterError.code,
|
||||
flutterError.message,
|
||||
flutterError.details,
|
||||
]
|
||||
}
|
||||
return [
|
||||
"\(error)",
|
||||
"\(Swift.type(of: error))",
|
||||
"Stacktrace: \(Thread.callStackSymbols)",
|
||||
]
|
||||
}
|
||||
|
||||
private func isNullish(_ value: Any?) -> Bool {
|
||||
return value is NSNull || value == nil
|
||||
}
|
||||
|
||||
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||
if value is NSNull { return nil }
|
||||
return value as! T?
|
||||
}
|
||||
|
||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||
protocol PermissionApi {
|
||||
func hasManageMediaPermission() throws -> Bool
|
||||
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
class PermissionApiSetup {
|
||||
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
|
||||
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
|
||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
do {
|
||||
let result = try api.hasManageMediaPermission()
|
||||
reply(wrapResult(result))
|
||||
} catch {
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasManageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
requestManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
api.requestManageMediaPermission { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
requestManageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
manageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||
api.manageMediaPermission { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
manageMediaPermissionChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
class PermissionApiImpl: PermissionApi {
|
||||
func hasManageMediaPermission() throws -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.success(false))
|
||||
}
|
||||
|
||||
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.success(false))
|
||||
}
|
||||
}
|
||||
Generated
+19
@@ -537,6 +537,7 @@ protocol NativeSyncApi {
|
||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||
func cancelHashing() throws
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
}
|
||||
|
||||
@@ -721,6 +722,24 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
if let api = api {
|
||||
restoreFromTrashByIdChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let mediaIdArg = args[0] as! String
|
||||
let typeArg = args[1] as! Int64
|
||||
api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
restoreFromTrashByIdChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getCloudIdForAssetIdsChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
|
||||
@@ -382,6 +382,10 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||
}
|
||||
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
completion(.success(false))
|
||||
}
|
||||
|
||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||
// Ensure to actually getting all assets for the Recents album
|
||||
|
||||
@@ -9,10 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -23,29 +23,29 @@ class LocalSyncService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
final StorageRepository _storageRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final Logger _log = Logger("DeviceSyncService");
|
||||
|
||||
LocalSyncService({
|
||||
required DriftLocalAlbumRepository localAlbumRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required LocalFilesManagerRepository localFilesManager,
|
||||
required StorageRepository storageRepository,
|
||||
required AssetMediaRepository assetMediaRepository,
|
||||
required IPermissionRepository permissionRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_localFilesManager = localFilesManager,
|
||||
_storageRepository = storageRepository,
|
||||
_assetMediaRepository = assetMediaRepository,
|
||||
_permissionRepository = permissionRepository,
|
||||
_nativeSyncApi = nativeSyncApi;
|
||||
|
||||
Future<void> sync({bool full = false}) async {
|
||||
final Stopwatch stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
final hasPermission = await _permissionRepository.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _syncTrashedAssets();
|
||||
} else {
|
||||
@@ -373,7 +373,7 @@ class LocalSyncService {
|
||||
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
||||
@@ -381,15 +381,15 @@ class LocalSyncService {
|
||||
|
||||
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||
_log.info("Moving to trash ${localIds.join(", ")} assets");
|
||||
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (movedIds.isNotEmpty) {
|
||||
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||
)..removeWhere((_, assets) => assets.isEmpty);
|
||||
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||
}
|
||||
} else {
|
||||
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
||||
|
||||
@@ -9,12 +9,12 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -34,8 +34,8 @@ class SyncStreamService {
|
||||
final SyncStreamRepository _syncStreamRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final LocalFilesManagerRepository _localFilesManager;
|
||||
final StorageRepository _storageRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final IPermissionRepository _permissionRepository;
|
||||
final SyncMigrationRepository _syncMigrationRepository;
|
||||
final ApiService _api;
|
||||
final bool Function()? _cancelChecker;
|
||||
@@ -45,8 +45,8 @@ class SyncStreamService {
|
||||
required SyncStreamRepository syncStreamRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required LocalFilesManagerRepository localFilesManager,
|
||||
required StorageRepository storageRepository,
|
||||
required AssetMediaRepository assetMediaRepository,
|
||||
required IPermissionRepository permissionRepository,
|
||||
required SyncMigrationRepository syncMigrationRepository,
|
||||
required ApiService api,
|
||||
bool Function()? cancelChecker,
|
||||
@@ -54,8 +54,8 @@ class SyncStreamService {
|
||||
_syncStreamRepository = syncStreamRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_localFilesManager = localFilesManager,
|
||||
_storageRepository = storageRepository,
|
||||
_assetMediaRepository = assetMediaRepository,
|
||||
_permissionRepository = permissionRepository,
|
||||
_syncMigrationRepository = syncMigrationRepository,
|
||||
_api = api,
|
||||
_cancelChecker = cancelChecker;
|
||||
@@ -500,22 +500,22 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||
_logger.info("Moving to trash ${localIds.join(", ")} assets");
|
||||
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (movedIds.isNotEmpty) {
|
||||
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||
)..removeWhere((_, assets) => assets.isEmpty);
|
||||
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyRemoteRestoreToLocal() async {
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
||||
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||
} else {
|
||||
_logger.info("No remote assets found for restoration");
|
||||
@@ -523,7 +523,7 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
@@ -533,7 +533,7 @@ class SyncStreamService {
|
||||
}
|
||||
|
||||
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
||||
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
||||
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
+19
@@ -654,6 +654,25 @@ class NativeSyncApi {
|
||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mediaId, type]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: unused_import, unused_shown_name
|
||||
// ignore_for_file: type=lint
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||
|
||||
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||
if (replyList == null) {
|
||||
throw PlatformException(
|
||||
code: 'channel-error',
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
} else if (replyList.length > 1) {
|
||||
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
}
|
||||
return replyList.firstOrNull;
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is int) {
|
||||
buffer.putUint8(4);
|
||||
buffer.putInt64(value);
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionApi {
|
||||
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
|
||||
/// available for dependency injection. If it is left null, the default
|
||||
/// BinaryMessenger will be used which routes to the host platform.
|
||||
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||
|
||||
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||
|
||||
final String pigeonVar_messageChannelSuffix;
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as bool;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/platform/network_api.g.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||
|
||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||
@@ -16,6 +17,8 @@ final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService
|
||||
|
||||
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||
|
||||
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
||||
|
||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||
|
||||
final localImageApi = LocalImageApi();
|
||||
|
||||
@@ -11,8 +11,8 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
|
||||
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
||||
|
||||
@@ -22,8 +22,8 @@ final syncStreamServiceProvider = Provider(
|
||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||
storageRepository: ref.watch(storageRepositoryProvider),
|
||||
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
||||
api: ref.watch(apiServiceProvider),
|
||||
cancelChecker: ref.watch(cancellationProvider),
|
||||
@@ -39,8 +39,8 @@ final localSyncServiceProvider = Provider(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
||||
storageRepository: ref.watch(storageRepositoryProvider),
|
||||
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,19 +8,24 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
|
||||
final assetMediaRepositoryProvider = Provider(
|
||||
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
|
||||
);
|
||||
|
||||
class AssetMediaRepository {
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
|
||||
const AssetMediaRepository(this._assetApiRepository);
|
||||
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
|
||||
|
||||
Future<bool> _androidSupportsTrash() async {
|
||||
if (Platform.isAndroid) {
|
||||
@@ -45,6 +50,27 @@ class AssetMediaRepository {
|
||||
return PhotoManager.editor.deleteWithIds(ids);
|
||||
}
|
||||
|
||||
Future<bool> _restoreFromTrashById(String mediaId, int type) async {
|
||||
try {
|
||||
return await _nativeSyncApi.restoreFromTrashById(mediaId, type);
|
||||
} catch (e, s) {
|
||||
_log.warning('Error restore file from trash by Id', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
||||
final restoredIds = <String>[];
|
||||
for (final asset in assets) {
|
||||
_log.info("Restoring from trash, localId: ${asset.id}, checksum: ${asset.checksum}");
|
||||
final result = await _restoreFromTrashById(asset.id, asset.type.index);
|
||||
if (result) {
|
||||
restoredIds.add(asset.id);
|
||||
}
|
||||
}
|
||||
return restoredIds;
|
||||
}
|
||||
|
||||
Future<AssetEntity?> get(String id) async {
|
||||
final entity = await AssetEntity.fromId(id);
|
||||
return entity;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/services/local_files_manager.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final localFilesManagerRepositoryProvider = Provider(
|
||||
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
|
||||
);
|
||||
|
||||
class LocalFilesManagerRepository {
|
||||
LocalFilesManagerRepository(this._service);
|
||||
|
||||
final Logger _logger = Logger('LocalFilesManagerRepo');
|
||||
final LocalFilesManagerService _service;
|
||||
|
||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||
return await _service.moveToTrash(mediaUrls);
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||
return await _service.restoreFromTrash(fileName, type);
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
return await _service.requestManageMediaPermission();
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
return await _service.hasManageMediaPermission();
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
return await _service.manageMediaPermission();
|
||||
}
|
||||
|
||||
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
||||
final restoredIds = <String>[];
|
||||
for (final asset in assets) {
|
||||
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
|
||||
try {
|
||||
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
|
||||
if (result) {
|
||||
restoredIds.add(asset.id);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning("Restoring failure: $e");
|
||||
}
|
||||
}
|
||||
return restoredIds;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
final permissionRepositoryProvider = Provider((_) {
|
||||
return const PermissionRepository();
|
||||
final permissionRepositoryProvider = Provider((ref) {
|
||||
return PermissionRepository(ref.watch(permissionApiProvider));
|
||||
});
|
||||
|
||||
class PermissionRepository implements IPermissionRepository {
|
||||
const PermissionRepository();
|
||||
final PermissionApi _permissionApi;
|
||||
|
||||
const PermissionRepository(this._permissionApi);
|
||||
|
||||
@override
|
||||
Future<bool> hasLocationWhenInUsePermission() {
|
||||
@@ -34,6 +38,21 @@ class PermissionRepository implements IPermissionRepository {
|
||||
Future<bool> openSettings() {
|
||||
return openAppSettings();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasManageMediaPermission() {
|
||||
return _permissionApi.hasManageMediaPermission();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> requestManageMediaPermission() {
|
||||
return _permissionApi.requestManageMediaPermission();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> manageMediaPermission() {
|
||||
return _permissionApi.manageMediaPermission();
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface class IPermissionRepository {
|
||||
@@ -42,4 +61,7 @@ abstract interface class IPermissionRepository {
|
||||
Future<bool> hasLocationAlwaysPermission();
|
||||
Future<bool> requestLocationAlwaysPermission();
|
||||
Future<bool> openSettings();
|
||||
Future<bool> hasManageMediaPermission();
|
||||
Future<bool> requestManageMediaPermission();
|
||||
Future<bool> manageMediaPermission();
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref) => const LocalFilesManagerService());
|
||||
|
||||
class LocalFilesManagerService {
|
||||
const LocalFilesManagerService();
|
||||
|
||||
static final Logger _logger = Logger('LocalFilesManager');
|
||||
static const MethodChannel _channel = MethodChannel('file_trash');
|
||||
|
||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||
try {
|
||||
return await _channel.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error moving file to trash', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||
try {
|
||||
return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type});
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error restore file from trash', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||
try {
|
||||
return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type});
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error restore file from trash by Id', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod('requestManageMediaPermission');
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error requesting manage media permission', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasManageMediaPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod('hasManageMediaPermission');
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error requesting manage media permission state', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> manageMediaPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod('manageMediaPermission');
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error requesting manage media permission settings', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
@@ -193,7 +193,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
getManageMediaPermission() async {
|
||||
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
|
||||
final hasPermission = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
@@ -224,7 +224,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||
unawaited(ref.read(permissionRepositoryProvider).requestManageMediaPermission());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
|
||||
@@ -139,8 +139,6 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
|
||||
PhotoViewHeroAttributes? get heroAttributes => widget.heroAttributes;
|
||||
|
||||
late ScaleBoundaries cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
|
||||
void handleScaleAnimation() {
|
||||
scale = _scaleAnimation!.value;
|
||||
}
|
||||
@@ -303,7 +301,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
controller.scaleAnimationBuilder(_animateControllerScale);
|
||||
controller.rotationAnimationBuilder(_animateControllerRotation);
|
||||
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
_updateScaleBoundaries();
|
||||
|
||||
_scaleAnimationController = AnimationController(vsync: this)
|
||||
..addListener(handleScaleAnimation)
|
||||
@@ -334,14 +332,27 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
widget.onTapDown?.call(context, details, controller.value);
|
||||
}
|
||||
|
||||
void _updateScaleBoundaries() {
|
||||
final prev = controller.scaleBoundaries;
|
||||
if (prev == widget.scaleBoundaries) return;
|
||||
|
||||
if (prev != null && controller.scale != null && prev.initialScale > 0) {
|
||||
final ratio = widget.scaleBoundaries.initialScale / prev.initialScale;
|
||||
controller.setScaleInvisibly(controller.scale! * ratio);
|
||||
} else {
|
||||
markNeedsScaleRecalc = true;
|
||||
}
|
||||
controller.scaleBoundaries = widget.scaleBoundaries;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PhotoViewCore oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_updateScaleBoundaries();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if we need a recalc on the scale
|
||||
if (widget.scaleBoundaries != cachedScaleBoundaries) {
|
||||
markNeedsScaleRecalc = true;
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
}
|
||||
|
||||
return StreamBuilder(
|
||||
stream: controller.outputStateStream,
|
||||
initialData: controller.prevValue,
|
||||
|
||||
@@ -145,7 +145,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
||||
_lastStack = null;
|
||||
|
||||
_didLoadSynchronously = synchronousCall;
|
||||
widget.controller.scaleBoundaries = scaleBoundaries;
|
||||
}
|
||||
|
||||
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
|
||||
|
||||
@@ -10,7 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
@@ -57,9 +57,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
() async {
|
||||
isManageMediaSupported.value = await checkAndroidVersion();
|
||||
if (isManageMediaSupported.value) {
|
||||
manageMediaAndroidPermission.value = await ref
|
||||
.read(localFilesManagerRepositoryProvider)
|
||||
.hasManageMediaPermission();
|
||||
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
||||
}
|
||||
}();
|
||||
return null;
|
||||
@@ -82,7 +80,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
||||
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
|
||||
manageLocalMediaAndroid.value = result;
|
||||
manageMediaAndroidPermission.value = result;
|
||||
}
|
||||
@@ -96,7 +94,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
? const Color.fromARGB(255, 243, 188, 106)
|
||||
: null,
|
||||
onActionTap: () async {
|
||||
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
|
||||
final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
|
||||
manageMediaAndroidPermission.value = result;
|
||||
},
|
||||
),
|
||||
|
||||
@@ -11,14 +11,7 @@ import 'package:pigeon/pigeon.dart';
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
enum PlatformAssetPlaybackStyle {
|
||||
unknown,
|
||||
image,
|
||||
video,
|
||||
imageAnimated,
|
||||
livePhoto,
|
||||
videoLooping,
|
||||
}
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
class PlatformAsset {
|
||||
final String id;
|
||||
@@ -142,6 +135,9 @@ abstract class NativeSyncApi {
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
Map<String, List<PlatformAsset>> getTrashedAssets();
|
||||
|
||||
@async
|
||||
bool restoreFromTrashById(String mediaId, int type);
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
@ConfigurePigeon(
|
||||
PigeonOptions(
|
||||
dartOut: 'lib/platform/permission_api.g.dart',
|
||||
swiftOut: 'ios/Runner/Permission/PermissionApi.g.swift',
|
||||
swiftOptions: SwiftOptions(),
|
||||
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
|
||||
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
|
||||
dartOptions: DartOptions(),
|
||||
dartPackageName: 'immich_mobile',
|
||||
),
|
||||
)
|
||||
@HostApi()
|
||||
abstract class PermissionApi {
|
||||
bool hasManageMediaPermission();
|
||||
|
||||
@async
|
||||
bool requestManageMediaPermission();
|
||||
|
||||
@async
|
||||
bool manageMediaPermission();
|
||||
}
|
||||
@@ -10,17 +10,15 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../domain/service.mock.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../mocks/asset_entity.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
|
||||
void main() {
|
||||
@@ -28,8 +26,8 @@ void main() {
|
||||
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||
late LocalFilesManagerRepository mockLocalFilesManager;
|
||||
late StorageRepository mockStorageRepository;
|
||||
late AssetMediaRepository mockAssetMediaRepository;
|
||||
late MockPermissionRepository mockPermissionRepository;
|
||||
late MockNativeSyncApi mockNativeSyncApi;
|
||||
late Drift db;
|
||||
|
||||
@@ -51,8 +49,8 @@ void main() {
|
||||
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||
mockLocalAssetRepository = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||
mockLocalFilesManager = MockLocalFilesManagerRepository();
|
||||
mockStorageRepository = MockStorageRepository();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
mockPermissionRepository = MockPermissionRepository();
|
||||
mockNativeSyncApi = MockNativeSyncApi();
|
||||
|
||||
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
||||
@@ -65,25 +63,28 @@ void main() {
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
|
||||
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
return ids;
|
||||
});
|
||||
|
||||
sut = LocalSyncService(
|
||||
localAlbumRepository: mockLocalAlbumRepository,
|
||||
localAssetRepository: mockLocalAssetRepository,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||
localFilesManager: mockLocalFilesManager,
|
||||
storageRepository: mockStorageRepository,
|
||||
assetMediaRepository: mockAssetMediaRepository,
|
||||
permissionRepository: mockPermissionRepository,
|
||||
nativeSyncApi: mockNativeSyncApi,
|
||||
);
|
||||
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
});
|
||||
|
||||
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
@@ -93,7 +94,7 @@ void main() {
|
||||
|
||||
test('skips syncTrashedAssets when store flag disabled', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
@@ -102,7 +103,7 @@ void main() {
|
||||
|
||||
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
@@ -114,7 +115,7 @@ void main() {
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
||||
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||
|
||||
await sut.sync();
|
||||
|
||||
@@ -131,13 +132,13 @@ void main() {
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
||||
final restoredIds = ['image1'];
|
||||
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
when(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requested, orderedEquals(assetsToRestore));
|
||||
return restoredIds;
|
||||
@@ -150,10 +151,6 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
final assetEntity = MockAssetEntity();
|
||||
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
|
||||
|
||||
await sut.processTrashedAssets({
|
||||
'album-a': [platformAsset],
|
||||
});
|
||||
@@ -168,12 +165,11 @@ void main() {
|
||||
expect(trashedEntry.asset.name, platformAsset.name);
|
||||
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
||||
|
||||
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
|
||||
verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1);
|
||||
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
||||
|
||||
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
|
||||
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
|
||||
expect(moveArgs, ['content://local-trash']);
|
||||
final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List<String>;
|
||||
expect(moveArgs, ['local-trash']);
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
@@ -181,6 +177,26 @@ void main() {
|
||||
expect(trashArgs['album-a'], [localAssetToTrash]);
|
||||
});
|
||||
|
||||
test('records only local assets that were moved to device trash', () async {
|
||||
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
|
||||
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
|
||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
|
||||
(_) async => {
|
||||
'album-a': [movedAsset],
|
||||
'album-b': [skippedAsset],
|
||||
},
|
||||
);
|
||||
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, ['album-a']);
|
||||
expect(trashArgs['album-a'], [movedAsset]);
|
||||
});
|
||||
|
||||
test('does not attempt restore when repository has no assets to restore', () async {
|
||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||
|
||||
@@ -190,7 +206,7 @@ void main() {
|
||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||
as Iterable<TrashedAsset>;
|
||||
expect(trashedSnapshot, isEmpty);
|
||||
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
|
||||
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
||||
});
|
||||
|
||||
@@ -199,7 +215,7 @@ void main() {
|
||||
|
||||
await sut.processTrashedAssets({});
|
||||
|
||||
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
|
||||
verifyNever(() => mockAssetMediaRepository.deleteAll(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
||||
});
|
||||
});
|
||||
@@ -215,7 +231,7 @@ void main() {
|
||||
isFavorite: false,
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1732000000,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
);
|
||||
|
||||
final localAsset = platformAsset.toLocalAsset();
|
||||
|
||||
@@ -12,12 +12,11 @@ import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -26,7 +25,6 @@ import '../../api.mocks.dart';
|
||||
import '../../fixtures/asset.stub.dart';
|
||||
import '../../fixtures/sync_stream.stub.dart';
|
||||
import '../../infrastructure/repository.mock.dart';
|
||||
import '../../mocks/asset_entity.mock.dart';
|
||||
import '../../repository.mocks.dart';
|
||||
import '../../service.mocks.dart';
|
||||
|
||||
@@ -52,8 +50,8 @@ void main() {
|
||||
late SyncApiRepository mockSyncApiRepo;
|
||||
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
|
||||
late StorageRepository mockStorageRepo;
|
||||
late AssetMediaRepository mockAssetMediaRepo;
|
||||
late MockPermissionRepository mockPermissionRepo;
|
||||
late MockApiService mockApi;
|
||||
late MockServerApi mockServerApi;
|
||||
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
||||
@@ -86,8 +84,8 @@ void main() {
|
||||
mockSyncApiRepo = MockSyncApiRepository();
|
||||
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
|
||||
mockStorageRepo = MockStorageRepository();
|
||||
mockAssetMediaRepo = MockAssetMediaRepository();
|
||||
mockPermissionRepo = MockPermissionRepository();
|
||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||
mockApi = MockApiService();
|
||||
@@ -159,8 +157,8 @@ void main() {
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
);
|
||||
@@ -170,10 +168,12 @@ void main() {
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
||||
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||
hasManageMediaPermission = false;
|
||||
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
|
||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
|
||||
when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
return ids;
|
||||
});
|
||||
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
});
|
||||
|
||||
@@ -241,8 +241,8 @@ void main() {
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
cancelChecker: cancellationChecker.call,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
@@ -282,8 +282,8 @@ void main() {
|
||||
syncStreamRepository: mockSyncStreamRepo,
|
||||
localAssetRepository: mockLocalAssetRepo,
|
||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||
localFilesManager: mockLocalFilesManagerRepo,
|
||||
storageRepository: mockStorageRepo,
|
||||
assetMediaRepository: mockAssetMediaRepo,
|
||||
permissionRepository: mockPermissionRepo,
|
||||
cancelChecker: cancellationChecker.call,
|
||||
api: mockApi,
|
||||
syncMigrationRepository: mockSyncMigrationRepo,
|
||||
@@ -424,18 +424,10 @@ void main() {
|
||||
return assetsByAlbum;
|
||||
});
|
||||
|
||||
final localEntity = MockAssetEntity();
|
||||
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
|
||||
|
||||
final mergedEntity = MockAssetEntity();
|
||||
when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local');
|
||||
when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity);
|
||||
|
||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async {
|
||||
final urls = invocation.positionalArguments.first as List<String>;
|
||||
expect(urls, unorderedEquals(['content://local-only', 'content://merged-local']));
|
||||
return true;
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||
final ids = invocation.positionalArguments.first as List<String>;
|
||||
expect(ids, unorderedEquals(['local-only', 'merged-local']));
|
||||
return ids;
|
||||
});
|
||||
|
||||
final events = [
|
||||
@@ -461,10 +453,51 @@ void main() {
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, unorderedEquals(['album-a', 'album-b']));
|
||||
expect(trashArgs['album-a'], [localAsset]);
|
||||
expect(trashArgs['album-b'], [mergedAsset]);
|
||||
verify(() => mockAssetMediaRepo.deleteAll(any())).called(1);
|
||||
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
|
||||
});
|
||||
|
||||
test("records only assets that were moved to device trash", () async {
|
||||
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
|
||||
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer(
|
||||
(_) async => {
|
||||
'album-a': [movedAsset],
|
||||
'album-b': [skippedAsset],
|
||||
},
|
||||
);
|
||||
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
|
||||
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-moved',
|
||||
checksum: movedAsset.checksum!,
|
||||
ack: 'asset-remote-moved',
|
||||
trashedAt: DateTime(2025, 5, 1),
|
||||
),
|
||||
SyncStreamStub.assetTrashed(
|
||||
id: 'remote-skipped',
|
||||
checksum: skippedAsset.checksum!,
|
||||
ack: 'asset-remote-skipped',
|
||||
trashedAt: DateTime(2025, 5, 2),
|
||||
),
|
||||
];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
final trashArgs =
|
||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
|
||||
as Map<String, List<LocalAsset>>;
|
||||
expect(trashArgs.keys, ['album-a']);
|
||||
expect(trashArgs['album-a'], [movedAsset]);
|
||||
});
|
||||
|
||||
test("skips device trashing when no local assets match the remote trash payload", () async {
|
||||
final events = [
|
||||
SyncStreamStub.assetTrashed(
|
||||
@@ -478,7 +511,7 @@ void main() {
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||
});
|
||||
|
||||
@@ -494,7 +527,7 @@ void main() {
|
||||
await simulateEvents(events);
|
||||
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||
});
|
||||
|
||||
@@ -505,7 +538,7 @@ void main() {
|
||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
||||
|
||||
final restoredIds = ['trashed-1'];
|
||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||
expect(requestedAssets, orderedEquals(trashedAssets));
|
||||
return restoredIds;
|
||||
|
||||
@@ -3,17 +3,17 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||
|
||||
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
||||
|
||||
class MockPermissionRepository extends Mock implements IPermissionRepository {}
|
||||
|
||||
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
|
||||
|
||||
class MockTagService extends Mock implements TagService {}
|
||||
|
||||
@@ -20793,7 +20793,14 @@
|
||||
"description": "Total number of matching assets",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.0.0",
|
||||
"state": "Deprecated"
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Deprecated"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
Generated
+5
-5
@@ -758,8 +758,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../packages/sdk
|
||||
'@immich/ui':
|
||||
specifier: ^0.77.0
|
||||
version: 0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
specifier: ^0.79.0
|
||||
version: 0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
'@mapbox/mapbox-gl-rtl-text':
|
||||
specifier: 0.4.0
|
||||
version: 0.4.0
|
||||
@@ -3204,8 +3204,8 @@ packages:
|
||||
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
||||
hasBin: true
|
||||
|
||||
'@immich/ui@0.77.3':
|
||||
resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==}
|
||||
'@immich/ui@0.79.0':
|
||||
resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.13.0
|
||||
svelte: ^5.0.0
|
||||
@@ -15879,7 +15879,7 @@ snapshots:
|
||||
pg-connection-string: 2.13.0
|
||||
postgres: 3.4.9
|
||||
|
||||
'@immich/ui@0.77.3(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
||||
'@immich/ui@0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.12.1
|
||||
'@mdi/js': 7.4.47
|
||||
|
||||
+2
-2
@@ -88,8 +88,8 @@ ENV NODE_ENV=production \
|
||||
COPY --from=server /output/server-pruned ./server
|
||||
COPY --from=web /usr/src/app/web/build /build/www
|
||||
COPY --from=cli /output/cli-pruned ./cli
|
||||
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-core-plugin/dist
|
||||
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-core-plugin/manifest.json
|
||||
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist
|
||||
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json
|
||||
RUN ln -s ../../cli/bin/immich server/bin/immich
|
||||
COPY LICENSE /licenses/LICENSE.txt
|
||||
COPY LICENSE /LICENSE
|
||||
|
||||
@@ -186,7 +186,11 @@ const SearchAlbumResponseSchema = z
|
||||
|
||||
const SearchAssetResponseSchema = z
|
||||
.object({
|
||||
total: z.int().min(0).describe('Total number of matching assets'),
|
||||
total: z
|
||||
.int()
|
||||
.min(0)
|
||||
.describe('Total number of matching assets')
|
||||
.meta(new HistoryBuilder().deprecated('v3.0.0').getExtensions()),
|
||||
count: z.int().min(0).describe('Number of assets in this page'),
|
||||
items: z.array(AssetResponseSchema),
|
||||
facets: z.array(SearchFacetResponseSchema),
|
||||
|
||||
@@ -47,6 +47,7 @@ export class WorkflowRepository {
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
search(dto: WorkflowSearchDto & { ownerId?: string }) {
|
||||
return this.queryBuilder()
|
||||
.$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!))
|
||||
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
|
||||
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
|
||||
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
|
||||
}
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.77.0",
|
||||
"@immich/ui": "^0.79.0",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
|
||||
+145
-1
@@ -1,17 +1,35 @@
|
||||
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountMultipleOutline,
|
||||
mdiAccountOutline,
|
||||
mdiArchiveArrowDownOutline,
|
||||
mdiBookshelf,
|
||||
mdiCog,
|
||||
mdiContentDuplicate,
|
||||
mdiCrosshairsGps,
|
||||
mdiFolderOutline,
|
||||
mdiHeartOutline,
|
||||
mdiImageAlbum,
|
||||
mdiImageMultipleOutline,
|
||||
mdiImageSizeSelectLarge,
|
||||
mdiKeyboard,
|
||||
mdiLink,
|
||||
mdiLockOutline,
|
||||
mdiMagnify,
|
||||
mdiMapOutline,
|
||||
mdiServer,
|
||||
mdiStateMachine,
|
||||
mdiSync,
|
||||
mdiTagMultipleOutline,
|
||||
mdiThemeLightDark,
|
||||
mdiToolboxOutline,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
|
||||
@@ -49,7 +67,133 @@ export const getPagesProvider = ($t: MessageFormatter) => {
|
||||
},
|
||||
].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin }));
|
||||
|
||||
return defaultProvider({ name: $t('page'), actions: adminPages });
|
||||
const userPages: ActionItem[] = [
|
||||
{
|
||||
title: $t('photos'),
|
||||
icon: mdiImageMultipleOutline,
|
||||
onAction: () => goto(Route.photos()),
|
||||
},
|
||||
{
|
||||
title: $t('explore'),
|
||||
icon: mdiMagnify,
|
||||
onAction: () => goto(Route.explore()),
|
||||
$if: () => authManager.authenticated && featureFlagsManager.value.search,
|
||||
},
|
||||
|
||||
{
|
||||
title: $t('map'),
|
||||
icon: mdiMapOutline,
|
||||
onAction: () => goto(Route.map()),
|
||||
$if: () => authManager.authenticated && featureFlagsManager.value.map,
|
||||
},
|
||||
{
|
||||
title: $t('people'),
|
||||
description: $t('people_feature_description'),
|
||||
icon: mdiAccountOutline,
|
||||
onAction: () => goto(Route.people()),
|
||||
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
|
||||
},
|
||||
{
|
||||
title: $t('shared_links'),
|
||||
icon: mdiLink,
|
||||
onAction: () => goto(Route.sharedLinks()),
|
||||
$if: () => authManager.authenticated && authManager.preferences.sharedLinks.enabled,
|
||||
},
|
||||
{
|
||||
title: $t('recently_added'),
|
||||
icon: mdiMagnify,
|
||||
onAction: () => goto(Route.recentlyAdded()),
|
||||
$if: () => authManager.authenticated,
|
||||
},
|
||||
{
|
||||
title: $t('sharing'),
|
||||
icon: mdiAccountMultipleOutline,
|
||||
onAction: () => goto(Route.sharing()),
|
||||
$if: () => authManager.authenticated,
|
||||
},
|
||||
{
|
||||
title: $t('favorites'),
|
||||
icon: mdiHeartOutline,
|
||||
onAction: () => goto(Route.favorites()),
|
||||
$if: () => authManager.authenticated,
|
||||
},
|
||||
{
|
||||
title: $t('albums'),
|
||||
description: $t('albums_feature_description'),
|
||||
icon: mdiImageAlbum,
|
||||
onAction: () => goto(Route.albums()),
|
||||
$if: () => authManager.authenticated,
|
||||
},
|
||||
{
|
||||
title: $t('tags'),
|
||||
description: $t('tag_feature_description'),
|
||||
icon: mdiTagMultipleOutline,
|
||||
onAction: () => goto(Route.tags()),
|
||||
$if: () => authManager.authenticated && authManager.preferences.tags.enabled,
|
||||
},
|
||||
{
|
||||
title: $t('folders'),
|
||||
description: $t('folders_feature_description'),
|
||||
icon: mdiFolderOutline,
|
||||
onAction: () => goto(Route.folders()),
|
||||
$if: () => authManager.authenticated && authManager.preferences.folders.enabled,
|
||||
},
|
||||
{
|
||||
title: $t('utilities'),
|
||||
icon: mdiToolboxOutline,
|
||||
onAction: () => goto(Route.utilities()),
|
||||
$if: () => authManager.authenticated,
|
||||
},
|
||||
{
|
||||
title: $t('archive'),
|
||||
icon: mdiArchiveArrowDownOutline,
|
||||
onAction: () => goto(Route.archive()),
|
||||
$if: () => authManager.authenticated,
|
||||
},
|
||||
{
|
||||
title: $t('locked_folder'),
|
||||
icon: mdiLockOutline,
|
||||
onAction: () => goto(Route.locked()),
|
||||
$if: () => authManager.authenticated,
|
||||
},
|
||||
{
|
||||
title: $t('trash'),
|
||||
icon: mdiTrashCanOutline,
|
||||
onAction: () => goto(Route.trash()),
|
||||
$if: () => authManager.authenticated && featureFlagsManager.value.trash,
|
||||
},
|
||||
{
|
||||
title: $t('admin.user_settings'),
|
||||
icon: mdiCog,
|
||||
onAction: () => goto(Route.userSettings()),
|
||||
$if: () => authManager.authenticated,
|
||||
},
|
||||
].map((route) => ({ $if: () => authManager.authenticated, ...route }));
|
||||
|
||||
const utilityPages: ActionItem[] = [
|
||||
{
|
||||
title: $t('review_duplicates'),
|
||||
icon: mdiContentDuplicate,
|
||||
onAction: () => goto(Route.duplicatesUtility()),
|
||||
},
|
||||
{
|
||||
title: $t('review_large_files'),
|
||||
icon: mdiImageSizeSelectLarge,
|
||||
onAction: () => goto(Route.largeFileUtility()),
|
||||
},
|
||||
{
|
||||
title: $t('manage_geolocation'),
|
||||
icon: mdiCrosshairsGps,
|
||||
onAction: () => goto(Route.geolocationUtility()),
|
||||
},
|
||||
{
|
||||
title: $t('workflows'),
|
||||
icon: mdiStateMachine,
|
||||
onAction: () => goto(Route.workflows()),
|
||||
},
|
||||
].map((route) => ({ ...route, $if: () => authManager.authenticated }));
|
||||
|
||||
return defaultProvider({ name: $t('page'), actions: [...userPages, ...utilityPages, ...adminPages] });
|
||||
};
|
||||
|
||||
const getMyImmichLink = () => {
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
<ControlAppBar showBackButton={false}>
|
||||
<ControlAppBar>
|
||||
{#snippet leading()}
|
||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
</div>
|
||||
</main>
|
||||
<header>
|
||||
<ControlAppBar showBackButton={false}>
|
||||
<ControlAppBar>
|
||||
{#snippet leading()}
|
||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||
<Logo variant="inline" />
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { IconButton, Logo, toastManager } from '@immich/ui';
|
||||
import { mdiArrowLeft, mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
|
||||
import { mdiDownload, mdiFileImagePlusOutline, mdiSelectAll } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
|
||||
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
|
||||
@@ -97,7 +97,7 @@
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}>
|
||||
<ControlAppBar>
|
||||
{#snippet leading()}
|
||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||
|
||||
@@ -1,97 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { ControlBar, ControlBarContent, ControlBarHeader, ControlBarOverflow, ControlBarTitle } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
showBackButton?: boolean;
|
||||
backIcon?: string;
|
||||
tailwindClasses?: string;
|
||||
forceDark?: boolean;
|
||||
multiRow?: boolean;
|
||||
class?: ClassValue;
|
||||
onClose?: () => void;
|
||||
title?: Snippet | string;
|
||||
leading?: Snippet;
|
||||
children?: Snippet;
|
||||
trailing?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
showBackButton = true,
|
||||
backIcon = mdiClose,
|
||||
tailwindClasses = '',
|
||||
forceDark = false,
|
||||
multiRow = false,
|
||||
onClose = () => {},
|
||||
leading,
|
||||
children,
|
||||
trailing,
|
||||
}: Props = $props();
|
||||
|
||||
let appBarBorder = $state('border border-subtle');
|
||||
|
||||
const onScroll = () => {
|
||||
if (window.scrollY > 80) {
|
||||
appBarBorder = 'border border-gray-200 bg-gray-50 dark:border-gray-600';
|
||||
|
||||
if (forceDark) {
|
||||
appBarBorder = 'border border-gray-600';
|
||||
}
|
||||
} else {
|
||||
appBarBorder = 'border border-subtle';
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
document.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
document.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
});
|
||||
let { backIcon = mdiClose, class: className = '', onClose, title, leading, children, trailing }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent">
|
||||
<nav
|
||||
id="asset-selection-app-bar"
|
||||
class={[
|
||||
'grid',
|
||||
multiRow && 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]',
|
||||
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]',
|
||||
'justify-between lg:grid-cols-[25%_50%_25%]',
|
||||
appBarBorder,
|
||||
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0',
|
||||
tailwindClasses,
|
||||
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray',
|
||||
]}
|
||||
>
|
||||
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}">
|
||||
{#if showBackButton}
|
||||
<IconButton
|
||||
aria-label={$t('close')}
|
||||
onclick={onClose}
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
icon={backIcon}
|
||||
size="large"
|
||||
/>
|
||||
{/if}
|
||||
{@render leading?.()}
|
||||
</div>
|
||||
<div class="absolute top-0 w-full bg-transparent p-2" id="control-bar">
|
||||
<ControlBar closeIcon={backIcon} {onClose} shape="round" class={className}>
|
||||
{#if title || leading}
|
||||
<ControlBarHeader>
|
||||
{#if title}
|
||||
<ControlBarTitle>
|
||||
{#if typeof title === 'string'}
|
||||
{title}
|
||||
{:else}
|
||||
{@render title()}
|
||||
{/if}
|
||||
</ControlBarTitle>
|
||||
{/if}
|
||||
{@render leading?.()}
|
||||
</ControlBarHeader>
|
||||
{/if}
|
||||
|
||||
<div class="w-full">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{#if children}
|
||||
<ControlBarContent>
|
||||
{@render children()}
|
||||
</ControlBarContent>
|
||||
{/if}
|
||||
|
||||
<div class="me-4 flex place-items-center gap-1 justify-self-end max-[350px]:me-0 max-[350px]:gap-0">
|
||||
{@render trailing?.()}
|
||||
</div>
|
||||
</nav>
|
||||
{#if trailing}
|
||||
<ControlBarOverflow>
|
||||
{@render trailing()}
|
||||
</ControlBarOverflow>
|
||||
{/if}
|
||||
</ControlBar>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
@@ -31,11 +30,14 @@
|
||||
|
||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
|
||||
const firstInOrNearViewport = $derived(viewerAssets.findIndex((a) => a.isInOrNearViewport));
|
||||
const lastInOrNearViewport = $derived(viewerAssets.findLastIndex((a) => a.isInOrNearViewport));
|
||||
</script>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
|
||||
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
|
||||
|
||||
@@ -7,19 +7,18 @@
|
||||
|
||||
type Props = {
|
||||
children?: Snippet;
|
||||
forceDark?: boolean;
|
||||
};
|
||||
|
||||
let { children, forceDark }: Props = $props();
|
||||
let { children }: Props = $props();
|
||||
|
||||
const onClose = () => assetMultiSelectManager.clear();
|
||||
|
||||
const assets = $derived(assetMultiSelectManager.assets);
|
||||
</script>
|
||||
|
||||
<ControlAppBar {onClose} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
|
||||
<ControlAppBar {onClose} backIcon={mdiClose}>
|
||||
{#snippet leading()}
|
||||
<div class="font-medium {forceDark ? 'text-immich-dark-primary' : 'text-primary'}">
|
||||
<div class="font-medium text-primary">
|
||||
<p class="block sm:hidden">{assets.length}</p>
|
||||
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
|
||||
</div>
|
||||
|
||||
@@ -17,13 +17,13 @@ export class TimelineDay {
|
||||
|
||||
height = $state(0);
|
||||
width = $state(0);
|
||||
isInOrNearViewport = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.isInOrNearViewport));
|
||||
|
||||
#top: number = $state(0);
|
||||
#start: number = $state(0);
|
||||
#row = $state(0);
|
||||
#col = $state(0);
|
||||
#deferredLayout = false;
|
||||
#lastInOrNearViewport = -1;
|
||||
|
||||
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
|
||||
this.index = index;
|
||||
@@ -154,4 +154,13 @@ export class TimelineDay {
|
||||
get absoluteTimelineDayTop() {
|
||||
return this.timelineMonth.top + this.#top;
|
||||
}
|
||||
|
||||
get isInOrNearViewport() {
|
||||
if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport);
|
||||
return this.#lastInOrNearViewport !== -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
const { trigger, selectedKey, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BasicModal title={$t('add_step')} {onClose}>
|
||||
<BasicModal title={$t('add_step')} {onClose} size="medium">
|
||||
{#await searchPluginMethods({ trigger })}
|
||||
<div class="flex w-full place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</script>
|
||||
|
||||
{#if method}
|
||||
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="small">
|
||||
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="medium">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{method.title}</Text>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</script>
|
||||
|
||||
{#if method}
|
||||
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="small">
|
||||
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="medium">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium">{method.title}</Text>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
const onSubmit = () => onClose(selected);
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
|
||||
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="medium">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pluginManager.triggers as item (item.trigger)}
|
||||
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
|
||||
|
||||
+3
-3
@@ -1,10 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidate, onNavigate } from '$app/navigation';
|
||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||
import AlbumDescription from './AlbumDescription.svelte';
|
||||
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
|
||||
import AlbumSummary from '$lib/components/album-page/AlbumSummary.svelte';
|
||||
import AlbumTitle from './AlbumTitle.svelte';
|
||||
import ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte';
|
||||
import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte';
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
@@ -78,6 +76,8 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { PageData } from './$types';
|
||||
import AlbumDescription from './AlbumDescription.svelte';
|
||||
import AlbumTitle from './AlbumTitle.svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@@ -499,7 +499,7 @@
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
{#if viewMode === AlbumPageViewMode.VIEW}
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
||||
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
||||
{#snippet trailing()}
|
||||
<ActionButton action={Cast} />
|
||||
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
|
||||
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/ControlAppBar.svelte';
|
||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
|
||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||
@@ -37,6 +34,7 @@
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiChevronUp,
|
||||
mdiClose,
|
||||
mdiDotsVertical,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
@@ -54,6 +52,8 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
import { Tween } from 'svelte/motion';
|
||||
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
|
||||
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
|
||||
|
||||
let memoryGallery: HTMLElement | undefined = $state();
|
||||
let memoryWrapper: HTMLElement | undefined = $state();
|
||||
@@ -328,7 +328,7 @@
|
||||
|
||||
{#if assetMultiSelectManager.selectionActive}
|
||||
<div class="dark sticky top-0 z-1">
|
||||
<AssetSelectControlBar forceDark>
|
||||
<AssetSelectControlBar>
|
||||
{@const Actions = getAssetBulkActions($t)}
|
||||
<CreateSharedLink />
|
||||
<IconButton
|
||||
@@ -365,22 +365,31 @@
|
||||
|
||||
<section
|
||||
id="memory-viewer"
|
||||
class="w-full bg-immich-dark-gray"
|
||||
class="dark w-full bg-immich-dark-gray text-white"
|
||||
bind:this={memoryWrapper}
|
||||
bind:clientHeight={viewport.height}
|
||||
bind:clientWidth={viewport.width}
|
||||
>
|
||||
{#if current}
|
||||
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
||||
{#snippet leading()}
|
||||
{#if current}
|
||||
<div class="dark grid grid-cols-[100%] p-2 max-md:h-auto max-md:flex-col md:grid-cols-[25%_50%_25%] md:p-4">
|
||||
{#if current}
|
||||
<div class="flex items-center gap-2 md:gap-6">
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
size="large"
|
||||
onclick={() => goto(Route.photos())}
|
||||
/>
|
||||
<p class="text-lg">
|
||||
{$memoryLaneTitle(current.memory)}
|
||||
</p>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="dark flex place-content-center place-items-center gap-2">
|
||||
<div class="dark flex w-full place-content-center place-items-center gap-2">
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
@@ -438,7 +447,7 @@
|
||||
</media-mute-button>
|
||||
{/if}
|
||||
</div>
|
||||
</ControlAppBar>
|
||||
</div>
|
||||
|
||||
{#if galleryInView}
|
||||
<div
|
||||
@@ -462,7 +471,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Viewer -->
|
||||
<section class="overflow-hidden pt-32 md:pt-20" bind:clientHeight={viewerHeight}>
|
||||
<section class="overflow-hidden pt-6 md:pt-0" bind:clientHeight={viewerHeight}>
|
||||
<div
|
||||
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] w-[300%] items-center justify-center gap-10 overflow-hidden md:h-[calc(100vh-180px)]"
|
||||
>
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@
|
||||
<DownloadAction />
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
|
||||
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
|
||||
{#snippet leading()}
|
||||
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
|
||||
{$t('partner_list_user_photos', { values: { user: data.partner.name } })}
|
||||
|
||||
+4
-4
@@ -5,9 +5,6 @@
|
||||
import { listNavigation } from '$lib/actions/list-navigation';
|
||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
||||
import EditNameInput from './EditNameInput.svelte';
|
||||
import MergeFaceSelector from './MergeFaceSelector.svelte';
|
||||
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||
@@ -54,6 +51,9 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import EditNameInput from './EditNameInput.svelte';
|
||||
import MergeFaceSelector from './MergeFaceSelector.svelte';
|
||||
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@@ -493,7 +493,7 @@
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
||||
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
||||
{#snippet trailing()}
|
||||
<ContextMenuButton
|
||||
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
|
||||
|
||||
@@ -387,8 +387,7 @@
|
||||
{:else}
|
||||
<div class="fixed inset-s-0 top-0 z-2 w-full">
|
||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||
<div class="absolute bg-light"></div>
|
||||
<div class="w-full flex-1 ps-4">
|
||||
<div class="mx-auto w-full max-w-2xl pe-2">
|
||||
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
||||
</div>
|
||||
</ControlAppBar>
|
||||
|
||||
@@ -4,26 +4,24 @@
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
|
||||
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CodeBlock,
|
||||
Container,
|
||||
Icon,
|
||||
IconButton,
|
||||
MenuItemType,
|
||||
menuManager,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@immich/ui';
|
||||
import { mdiClose, mdiDotsVertical } from '@mdi/js';
|
||||
import { mdiClose, mdiDotsVertical, mdiFlashOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageData } from './$types';
|
||||
@@ -46,20 +44,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const getTriggerLabel = (triggerType: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
AssetCreate: $t('asset_created'),
|
||||
PersonRecognized: $t('person_recognized'),
|
||||
};
|
||||
return labels[triggerType] || triggerType;
|
||||
};
|
||||
|
||||
const formatTimestamp = (createdAt: string) =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(createdAt));
|
||||
|
||||
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
|
||||
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
|
||||
void menuManager.show({
|
||||
@@ -92,12 +76,6 @@
|
||||
|
||||
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
|
||||
|
||||
{#snippet chipItem(title: string)}
|
||||
<span class="rounded-xl border border-gray-200/80 bg-light px-3 py-1.5 text-sm dark:border-gray-600">
|
||||
<span class="font-medium text-dark">{title}</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
|
||||
<section class="flex place-content-center sm:mx-4">
|
||||
<Container center size="large" class="pb-28">
|
||||
@@ -111,92 +89,77 @@
|
||||
class="mx-auto mt-10"
|
||||
/>
|
||||
{:else}
|
||||
<div class="my-6 grid gap-6">
|
||||
<div class="my-6 flex flex-col gap-3">
|
||||
{#each workflows as workflow (workflow.id)}
|
||||
<Card class="border border-light-200">
|
||||
<CardHeader
|
||||
class={`flex flex-row gap-4 px-8 py-6 sm:items-center sm:gap-6 ${
|
||||
workflow.enabled
|
||||
? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
|
||||
: 'bg-neutral-50 dark:bg-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-full {workflow.enabled ? 'size-3 bg-success' : 'size-3 rounded-full bg-muted'}"
|
||||
></span>
|
||||
<CardTitle>{workflow.name || $t('workflow')}</CardTitle>
|
||||
<Card class="group shadow-none transition-colors hover:border-primary">
|
||||
<CardHeader>
|
||||
<a
|
||||
href={Route.viewWorkflow({ id: workflow.id })}
|
||||
class="flex items-center gap-4"
|
||||
class:opacity-55={!workflow.enabled}
|
||||
>
|
||||
<div
|
||||
class={`flex size-11 shrink-0 items-center justify-center rounded-xl ${
|
||||
workflow.enabled
|
||||
? 'bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary'
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Icon icon={mdiFlashOutline} size="20" />
|
||||
</div>
|
||||
{#if workflow.description}
|
||||
<CardDescription class="mt-1 text-sm">{workflow.description}</CardDescription>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="hidden text-right sm:block">
|
||||
<Text size="tiny">{$t('created_at')}</Text>
|
||||
<Text size="small" fontWeight="medium">
|
||||
{formatTimestamp(workflow.createdAt)}
|
||||
</Text>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<CardTitle class="truncate font-semibold text-dark group-hover:text-primary">
|
||||
{workflow.name || $t('workflow')}
|
||||
</CardTitle>
|
||||
|
||||
{#if !workflow.enabled}
|
||||
<Badge size="small" color="secondary">
|
||||
{$t('disabled')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if workflow.description}
|
||||
<CardDescription class="mt-0.5 truncate">
|
||||
{workflow.description}
|
||||
</CardDescription>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
icon={mdiDotsVertical}
|
||||
aria-label={$t('menu')}
|
||||
onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)}
|
||||
onclick={(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
showWorkflowMenu(event, workflow);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody class="space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<!-- Trigger Section -->
|
||||
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||
<div class="mb-3">
|
||||
<Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
|
||||
</div>
|
||||
{@render chipItem(getTriggerLabel(workflow.trigger))}
|
||||
</div>
|
||||
|
||||
<!-- Actions Section -->
|
||||
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||
<div class="mb-3">
|
||||
<Text size="tiny" color="muted" fontWeight="medium">{$t('steps')}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if workflow.steps.length === 0}
|
||||
<span class="text-sm text-light-600">
|
||||
{$t('no_steps')}
|
||||
</span>
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each workflow.steps as step, i (i)}
|
||||
{@render chipItem(pluginManager.getMethodLabel(step.method))}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{#if expandedIds.has(workflow.id)}
|
||||
{#await getWorkflowForShare({ id: workflow.id }) then result}
|
||||
<VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
|
||||
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
|
||||
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
|
||||
<Button
|
||||
class="mt-2"
|
||||
leadingIcon={mdiClose}
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onclick={() => toggleExpanded(workflow.id)}>{$t('close')}</Button
|
||||
onclick={() => toggleExpanded(workflow.id)}
|
||||
>
|
||||
</VStack>
|
||||
{$t('close')}
|
||||
</Button>
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
</CardBody>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import { beforeNavigate, goto, invalidate } from '$app/navigation';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
|
||||
@@ -8,7 +8,7 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||
import type { WorkflowResponseDto, WorkflowStepDto } from '@immich/sdk';
|
||||
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
|
||||
import {
|
||||
ActionBar,
|
||||
AppShell,
|
||||
@@ -29,26 +29,40 @@
|
||||
IconButton,
|
||||
Input,
|
||||
modalManager,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
VStack,
|
||||
type ActionItem,
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiCodeJson,
|
||||
mdiContentSave,
|
||||
mdiFlashOutline,
|
||||
mdiFormatListBulletedSquare,
|
||||
mdiInformationOutline,
|
||||
mdiPencilOutline,
|
||||
mdiPlus,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { flushSync } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
|
||||
import WorkflowStepCard from './WorkflowStepCard.svelte';
|
||||
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
|
||||
import WorkflowSummary from './WorkflowSummary.svelte';
|
||||
|
||||
type WorkflowJsonContent = Required<
|
||||
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
|
||||
>;
|
||||
|
||||
type EditMode = 'visual' | 'json';
|
||||
type StepDragImage = {
|
||||
description?: string;
|
||||
isFilter: boolean;
|
||||
label: string;
|
||||
stepNumber: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
@@ -57,6 +71,27 @@
|
||||
let { data }: Props = $props();
|
||||
|
||||
let { id, enabled, name, description, trigger, steps } = $derived(data.workflow);
|
||||
let savedWorkflow = $state(cloneDeep(data.workflow));
|
||||
let allowNavigation = $state(false);
|
||||
let isShowingNavigationDialog = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let editMode = $state<EditMode>('visual');
|
||||
let draggedIndex = $state<number | null>(null);
|
||||
let dragHandleHoverIndex = $state<number | null>(null);
|
||||
let dragImageElement = $state<HTMLElement | null>(null);
|
||||
let dragImage = $state<StepDragImage>({ isFilter: false, label: '', stepNumber: 1 });
|
||||
let dropTargetIndex = $state<number | null>(null);
|
||||
|
||||
const workflowSummary = $derived({ name, description, trigger, steps });
|
||||
const workflowJsonContent = $derived<WorkflowJsonContent>({ description, enabled, name, steps, trigger });
|
||||
|
||||
const hasChanges = $derived(
|
||||
enabled !== savedWorkflow.enabled ||
|
||||
name !== savedWorkflow.name ||
|
||||
description !== savedWorkflow.description ||
|
||||
!isEqual(trigger, savedWorkflow.trigger) ||
|
||||
!isEqual(steps, savedWorkflow.steps),
|
||||
);
|
||||
|
||||
const handleAddStep = async () => {
|
||||
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
|
||||
@@ -65,13 +100,90 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditStep = async (step: WorkflowStepDto) => {
|
||||
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step });
|
||||
if (result) {
|
||||
Object.assign(step, result);
|
||||
const handleInsertStep = async (index: number) => {
|
||||
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
|
||||
if (step) {
|
||||
steps = [...steps.slice(0, index), step, ...steps.slice(index)];
|
||||
}
|
||||
};
|
||||
|
||||
const replaceStep = (index: number, step: WorkflowStepDto) => {
|
||||
steps = steps.map((current, i) => (i === index ? cloneDeep(step) : current));
|
||||
};
|
||||
|
||||
const handleEditStep = async (index: number) => {
|
||||
const step = steps[index];
|
||||
if (!step) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step: cloneDeep(step) });
|
||||
if (result) {
|
||||
replaceStep(index, result);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number, event: DragEvent) => {
|
||||
draggedIndex = index;
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(index));
|
||||
|
||||
const step = steps[index];
|
||||
const method = step ? pluginManager.getMethod(step.method) : undefined;
|
||||
dragImage = {
|
||||
description: method?.description,
|
||||
isFilter: method?.uiHints?.includes('filter') ?? false,
|
||||
label: step ? pluginManager.getMethodLabel(step.method) : '',
|
||||
stepNumber: index + 1,
|
||||
};
|
||||
flushSync();
|
||||
|
||||
if (dragImageElement) {
|
||||
event.dataTransfer.setDragImage(dragImageElement, 16, 22);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (index: number, event: DragEvent) => {
|
||||
if (draggedIndex === null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
if (dropTargetIndex !== index) {
|
||||
dropTargetIndex = index;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (index: number) => {
|
||||
if (dropTargetIndex === index) {
|
||||
dropTargetIndex = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (index: number, event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
const from = draggedIndex;
|
||||
draggedIndex = null;
|
||||
dropTargetIndex = null;
|
||||
if (from === null || from === index) {
|
||||
return;
|
||||
}
|
||||
const next = [...steps];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(index, 0, moved);
|
||||
steps = next;
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
draggedIndex = null;
|
||||
dragHandleHoverIndex = null;
|
||||
dropTargetIndex = null;
|
||||
};
|
||||
|
||||
const handleDeleteStep = async (index: number) => {
|
||||
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
|
||||
if (confirmed) {
|
||||
@@ -80,11 +192,16 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = async () => {
|
||||
// check for pending changes
|
||||
await goto(Route.workflows());
|
||||
const handleJsonContentChange = (content: WorkflowJsonContent) => {
|
||||
enabled = content.enabled;
|
||||
name = content.name;
|
||||
description = content.description;
|
||||
trigger = content.trigger;
|
||||
steps = cloneDeep(content.steps);
|
||||
};
|
||||
|
||||
const onClose = () => goto(Route.workflows());
|
||||
|
||||
const onChangeTrigger = async () => {
|
||||
const newTrigger = await modalManager.show(WorkflowTriggerPicker, { selected: trigger });
|
||||
if (newTrigger) {
|
||||
@@ -95,163 +212,228 @@
|
||||
const onWorkflowUpdate = async (response: WorkflowResponseDto) => {
|
||||
if (id === response.id) {
|
||||
data.workflow = response;
|
||||
savedWorkflow = cloneDeep(response);
|
||||
await invalidate('workflow:data');
|
||||
}
|
||||
};
|
||||
|
||||
const Done: ActionItem = {
|
||||
title: $t('save'),
|
||||
icon: mdiContentSave,
|
||||
color: 'primary',
|
||||
onAction: () => handleUpdateWorkflow(id, { enabled, name, description, trigger, steps }),
|
||||
const confirmNavigation = async () => {
|
||||
if (!hasChanges) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isShowingNavigationDialog) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
isShowingNavigationDialog = true;
|
||||
return await modalManager.showDialog({
|
||||
prompt: $t('workflow_navigation_prompt'),
|
||||
confirmColor: 'primary',
|
||||
});
|
||||
} finally {
|
||||
isShowingNavigationDialog = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveWorkflow = async () => {
|
||||
if (!hasChanges || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
const submitted = { enabled, name, description, trigger, steps: cloneDeep(steps) };
|
||||
const saved = await handleUpdateWorkflow(id, submitted);
|
||||
|
||||
if (saved) {
|
||||
Object.assign(savedWorkflow, submitted);
|
||||
}
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
beforeNavigate(({ cancel, to, willUnload }) => {
|
||||
if (!hasChanges || allowNavigation) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancel();
|
||||
|
||||
if (willUnload || !to) {
|
||||
return;
|
||||
}
|
||||
|
||||
void confirmNavigation().then((confirmed) => {
|
||||
if (confirmed) {
|
||||
allowNavigation = true;
|
||||
void goto(to.url);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<OnEvents {onWorkflowUpdate} />
|
||||
|
||||
<AppShell>
|
||||
<AppShell class="">
|
||||
<AppShellBar>
|
||||
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
|
||||
<ControlBarHeader>
|
||||
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
|
||||
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
|
||||
</ControlBarHeader>
|
||||
<ControlBarContent class="flex justify-end">
|
||||
<HeaderActionButton action={Done} variant="filled" />
|
||||
<ControlBarContent class="flex items-center justify-end gap-6">
|
||||
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
|
||||
<Button
|
||||
variant={editMode === 'visual' ? 'filled' : 'ghost'}
|
||||
color={editMode === 'visual' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
leadingIcon={mdiFormatListBulletedSquare}
|
||||
aria-pressed={editMode === 'visual'}
|
||||
onclick={() => (editMode = 'visual')}
|
||||
shape="round"
|
||||
>
|
||||
{$t('visual')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={editMode === 'json' ? 'filled' : 'ghost'}
|
||||
color={editMode === 'json' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
leadingIcon={mdiCodeJson}
|
||||
aria-pressed={editMode === 'json'}
|
||||
onclick={() => (editMode = 'json')}
|
||||
shape="round"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="filled"
|
||||
size="small"
|
||||
color="primary"
|
||||
leadingIcon={mdiContentSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
loading={isSaving}
|
||||
onclick={saveWorkflow}
|
||||
>
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</ControlBarContent>
|
||||
</ActionBar>
|
||||
</AppShellBar>
|
||||
|
||||
<Container size="medium" class="pt-8 pb-24" center>
|
||||
<VStack gap={4}>
|
||||
<Card expandable>
|
||||
<CardHeader>
|
||||
<div class="flex place-items-start gap-3">
|
||||
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle>
|
||||
{$t('workflow_info')}
|
||||
</CardTitle>
|
||||
{#if editMode === 'visual'}
|
||||
<Card class="shadow-none" expandable>
|
||||
<CardHeader>
|
||||
<div class="flex place-items-start gap-3">
|
||||
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle>
|
||||
{$t('workflow_info')}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
<VStack gap={4}>
|
||||
<div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}>
|
||||
<Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}>
|
||||
<Switch bind:checked={enabled} />
|
||||
<CardBody>
|
||||
<VStack gap={4}>
|
||||
<div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}>
|
||||
<Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}>
|
||||
<Switch bind:checked={enabled} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={$t('name')} required>
|
||||
<Input
|
||||
placeholder={$t('workflow_name')}
|
||||
bind:value={() => name ?? '', (value) => (name = value || null)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={$t('description')} for="workflow-description">
|
||||
<Textarea
|
||||
id="workflow-description"
|
||||
grow
|
||||
placeholder={$t('workflow_description')}
|
||||
bind:value={() => description ?? '', (value) => (description = value || null)}
|
||||
/>
|
||||
</Field>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Field label={$t('name')} required>
|
||||
<Input
|
||||
placeholder={$t('workflow_name')}
|
||||
bind:value={() => name ?? '', (value) => (name = value || null)}
|
||||
<div class="my-4 h-px w-[98%] bg-light-200"></div>
|
||||
|
||||
<Card class="shadow-none">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-success-50">
|
||||
<Icon icon={mdiFlashOutline} size="20" class="text-success" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle class="truncate">{getTriggerName($t, trigger)}</CardTitle>
|
||||
<CardDescription class="truncate">{getTriggerDescription($t, trigger)}</CardDescription>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
icon={mdiPencilOutline}
|
||||
aria-label={$t('edit')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onclick={onChangeTrigger}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={$t('description')} for="workflow-description">
|
||||
<Textarea
|
||||
id="workflow-description"
|
||||
grow
|
||||
placeholder={$t('workflow_description')}
|
||||
bind:value={() => description ?? '', (value) => (description = value || null)}
|
||||
/>
|
||||
</Field>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div class="my-4 h-px w-[98%] bg-light-200"></div>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="bg-success-50">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-success" />
|
||||
<div class="flex grow flex-col">
|
||||
<CardTitle class="text-left text-success">{$t('trigger')}</CardTitle>
|
||||
<CardDescription>{$t('trigger_description')}</CardDescription>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<Button leadingIcon={mdiPencilOutline} size="small" color="secondary" onclick={onChangeTrigger}>
|
||||
{$t('edit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<CardBody>
|
||||
<div class="flex flex-col items-start">
|
||||
<Text>{getTriggerName($t, trigger)}</Text>
|
||||
<Text size="small" color="muted">{getTriggerDescription($t, trigger)}</Text>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{#each steps as step, index (index)}
|
||||
<WorkflowStepCard
|
||||
{step}
|
||||
{index}
|
||||
isDragging={draggedIndex === index}
|
||||
isDragHandleHovered={dragHandleHoverIndex === index}
|
||||
isDropTarget={dropTargetIndex === index && draggedIndex !== null && draggedIndex !== index}
|
||||
onEdit={handleEditStep}
|
||||
onDelete={handleDeleteStep}
|
||||
onInsertBefore={handleInsertStep}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragHandleEnter={(i) => (dragHandleHoverIndex = i)}
|
||||
onDragHandleLeave={() => (dragHandleHoverIndex = null)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<Card>
|
||||
<CardHeader class="bg-primary-50">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon={mdiFormatListBulletedSquare} size="20" class="mt-1 text-primary" />
|
||||
<CardTitle class="text-left text-primary">{$t('steps')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
{#if steps.length === 0}
|
||||
<Button leadingIcon={mdiPlus} onclick={handleAddStep}>{$t('add_step')}</Button>
|
||||
{:else}
|
||||
<Stack gap={2}>
|
||||
{#each steps as step, index (index)}
|
||||
{@const method = pluginManager.getMethod(step.method)}
|
||||
{#if index > 0}
|
||||
<hr />
|
||||
{/if}
|
||||
<div
|
||||
// {@attach dragAndDrop({
|
||||
// index,
|
||||
// onDragStart: handleFilterDragStart,
|
||||
// onDragEnter: handleFilterDragEnter,
|
||||
// onDrop: handleFilterDrop,
|
||||
// onDragEnd: handleFilterDragEnd,
|
||||
// isDragging: draggedIndex === index,
|
||||
// isDragOver: dragOverIndex === index,
|
||||
// })}
|
||||
class="flex cursor-move justify-between gap-2 rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Text>{pluginManager.getMethodLabel(step.method)}</Text>
|
||||
{#if method?.description}
|
||||
<Text color="muted" size="small">{method.description}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<IconButton
|
||||
icon={mdiPencilOutline}
|
||||
aria-label={$t('edit')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
onclick={() => handleEditStep(step)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={mdiTrashCanOutline}
|
||||
aria-label={$t('delete')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="danger"
|
||||
onclick={() => handleDeleteStep(index)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<Button size="small" fullWidth variant="ghost" leadingIcon={mdiPlus} onclick={handleAddStep}>
|
||||
{$t('add_step')}
|
||||
</Button>
|
||||
</Stack>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Button
|
||||
size="small"
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
leadingIcon={mdiPlus}
|
||||
class="border border-dashed"
|
||||
onclick={handleAddStep}
|
||||
>
|
||||
{$t('add_step')}
|
||||
</Button>
|
||||
{:else}
|
||||
<WorkflowJsonEditor jsonContent={workflowJsonContent} onContentChange={handleJsonContentChange} />
|
||||
{/if}
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
<WorkflowStepDragImage
|
||||
bind:ref={dragImageElement}
|
||||
description={dragImage.description}
|
||||
isFilter={dragImage.isFilter}
|
||||
label={dragImage.label}
|
||||
stepNumber={dragImage.stepNumber}
|
||||
/>
|
||||
<WorkflowSummary workflow={workflowSummary} />
|
||||
</AppShell>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { WorkflowResponseDto } from '@immich/sdk';
|
||||
import { WorkflowTrigger, type WorkflowStepDto, type WorkflowUpdateDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
@@ -13,40 +12,91 @@
|
||||
VStack,
|
||||
} from '@immich/ui';
|
||||
import { mdiCodeJson } from '@mdi/js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { untrack } from 'svelte';
|
||||
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type WorkflowJsonContent = Required<
|
||||
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
|
||||
>;
|
||||
|
||||
type Props = {
|
||||
jsonContent: WorkflowResponseDto;
|
||||
onApply: () => void;
|
||||
onContentChange: (content: WorkflowResponseDto) => void;
|
||||
jsonContent: WorkflowJsonContent;
|
||||
onContentChange: (content: WorkflowJsonContent) => void;
|
||||
};
|
||||
|
||||
let { jsonContent, onApply, onContentChange }: Props = $props();
|
||||
let { jsonContent, onContentChange }: Props = $props();
|
||||
|
||||
let content: Content = $derived({ json: jsonContent });
|
||||
let canApply = $state(false);
|
||||
let content: Content = $state({ json: jsonContent });
|
||||
let editorClass = $derived(themeManager.value === Theme.Dark ? 'jse-theme-dark' : '');
|
||||
|
||||
const isWorkflowStep = (value: unknown): value is WorkflowStepDto => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const step = value as Partial<WorkflowStepDto>;
|
||||
return (
|
||||
typeof step.method === 'string' &&
|
||||
(step.config === null || (typeof step.config === 'object' && !Array.isArray(step.config))) &&
|
||||
(step.enabled === undefined || typeof step.enabled === 'boolean')
|
||||
);
|
||||
};
|
||||
|
||||
const isWorkflowJsonContent = (value: unknown): value is WorkflowJsonContent => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workflow = value as Partial<WorkflowJsonContent>;
|
||||
return (
|
||||
typeof workflow.enabled === 'boolean' &&
|
||||
(workflow.name === null || typeof workflow.name === 'string') &&
|
||||
(workflow.description === null || typeof workflow.description === 'string') &&
|
||||
Object.values(WorkflowTrigger).includes(workflow.trigger as WorkflowTrigger) &&
|
||||
Array.isArray(workflow.steps) &&
|
||||
workflow.steps.every(isWorkflowStep)
|
||||
);
|
||||
};
|
||||
|
||||
const parseContent = (updated: Content) => {
|
||||
if ('json' in updated) {
|
||||
return updated.json;
|
||||
}
|
||||
|
||||
return JSON.parse(updated.text);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const nextContent = jsonContent;
|
||||
let isSynced = false;
|
||||
|
||||
try {
|
||||
isSynced = isEqual(
|
||||
untrack(() => parseContent(content)),
|
||||
nextContent,
|
||||
);
|
||||
} catch {
|
||||
// The editor can be temporarily invalid while typing in text mode.
|
||||
}
|
||||
|
||||
if (!isSynced) {
|
||||
content = { json: nextContent };
|
||||
}
|
||||
});
|
||||
|
||||
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
|
||||
if (status.contentErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
canApply = true;
|
||||
|
||||
if ('text' in updated && updated.text !== undefined) {
|
||||
try {
|
||||
const parsed = JSON.parse(updated.text);
|
||||
onContentChange(parsed);
|
||||
} catch (error_) {
|
||||
console.error('Invalid JSON in text mode:', error_);
|
||||
}
|
||||
const parsed = parseContent(updated);
|
||||
if (!isWorkflowJsonContent(parsed)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onApply();
|
||||
canApply = false;
|
||||
onContentChange(parsed);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -57,17 +107,16 @@
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle>Workflow JSON</CardTitle>
|
||||
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
|
||||
<CardTitle>{$t('workflow_json')}</CardTitle>
|
||||
<CardDescription>{$t('workflow_json_help')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack gap={2}>
|
||||
<div class="h-[600px] w-full overflow-hidden rounded-lg border {editorClass}">
|
||||
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
||||
<JSONEditor bind:content onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
||||
</div>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import type { WorkflowStepDto } from '@immich/sdk';
|
||||
import { Badge, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, IconButton } from '@immich/ui';
|
||||
import {
|
||||
mdiAutoFix,
|
||||
mdiDragVertical,
|
||||
mdiFilterVariant,
|
||||
mdiPencilOutline,
|
||||
mdiPlus,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
step: WorkflowStepDto;
|
||||
index: number;
|
||||
isDragging: boolean;
|
||||
isDragHandleHovered: boolean;
|
||||
isDropTarget: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onInsertBefore: (index: number) => void;
|
||||
onDragStart: (index: number, event: DragEvent) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (index: number, event: DragEvent) => void;
|
||||
onDragLeave: (index: number) => void;
|
||||
onDrop: (index: number, event: DragEvent) => void;
|
||||
onDragHandleEnter: (index: number) => void;
|
||||
onDragHandleLeave: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
step,
|
||||
index,
|
||||
isDragging,
|
||||
isDragHandleHovered,
|
||||
isDropTarget,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onInsertBefore,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragHandleEnter,
|
||||
onDragHandleLeave,
|
||||
}: Props = $props();
|
||||
|
||||
const method = $derived(pluginManager.getMethod(step.method));
|
||||
const isFilter = $derived(method?.uiHints?.includes('filter') ?? false);
|
||||
const configEntries = $derived(
|
||||
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
|
||||
);
|
||||
|
||||
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
|
||||
|
||||
const formatConfigValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'on' : 'off';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return `"${truncate(value)}"`;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return $t('none');
|
||||
}
|
||||
const items = value.map((v) => (v !== null && typeof v === 'object' ? '{…}' : String(v)));
|
||||
const joined = items.join(' · ');
|
||||
if (joined.length <= 28) {
|
||||
return `"${joined}"`;
|
||||
}
|
||||
return $t('items_count', { values: { count: value.length } });
|
||||
}
|
||||
return '{…}';
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="group/step-row flex w-full flex-col">
|
||||
<div class="-mt-4 ml-18 flex w-full items-center gap-4">
|
||||
<div class="relative flex w-1 shrink-0 justify-start">
|
||||
<div class="h-10 w-0.5 bg-light-200"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
|
||||
aria-label={$t('add_step')}
|
||||
title={$t('add_step')}
|
||||
onclick={() => onInsertBefore(index)}
|
||||
>
|
||||
<Icon icon={mdiPlus} size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full transition-all"
|
||||
class:opacity-40={isDragging}
|
||||
class:scale-[0.99]={isDragging}
|
||||
ondragover={(event) => onDragOver(index, event)}
|
||||
ondragleave={() => onDragLeave(index)}
|
||||
ondrop={(event) => onDrop(index, event)}
|
||||
role="listitem"
|
||||
>
|
||||
<Card
|
||||
class="shadow-none transition-colors {isDropTarget
|
||||
? 'border-primary ring-2 ring-primary-200'
|
||||
: isDragHandleHovered
|
||||
? 'border-dashed border-primary'
|
||||
: ''}"
|
||||
>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex shrink-0 cursor-grab items-center justify-center rounded-md border border-transparent p-1 text-light-400 select-none hover:border-primary-200 hover:bg-primary-50 hover:text-primary active:cursor-grabbing"
|
||||
aria-label={$t('drag_to_reorder')}
|
||||
draggable="true"
|
||||
onmouseenter={() => onDragHandleEnter(index)}
|
||||
onmouseleave={onDragHandleLeave}
|
||||
ondragstart={(event) => onDragStart(index, event)}
|
||||
ondragend={onDragEnd}
|
||||
title={$t('drag_to_reorder')}
|
||||
>
|
||||
<Icon icon={mdiDragVertical} size="20" />
|
||||
</div>
|
||||
<div
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-lg"
|
||||
class:bg-primary-50={isFilter}
|
||||
class:bg-warning-50={!isFilter}
|
||||
>
|
||||
<Icon
|
||||
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
|
||||
size="20"
|
||||
class={isFilter ? 'text-primary' : 'text-warning'}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle class="truncate">
|
||||
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
|
||||
{pluginManager.getMethodLabel(step.method)}
|
||||
</CardTitle>
|
||||
{#if method?.description}
|
||||
<CardDescription class="truncate">{method.description}</CardDescription>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<IconButton
|
||||
icon={mdiPencilOutline}
|
||||
aria-label={$t('edit')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onclick={() => onEdit(index)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={mdiTrashCanOutline}
|
||||
aria-label={$t('delete')}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="danger"
|
||||
size="small"
|
||||
onclick={() => onDelete(index)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{#if configEntries.length > 0}
|
||||
<CardBody class="py-3">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#each configEntries as [key, value] (key)}
|
||||
<Badge
|
||||
color={isFilter ? 'info' : 'warning'}
|
||||
shape="round"
|
||||
size="small"
|
||||
class="border font-mono {isFilter ? 'border-primary-200' : 'border-warning-200'}"
|
||||
>
|
||||
<span class="opacity-60">{key}</span>{formatConfigValue(value)}
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
</CardBody>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiAutoFix, mdiFilterVariant } from '@mdi/js';
|
||||
|
||||
type Props = {
|
||||
ref?: HTMLElement | null;
|
||||
description?: string;
|
||||
isFilter: boolean;
|
||||
label: string;
|
||||
stepNumber: number;
|
||||
};
|
||||
|
||||
let { ref = $bindable(null), description, isFilter, label, stepNumber }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none fixed top-[-1000px] left-0 flex w-80 items-center gap-2.5 rounded-lg border border-light-200 bg-light px-3 py-2.5 text-sm/5 text-dark shadow-2xl"
|
||||
>
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-lg"
|
||||
class:bg-primary-50={isFilter}
|
||||
class:bg-warning-50={!isFilter}
|
||||
>
|
||||
<Icon
|
||||
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
|
||||
size="18"
|
||||
class={isFilter ? 'text-primary' : 'text-warning'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="shrink-0 font-bold text-light-500">#{stepNumber}</span>
|
||||
<span class="truncate font-bold">{label}</span>
|
||||
</div>
|
||||
|
||||
{#if description}
|
||||
<div class="mt-0.5 truncate text-xs/4 text-light-500">{description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,137 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { getTriggerName } from '$lib/utils/workflow';
|
||||
import type { WorkflowResponseDto } from '@immich/sdk';
|
||||
import type { WorkflowStepDto, WorkflowTrigger } from '@immich/sdk';
|
||||
import { Icon, IconButton, Text } from '@immich/ui';
|
||||
import { mdiClose, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
|
||||
import { mdiCheck, mdiClose, mdiContentCopy, mdiViewDashboardOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
type WorkflowSummaryData = {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
trigger: WorkflowTrigger;
|
||||
steps: WorkflowStepDto[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
workflow: WorkflowResponseDto;
|
||||
workflow: WorkflowSummaryData;
|
||||
};
|
||||
|
||||
let { workflow }: Props = $props();
|
||||
const { trigger, steps } = $derived(workflow);
|
||||
|
||||
let isOpen = $state(false);
|
||||
let position = $state({ x: 0, y: 0 });
|
||||
let isDragging = $state(false);
|
||||
let dragOffset = $state({ x: 0, y: 0 });
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (!containerEl) {
|
||||
return;
|
||||
}
|
||||
isDragging = true;
|
||||
const rect = containerEl.getBoundingClientRect();
|
||||
dragOffset = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
position = {
|
||||
x: e.clientX - dragOffset.x,
|
||||
y: e.clientY - dragOffset.y,
|
||||
};
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
let justCopied = $state(false);
|
||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let panelElement = $state<HTMLElement | undefined>(undefined);
|
||||
|
||||
$effect(() => {
|
||||
// Initialize position to bottom-right on mount
|
||||
if (globalThis.window && position.x === 0 && position.y === 0) {
|
||||
position = {
|
||||
x: globalThis.innerWidth - 280,
|
||||
y: globalThis.innerHeight - 400,
|
||||
};
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
isOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (panelElement && event.target instanceof Node && !panelElement.contains(event.target)) {
|
||||
isOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeydown, { capture: true });
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown, { capture: true });
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
};
|
||||
});
|
||||
|
||||
const formatConfigValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return `"${value}"`;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '[]';
|
||||
}
|
||||
return '[' + value.map((v) => (v !== null && typeof v === 'object' ? '{…}' : String(v))).join(', ') + ']';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getConfigEntries = (config: WorkflowStepDto['config']) =>
|
||||
Object.entries(config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== '');
|
||||
|
||||
const asciiSummary = $derived.by(() => {
|
||||
const lines: string[] = [];
|
||||
const title = workflow.name ?? $t('no_name');
|
||||
lines.push(`${title}`);
|
||||
if (workflow.description) {
|
||||
lines.push(workflow.description);
|
||||
}
|
||||
|
||||
lines.push('', ' WHEN', ` ⚡ ${getTriggerName($t, workflow.trigger)}`, '', ' THEN');
|
||||
|
||||
if (workflow.steps.length === 0) {
|
||||
lines.push(` ${$t('no_steps')}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
for (const [i, step] of workflow.steps.entries()) {
|
||||
const method = pluginManager.getMethod(step.method);
|
||||
const isFilter = method?.uiHints?.includes('filter') ?? false;
|
||||
const type = isFilter ? $t('filter') : $t('action');
|
||||
const label = pluginManager.getMethodLabel(step.method);
|
||||
lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`);
|
||||
for (const [key, value] of getConfigEntries(step.config)) {
|
||||
lines.push(` ${key} = ${formatConfigValue(value)}`);
|
||||
}
|
||||
if (i < workflow.steps.length - 1) {
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
});
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(asciiSummary);
|
||||
justCopied = true;
|
||||
if (copyTimer) {
|
||||
clearTimeout(copyTimer);
|
||||
}
|
||||
copyTimer = setTimeout(() => (justCopied = false), 1500);
|
||||
} catch {
|
||||
// ignore — clipboard may be unavailable
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={containerEl}
|
||||
class="fixed hidden w-64 select-none hover:cursor-grab sm:block"
|
||||
style="left: {position.x}px; top: {position.y}px;"
|
||||
class:cursor-grabbing={isDragging}
|
||||
onmousedown={handleMouseDown}
|
||||
<aside
|
||||
bind:this={panelElement}
|
||||
class="fixed inset-y-20 right-4 bottom-4 hidden max-w-lg flex-col overflow-hidden rounded-2xl border border-light-200 bg-light shadow-2xl sm:flex"
|
||||
transition:fly={{ x: 400, duration: 250 }}
|
||||
aria-label={$t('workflow_summary')}
|
||||
>
|
||||
<div
|
||||
class="rounded-xl border-2 border-transparent bg-light-50 p-4 shadow-sm transition-all hover:border-dashed hover:border-light-300 hover:shadow-xl"
|
||||
>
|
||||
<div class="mb-4 flex cursor-grab items-center justify-between select-none">
|
||||
<Text size="small" fontWeight="semi-bold">{$t('workflow_summary')}</Text>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton
|
||||
icon={mdiClose}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
title="Close summary"
|
||||
aria-label="Close summary"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
isOpen = false;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Trigger -->
|
||||
<div class="rounded-lg border bg-light-100 p-3">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
|
||||
<Text size="tiny" fontWeight="semi-bold">{$t('trigger')}</Text>
|
||||
</div>
|
||||
<p class="truncate pl-5 text-sm">{getTriggerName($t, trigger)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Connector -->
|
||||
<div class="flex justify-center">
|
||||
<div class="h-3 w-0.5 bg-light-400"></div>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
{#if steps.length > 0}
|
||||
<div class="rounded-lg border bg-light-100 p-3">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
|
||||
<Text size="tiny" fontWeight="semi-bold">{$t('actions')}</Text>
|
||||
</div>
|
||||
<div class="space-y-1 pl-5">
|
||||
{#each steps as step, index (index)}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-full bg-light-200 text-[10px] font-medium"
|
||||
>{index + 1}</span
|
||||
>
|
||||
<p class="truncate text-sm">{step.method}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Header -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-light-200 px-4 py-2.5">
|
||||
<Text size="small" fontWeight="semi-bold" color="muted">{$t('workflow_summary')}</Text>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton
|
||||
icon={justCopied ? mdiCheck : mdiContentCopy}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color={justCopied ? 'success' : 'secondary'}
|
||||
title={$t('copy_to_clipboard')}
|
||||
aria-label={$t('copy_to_clipboard')}
|
||||
onclick={handleCopy}
|
||||
/>
|
||||
<IconButton
|
||||
icon={mdiClose}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
title="Close summary"
|
||||
aria-label="Close summary"
|
||||
onclick={() => (isOpen = false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ASCII body — what you see is what you copy -->
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<pre
|
||||
class="m-0 overflow-auto rounded-lg border border-light-200 bg-light-100 px-4 py-3 font-mono text-xs/relaxed whitespace-pre">{asciiSummary}</pre>
|
||||
</div>
|
||||
</aside>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed right-6 bottom-6 hidden size-14 items-center justify-center rounded-full bg-primary text-light shadow-lg transition-colors hover:bg-primary/90 sm:flex"
|
||||
title={$t('workflow_summary')}
|
||||
aria-label={$t('workflow_summary')}
|
||||
onclick={() => (isOpen = true)}
|
||||
>
|
||||
<Icon icon={mdiViewDashboardOutline} size="24" />
|
||||
|
||||
Reference in New Issue
Block a user