mirror of
https://github.com/immich-app/immich.git
synced 2026-05-27 10:02:31 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79ca7ac444 | |||
| dc66892ca1 | |||
| 53a24783f5 | |||
| 0546bc900c | |||
| 7c25bcc0a7 | |||
| 7905853639 | |||
| 073dcc1fbe | |||
| ccdaa4223c | |||
| 5386b62dc4 | |||
| 9733fa4872 | |||
| 3b34c53092 |
@@ -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",
|
||||
|
||||
@@ -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
+1
-1
@@ -1004,7 +1004,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.95.2,<1.0" },
|
||||
{ name = "gunicorn", specifier = ">=21.1.0" },
|
||||
{ name = "huggingface-hub", specifier = ">=1.0,<2.0" },
|
||||
{ name = "insightface", specifier = ">=0.7.3,<1.0" },
|
||||
{ name = "insightface", specifier = ">=0.7.3,<2.0" },
|
||||
{ name = "numpy", specifier = ">=2.4.0,<3.0" },
|
||||
{ name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" },
|
||||
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.23.2,<2" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
+9
-129
@@ -207,18 +207,6 @@ enum class PlatformAssetPlaybackStyle(val raw: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
enum class EditState(val raw: Int) {
|
||||
NOT_EDITED(0),
|
||||
EDITED(1),
|
||||
UNKNOWN(2);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): EditState? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class PlatformAsset (
|
||||
val id: String,
|
||||
@@ -484,52 +472,6 @@ data class CloudIdResult (
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class BaseResource (
|
||||
val path: String,
|
||||
val sha1: String,
|
||||
val sizeBytes: Long,
|
||||
val mimeType: String
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): BaseResource {
|
||||
val path = pigeonVar_list[0] as String
|
||||
val sha1 = pigeonVar_list[1] as String
|
||||
val sizeBytes = pigeonVar_list[2] as Long
|
||||
val mimeType = pigeonVar_list[3] as String
|
||||
return BaseResource(path, sha1, sizeBytes, mimeType)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
path,
|
||||
sha1,
|
||||
sizeBytes,
|
||||
mimeType,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null || other.javaClass != javaClass) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
val other = other as BaseResource
|
||||
return MessagesPigeonUtils.deepEquals(this.path, other.path) && MessagesPigeonUtils.deepEquals(this.sha1, other.sha1) && MessagesPigeonUtils.deepEquals(this.sizeBytes, other.sizeBytes) && MessagesPigeonUtils.deepEquals(this.mimeType, other.mimeType)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = javaClass.hashCode()
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.path)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.sha1)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.sizeBytes)
|
||||
result = 31 * result + MessagesPigeonUtils.deepHash(this.mimeType)
|
||||
return result
|
||||
}
|
||||
}
|
||||
private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
@@ -539,40 +481,30 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
}
|
||||
}
|
||||
130.toByte() -> {
|
||||
return (readValue(buffer) as Long?)?.let {
|
||||
EditState.ofRaw(it.toInt())
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAsset.fromList(it)
|
||||
}
|
||||
}
|
||||
132.toByte() -> {
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
PlatformAlbum.fromList(it)
|
||||
}
|
||||
}
|
||||
133.toByte() -> {
|
||||
132.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
SyncDelta.fromList(it)
|
||||
}
|
||||
}
|
||||
134.toByte() -> {
|
||||
133.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
HashResult.fromList(it)
|
||||
}
|
||||
}
|
||||
135.toByte() -> {
|
||||
134.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
CloudIdResult.fromList(it)
|
||||
}
|
||||
}
|
||||
136.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
BaseResource.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
@@ -582,32 +514,24 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(129)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is EditState -> {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.raw.toLong())
|
||||
}
|
||||
is PlatformAsset -> {
|
||||
stream.write(131)
|
||||
stream.write(130)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is PlatformAlbum -> {
|
||||
stream.write(132)
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is SyncDelta -> {
|
||||
stream.write(133)
|
||||
stream.write(132)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is HashResult -> {
|
||||
stream.write(134)
|
||||
stream.write(133)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is CloudIdResult -> {
|
||||
stream.write(135)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is BaseResource -> {
|
||||
stream.write(136)
|
||||
stream.write(134)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
@@ -631,8 +555,6 @@ interface NativeSyncApi {
|
||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit)
|
||||
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit)
|
||||
|
||||
companion object {
|
||||
/** The codec used by NativeSyncApi. */
|
||||
@@ -864,48 +786,6 @@ interface NativeSyncApi {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getBaseResource(assetIdArg, allowNetworkAccessArg) { result: Result<BaseResource?> ->
|
||||
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.getEditState$separatedMessageChannelSuffix", codec, taskQueue)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val assetIdArg = args[0] as String
|
||||
val allowNetworkAccessArg = args[1] as Boolean
|
||||
api.getEditState(assetIdArg, allowNetworkAccessArg) { result: Result<EditState> ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,14 +476,4 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
|
||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Android has no Photos-style edit original to stack; iOS-only.
|
||||
fun getBaseResource(assetId: String, allowNetworkAccess: Boolean, callback: (Result<BaseResource?>) -> Unit) {
|
||||
callback(Result.success(null))
|
||||
}
|
||||
|
||||
// iOS-only; Android assets never carry a Photos-style edit.
|
||||
fun getEditState(assetId: String, allowNetworkAccess: Boolean, callback: (Result<EditState>) -> Unit) {
|
||||
callback(Result.success(EditState.NOT_EDITED))
|
||||
}
|
||||
}
|
||||
|
||||
-3411
File diff suppressed because it is too large
Load Diff
Generated
+9
-117
@@ -183,12 +183,6 @@ enum PlatformAssetPlaybackStyle: Int {
|
||||
case videoLooping = 5
|
||||
}
|
||||
|
||||
enum EditState: Int {
|
||||
case notEdited = 0
|
||||
case edited = 1
|
||||
case unknown = 2
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct PlatformAsset: Hashable {
|
||||
var id: String
|
||||
@@ -464,52 +458,6 @@ struct CloudIdResult: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generated class from Pigeon that represents data sent in messages.
|
||||
struct BaseResource: Hashable {
|
||||
var path: String
|
||||
var sha1: String
|
||||
var sizeBytes: Int64
|
||||
var mimeType: String
|
||||
|
||||
|
||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||
static func fromList(_ pigeonVar_list: [Any?]) -> BaseResource? {
|
||||
let path = pigeonVar_list[0] as! String
|
||||
let sha1 = pigeonVar_list[1] as! String
|
||||
let sizeBytes = pigeonVar_list[2] as! Int64
|
||||
let mimeType = pigeonVar_list[3] as! String
|
||||
|
||||
return BaseResource(
|
||||
path: path,
|
||||
sha1: sha1,
|
||||
sizeBytes: sizeBytes,
|
||||
mimeType: mimeType
|
||||
)
|
||||
}
|
||||
func toList() -> [Any?] {
|
||||
return [
|
||||
path,
|
||||
sha1,
|
||||
sizeBytes,
|
||||
mimeType,
|
||||
]
|
||||
}
|
||||
static func == (lhs: BaseResource, rhs: BaseResource) -> Bool {
|
||||
if Swift.type(of: lhs) != Swift.type(of: rhs) {
|
||||
return false
|
||||
}
|
||||
return deepEqualsMessages(lhs.path, rhs.path) && deepEqualsMessages(lhs.sha1, rhs.sha1) && deepEqualsMessages(lhs.sizeBytes, rhs.sizeBytes) && deepEqualsMessages(lhs.mimeType, rhs.mimeType)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine("BaseResource")
|
||||
deepHashMessages(value: path, hasher: &hasher)
|
||||
deepHashMessages(value: sha1, hasher: &hasher)
|
||||
deepHashMessages(value: sizeBytes, hasher: &hasher)
|
||||
deepHashMessages(value: mimeType, hasher: &hasher)
|
||||
}
|
||||
}
|
||||
|
||||
private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
override func readValue(ofType type: UInt8) -> Any? {
|
||||
switch type {
|
||||
@@ -520,23 +468,15 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
||||
}
|
||||
return nil
|
||||
case 130:
|
||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
||||
if let enumResultAsInt = enumResultAsInt {
|
||||
return EditState(rawValue: enumResultAsInt)
|
||||
}
|
||||
return nil
|
||||
case 131:
|
||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||
case 132:
|
||||
case 131:
|
||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||
case 133:
|
||||
case 132:
|
||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||
case 134:
|
||||
case 133:
|
||||
return HashResult.fromList(self.readValue() as! [Any?])
|
||||
case 135:
|
||||
case 134:
|
||||
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||
case 136:
|
||||
return BaseResource.fromList(self.readValue() as! [Any?])
|
||||
default:
|
||||
return super.readValue(ofType: type)
|
||||
}
|
||||
@@ -548,26 +488,20 @@ private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
||||
if let value = value as? PlatformAssetPlaybackStyle {
|
||||
super.writeByte(129)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? EditState {
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.rawValue)
|
||||
} else if let value = value as? PlatformAsset {
|
||||
super.writeByte(131)
|
||||
super.writeByte(130)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? PlatformAlbum {
|
||||
super.writeByte(132)
|
||||
super.writeByte(131)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? SyncDelta {
|
||||
super.writeByte(133)
|
||||
super.writeByte(132)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? HashResult {
|
||||
super.writeByte(134)
|
||||
super.writeByte(133)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? CloudIdResult {
|
||||
super.writeByte(135)
|
||||
super.writeValue(value.toList())
|
||||
} else if let value = value as? BaseResource {
|
||||
super.writeByte(136)
|
||||
super.writeByte(134)
|
||||
super.writeValue(value.toList())
|
||||
} else {
|
||||
super.writeValue(value)
|
||||
@@ -605,8 +539,6 @@ protocol NativeSyncApi {
|
||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||
func getBaseResource(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<BaseResource?, Error>) -> Void)
|
||||
func getEditState(assetId: String, allowNetworkAccess: Bool, completion: @escaping (Result<EditState, Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||
@@ -825,45 +757,5 @@ class NativeSyncApiSetup {
|
||||
} else {
|
||||
getCloudIdForAssetIdsChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getBaseResourceChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getBaseResourceChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getBaseResource(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getBaseResourceChannel.setMessageHandler(nil)
|
||||
}
|
||||
let getEditStateChannel = taskQueue == nil
|
||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||
if let api = api {
|
||||
getEditStateChannel.setMessageHandler { message, reply in
|
||||
let args = message as! [Any?]
|
||||
let assetIdArg = args[0] as! String
|
||||
let allowNetworkAccessArg = args[1] as! Bool
|
||||
api.getEditState(assetId: assetIdArg, allowNetworkAccess: allowNetworkAccessArg) { result in
|
||||
switch result {
|
||||
case .success(let res):
|
||||
reply(wrapResult(res))
|
||||
case .failure(let error):
|
||||
reply(wrapError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
getEditStateChannel.setMessageHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Photos
|
||||
import CryptoKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct AssetWrapper: Hashable, Equatable {
|
||||
let asset: PlatformAsset
|
||||
@@ -420,169 +419,4 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
}
|
||||
return mappings;
|
||||
}
|
||||
|
||||
func getBaseResource(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<BaseResource?, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
|
||||
let resources = PHAssetResource.assetResources(for: asset)
|
||||
let state = await Self.classifyEdit(resources: resources, allowNetworkAccess: allowNetworkAccess)
|
||||
guard state == .edited, let original = resources.first(where: { $0.type == .photo }) else {
|
||||
return self.completeWhenActive(for: completion, with: .success(nil))
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await self.streamBaseResource(
|
||||
resource: original,
|
||||
localId: asset.localIdentifier,
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(result))
|
||||
} catch {
|
||||
self.completeWhenActive(for: completion, with: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns whether the asset carries a live Photos edit without reading the photo
|
||||
// itself, only the small adjustment metadata. The revert probe relies on this to
|
||||
// tell "not edited" apart from "couldn't read" (offloaded to iCloud), so it never
|
||||
// mistakes an unreadable edit for a revert.
|
||||
func getEditState(
|
||||
assetId: String,
|
||||
allowNetworkAccess: Bool,
|
||||
completion: @escaping (Result<EditState, Error>) -> Void
|
||||
) {
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil).firstObject else {
|
||||
// Not in the library, so don't answer "not edited" (the caller acts on that).
|
||||
return self.completeWhenActive(for: completion, with: .success(.unknown))
|
||||
}
|
||||
let state = await Self.classifyEdit(
|
||||
resources: PHAssetResource.assetResources(for: asset),
|
||||
allowNetworkAccess: allowNetworkAccess
|
||||
)
|
||||
self.completeWhenActive(for: completion, with: .success(state))
|
||||
}
|
||||
}
|
||||
|
||||
// adjustmentRenderTypes for a photo with no real edit: a plain capture, a
|
||||
// Photographic Style, or a reverted edit. A real edit changes this value.
|
||||
private static let kNoEditRenderTypes = 27648
|
||||
|
||||
// Works out the edit state from Adjustments.plist only (never reads the photo).
|
||||
// adjustmentRenderTypes is the signal: a real edit moves it off the baseline, while a
|
||||
// plain capture, a Photographic Style, and a reverted edit all sit at the baseline. The
|
||||
// editor id is NOT reliable: com.apple.camera authors both styles and some real edits
|
||||
// (e.g. changing the Photographic Style after capture), so we key off the render types
|
||||
// alone. Cleanup and object-removal write AdjustmentsSecondary.data, which we count as
|
||||
// edited. unknown = couldn't read the plist (offloaded, no network).
|
||||
private static func classifyEdit(resources: [PHAssetResource], allowNetworkAccess: Bool) async -> EditState {
|
||||
if resources.contains(where: { $0.originalFilename == "AdjustmentsSecondary.data" }) {
|
||||
return .edited
|
||||
}
|
||||
guard let adjRes = resources.first(where: { $0.originalFilename == "Adjustments.plist" }) else {
|
||||
return .notEdited
|
||||
}
|
||||
guard let buf = await collectResourceData(adjRes, allowNetworkAccess: allowNetworkAccess),
|
||||
let plist = try? PropertyListSerialization.propertyList(from: buf, options: [], format: nil) as? [String: Any]
|
||||
else {
|
||||
return .unknown
|
||||
}
|
||||
let renderTypes = (plist["adjustmentRenderTypes"] as? NSNumber)?.intValue
|
||||
let isUserEdit = renderTypes != nil && renderTypes != kNoEditRenderTypes
|
||||
return isUserEdit ? .edited : .notEdited
|
||||
}
|
||||
|
||||
private func streamBaseResource(
|
||||
resource: PHAssetResource,
|
||||
localId: String,
|
||||
allowNetworkAccess: Bool
|
||||
) async throws -> BaseResource {
|
||||
let safeId = localId.replacingOccurrences(of: "/", with: "_")
|
||||
let suffix = UTType(resource.uniformTypeIdentifier)?.preferredFilenameExtension ?? "bin"
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
.appendingPathComponent("immich_base", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
|
||||
let unique = UUID().uuidString.prefix(8)
|
||||
let tempUrl = tempDir.appendingPathComponent("\(safeId)_\(unique)_base.\(suffix)")
|
||||
|
||||
// Write the resource to disk and hash it chunk by chunk, so a big original (e.g.
|
||||
// ProRAW) never sits fully in memory on the upload thread.
|
||||
FileManager.default.createFile(atPath: tempUrl.path, contents: nil)
|
||||
guard let handle = try? FileHandle(forWritingTo: tempUrl) else {
|
||||
throw NSError(
|
||||
domain: "NativeSyncApi",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to open temp file for base resource \(localId)"]
|
||||
)
|
||||
}
|
||||
|
||||
var hasher = Insecure.SHA1()
|
||||
var totalBytes: Int64 = 0
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
|
||||
let succeeded = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
|
||||
var writeFailed = false
|
||||
PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { chunk in
|
||||
if writeFailed { return }
|
||||
do {
|
||||
try handle.write(contentsOf: chunk)
|
||||
hasher.update(data: chunk)
|
||||
totalBytes += Int64(chunk.count)
|
||||
} catch {
|
||||
writeFailed = true
|
||||
}
|
||||
},
|
||||
completionHandler: { error in continuation.resume(returning: error == nil && !writeFailed) }
|
||||
)
|
||||
}
|
||||
|
||||
try? handle.close()
|
||||
|
||||
guard succeeded else {
|
||||
try? FileManager.default.removeItem(at: tempUrl)
|
||||
throw NSError(
|
||||
domain: "NativeSyncApi",
|
||||
code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to read base resource for \(localId)"]
|
||||
)
|
||||
}
|
||||
|
||||
let sha1 = Data(hasher.finalize()).base64EncodedString()
|
||||
let mime = UTType(resource.uniformTypeIdentifier)?.preferredMIMEType ?? "application/octet-stream"
|
||||
return BaseResource(path: tempUrl.path, sha1: sha1, sizeBytes: totalBytes, mimeType: mime)
|
||||
}
|
||||
|
||||
private static func collectResourceData(
|
||||
_ resource: PHAssetResource,
|
||||
allowNetworkAccess: Bool
|
||||
) async -> Data? {
|
||||
let options = PHAssetResourceRequestOptions()
|
||||
options.isNetworkAccessAllowed = allowNetworkAccess
|
||||
var buffer = Data()
|
||||
return await withCheckedContinuation { (continuation: CheckedContinuation<Data?, Never>) in
|
||||
PHAssetResourceManager.default().requestData(
|
||||
for: resource,
|
||||
options: options,
|
||||
dataReceivedHandler: { data in buffer.append(data) },
|
||||
completionHandler: { error in continuation.resume(returning: error == nil ? buffer : nil) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ const String kSecuredPinCode = "secured_pin_code";
|
||||
const String kManualUploadGroup = 'manual_upload_group';
|
||||
const String kBackupGroup = 'backup_group';
|
||||
const String kBackupLivePhotoGroup = 'backup_live_photo_group';
|
||||
const String kBackupEditPairGroup = 'backup_edit_pair_group';
|
||||
const String kDownloadGroupImage = 'group_image';
|
||||
const String kDownloadGroupVideo = 'group_video';
|
||||
const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||
|
||||
@@ -12,13 +12,6 @@ class LocalAsset extends BaseAsset {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
|
||||
// Remote id of this asset's previous upload; used to stack a new edit under it.
|
||||
final String? priorRemoteId;
|
||||
|
||||
// Local checksum at the last sync action; lets backup skip an already-handled
|
||||
// local whose current render hashes fresh (the iOS revert case).
|
||||
final String? syncedChecksum;
|
||||
|
||||
const LocalAsset({
|
||||
required this.id,
|
||||
String? remoteId,
|
||||
@@ -39,8 +32,6 @@ class LocalAsset extends BaseAsset {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required super.isEdited,
|
||||
this.priorRemoteId,
|
||||
this.syncedChecksum,
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
@@ -129,8 +120,6 @@ class LocalAsset extends BaseAsset {
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
bool? isEdited,
|
||||
String? priorRemoteId,
|
||||
String? syncedChecksum,
|
||||
}) {
|
||||
return LocalAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -151,8 +140,6 @@ class LocalAsset extends BaseAsset {
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Handles an edit that was reverted in Photos. The local was uploaded as an edit
|
||||
/// before but isn't edited now, so flip the stack primary back to the original (via
|
||||
/// prior_remote_id) and mark it handled so we don't re-upload the reverted render.
|
||||
/// Nothing is trashed; all the edits stay in the stack.
|
||||
class EditRevertService {
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final _log = Logger('EditRevertService');
|
||||
|
||||
EditRevertService({
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
required DriftStackRepository stackRepository,
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required AssetApiRepository assetApiRepository,
|
||||
}) : _nativeSyncApi = nativeSyncApi,
|
||||
_stackRepository = stackRepository,
|
||||
_localAssetRepository = localAssetRepository,
|
||||
_assetApiRepository = assetApiRepository;
|
||||
|
||||
/// Returns true if the asset was a revert and was handled (caller skips the
|
||||
/// upload); false to fall through to the normal upload path.
|
||||
Future<bool> tryHandleRevert(LocalAsset asset) async {
|
||||
if (asset.priorRemoteId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only "not edited" is a revert. `edited` is a fresh edit, so let the pair flow
|
||||
// take it. `unknown` means we couldn't read the adjustment (offloaded to iCloud,
|
||||
// network off); bail there too instead of mistaking an unreadable edit for a
|
||||
// revert and flipping the stack. Network off keeps this a cheap offline read.
|
||||
try {
|
||||
final editState = await _nativeSyncApi.getEditState(asset.id, allowNetworkAccess: false);
|
||||
if (editState != EditState.notEdited) {
|
||||
return false;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.warning("edit-state probe failed for ${asset.id}", error, stack);
|
||||
return false;
|
||||
}
|
||||
|
||||
// It's a revert. Styled photos hit this path because iOS re-encodes the revert to
|
||||
// fresh bytes, so it looks like a new backup candidate and reaches upload.
|
||||
// Non-styled reverts hash back to the base instead, aren't candidates, and get
|
||||
// flipped at hash time in HashService._reconcileReverts. Fresh bytes match nothing
|
||||
// remote, so flip by structure: prior_remote_id is the current primary (the latest
|
||||
// edit), flip it back to the base.
|
||||
final String stackId;
|
||||
final String baseId;
|
||||
try {
|
||||
final foundStack = await _stackRepository.findStackIdByRemoteId(asset.priorRemoteId!);
|
||||
if (foundStack == null) {
|
||||
return false;
|
||||
}
|
||||
final base = await _stackRepository.findStackBaseId(foundStack, excludeId: asset.priorRemoteId!);
|
||||
if (base == null) {
|
||||
return false;
|
||||
}
|
||||
stackId = foundStack;
|
||||
baseId = base;
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert stack lookup failed for ${asset.id}", error, stack);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await _assetApiRepository.setStackPrimary(stackId, baseId);
|
||||
await _stackRepository.setPrimary(stackId, baseId);
|
||||
await _localAssetRepository.markSynced(asset.id, priorRemoteId: baseId, syncedChecksum: asset.checksum ?? '');
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert primary flip failed for ${asset.id}", error, stack);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,8 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.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/stack.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/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
const String _kHashCancelledCode = "HASH_CANCELLED";
|
||||
@@ -19,8 +17,6 @@ class HashService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftStackRepository _stackRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
final bool Function()? _cancelChecker;
|
||||
final _log = Logger('HashService');
|
||||
|
||||
@@ -29,8 +25,6 @@ class HashService {
|
||||
required DriftLocalAssetRepository localAssetRepository,
|
||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||
required NativeSyncApi nativeSyncApi,
|
||||
required DriftStackRepository stackRepository,
|
||||
required AssetApiRepository assetApiRepository,
|
||||
bool Function()? cancelChecker,
|
||||
int? batchSize,
|
||||
}) : _localAlbumRepository = localAlbumRepository,
|
||||
@@ -38,8 +32,6 @@ class HashService {
|
||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||
_cancelChecker = cancelChecker,
|
||||
_nativeSyncApi = nativeSyncApi,
|
||||
_stackRepository = stackRepository,
|
||||
_assetApiRepository = assetApiRepository,
|
||||
_batchSize = batchSize ?? kBatchHashFileLimit;
|
||||
|
||||
bool get isCancelled => _cancelChecker?.call() ?? false;
|
||||
@@ -53,7 +45,6 @@ class HashService {
|
||||
|
||||
// Sorted by backupSelection followed by isCloud
|
||||
final localAlbums = await _localAlbumRepository.getBackupAlbums();
|
||||
final hashedIds = <String>{};
|
||||
|
||||
for (final album in localAlbums) {
|
||||
if (isCancelled) {
|
||||
@@ -63,7 +54,7 @@ class HashService {
|
||||
|
||||
final assetsToHash = await _localAlbumRepository.getAssetsToHash(album.id);
|
||||
if (assetsToHash.isNotEmpty) {
|
||||
await _hashAssets(album, assetsToHash, hashedIds: hashedIds);
|
||||
await _hashAssets(album, assetsToHash);
|
||||
}
|
||||
}
|
||||
if (CurrentPlatform.isAndroid && localAlbums.isNotEmpty) {
|
||||
@@ -71,18 +62,9 @@ class HashService {
|
||||
final trashedToHash = await _trashedLocalAssetRepository.getAssetsToHash(backupAlbumIds);
|
||||
if (trashedToHash.isNotEmpty) {
|
||||
final pseudoAlbum = LocalAlbum(id: '-pseudoAlbum', name: 'Trash', updatedAt: DateTime.now());
|
||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true, hashedIds: hashedIds);
|
||||
await _hashAssets(pseudoAlbum, trashedToHash, isTrashed: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Revert reconcile for non-styled photos: the reverted edit hashes back to the
|
||||
// original's exact bytes, which are already the stack base, so it's not a backup
|
||||
// candidate and never reaches upload. Flip the primary here. Styled photos
|
||||
// re-encode to fresh bytes and get flipped on the upload path instead
|
||||
// (EditRevertService.tryHandleRevert).
|
||||
if (CurrentPlatform.isIOS && hashedIds.isNotEmpty && !isCancelled) {
|
||||
await _reconcileReverts(hashedIds);
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
if (e.code == _kHashCancelledCode) {
|
||||
_log.warning("Hashing cancelled by platform");
|
||||
@@ -99,12 +81,7 @@ class HashService {
|
||||
/// Processes a list of [LocalAsset]s, storing their hash and updating the assets in the DB
|
||||
/// with hash for those that were successfully hashed. Hashes are looked up in a table
|
||||
/// [LocalAssetHashEntity] by local id. Only missing entries are newly hashed and added to the DB.
|
||||
Future<void> _hashAssets(
|
||||
LocalAlbum album,
|
||||
List<LocalAsset> assetsToHash, {
|
||||
bool isTrashed = false,
|
||||
required Set<String> hashedIds,
|
||||
}) async {
|
||||
Future<void> _hashAssets(LocalAlbum album, List<LocalAsset> assetsToHash, {bool isTrashed = false}) async {
|
||||
final toHash = <String, LocalAsset>{};
|
||||
|
||||
for (final asset in assetsToHash) {
|
||||
@@ -115,21 +92,16 @@ class HashService {
|
||||
|
||||
toHash[asset.id] = asset;
|
||||
if (toHash.length == _batchSize) {
|
||||
await _processBatch(album, toHash, isTrashed, hashedIds);
|
||||
await _processBatch(album, toHash, isTrashed);
|
||||
toHash.clear();
|
||||
}
|
||||
}
|
||||
|
||||
await _processBatch(album, toHash, isTrashed, hashedIds);
|
||||
await _processBatch(album, toHash, isTrashed);
|
||||
}
|
||||
|
||||
/// Processes a batch of assets.
|
||||
Future<void> _processBatch(
|
||||
LocalAlbum album,
|
||||
Map<String, LocalAsset> toHash,
|
||||
bool isTrashed,
|
||||
Set<String> hashedIds,
|
||||
) async {
|
||||
Future<void> _processBatch(LocalAlbum album, Map<String, LocalAsset> toHash, bool isTrashed) async {
|
||||
if (toHash.isEmpty) {
|
||||
return;
|
||||
}
|
||||
@@ -169,33 +141,5 @@ class HashService {
|
||||
} else {
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
}
|
||||
hashedIds.addAll(hashed.keys);
|
||||
}
|
||||
|
||||
Future<void> _reconcileReverts(Set<String> localIds) async {
|
||||
final List<StackReconcileTarget> targets;
|
||||
try {
|
||||
targets = await _stackRepository.findRevertReconcileTargets(localIds);
|
||||
} catch (error, stack) {
|
||||
_log.warning("findRevertReconcileTargets failed", error, stack);
|
||||
return;
|
||||
}
|
||||
|
||||
for (final target in targets) {
|
||||
try {
|
||||
await _assetApiRepository.setStackPrimary(target.stackId, target.newPrimaryId);
|
||||
await _stackRepository.setPrimary(target.stackId, target.newPrimaryId);
|
||||
// Roll priorRemoteId forward to the matched member (now the primary) so a
|
||||
// later edit stacks onto THAT (the current render), not the old edit.
|
||||
await _localAssetRepository.markSynced(
|
||||
target.localAssetId,
|
||||
priorRemoteId: target.newPrimaryId,
|
||||
syncedChecksum: target.localAssetChecksum,
|
||||
);
|
||||
} catch (error, stack) {
|
||||
_log.warning("revert reconcile flip failed for stack ${target.stackId}", error, stack);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,6 @@ class BackgroundSyncManager {
|
||||
} on CanceledError {
|
||||
// Ignore cancellation errors
|
||||
}
|
||||
|
||||
// Stop the local-sync and hash slots too. The revert reconcile runs in the hash
|
||||
// task and shouldn't outlive the session.
|
||||
await cancelLocal();
|
||||
}
|
||||
|
||||
Future<void> cancelLocal() async {
|
||||
@@ -190,22 +186,6 @@ class BackgroundSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs a remote sync guaranteed to observe changes up to now. [syncRemote]
|
||||
/// joins an in-flight sync whose snapshot can pre-date a just-received change
|
||||
/// (e.g. a stack update) and miss it, so wait for any in-flight sync to finish
|
||||
/// first, then run a fresh one.
|
||||
Future<void> runFreshRemoteSync() async {
|
||||
final inflight = _syncTask;
|
||||
if (inflight != null) {
|
||||
try {
|
||||
await inflight.future;
|
||||
} catch (_) {
|
||||
// The in-flight sync's outcome doesn't matter; we only need a fresh one after it.
|
||||
}
|
||||
}
|
||||
await syncRemote();
|
||||
}
|
||||
|
||||
Future<void> syncWebsocketBatchV1(List<dynamic> batchData) {
|
||||
if (_syncWebsocketTask != null) {
|
||||
return _syncWebsocketTask!.future;
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)')
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)')
|
||||
class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
const LocalAssetEntity();
|
||||
|
||||
@@ -28,14 +27,6 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
|
||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
// remote id of the previous upload (iOS edit-pair stacking)
|
||||
TextColumn get priorRemoteId => text().nullable()();
|
||||
|
||||
// local checksum at the last sync action. Lets the backup query skip a local
|
||||
// whose current hash matches nothing remote but is still "handled": the iOS
|
||||
// revert case, where the reverted render hashes fresh but is already reconciled.
|
||||
TextColumn get syncedChecksum => text().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -60,7 +51,5 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
longitude: longitude,
|
||||
cloudId: iCloudId,
|
||||
isEdited: false,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
);
|
||||
}
|
||||
|
||||
+3
-155
@@ -26,8 +26,6 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
i0.Value<String?> priorRemoteId,
|
||||
i0.Value<String?> syncedChecksum,
|
||||
});
|
||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.LocalAssetEntityCompanion Function({
|
||||
@@ -47,8 +45,6 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
i0.Value<String?> priorRemoteId,
|
||||
i0.Value<String?> syncedChecksum,
|
||||
});
|
||||
|
||||
class $$LocalAssetEntityTableFilterComposer
|
||||
@@ -145,16 +141,6 @@ class $$LocalAssetEntityTableFilterComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnFilters<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableOrderingComposer
|
||||
@@ -245,16 +231,6 @@ class $$LocalAssetEntityTableOrderingComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableAnnotationComposer
|
||||
@@ -324,16 +300,6 @@ class $$LocalAssetEntityTableAnnotationComposer
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<String> get priorRemoteId => $composableBuilder(
|
||||
column: $table.priorRemoteId,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
i0.GeneratedColumn<String> get syncedChecksum => $composableBuilder(
|
||||
column: $table.syncedChecksum,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableTableManager
|
||||
@@ -393,8 +359,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -412,8 +376,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -434,8 +396,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -453,8 +413,6 @@ class $$LocalAssetEntityTableTableManager
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
priorRemoteId: priorRemoteId,
|
||||
syncedChecksum: syncedChecksum,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -679,28 +637,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
).withConverter<i2.AssetPlaybackStyle>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle,
|
||||
);
|
||||
static const i0.VerificationMeta _priorRemoteIdMeta =
|
||||
const i0.VerificationMeta('priorRemoteId');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> priorRemoteId =
|
||||
i0.GeneratedColumn<String>(
|
||||
'prior_remote_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
static const i0.VerificationMeta _syncedChecksumMeta =
|
||||
const i0.VerificationMeta('syncedChecksum');
|
||||
@override
|
||||
late final i0.GeneratedColumn<String> syncedChecksum =
|
||||
i0.GeneratedColumn<String>(
|
||||
'synced_checksum',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i0.DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
@@ -719,8 +655,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
priorRemoteId,
|
||||
syncedChecksum,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -825,24 +759,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('prior_remote_id')) {
|
||||
context.handle(
|
||||
_priorRemoteIdMeta,
|
||||
priorRemoteId.isAcceptableOrUnknown(
|
||||
data['prior_remote_id']!,
|
||||
_priorRemoteIdMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('synced_checksum')) {
|
||||
context.handle(
|
||||
_syncedChecksumMeta,
|
||||
syncedChecksum.isAcceptableOrUnknown(
|
||||
data['synced_checksum']!,
|
||||
_syncedChecksumMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -923,14 +839,6 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
data['${effectivePrefix}playback_style'],
|
||||
)!,
|
||||
),
|
||||
priorRemoteId: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}prior_remote_id'],
|
||||
),
|
||||
syncedChecksum: attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.string,
|
||||
data['${effectivePrefix}synced_checksum'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -969,8 +877,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final i2.AssetPlaybackStyle playbackStyle;
|
||||
final String? priorRemoteId;
|
||||
final String? syncedChecksum;
|
||||
const LocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -988,8 +894,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
this.priorRemoteId,
|
||||
this.syncedChecksum,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -1034,12 +938,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
|
||||
);
|
||||
}
|
||||
if (!nullToAbsent || priorRemoteId != null) {
|
||||
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId);
|
||||
}
|
||||
if (!nullToAbsent || syncedChecksum != null) {
|
||||
map['synced_checksum'] = i0.Variable<String>(syncedChecksum);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1069,8 +967,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
||||
serializer.fromJson<int>(json['playbackStyle']),
|
||||
),
|
||||
priorRemoteId: serializer.fromJson<String?>(json['priorRemoteId']),
|
||||
syncedChecksum: serializer.fromJson<String?>(json['syncedChecksum']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -1097,8 +993,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
'playbackStyle': serializer.toJson<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
||||
),
|
||||
'priorRemoteId': serializer.toJson<String?>(priorRemoteId),
|
||||
'syncedChecksum': serializer.toJson<String?>(syncedChecksum),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1119,8 +1013,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i2.AssetPlaybackStyle? playbackStyle,
|
||||
i0.Value<String?> priorRemoteId = const i0.Value.absent(),
|
||||
i0.Value<String?> syncedChecksum = const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -1140,12 +1032,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
latitude: latitude.present ? latitude.value : this.latitude,
|
||||
longitude: longitude.present ? longitude.value : this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
priorRemoteId: priorRemoteId.present
|
||||
? priorRemoteId.value
|
||||
: this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum.present
|
||||
? syncedChecksum.value
|
||||
: this.syncedChecksum,
|
||||
);
|
||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||
return LocalAssetEntityData(
|
||||
@@ -1175,12 +1061,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
playbackStyle: data.playbackStyle.present
|
||||
? data.playbackStyle.value
|
||||
: this.playbackStyle,
|
||||
priorRemoteId: data.priorRemoteId.present
|
||||
? data.priorRemoteId.value
|
||||
: this.priorRemoteId,
|
||||
syncedChecksum: data.syncedChecksum.present
|
||||
? data.syncedChecksum.value
|
||||
: this.syncedChecksum,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1202,9 +1082,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle, ')
|
||||
..write('priorRemoteId: $priorRemoteId, ')
|
||||
..write('syncedChecksum: $syncedChecksum')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1227,8 +1105,6 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
priorRemoteId,
|
||||
syncedChecksum,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1249,9 +1125,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
other.adjustmentTime == this.adjustmentTime &&
|
||||
other.latitude == this.latitude &&
|
||||
other.longitude == this.longitude &&
|
||||
other.playbackStyle == this.playbackStyle &&
|
||||
other.priorRemoteId == this.priorRemoteId &&
|
||||
other.syncedChecksum == this.syncedChecksum);
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
}
|
||||
|
||||
class LocalAssetEntityCompanion
|
||||
@@ -1272,8 +1146,6 @@ class LocalAssetEntityCompanion
|
||||
final i0.Value<double?> latitude;
|
||||
final i0.Value<double?> longitude;
|
||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||
final i0.Value<String?> priorRemoteId;
|
||||
final i0.Value<String?> syncedChecksum;
|
||||
const LocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -1291,8 +1163,6 @@ class LocalAssetEntityCompanion
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
this.priorRemoteId = const i0.Value.absent(),
|
||||
this.syncedChecksum = const i0.Value.absent(),
|
||||
});
|
||||
LocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -1311,8 +1181,6 @@ class LocalAssetEntityCompanion
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
this.priorRemoteId = const i0.Value.absent(),
|
||||
this.syncedChecksum = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id);
|
||||
@@ -1333,8 +1201,6 @@ class LocalAssetEntityCompanion
|
||||
i0.Expression<double>? latitude,
|
||||
i0.Expression<double>? longitude,
|
||||
i0.Expression<int>? playbackStyle,
|
||||
i0.Expression<String>? priorRemoteId,
|
||||
i0.Expression<String>? syncedChecksum,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -1353,8 +1219,6 @@ class LocalAssetEntityCompanion
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
if (priorRemoteId != null) 'prior_remote_id': priorRemoteId,
|
||||
if (syncedChecksum != null) 'synced_checksum': syncedChecksum,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1375,8 +1239,6 @@ class LocalAssetEntityCompanion
|
||||
i0.Value<double?>? latitude,
|
||||
i0.Value<double?>? longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||
i0.Value<String?>? priorRemoteId,
|
||||
i0.Value<String?>? syncedChecksum,
|
||||
}) {
|
||||
return i1.LocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -1395,8 +1257,6 @@ class LocalAssetEntityCompanion
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
priorRemoteId: priorRemoteId ?? this.priorRemoteId,
|
||||
syncedChecksum: syncedChecksum ?? this.syncedChecksum,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1457,12 +1317,6 @@ class LocalAssetEntityCompanion
|
||||
),
|
||||
);
|
||||
}
|
||||
if (priorRemoteId.present) {
|
||||
map['prior_remote_id'] = i0.Variable<String>(priorRemoteId.value);
|
||||
}
|
||||
if (syncedChecksum.present) {
|
||||
map['synced_checksum'] = i0.Variable<String>(syncedChecksum.value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1484,9 +1338,7 @@ class LocalAssetEntityCompanion
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle, ')
|
||||
..write('priorRemoteId: $priorRemoteId, ')
|
||||
..write('syncedChecksum: $syncedChecksum')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1496,7 +1348,3 @@ i0.Index get idxLocalAssetCloudId => i0.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
i0.Index get idxLocalAssetPriorRemoteId => i0.Index(
|
||||
'idx_local_asset_prior_remote_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||
);
|
||||
|
||||
@@ -7,13 +7,7 @@ import 'local_album_asset.entity.dart';
|
||||
mergedAsset:
|
||||
SELECT
|
||||
rae.id as remote_id,
|
||||
-- local_id links a remote to its on-device copy, normally by checksum. A reverted iOS
|
||||
-- edit re-encodes to fresh bytes so the checksum no longer matches, but its
|
||||
-- prior_remote_id still points at this remote, so fall back to that.
|
||||
COALESCE(
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1),
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.prior_remote_id = rae.id LIMIT 1)
|
||||
) as local_id,
|
||||
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,
|
||||
rae.name,
|
||||
rae."type",
|
||||
rae.created_at as created_at,
|
||||
@@ -89,13 +83,6 @@ AND NOT EXISTS (
|
||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||
)
|
||||
-- iOS edit-in-progress / revert: if this local was already uploaded (its
|
||||
-- prior_remote_id resolves to a live remote), hide the local tile so the remote
|
||||
-- (the edit, or the flipped-back original) is the single source of truth. Kills
|
||||
-- the transient 2-tile flicker and stops a reverted local from re-appearing.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids AND rae.deleted_at IS NULL
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $limit;
|
||||
|
||||
@@ -149,10 +136,6 @@ FROM
|
||||
INNER JOIN local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id AND la.backup_selection = 2 -- excluded
|
||||
)
|
||||
-- iOS edit-in-progress / revert: hide a local already represented by a live remote.
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM remote_asset_entity rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN :user_ids AND rae.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
GROUP BY bucket_date
|
||||
ORDER BY bucket_date DESC;
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
);
|
||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||
return customSelect(
|
||||
'SELECT rae.id AS remote_id, COALESCE((SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1), (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.prior_remote_id = rae.id LIMIT 1)) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds) AND rae.deleted_at IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
variables: [
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
...generatedlimit.introducedVariables,
|
||||
@@ -81,7 +81,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
|
||||
$arrayStartIndex += userIds.length;
|
||||
return customSelect(
|
||||
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) AND NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.id = lae.prior_remote_id AND rae.owner_id IN ($expandeduserIds) AND rae.deleted_at IS NULL)) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||
'SELECT COUNT(*) AS asset_count, bucket_date FROM (SELECT CASE WHEN ?1 = 0 THEN COALESCE(STRFTIME(\'%Y-%m-%d\', rae.local_date_time), STRFTIME(\'%Y-%m-%d\', rae.created_at, \'localtime\')) WHEN ?1 = 1 THEN COALESCE(STRFTIME(\'%Y-%m\', rae.local_date_time), STRFTIME(\'%Y-%m\', rae.created_at, \'localtime\')) END AS bucket_date FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', lae.created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', lae.created_at, \'localtime\') END AS bucket_date FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
|
||||
variables: [
|
||||
i0.Variable<int>(groupBy),
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
|
||||
@@ -58,8 +58,7 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
INNER JOIN main.local_album_entity la on laa.album_id = la.id
|
||||
WHERE laa.asset_id = lae.id
|
||||
AND la.backup_selection = ?3
|
||||
)
|
||||
AND (lae.synced_checksum IS NULL OR lae.synced_checksum != lae.checksum);
|
||||
);
|
||||
''';
|
||||
|
||||
final row = await _db
|
||||
@@ -105,10 +104,6 @@ class DriftBackupRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & _db.remoteAssetEntity.ownerId.equals(userId),
|
||||
),
|
||||
) &
|
||||
// iOS revert: a reverted local hashes fresh (matches nothing remote),
|
||||
// but if it was already reconciled (syncedChecksum == current checksum)
|
||||
// it's handled, so don't re-queue it as a fresh upload.
|
||||
(lae.syncedChecksum.isNull() | lae.syncedChecksum.equalsExp(lae.checksum).not()) &
|
||||
lae.id.isNotInQuery(_getExcludedSubquery()),
|
||||
)
|
||||
..orderBy([(localAsset) => OrderingTerm.desc(localAsset.createdAt)]);
|
||||
|
||||
@@ -98,7 +98,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 27;
|
||||
int get schemaVersion => 26;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -276,11 +276,6 @@ class Drift extends $Drift {
|
||||
from25To26: (m, v26) async {
|
||||
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
|
||||
},
|
||||
from26To27: (m, v27) async {
|
||||
await m.addColumn(v27.localAssetEntity, v27.localAssetEntity.priorRemoteId);
|
||||
await m.addColumn(v27.localAssetEntity, v27.localAssetEntity.syncedChecksum);
|
||||
await m.createIndex(v27.idxLocalAssetPriorRemoteId);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -112,7 +112,6 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i7.idxLocalAlbumAssetAlbumAsset,
|
||||
i4.idxLocalAssetChecksum,
|
||||
i4.idxLocalAssetCloudId,
|
||||
i4.idxLocalAssetPriorRemoteId,
|
||||
i3.idxStackPrimaryAssetId,
|
||||
i2.uQRemoteAssetsOwnerChecksum,
|
||||
i2.uQRemoteAssetsOwnerLibraryChecksum,
|
||||
|
||||
@@ -13539,613 +13539,6 @@ i1.GeneratedColumn<String> _column_212(String aliasedName) =>
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
|
||||
final class Schema27 extends i0.VersionedSchema {
|
||||
Schema27({required super.database}) : super(version: 27);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxLocalAssetPriorRemoteId,
|
||||
idxStackPrimaryAssetId,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
metadata,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteExifCity,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxAssetFaceVisiblePerson,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
idxAssetEditAssetId,
|
||||
];
|
||||
late final Shape33 userEntity = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape50 remoteAssetEntity = Shape50(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_212,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 stackEntity = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_130,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape51 localAssetEntity = Shape51(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
_column_213,
|
||||
_column_214,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 remoteAlbumEntity = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_138,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 localAlbumEntity = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_115,
|
||||
_column_142,
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_146, _column_147, _column_145],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetPriorRemoteId = i1.Index(
|
||||
'idx_local_asset_prior_remote_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_prior_remote_id ON local_asset_entity (prior_remote_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
|
||||
'idx_remote_asset_owner_visibility_deleted_created',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
|
||||
);
|
||||
late final Shape40 authUserEntity = Shape40(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_148,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_149,
|
||||
_column_150,
|
||||
_column_151,
|
||||
_column_152,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_153, _column_154, _column_155],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape41 partnerEntity = Shape41(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_156, _column_157, _column_158],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape42 remoteExifEntity = Shape42(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_160,
|
||||
_column_161,
|
||||
_column_162,
|
||||
_column_163,
|
||||
_column_164,
|
||||
_column_117,
|
||||
_column_116,
|
||||
_column_165,
|
||||
_column_166,
|
||||
_column_167,
|
||||
_column_168,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_169,
|
||||
_column_170,
|
||||
_column_171,
|
||||
_column_172,
|
||||
_column_173,
|
||||
_column_174,
|
||||
_column_175,
|
||||
_column_176,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_159, _column_177],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_177, _column_153, _column_178],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape43 remoteAssetCloudIdEntity = Shape43(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_179,
|
||||
_column_180,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape44 memoryEntity = Shape44(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_124,
|
||||
_column_121,
|
||||
_column_113,
|
||||
_column_181,
|
||||
_column_182,
|
||||
_column_183,
|
||||
_column_184,
|
||||
_column_185,
|
||||
_column_186,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_159, _column_187],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape45 personEntity = Shape45(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_108,
|
||||
_column_188,
|
||||
_column_189,
|
||||
_column_190,
|
||||
_column_191,
|
||||
_column_192,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape46 assetFaceEntity = Shape46(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_193,
|
||||
_column_194,
|
||||
_column_195,
|
||||
_column_196,
|
||||
_column_197,
|
||||
_column_198,
|
||||
_column_199,
|
||||
_column_200,
|
||||
_column_201,
|
||||
_column_124,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_202, _column_203, _column_204],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape47 trashedLocalAssetEntity = Shape47(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_205,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_206,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape32 assetEditEntity = Shape32(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_edit_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_207,
|
||||
_column_208,
|
||||
_column_209,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 metadata = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'metadata',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_211, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteExifCity = i1.Index(
|
||||
'idx_remote_exif_city',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceVisiblePerson = i1.Index(
|
||||
'idx_asset_face_visible_person',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||
'idx_asset_edit_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape51 extends i0.VersionedTable {
|
||||
Shape51({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationMs =>
|
||||
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get iCloudId =>
|
||||
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get adjustmentTime =>
|
||||
columnsByName['adjustment_time']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<double> get latitude =>
|
||||
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get longitude =>
|
||||
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get priorRemoteId =>
|
||||
columnsByName['prior_remote_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get syncedChecksum =>
|
||||
columnsByName['synced_checksum']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_213(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'prior_remote_id',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_214(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'synced_checksum',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -14172,7 +13565,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -14301,11 +13693,6 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from25To26(migrator, schema);
|
||||
return 26;
|
||||
case 26:
|
||||
final schema = Schema27(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from26To27(migrator, schema);
|
||||
return 27;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -14338,7 +13725,6 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
|
||||
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
|
||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -14366,6 +13752,5 @@ i1.OnUpgrade stepByStep({
|
||||
from23To24: from23To24,
|
||||
from24To25: from24To25,
|
||||
from25To26: from25To26,
|
||||
from26To27: from26To27,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -64,12 +64,6 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> markSynced(String localId, {required String priorRemoteId, required String syncedChecksum}) {
|
||||
return (_db.localAssetEntity.update()..where((e) => e.id.equals(localId))).write(
|
||||
LocalAssetEntityCompanion(priorRemoteId: Value(priorRemoteId), syncedChecksum: Value(syncedChecksum)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete(List<String> ids) {
|
||||
if (ids.isEmpty) {
|
||||
return Future.value();
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class StackReconcileTarget {
|
||||
final String stackId;
|
||||
final String newPrimaryId;
|
||||
final String localAssetId;
|
||||
final String localAssetChecksum;
|
||||
|
||||
const StackReconcileTarget({
|
||||
required this.stackId,
|
||||
required this.newPrimaryId,
|
||||
required this.localAssetId,
|
||||
required this.localAssetChecksum,
|
||||
});
|
||||
}
|
||||
|
||||
class DriftStackRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftStackRepository(this._db) : super(_db);
|
||||
@@ -30,95 +14,6 @@ class DriftStackRepository extends DriftDatabaseRepository {
|
||||
return stack.toDto();
|
||||
}).get();
|
||||
}
|
||||
|
||||
// Per local id, find a stack member whose checksum matches the local's current
|
||||
// checksum but isn't the stack primary. That's the revert case: the local hashed
|
||||
// back to the base while the primary still points at the edit.
|
||||
Future<List<StackReconcileTarget>> findRevertReconcileTargets(Iterable<String> localAssetIds) async {
|
||||
final ids = localAssetIds.toSet();
|
||||
if (ids.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final targets = <StackReconcileTarget>[];
|
||||
for (final slice in ids.slices(kDriftMaxChunk)) {
|
||||
final placeholders = List.filled(slice.length, '?').join(',');
|
||||
final rows = await _db
|
||||
.customSelect(
|
||||
'''
|
||||
SELECT
|
||||
s.id AS stack_id,
|
||||
member.id AS new_primary,
|
||||
local.id AS local_id,
|
||||
local.checksum AS local_checksum
|
||||
FROM local_asset_entity local
|
||||
INNER JOIN remote_asset_entity prior ON prior.id = local.prior_remote_id AND prior.deleted_at IS NULL
|
||||
INNER JOIN stack_entity s ON s.id = prior.stack_id
|
||||
INNER JOIN remote_asset_entity member
|
||||
ON member.stack_id = s.id
|
||||
AND member.checksum = local.checksum
|
||||
AND member.deleted_at IS NULL
|
||||
WHERE local.id IN ($placeholders)
|
||||
AND s.primary_asset_id != member.id
|
||||
''',
|
||||
variables: slice.map((id) => Variable<String>(id)).toList(),
|
||||
readsFrom: {_db.localAssetEntity, _db.remoteAssetEntity, _db.stackEntity},
|
||||
)
|
||||
.get();
|
||||
|
||||
for (final row in rows) {
|
||||
targets.add(
|
||||
StackReconcileTarget(
|
||||
stackId: row.read<String>('stack_id'),
|
||||
newPrimaryId: row.read<String>('new_primary'),
|
||||
localAssetId: row.read<String>('local_id'),
|
||||
localAssetChecksum: row.read<String>('local_checksum'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
// The stack a remote asset belongs to, if any. Used by the revert path to find
|
||||
// the stack from prior_remote_id when the reverted bytes can't be checksum-matched.
|
||||
Future<String?> findStackIdByRemoteId(String remoteId) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
'SELECT stack_id FROM remote_asset_entity WHERE id = ? AND stack_id IS NOT NULL AND deleted_at IS NULL',
|
||||
variables: [Variable<String>(remoteId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
return row?.read<String?>('stack_id');
|
||||
}
|
||||
|
||||
// The stack's original base member to flip back to on revert: the earliest-
|
||||
// uploaded member that isn't the (latest-edit) prior. The base is uploaded
|
||||
// before its edits, so oldest uploaded_at = the original.
|
||||
Future<String?> findStackBaseId(String stackId, {required String excludeId}) async {
|
||||
final row = await _db
|
||||
.customSelect(
|
||||
'''
|
||||
SELECT id FROM remote_asset_entity
|
||||
WHERE stack_id = ? AND id != ? AND deleted_at IS NULL
|
||||
ORDER BY uploaded_at IS NULL, uploaded_at ASC, id ASC
|
||||
LIMIT 1
|
||||
''',
|
||||
variables: [Variable<String>(stackId), Variable<String>(excludeId)],
|
||||
readsFrom: {_db.remoteAssetEntity},
|
||||
)
|
||||
.getSingleOrNull();
|
||||
return row?.read<String?>('id');
|
||||
}
|
||||
|
||||
// Optimistic local primary flip so the timeline updates immediately; the
|
||||
// server's stack-update websocket rewrites it shortly after.
|
||||
Future<void> setPrimary(String stackId, String primaryAssetId) {
|
||||
return (_db.stackEntity.update()..where((e) => e.id.equals(stackId))).write(
|
||||
StackEntityCompanion(primaryAssetId: Value(primaryAssetId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackEntityData {
|
||||
|
||||
+9
-109
@@ -88,8 +88,6 @@ int _deepHash(Object? value) {
|
||||
|
||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
enum EditState { notEdited, edited, unknown }
|
||||
|
||||
class PlatformAsset {
|
||||
PlatformAsset({
|
||||
required this.id,
|
||||
@@ -397,55 +395,6 @@ class CloudIdResult {
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class BaseResource {
|
||||
BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
|
||||
|
||||
String path;
|
||||
|
||||
String sha1;
|
||||
|
||||
int sizeBytes;
|
||||
|
||||
String mimeType;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[path, sha1, sizeBytes, mimeType];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList();
|
||||
}
|
||||
|
||||
static BaseResource decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return BaseResource(
|
||||
path: result[0]! as String,
|
||||
sha1: result[1]! as String,
|
||||
sizeBytes: result[2]! as int,
|
||||
mimeType: result[3]! as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! BaseResource || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(path, other.path) &&
|
||||
_deepEquals(sha1, other.sha1) &&
|
||||
_deepEquals(sizeBytes, other.sizeBytes) &&
|
||||
_deepEquals(mimeType, other.mimeType);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
|
||||
}
|
||||
|
||||
class _PigeonCodec extends StandardMessageCodec {
|
||||
const _PigeonCodec();
|
||||
@override
|
||||
@@ -456,26 +405,20 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
} else if (value is PlatformAssetPlaybackStyle) {
|
||||
buffer.putUint8(129);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is EditState) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.index);
|
||||
} else if (value is PlatformAsset) {
|
||||
buffer.putUint8(131);
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is PlatformAlbum) {
|
||||
buffer.putUint8(132);
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is SyncDelta) {
|
||||
buffer.putUint8(133);
|
||||
buffer.putUint8(132);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is HashResult) {
|
||||
buffer.putUint8(134);
|
||||
buffer.putUint8(133);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is CloudIdResult) {
|
||||
buffer.putUint8(135);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is BaseResource) {
|
||||
buffer.putUint8(136);
|
||||
buffer.putUint8(134);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
@@ -489,20 +432,15 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
||||
case 130:
|
||||
final value = readValue(buffer) as int?;
|
||||
return value == null ? null : EditState.values[value];
|
||||
case 131:
|
||||
return PlatformAsset.decode(readValue(buffer)!);
|
||||
case 132:
|
||||
case 131:
|
||||
return PlatformAlbum.decode(readValue(buffer)!);
|
||||
case 133:
|
||||
case 132:
|
||||
return SyncDelta.decode(readValue(buffer)!);
|
||||
case 134:
|
||||
case 133:
|
||||
return HashResult.decode(readValue(buffer)!);
|
||||
case 135:
|
||||
case 134:
|
||||
return CloudIdResult.decode(readValue(buffer)!);
|
||||
case 136:
|
||||
return BaseResource.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
@@ -753,42 +691,4 @@ class NativeSyncApi {
|
||||
);
|
||||
return (pigeonVar_replyValue! as List<Object?>).cast<CloudIdResult>();
|
||||
}
|
||||
|
||||
Future<BaseResource?> getBaseResource(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getBaseResource$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: true,
|
||||
);
|
||||
return pigeonVar_replyValue as BaseResource?;
|
||||
}
|
||||
|
||||
Future<EditState> getEditState(String assetId, {bool allowNetworkAccess = false}) async {
|
||||
final pigeonVar_channelName =
|
||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getEditState$pigeonVar_messageChannelSuffix';
|
||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, allowNetworkAccess]);
|
||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||
|
||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||
pigeonVar_replyList,
|
||||
pigeonVar_channelName,
|
||||
isNullValid: false,
|
||||
);
|
||||
return pigeonVar_replyValue! as EditState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.da
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
@@ -21,6 +22,7 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -536,14 +538,22 @@ class ActionNotifier extends Notifier<void> {
|
||||
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
|
||||
}
|
||||
|
||||
final completer = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
|
||||
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
|
||||
return eventAsset?.id == ids.first;
|
||||
}, const Duration(seconds: 10));
|
||||
Future<void> editReady;
|
||||
if (ref.read(serverInfoProvider).serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) {
|
||||
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV2", (dynamic data) {
|
||||
final eventAsset = SyncAssetV2.fromJson(data["asset"]);
|
||||
return eventAsset?.id == ids.first;
|
||||
}, const Duration(seconds: 10));
|
||||
} else {
|
||||
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
|
||||
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
|
||||
return eventAsset?.id == ids.first;
|
||||
}, const Duration(seconds: 10));
|
||||
}
|
||||
|
||||
try {
|
||||
await _service.applyEdits(ids.first, edits);
|
||||
await completer;
|
||||
await editReady;
|
||||
return const ActionResult(count: 1, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to apply edits to assets', error, stack);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
@@ -12,8 +11,6 @@ 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/stack.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
|
||||
@@ -48,22 +45,11 @@ final localSyncServiceProvider = Provider(
|
||||
),
|
||||
);
|
||||
|
||||
final editRevertServiceProvider = Provider(
|
||||
(ref) => EditRevertService(
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
stackRepository: ref.watch(driftStackProvider),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
final hashServiceProvider = Provider(
|
||||
(ref) => HashService(
|
||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||
localAssetRepository: ref.watch(localAssetRepository),
|
||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||
stackRepository: ref.watch(driftStackProvider),
|
||||
assetApiRepository: ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -105,7 +105,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||
socket.on('on_new_release', _handleReleaseUpdates);
|
||||
socket.on('on_asset_stack_update', _handleAssetStackUpdate);
|
||||
} catch (e) {
|
||||
dPrint(() => "[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||
}
|
||||
@@ -189,13 +188,6 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
|
||||
}
|
||||
|
||||
// Server stacked/restacked assets (e.g. an edit stacked onto its original).
|
||||
// Pull a fresh remote sync so the stack_entity lands and the timeline shows
|
||||
// the stacked primary instead of briefly hiding the asset.
|
||||
void _handleAssetStackUpdate(dynamic _) {
|
||||
unawaited(_ref.read(backgroundSyncProvider).runFreshRemoteSync());
|
||||
}
|
||||
|
||||
void _processBatchedAssetUploadReadyV1() {
|
||||
if (_batchedAssetUploadReady.isEmpty) {
|
||||
return;
|
||||
|
||||
@@ -67,10 +67,6 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
|
||||
}
|
||||
|
||||
Future<void> setStackPrimary(String stackId, String primaryAssetId) async {
|
||||
await _stacksApi.updateStack(stackId, StackUpdateDto(primaryAssetId: primaryAssetId));
|
||||
}
|
||||
|
||||
Future<Response> downloadAsset(String id, {required bool edited}) {
|
||||
return _api.downloadAssetWithHttpInfo(id, edited: edited);
|
||||
}
|
||||
|
||||
@@ -30,11 +30,6 @@ class UploadRepository {
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kBackupEditPairGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
FileDownloader().registerCallbacks(
|
||||
group: kManualUploadGroup,
|
||||
taskStatusCallback: (update) => onUploadStatus?.call(update),
|
||||
|
||||
@@ -9,22 +9,17 @@ import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/edit_pair.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -36,8 +31,6 @@ final backgroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(nativeSyncApiProvider),
|
||||
ref.watch(editRevertServiceProvider),
|
||||
);
|
||||
|
||||
ref.onDispose(service.dispose);
|
||||
@@ -50,35 +43,13 @@ class UploadTaskMetadata {
|
||||
final bool isLivePhotos;
|
||||
final String livePhotoVideoId;
|
||||
|
||||
// Marks the base upload of an edit pair. On completion the chained edit
|
||||
// upload is enqueued with stackParentId = this base's remote id.
|
||||
final bool isEditPair;
|
||||
const UploadTaskMetadata({required this.localAssetId, required this.isLivePhotos, required this.livePhotoVideoId});
|
||||
|
||||
// Path of the native temp file backing this task (the edit base), so it can
|
||||
// be cleaned up on terminal status.
|
||||
final String basePath;
|
||||
|
||||
const UploadTaskMetadata({
|
||||
required this.localAssetId,
|
||||
required this.isLivePhotos,
|
||||
required this.livePhotoVideoId,
|
||||
this.isEditPair = false,
|
||||
this.basePath = '',
|
||||
});
|
||||
|
||||
UploadTaskMetadata copyWith({
|
||||
String? localAssetId,
|
||||
bool? isLivePhotos,
|
||||
String? livePhotoVideoId,
|
||||
bool? isEditPair,
|
||||
String? basePath,
|
||||
}) {
|
||||
UploadTaskMetadata copyWith({String? localAssetId, bool? isLivePhotos, String? livePhotoVideoId}) {
|
||||
return UploadTaskMetadata(
|
||||
localAssetId: localAssetId ?? this.localAssetId,
|
||||
isLivePhotos: isLivePhotos ?? this.isLivePhotos,
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
isEditPair: isEditPair ?? this.isEditPair,
|
||||
basePath: basePath ?? this.basePath,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,8 +58,6 @@ class UploadTaskMetadata {
|
||||
'localAssetId': localAssetId,
|
||||
'isLivePhotos': isLivePhotos,
|
||||
'livePhotoVideoId': livePhotoVideoId,
|
||||
'isEditPair': isEditPair,
|
||||
'basePath': basePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,8 +66,6 @@ class UploadTaskMetadata {
|
||||
localAssetId: map['localAssetId'] as String,
|
||||
isLivePhotos: map['isLivePhotos'] as bool,
|
||||
livePhotoVideoId: map['livePhotoVideoId'] as String,
|
||||
isEditPair: (map['isEditPair'] as bool?) ?? false,
|
||||
basePath: (map['basePath'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,7 +76,7 @@ class UploadTaskMetadata {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId, isEditPair: $isEditPair, basePath: $basePath)';
|
||||
'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant UploadTaskMetadata other) {
|
||||
@@ -119,18 +86,11 @@ class UploadTaskMetadata {
|
||||
|
||||
return other.localAssetId == localAssetId &&
|
||||
other.isLivePhotos == isLivePhotos &&
|
||||
other.livePhotoVideoId == livePhotoVideoId &&
|
||||
other.isEditPair == isEditPair &&
|
||||
other.basePath == basePath;
|
||||
other.livePhotoVideoId == livePhotoVideoId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
localAssetId.hashCode ^
|
||||
isLivePhotos.hashCode ^
|
||||
livePhotoVideoId.hashCode ^
|
||||
isEditPair.hashCode ^
|
||||
basePath.hashCode;
|
||||
int get hashCode => localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode;
|
||||
}
|
||||
|
||||
/// Service for handling background uploads using iOS URLSession (background_downloader)
|
||||
@@ -144,8 +104,6 @@ class BackgroundUploadService {
|
||||
this._localAssetRepository,
|
||||
this._backupRepository,
|
||||
this._assetMediaRepository,
|
||||
this._nativeSyncApi,
|
||||
this._editRevertService,
|
||||
) {
|
||||
_uploadRepository.onUploadStatus = _onUploadCallback;
|
||||
_uploadRepository.onTaskProgress = _onTaskProgressCallback;
|
||||
@@ -156,8 +114,6 @@ class BackgroundUploadService {
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final EditRevertService _editRevertService;
|
||||
final Logger _logger = Logger('BackgroundUploadService');
|
||||
|
||||
final StreamController<TaskStatusUpdate> _taskStatusController = StreamController<TaskStatusUpdate>.broadcast();
|
||||
@@ -237,13 +193,10 @@ class BackgroundUploadService {
|
||||
|
||||
await _storageRepository.clearCache();
|
||||
await _uploadRepository.reset(kBackupGroup);
|
||||
await _uploadRepository.reset(kBackupEditPairGroup);
|
||||
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
||||
await _uploadRepository.deleteDatabaseRecords(kBackupEditPairGroup);
|
||||
|
||||
final activeTasks = await _uploadRepository.getActiveTasks(kBackupGroup);
|
||||
final activeEditTasks = await _uploadRepository.getActiveTasks(kBackupEditPairGroup);
|
||||
return activeTasks.length + activeEditTasks.length;
|
||||
return activeTasks.length;
|
||||
}
|
||||
|
||||
/// Resume background backup processing
|
||||
@@ -252,20 +205,9 @@ class BackgroundUploadService {
|
||||
}
|
||||
|
||||
void _handleTaskStatusUpdate(TaskStatusUpdate update) async {
|
||||
UploadTaskMetadata? metadata;
|
||||
if (update.task.metaData.isNotEmpty) {
|
||||
try {
|
||||
metadata = UploadTaskMetadata.fromJson(update.task.metaData);
|
||||
} catch (_) {
|
||||
metadata = null;
|
||||
}
|
||||
}
|
||||
|
||||
switch (update.status) {
|
||||
case TaskStatus.complete:
|
||||
unawaited(_handleLivePhoto(update, metadata));
|
||||
unawaited(handleEditPair(update, metadata));
|
||||
unawaited(recordPriorRemoteIdOnSuccess(update, metadata));
|
||||
unawaited(_handleLivePhoto(update));
|
||||
|
||||
if (CurrentPlatform.isIOS) {
|
||||
try {
|
||||
@@ -278,20 +220,19 @@ class BackgroundUploadService {
|
||||
|
||||
break;
|
||||
|
||||
case TaskStatus.failed:
|
||||
case TaskStatus.canceled:
|
||||
case TaskStatus.notFound:
|
||||
unawaited(_cleanupTempResourceOnFailure(metadata));
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLivePhoto(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
|
||||
try {
|
||||
if (metadata == null || !metadata.isLivePhotos) {
|
||||
if (update.task.metaData.isEmpty || update.task.metaData == '') {
|
||||
return;
|
||||
}
|
||||
|
||||
final metadata = UploadTaskMetadata.fromJson(update.task.metaData);
|
||||
if (!metadata.isLivePhotos) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -317,143 +258,6 @@ class BackgroundUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
/// When an edit-pair base upload finishes, enqueue the edit on top of it
|
||||
/// (stackParentId = the base's new remote id).
|
||||
@visibleForTesting
|
||||
Future<void> handleEditPair(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||
try {
|
||||
if (metadata == null || !metadata.isEditPair) {
|
||||
return;
|
||||
}
|
||||
if (metadata.basePath.isNotEmpty) {
|
||||
try {
|
||||
await File(metadata.basePath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
final baseRemoteId = _remoteIdFromResponse(update);
|
||||
if (baseRemoteId == null) {
|
||||
return;
|
||||
}
|
||||
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
|
||||
if (localAsset == null) {
|
||||
return;
|
||||
}
|
||||
final editTask = await getEditUploadTask(localAsset, baseRemoteId);
|
||||
if (editTask != null) {
|
||||
await enqueueTasks([editTask]);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
dPrint(() => "Error handling edit pair task: $error $stackTrace");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the uploaded remote id as the asset's priorRemoteId so a later edit
|
||||
/// stacks onto it. Skipped for edit-pair base uploads; the chained edit records it.
|
||||
@visibleForTesting
|
||||
Future<void> recordPriorRemoteIdOnSuccess(TaskStatusUpdate update, UploadTaskMetadata? metadata) async {
|
||||
try {
|
||||
if (metadata == null || metadata.isEditPair || metadata.isLivePhotos || metadata.localAssetId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final remoteId = _remoteIdFromResponse(update);
|
||||
if (remoteId == null) {
|
||||
return;
|
||||
}
|
||||
final localAsset = await _localAssetRepository.getById(metadata.localAssetId);
|
||||
await _localAssetRepository.markSynced(
|
||||
metadata.localAssetId,
|
||||
priorRemoteId: remoteId,
|
||||
syncedChecksum: localAsset?.checksum ?? '',
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
dPrint(() => "Error recording priorRemoteId: $error $stackTrace");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupTempResourceOnFailure(UploadTaskMetadata? metadata) async {
|
||||
if (metadata == null || metadata.basePath.isEmpty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await File(metadata.basePath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// The new asset's remote id from an upload's response body, or null if the
|
||||
/// body is missing/malformed.
|
||||
String? _remoteIdFromResponse(TaskStatusUpdate update) {
|
||||
final body = update.responseBody;
|
||||
if (body == null || body.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return jsonDecode(body)['id'] as String?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<UploadTask> _buildBaseUploadTask(LocalAsset asset, BaseResource base) async {
|
||||
final metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
isLivePhotos: false,
|
||||
livePhotoVideoId: '',
|
||||
isEditPair: true,
|
||||
basePath: base.path,
|
||||
).toJson();
|
||||
|
||||
// The base is the unedited original (no adjustmentTime); the `_base`
|
||||
// deviceAssetId keeps it distinct from the chained edit task.
|
||||
return buildUploadTask(
|
||||
File(base.path),
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: p.setExtension(asset.name, p.extension(base.path)),
|
||||
deviceAssetId: '${asset.id}_base',
|
||||
metadata: metadata,
|
||||
group: kBackupGroup,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: _shouldRequireWiFi(asset),
|
||||
cloudId: asset.cloudId,
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<UploadTask?> getEditUploadTask(LocalAsset asset, String stackParentId) async {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final fields = {'stackParentId': stackParentId};
|
||||
final originalFileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
|
||||
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '').toJson();
|
||||
|
||||
return buildUploadTask(
|
||||
file,
|
||||
createdAt: asset.createdAt,
|
||||
modifiedAt: asset.updatedAt,
|
||||
originalFileName: originalFileName,
|
||||
deviceAssetId: asset.id,
|
||||
metadata: metadata,
|
||||
fields: fields,
|
||||
group: kBackupEditPairGroup,
|
||||
priority: 0,
|
||||
isFavorite: asset.isFavorite,
|
||||
requiresWiFi: _shouldRequireWiFi(asset),
|
||||
cloudId: asset.cloudId,
|
||||
adjustmentTime: asset.adjustmentTime?.toIso8601String(),
|
||||
latitude: asset.latitude?.toString(),
|
||||
longitude: asset.longitude?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<UploadTask?> getUploadTask(LocalAsset asset, {String group = kBackupGroup, int? priority}) async {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
@@ -462,24 +266,6 @@ class BackgroundUploadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// iOS edit pair: stack a user edit onto its original. resolveEditPair decides
|
||||
// whether to reuse a prior upload or upload the base first. Live photos skip this.
|
||||
if (!entity.isLivePhoto && CurrentPlatform.isIOS) {
|
||||
// A reverted edit flips the stack back to the original and skips the upload.
|
||||
if (asset.priorRemoteId != null && await _editRevertService.tryHandleRevert(asset)) {
|
||||
return null;
|
||||
}
|
||||
final plan = await resolveEditPair(_nativeSyncApi, asset, log: _logger);
|
||||
switch (plan) {
|
||||
case UploadBaseFirst(:final base):
|
||||
return _buildBaseUploadTask(asset, base);
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
return getEditUploadTask(asset, parentId);
|
||||
case NoEditPair():
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
File? file;
|
||||
|
||||
/// iOS LivePhoto has two files: a photo and a video.
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// What to do with an edited iOS photo when backing it up.
|
||||
sealed class EditPairPlan {
|
||||
const EditPairPlan();
|
||||
}
|
||||
|
||||
/// Not something we stack: not edited, identical bytes, or couldn't read it.
|
||||
class NoEditPair extends EditPairPlan {
|
||||
const NoEditPair();
|
||||
}
|
||||
|
||||
/// Already uploaded before; stack the edit onto that remote id.
|
||||
class AbsorbIntoPrior extends EditPairPlan {
|
||||
final String parentId;
|
||||
const AbsorbIntoPrior(this.parentId);
|
||||
}
|
||||
|
||||
/// Upload the original first; [base] is its temp file.
|
||||
class UploadBaseFirst extends EditPairPlan {
|
||||
final BaseResource base;
|
||||
const UploadBaseFirst(this.base);
|
||||
}
|
||||
|
||||
/// Works out how an edited photo should stack: reuse a prior upload, upload the
|
||||
/// original first, or do nothing. Shared by the foreground and background upload
|
||||
/// paths. The caller already checked it's iOS and not a live photo.
|
||||
///
|
||||
/// A photo that was never edited only carries the capture-time Photographic Style,
|
||||
/// which iOS stamps at [LocalAsset.createdAt]; a real edit moves [LocalAsset.adjustmentTime]
|
||||
/// later. When they match (or there's no adjustment at all) there's nothing to stack, so
|
||||
/// we skip the native read. Anything that moved the timestamp (edit, retime, revert) falls
|
||||
/// through to [NativeSyncApi.getBaseResource], which reads the adjustment plist and decides.
|
||||
Future<EditPairPlan> resolveEditPair(NativeSyncApi nativeSyncApi, LocalAsset asset, {Logger? log}) async {
|
||||
if (asset.priorRemoteId != null) {
|
||||
return AbsorbIntoPrior(asset.priorRemoteId!);
|
||||
}
|
||||
|
||||
if (!_mightBeEdited(asset)) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
BaseResource? base;
|
||||
try {
|
||||
base = await nativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: true);
|
||||
} catch (error, stack) {
|
||||
log?.warning(() => "Failed to read base resource for ${asset.id}", error, stack);
|
||||
return const NoEditPair();
|
||||
}
|
||||
if (base == null) {
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
// Identical bytes (e.g. auto-HDR), nothing real to stack. Drop the temp copy.
|
||||
if (base.sha1 == asset.checksum) {
|
||||
try {
|
||||
await File(base.path).delete();
|
||||
} catch (_) {}
|
||||
return const NoEditPair();
|
||||
}
|
||||
|
||||
return UploadBaseFirst(base);
|
||||
}
|
||||
|
||||
/// iOS stamps the capture-time Photographic Style at the creation time and moves the
|
||||
/// adjustment timestamp on any later change. A gap past a small tolerance (capture jitter
|
||||
/// is sub-second, real edits are seconds apart) is worth a native check; no adjustment at
|
||||
/// all means the photo was never touched.
|
||||
bool _mightBeEdited(LocalAsset asset) {
|
||||
final adjustedAt = asset.adjustmentTime;
|
||||
if (adjustedAt == null) {
|
||||
return false;
|
||||
}
|
||||
return adjustedAt.difference(asset.createdAt).inSeconds.abs() > _editTimestampToleranceSeconds;
|
||||
}
|
||||
|
||||
const _editTimestampToleranceSeconds = 2;
|
||||
@@ -6,24 +6,18 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/upload.repository.dart';
|
||||
import 'package:immich_mobile/services/edit_pair.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
@@ -45,9 +39,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
||||
ref.watch(backupRepositoryProvider),
|
||||
ref.watch(connectivityApiProvider),
|
||||
ref.watch(assetMediaRepositoryProvider),
|
||||
ref.watch(nativeSyncApiProvider),
|
||||
ref.watch(localAssetRepository),
|
||||
ref.watch(editRevertServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -63,9 +54,6 @@ class ForegroundUploadService {
|
||||
this._backupRepository,
|
||||
this._connectivityApi,
|
||||
this._assetMediaRepository,
|
||||
this._nativeSyncApi,
|
||||
this._localAssetRepository,
|
||||
this._editRevertService,
|
||||
);
|
||||
|
||||
final UploadRepository _uploadRepository;
|
||||
@@ -73,9 +61,6 @@ class ForegroundUploadService {
|
||||
final DriftBackupRepository _backupRepository;
|
||||
final ConnectivityApi _connectivityApi;
|
||||
final AssetMediaRepository _assetMediaRepository;
|
||||
final NativeSyncApi _nativeSyncApi;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
final EditRevertService _editRevertService;
|
||||
final Logger _logger = Logger('ForegroundUploadService');
|
||||
|
||||
bool shouldAbortUpload = false;
|
||||
@@ -265,12 +250,6 @@ class ForegroundUploadService {
|
||||
return;
|
||||
}
|
||||
|
||||
// A reverted iOS edit flips the stack back to the original and skips the upload.
|
||||
if (CurrentPlatform.isIOS && asset.priorRemoteId != null && await _editRevertService.tryHandleRevert(asset)) {
|
||||
callbacks.onSuccess?.call(asset.localId!, asset.priorRemoteId!);
|
||||
return;
|
||||
}
|
||||
|
||||
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||
|
||||
if (!isAvailableLocally && CurrentPlatform.isIOS) {
|
||||
@@ -392,13 +371,6 @@ class ForegroundUploadService {
|
||||
]);
|
||||
}
|
||||
|
||||
final stackParentId = entity.isLivePhoto
|
||||
? null
|
||||
: await _maybeUploadBaseResource(asset, Map.of(fields), cancelToken);
|
||||
if (stackParentId != null) {
|
||||
fields['stackParentId'] = stackParentId;
|
||||
}
|
||||
|
||||
final onProgress = callbacks.onProgress;
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: file,
|
||||
@@ -412,13 +384,6 @@ class ForegroundUploadService {
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
unawaited(
|
||||
_localAssetRepository.markSynced(
|
||||
asset.localId!,
|
||||
priorRemoteId: result.remoteAssetId!,
|
||||
syncedChecksum: asset.checksum ?? '',
|
||||
),
|
||||
);
|
||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||
} else if (result.isCancelled) {
|
||||
_logger.warning(() => "Backup was cancelled by the user");
|
||||
@@ -450,43 +415,6 @@ class ForegroundUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
/// For an edited iOS photo, uploads the original camera bytes and returns its
|
||||
/// remote id to use as the edit's stackParentId. Returns null for non-edits.
|
||||
Future<String?> _maybeUploadBaseResource(
|
||||
LocalAsset asset,
|
||||
Map<String, String> baseFields,
|
||||
Completer<void>? cancelToken,
|
||||
) async {
|
||||
if (!CurrentPlatform.isIOS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final plan = await resolveEditPair(_nativeSyncApi, asset, log: _logger);
|
||||
switch (plan) {
|
||||
case NoEditPair():
|
||||
return null;
|
||||
case AbsorbIntoPrior(:final parentId):
|
||||
return parentId;
|
||||
case UploadBaseFirst(:final base):
|
||||
final baseFile = File(base.path);
|
||||
try {
|
||||
final baseName = p.setExtension(asset.name, p.extension(base.path));
|
||||
final result = await _uploadRepository.uploadFile(
|
||||
file: baseFile,
|
||||
originalFileName: baseName,
|
||||
fields: baseFields,
|
||||
cancelToken: cancelToken,
|
||||
logContext: 'baseResource[${asset.localId}]',
|
||||
);
|
||||
return result.isSuccess ? result.remoteAssetId : null;
|
||||
} finally {
|
||||
try {
|
||||
await baseFile.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<UploadResult> _uploadSingleFile(
|
||||
File file, {
|
||||
required String deviceAssetId,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+3
-13
@@ -1252,11 +1252,8 @@ class AssetsApi {
|
||||
/// * [MultipartFile] sidecarData:
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [String] stackParentId:
|
||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||
///
|
||||
/// * [AssetVisibility] visibility:
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, }) async {
|
||||
Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets';
|
||||
|
||||
@@ -1320,10 +1317,6 @@ class AssetsApi {
|
||||
mp.fields[r'sidecarData'] = sidecarData.field;
|
||||
mp.files.add(sidecarData);
|
||||
}
|
||||
if (stackParentId != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'stackParentId'] = parameterToString(stackParentId);
|
||||
}
|
||||
if (visibility != null) {
|
||||
hasFields = true;
|
||||
mp.fields[r'visibility'] = parameterToString(visibility);
|
||||
@@ -1383,12 +1376,9 @@ class AssetsApi {
|
||||
/// * [MultipartFile] sidecarData:
|
||||
/// Sidecar file data
|
||||
///
|
||||
/// * [String] stackParentId:
|
||||
/// Stack this asset onto the parent asset, with the new asset as the stack primary
|
||||
///
|
||||
/// * [AssetVisibility] visibility:
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, String? stackParentId, AssetVisibility? visibility, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, stackParentId: stackParentId, visibility: visibility, );
|
||||
Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List<AssetMetadataUpsertItemDto>? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async {
|
||||
final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
@@ -103,21 +103,6 @@ class CloudIdResult {
|
||||
const CloudIdResult({required this.assetId, this.error, this.cloudId});
|
||||
}
|
||||
|
||||
class BaseResource {
|
||||
final String path;
|
||||
final String sha1;
|
||||
final int sizeBytes;
|
||||
final String mimeType;
|
||||
|
||||
const BaseResource({required this.path, required this.sha1, required this.sizeBytes, required this.mimeType});
|
||||
}
|
||||
|
||||
// Whether an iOS asset currently carries a user edit, as opposed to a
|
||||
// capture-time Photographic Style or a reverted edit. `unknown` means the
|
||||
// adjustment data couldn't be read (e.g. the asset is offloaded to iCloud and
|
||||
// network wasn't allowed), so callers must not treat it as "not edited".
|
||||
enum EditState { notEdited, edited, unknown }
|
||||
|
||||
@HostApi()
|
||||
abstract class NativeSyncApi {
|
||||
bool shouldFullSync();
|
||||
@@ -155,12 +140,4 @@ abstract class NativeSyncApi {
|
||||
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
BaseResource? getBaseResource(String assetId, {bool allowNetworkAccess = false});
|
||||
|
||||
@async
|
||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||
EditState getEditState(String assetId, {bool allowNetworkAccess = false});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
@@ -12,5 +11,3 @@ class MockBackgroundSyncManager extends Mock implements BackgroundSyncManager {}
|
||||
class MockNativeSyncApi extends Mock implements NativeSyncApi {}
|
||||
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockEditRevertService extends Mock implements EditRevertService {}
|
||||
|
||||
-4
@@ -30,7 +30,6 @@ import 'schema_v23.dart' as v23;
|
||||
import 'schema_v24.dart' as v24;
|
||||
import 'schema_v25.dart' as v25;
|
||||
import 'schema_v26.dart' as v26;
|
||||
import 'schema_v27.dart' as v27;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -88,8 +87,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v25.DatabaseAtV25(db);
|
||||
case 26:
|
||||
return v26.DatabaseAtV26(db);
|
||||
case 27:
|
||||
return v27.DatabaseAtV27(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -122,6 +119,5 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
27,
|
||||
];
|
||||
}
|
||||
|
||||
-9471
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.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';
|
||||
@@ -37,8 +36,6 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
|
||||
|
||||
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
|
||||
|
||||
class MockDriftStackRepository extends Mock implements DriftStackRepository {}
|
||||
|
||||
class MockStorageRepository extends Mock implements StorageRepository {}
|
||||
|
||||
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:drift/drift.dart' hide isNull, isNotNull;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
@@ -15,11 +13,9 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:immich_mobile/services/background_upload.service.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';
|
||||
@@ -32,14 +28,10 @@ void main() {
|
||||
late MockDriftLocalAssetRepository mockLocalAssetRepository;
|
||||
late MockDriftBackupRepository mockBackupRepository;
|
||||
late MockAssetMediaRepository mockAssetMediaRepository;
|
||||
late MockNativeSyncApi mockNativeSyncApi;
|
||||
late MockEditRevertService mockEditRevertService;
|
||||
late Drift db;
|
||||
|
||||
setUpAll(() async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
registerFallbackValue(<UploadTask>[]);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.flutter.io/path_provider'),
|
||||
(MethodCall methodCall) async => 'test',
|
||||
@@ -58,8 +50,6 @@ void main() {
|
||||
mockLocalAssetRepository = MockDriftLocalAssetRepository();
|
||||
mockBackupRepository = MockDriftBackupRepository();
|
||||
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||
mockNativeSyncApi = MockNativeSyncApi();
|
||||
mockEditRevertService = MockEditRevertService();
|
||||
|
||||
sut = BackgroundUploadService(
|
||||
mockUploadRepository,
|
||||
@@ -67,18 +57,8 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAssetMediaRepository,
|
||||
mockNativeSyncApi,
|
||||
mockEditRevertService,
|
||||
);
|
||||
|
||||
// Default: no edit base, so getUploadTask falls through to the normal path.
|
||||
when(
|
||||
() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => null);
|
||||
|
||||
// Default: not a revert, so getUploadTask proceeds with the normal flow.
|
||||
when(() => mockEditRevertService.tryHandleRevert(any())).thenAnswer((_) async => false);
|
||||
|
||||
mockUploadRepository.onUploadStatus = (_) {};
|
||||
mockUploadRepository.onTaskProgress = (_) {};
|
||||
});
|
||||
@@ -142,234 +122,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('getUploadTask edit pair', () {
|
||||
test('absorption: stacks the edit under the prior upload via stackParentId', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final asset = LocalAssetStub.image1.copyWith(priorRemoteId: 'prior-remote-1');
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.group, kBackupEditPairGroup);
|
||||
expect(task.fields['stackParentId'], 'prior-remote-1');
|
||||
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('builds a base upload task for an unsynced edit', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final asset = LocalAssetStub.image1.copyWith(
|
||||
checksum: 'edited-sha1',
|
||||
adjustmentTime: DateTime(2025, 1, 1, 0, 0, 30),
|
||||
);
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(
|
||||
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer(
|
||||
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'original-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
|
||||
);
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.group, kBackupGroup);
|
||||
expect(task.metaData, contains('"isEditPair":true'));
|
||||
});
|
||||
|
||||
test('falls through to a normal upload when base bytes match the checksum', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final asset = LocalAssetStub.image1.copyWith(
|
||||
checksum: 'same-sha1',
|
||||
adjustmentTime: DateTime(2025, 1, 1, 0, 0, 30),
|
||||
);
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||
when(
|
||||
() => mockNativeSyncApi.getBaseResource(asset.id, allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer(
|
||||
(_) async => BaseResource(path: '/tmp/base.jpg', sha1: 'same-sha1', sizeBytes: 100, mimeType: 'image/jpeg'),
|
||||
);
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.group, kBackupGroup);
|
||||
expect(task.fields.containsKey('stackParentId'), isFalse);
|
||||
});
|
||||
|
||||
test('gate: skips the native read for an unedited photo (adjustmentTime == createdAt)', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final asset = LocalAssetStub.image1.copyWith(adjustmentTime: LocalAssetStub.image1.createdAt);
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.group, kBackupGroup);
|
||||
expect(task.fields.containsKey('stackParentId'), isFalse);
|
||||
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('gate: skips the native read when the photo has no adjustmentTime', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final asset = LocalAssetStub.image1; // adjustmentTime is null
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/file.jpg'));
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'photo.jpg');
|
||||
|
||||
final task = await sut.getUploadTask(asset);
|
||||
|
||||
expect(task, isNotNull);
|
||||
expect(task!.group, kBackupGroup);
|
||||
verifyNever(() => mockNativeSyncApi.getBaseResource(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
});
|
||||
|
||||
group('edit pair completion', () {
|
||||
test('handleEditPair: enqueues the edit stacked onto the uploaded base', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final metadata = UploadTaskMetadata(
|
||||
localAssetId: asset.id,
|
||||
isLivePhotos: false,
|
||||
livePhotoVideoId: '',
|
||||
isEditPair: true,
|
||||
);
|
||||
final update = TaskStatusUpdate(
|
||||
UploadTask(url: 'http://test-server.com', filename: 'base.jpg'),
|
||||
TaskStatus.complete,
|
||||
null,
|
||||
'{"id":"base-remote-1"}',
|
||||
);
|
||||
final mockEntity = MockAssetEntity();
|
||||
when(() => mockEntity.isLivePhoto).thenReturn(false);
|
||||
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => asset);
|
||||
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
|
||||
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => File('/path/to/edit.jpg'));
|
||||
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'edit.jpg');
|
||||
when(() => mockUploadRepository.enqueueBackgroundAll(any())).thenAnswer((_) async => [true]);
|
||||
|
||||
await sut.handleEditPair(update, metadata);
|
||||
|
||||
final enqueued =
|
||||
verify(() => mockUploadRepository.enqueueBackgroundAll(captureAny())).captured.single as List<UploadTask>;
|
||||
expect(enqueued.single.fields['stackParentId'], 'base-remote-1');
|
||||
expect(enqueued.single.group, kBackupEditPairGroup);
|
||||
});
|
||||
|
||||
test('handleEditPair: does nothing for a non edit-pair upload', () async {
|
||||
const metadata = UploadTaskMetadata(localAssetId: 'local-1', isLivePhotos: false, livePhotoVideoId: '');
|
||||
final update = TaskStatusUpdate(
|
||||
UploadTask(url: 'http://test-server.com', filename: 'photo.jpg'),
|
||||
TaskStatus.complete,
|
||||
null,
|
||||
'{"id":"remote-1"}',
|
||||
);
|
||||
|
||||
await sut.handleEditPair(update, metadata);
|
||||
|
||||
verifyNever(() => mockUploadRepository.enqueueBackgroundAll(any()));
|
||||
});
|
||||
|
||||
test('recordPriorRemoteIdOnSuccess: marks the local synced with the uploaded id', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
final metadata = UploadTaskMetadata(localAssetId: asset.id, isLivePhotos: false, livePhotoVideoId: '');
|
||||
final update = TaskStatusUpdate(
|
||||
UploadTask(url: 'http://test-server.com', filename: 'photo.jpg'),
|
||||
TaskStatus.complete,
|
||||
null,
|
||||
'{"id":"remote-1"}',
|
||||
);
|
||||
when(() => mockLocalAssetRepository.getById(asset.id)).thenAnswer((_) async => asset);
|
||||
when(
|
||||
() => mockLocalAssetRepository.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
|
||||
|
||||
verify(
|
||||
() => mockLocalAssetRepository.markSynced(
|
||||
asset.id,
|
||||
priorRemoteId: 'remote-1',
|
||||
syncedChecksum: asset.checksum ?? '',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('recordPriorRemoteIdOnSuccess: skips edit-pair base uploads', () async {
|
||||
const metadata = UploadTaskMetadata(
|
||||
localAssetId: 'local-1',
|
||||
isLivePhotos: false,
|
||||
livePhotoVideoId: '',
|
||||
isEditPair: true,
|
||||
);
|
||||
final update = TaskStatusUpdate(
|
||||
UploadTask(url: 'http://test-server.com', filename: 'base.jpg'),
|
||||
TaskStatus.complete,
|
||||
null,
|
||||
'{"id":"base-remote-1"}',
|
||||
);
|
||||
|
||||
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
|
||||
|
||||
verifyNever(
|
||||
() => mockLocalAssetRepository.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('recordPriorRemoteIdOnSuccess: skips live photos', () async {
|
||||
const metadata = UploadTaskMetadata(localAssetId: 'local-1', isLivePhotos: true, livePhotoVideoId: '');
|
||||
final update = TaskStatusUpdate(
|
||||
UploadTask(url: 'http://test-server.com', filename: 'live.mov'),
|
||||
TaskStatus.complete,
|
||||
null,
|
||||
'{"id":"video-remote-1"}',
|
||||
);
|
||||
|
||||
await sut.recordPriorRemoteIdOnSuccess(update, metadata);
|
||||
|
||||
verifyNever(
|
||||
() => mockLocalAssetRepository.markSynced(
|
||||
any(),
|
||||
priorRemoteId: any(named: 'priorRemoteId'),
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('getLivePhotoUploadTask', () {
|
||||
test('should call getOriginalFilename for live photo upload task', () async {
|
||||
final asset = LocalAssetStub.image1;
|
||||
@@ -420,8 +172,6 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAssetMediaRepository,
|
||||
mockNativeSyncApi,
|
||||
mockEditRevertService,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
@@ -472,8 +222,6 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAssetMediaRepository,
|
||||
mockNativeSyncApi,
|
||||
mockEditRevertService,
|
||||
);
|
||||
addTearDown(() => sutAndroid.dispose());
|
||||
|
||||
@@ -514,8 +262,6 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAssetMediaRepository,
|
||||
mockNativeSyncApi,
|
||||
mockEditRevertService,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
@@ -556,8 +302,6 @@ void main() {
|
||||
mockLocalAssetRepository,
|
||||
mockBackupRepository,
|
||||
mockAssetMediaRepository,
|
||||
mockNativeSyncApi,
|
||||
mockEditRevertService,
|
||||
);
|
||||
addTearDown(() => sutWithV24.dispose());
|
||||
|
||||
|
||||
@@ -4,14 +4,11 @@ import 'package:mocktail/mocktail.dart' as mocktail;
|
||||
|
||||
import '../domain/service.mock.dart';
|
||||
import '../infrastructure/repository.mock.dart';
|
||||
import '../repository.mocks.dart';
|
||||
|
||||
class UnitMocks {
|
||||
final localAlbum = MockLocalAlbumRepository();
|
||||
final localAsset = MockDriftLocalAssetRepository();
|
||||
final trashedAsset = MockTrashedLocalAssetRepository();
|
||||
final stack = MockDriftStackRepository();
|
||||
final assetApi = MockAssetApiRepository();
|
||||
|
||||
final nativeApi = MockNativeSyncApi();
|
||||
|
||||
@@ -34,8 +31,6 @@ class UnitMocks {
|
||||
mocktail.reset(localAlbum);
|
||||
mocktail.reset(localAsset);
|
||||
mocktail.reset(trashedAsset);
|
||||
mocktail.reset(stack);
|
||||
mocktail.reset(assetApi);
|
||||
mocktail.reset(nativeApi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/edit_revert.service.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../mocks.dart';
|
||||
|
||||
void main() {
|
||||
late EditRevertService sut;
|
||||
final mocks = UnitMocks();
|
||||
|
||||
LocalAsset asset({String? priorRemoteId, String? checksum = 'reverted-sha1'}) => LocalAsset(
|
||||
id: 'local-1',
|
||||
name: 'photo.jpg',
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025, 2),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
priorRemoteId: priorRemoteId,
|
||||
checksum: checksum,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
sut = EditRevertService(
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
stackRepository: mocks.stack,
|
||||
localAssetRepository: mocks.localAsset,
|
||||
assetApiRepository: mocks.assetApi,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
mocks.reset();
|
||||
});
|
||||
|
||||
group('tryHandleRevert', () {
|
||||
test('returns false when the asset was never uploaded as an edit', () async {
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: null)), isFalse);
|
||||
verifyNever(() => mocks.nativeApi.getEditState(any(), allowNetworkAccess: any(named: 'allowNetworkAccess')));
|
||||
});
|
||||
|
||||
test('returns false (lets the pair flow run) when there is still a live edit', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => EditState.edited);
|
||||
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
|
||||
});
|
||||
|
||||
test('returns false when the edit state cannot be read (offloaded to iCloud)', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => EditState.unknown);
|
||||
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||
verifyNever(() => mocks.stack.findStackIdByRemoteId(any()));
|
||||
});
|
||||
|
||||
test('returns false when the prior remote is not in a stack', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => EditState.notEdited);
|
||||
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => null);
|
||||
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
|
||||
});
|
||||
|
||||
test('returns false when the stack has no base member to flip back to', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => EditState.notEdited);
|
||||
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
|
||||
when(() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit')).thenAnswer((_) async => null);
|
||||
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isFalse);
|
||||
verifyNever(() => mocks.assetApi.setStackPrimary(any(), any()));
|
||||
});
|
||||
|
||||
test('flips the primary back to the base via prior_remote_id and keeps the edit (no trash)', () async {
|
||||
when(
|
||||
() => mocks.nativeApi.getEditState('local-1', allowNetworkAccess: any(named: 'allowNetworkAccess')),
|
||||
).thenAnswer((_) async => EditState.notEdited);
|
||||
when(() => mocks.stack.findStackIdByRemoteId('remote-edit')).thenAnswer((_) async => 'stack-1');
|
||||
when(
|
||||
() => mocks.stack.findStackBaseId('stack-1', excludeId: 'remote-edit'),
|
||||
).thenAnswer((_) async => 'remote-base');
|
||||
when(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
|
||||
when(() => mocks.stack.setPrimary('stack-1', 'remote-base')).thenAnswer((_) async {});
|
||||
when(
|
||||
() => mocks.localAsset.markSynced(
|
||||
'local-1',
|
||||
priorRemoteId: 'remote-base',
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
expect(await sut.tryHandleRevert(asset(priorRemoteId: 'remote-edit')), isTrue);
|
||||
|
||||
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'remote-base')).called(1);
|
||||
verify(() => mocks.stack.setPrimary('stack-1', 'remote-base')).called(1);
|
||||
verify(
|
||||
() => mocks.localAsset.markSynced(
|
||||
'local-1',
|
||||
priorRemoteId: 'remote-base',
|
||||
syncedChecksum: any(named: 'syncedChecksum'),
|
||||
),
|
||||
).called(1);
|
||||
// Nothing is trashed or unstacked; every edit stays in the stack.
|
||||
verifyNever(() => mocks.assetApi.delete(any(), any()));
|
||||
verifyNever(() => mocks.assetApi.unStack(any()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart';
|
||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -20,8 +18,6 @@ void main() {
|
||||
localAssetRepository: mocks.localAsset,
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
stackRepository: mocks.stack,
|
||||
assetApiRepository: mocks.assetApi,
|
||||
);
|
||||
|
||||
when(() => mocks.localAsset.reconcileHashesFromCloudId()).thenAnswer((_) async => {});
|
||||
@@ -114,8 +110,6 @@ void main() {
|
||||
nativeSyncApi: mocks.nativeApi,
|
||||
batchSize: batchSize,
|
||||
trashedLocalAssetRepository: mocks.trashedAsset,
|
||||
stackRepository: mocks.stack,
|
||||
assetApiRepository: mocks.assetApi,
|
||||
);
|
||||
|
||||
final album = LocalAlbumFactory.create();
|
||||
@@ -189,61 +183,5 @@ void main() {
|
||||
verify(() => mocks.nativeApi.hashAssets([asset2.id], allowNetworkAccess: false)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('iOS revert reconcile', () {
|
||||
test('flips the stack primary for a non-styled revert that re-hashed to the base', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
registerFallbackValue(<String>[]);
|
||||
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'h')]);
|
||||
|
||||
const target = StackReconcileTarget(
|
||||
stackId: 'stack-1',
|
||||
newPrimaryId: 'base-1',
|
||||
localAssetId: 'local-1',
|
||||
localAssetChecksum: 'reverted-sha1',
|
||||
);
|
||||
when(() => mocks.stack.findRevertReconcileTargets(any())).thenAnswer((_) async => [target]);
|
||||
when(() => mocks.assetApi.setStackPrimary('stack-1', 'base-1')).thenAnswer((_) async {});
|
||||
when(() => mocks.stack.setPrimary('stack-1', 'base-1')).thenAnswer((_) async {});
|
||||
when(
|
||||
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'base-1', syncedChecksum: 'reverted-sha1'),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verify(() => mocks.assetApi.setStackPrimary('stack-1', 'base-1')).called(1);
|
||||
verify(() => mocks.stack.setPrimary('stack-1', 'base-1')).called(1);
|
||||
verify(
|
||||
() => mocks.localAsset.markSynced('local-1', priorRemoteId: 'base-1', syncedChecksum: 'reverted-sha1'),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('does not reconcile on a non-iOS platform', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
registerFallbackValue(<String>[]);
|
||||
|
||||
final album = LocalAlbumFactory.create();
|
||||
final asset = LocalAssetFactory.create();
|
||||
when(() => mocks.localAlbum.getBackupAlbums()).thenAnswer((_) async => [album]);
|
||||
when(() => mocks.localAlbum.getAssetsToHash(album.id)).thenAnswer((_) async => [asset]);
|
||||
when(() => mocks.trashedAsset.getAssetsToHash(any())).thenAnswer((_) async => []);
|
||||
when(
|
||||
() => mocks.nativeApi.hashAssets([asset.id], allowNetworkAccess: false),
|
||||
).thenAnswer((_) async => [HashResult(assetId: asset.id, hash: 'h')]);
|
||||
|
||||
await sut.hashAssets();
|
||||
|
||||
verifyNever(() => mocks.stack.findRevertReconcileTargets(any()));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16490,12 +16490,6 @@
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
},
|
||||
"stackParentId": {
|
||||
"description": "Stack this asset onto the parent asset, with the new asset as the stack primary",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
},
|
||||
"visibility": {
|
||||
"$ref": "#/components/schemas/AssetVisibility"
|
||||
}
|
||||
@@ -20799,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": [
|
||||
|
||||
@@ -630,8 +630,6 @@ export type AssetMediaCreateDto = {
|
||||
metadata?: AssetMetadataUpsertItemDto[];
|
||||
/** Sidecar file data */
|
||||
sidecarData?: Blob;
|
||||
/** Stack this asset onto the parent asset, with the new asset as the stack primary */
|
||||
stackParentId?: string;
|
||||
visibility?: AssetVisibility;
|
||||
};
|
||||
export type AssetMediaResponseDto = {
|
||||
|
||||
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
|
||||
|
||||
@@ -48,10 +48,6 @@ const AssetMediaCreateSchema = AssetMediaBaseSchema.extend({
|
||||
isFavorite: stringToBool.optional().describe('Mark as favorite'),
|
||||
visibility: AssetVisibilitySchema.optional(),
|
||||
livePhotoVideoId: z.uuidv4().optional().describe('Live photo video ID'),
|
||||
stackParentId: z
|
||||
.uuidv4()
|
||||
.optional()
|
||||
.describe('Stack this asset onto the parent asset, with the new asset as the stack primary'),
|
||||
metadata: JsonParsed.pipe(z.array(AssetMetadataUpsertItemSchema)).optional().describe('Asset metadata items'),
|
||||
[UploadFieldName.SIDECAR_DATA]: z
|
||||
.any()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { JobItem, JobSource } from 'src/types';
|
||||
import { JobItem, JobSource, UploadFile } from 'src/types';
|
||||
|
||||
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
|
||||
|
||||
@@ -42,7 +42,7 @@ type EventMap = {
|
||||
AlbumInvite: [{ id: string; userId: string; senderName: string }];
|
||||
|
||||
// asset events
|
||||
AssetCreate: [{ asset: Asset }];
|
||||
AssetCreate: [{ asset: Asset; file: UploadFile }];
|
||||
AssetTag: [{ assetId: string }];
|
||||
AssetUntag: [{ assetId: string }];
|
||||
AssetHide: [{ assetId: string; userId: string }];
|
||||
|
||||
@@ -6,7 +6,7 @@ import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { DB } from 'src/schema';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
import { asUuid, isStackPrimaryConstraint, withDefaultVisibility } from 'src/utils/database';
|
||||
import { asUuid, withDefaultVisibility } from 'src/utils/database';
|
||||
|
||||
export interface StackSearch {
|
||||
ownerId: string;
|
||||
@@ -124,63 +124,6 @@ export class StackRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async linkAsset(
|
||||
ownerId: string,
|
||||
newAssetId: string,
|
||||
parentId: string,
|
||||
): Promise<{ stackId: string; created: boolean } | null> {
|
||||
try {
|
||||
return await this.db.transaction().execute(async (tx) => {
|
||||
// Lock the parent so two concurrent uploads can't each create a stack for it.
|
||||
const parent = await tx
|
||||
.selectFrom('asset')
|
||||
.select(['id', 'ownerId', 'stackId', 'deletedAt'])
|
||||
.where('id', '=', asUuid(parentId))
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!parent || parent.ownerId !== ownerId || parent.deletedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parent.stackId) {
|
||||
await tx
|
||||
.updateTable('asset')
|
||||
.set({ stackId: parent.stackId, updatedAt: new Date() })
|
||||
.where('id', '=', asUuid(newAssetId))
|
||||
.execute();
|
||||
await tx
|
||||
.updateTable('stack')
|
||||
.set({ primaryAssetId: newAssetId, updatedAt: new Date() })
|
||||
.where('id', '=', parent.stackId)
|
||||
.execute();
|
||||
return { stackId: parent.stackId, created: false };
|
||||
}
|
||||
|
||||
const stack = await tx
|
||||
.insertInto('stack')
|
||||
.values({ ownerId, primaryAssetId: newAssetId })
|
||||
.returning('id')
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await tx
|
||||
.updateTable('asset')
|
||||
.set({ stackId: stack.id, updatedAt: new Date() })
|
||||
.where('id', 'in', [asUuid(newAssetId), parent.id])
|
||||
.execute();
|
||||
|
||||
return { stackId: stack.id, created: true };
|
||||
});
|
||||
} catch (error) {
|
||||
// newAssetId may already be another stack's primary (e.g. a retried upload).
|
||||
// Treat the unique-constraint hit as "couldn't stack" rather than a 500.
|
||||
if (isStackPrimaryConstraint(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.db.deleteFrom('stack').where('id', '=', asUuid(id)).execute();
|
||||
|
||||
@@ -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!))
|
||||
|
||||
@@ -418,79 +418,6 @@ describe(AssetMediaService.name, () => {
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stack a new asset onto the parent and emit the populated stackId', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 42,
|
||||
};
|
||||
const parent = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
|
||||
mocks.asset.create.mockResolvedValue(assetEntity);
|
||||
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: true });
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
|
||||
id: 'id_1',
|
||||
status: AssetMediaStatus.CREATED,
|
||||
});
|
||||
|
||||
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, assetEntity.id, parent.id);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AssetCreate', {
|
||||
asset: expect.objectContaining({ stackId: 'stack-1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject stacking onto a trashed asset', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 42,
|
||||
};
|
||||
const parent = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce({ ...getForAsset(parent), deletedAt: new Date() });
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
expect(mocks.stack.linkAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should adopt a duplicate into the stack when stacking', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 0,
|
||||
};
|
||||
const parent = AssetFactory.create();
|
||||
const error = new Error('unique key violation');
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([parent.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(parent));
|
||||
mocks.asset.create.mockRejectedValue(error);
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('dup-id');
|
||||
mocks.stack.linkAsset.mockResolvedValue({ stackId: 'stack-1', created: false });
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, { ...createDto, stackParentId: parent.id }, file)).resolves.toEqual({
|
||||
id: 'dup-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
|
||||
expect(mocks.stack.linkAsset).toHaveBeenCalledWith(authStub.user1.user.id, 'dup-id', parent.id);
|
||||
});
|
||||
|
||||
it('should hide the linked motion asset', async () => {
|
||||
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
@@ -140,61 +140,86 @@ export class AssetMediaService extends BaseService {
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
|
||||
if (dto.stackParentId) {
|
||||
if (auth.sharedLink) {
|
||||
throw new BadRequestException('Cannot stack an asset uploaded via shared link');
|
||||
}
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.stackParentId] });
|
||||
const parent = await this.assetRepository.getById(dto.stackParentId);
|
||||
if (!parent || parent.deletedAt) {
|
||||
throw new BadRequestException('Cannot stack onto a trashed or missing asset');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.livePhotoVideoId) {
|
||||
await onBeforeLink(
|
||||
{ asset: this.assetRepository, event: this.eventRepository },
|
||||
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
|
||||
);
|
||||
}
|
||||
// When stacking, defer the AssetCreate event and emit it below with the
|
||||
// populated stackId, so clients don't briefly see the asset as standalone.
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile, { skipEventEmit: !!dto.stackParentId });
|
||||
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId: auth.user.id,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
duration: dto.duration || null,
|
||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: dto.filename || file.originalName,
|
||||
});
|
||||
|
||||
if (dto.metadata?.length) {
|
||||
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
|
||||
}
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({
|
||||
exif: { assetId: asset.id, fileSizeInByte: file.size },
|
||||
lockedPropertiesBehavior: 'override',
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, asset.id);
|
||||
}
|
||||
|
||||
if (dto.stackParentId) {
|
||||
const linkResult = await this.linkToStackParent(auth.user.id, asset.id, dto.stackParentId);
|
||||
await this.eventRepository.emit('AssetCreate', {
|
||||
asset: linkResult ? { ...asset, stackId: linkResult.stackId } : asset,
|
||||
});
|
||||
}
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
await this.eventRepository.emit('AssetCreate', { asset, file });
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||
} catch (error: any) {
|
||||
return this.handleUploadError(error, auth, file, sidecarFile, dto.stackParentId);
|
||||
}
|
||||
}
|
||||
// clean up files
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
||||
});
|
||||
|
||||
private async linkToStackParent(
|
||||
ownerId: string,
|
||||
newAssetId: string,
|
||||
parentId: string,
|
||||
): Promise<{ stackId: string; created: boolean } | null> {
|
||||
const result = await this.stackRepository.linkAsset(ownerId, newAssetId, parentId);
|
||||
if (!result) {
|
||||
this.logger.warn(`Could not link asset ${newAssetId} to stack parent ${parentId}: parent missing or not owned`);
|
||||
return null;
|
||||
// handle duplicates with a success response
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
throw error;
|
||||
}
|
||||
await this.eventRepository.emit(result.created ? 'StackCreate' : 'StackUpdate', {
|
||||
stackId: result.stackId,
|
||||
userId: ownerId,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
|
||||
@@ -327,93 +352,7 @@ export class AssetMediaService extends BaseService {
|
||||
auth: AuthDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
stackParentId?: string,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
// clean up files
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
if (stackParentId) {
|
||||
// Adopt the existing duplicate into the stack so a re-uploaded edit still
|
||||
// stacks instead of silently staying separate.
|
||||
await this.linkToStackParent(auth.user.id, duplicateId, stackParentId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
throw error;
|
||||
}
|
||||
|
||||
private async create(
|
||||
ownerId: string,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
options?: { skipEventEmit?: boolean },
|
||||
) {
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
checksumAlgorithm: ChecksumAlgorithm.sha1File,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
duration: dto.duration || null,
|
||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: dto.filename || file.originalName,
|
||||
});
|
||||
|
||||
if (dto.metadata?.length) {
|
||||
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
|
||||
}
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({
|
||||
exif: { assetId: asset.id, fileSizeInByte: file.size },
|
||||
lockedPropertiesBehavior: 'override',
|
||||
});
|
||||
|
||||
if (!options?.skipEventEmit) {
|
||||
await this.eventRepository.emit('AssetCreate', { asset });
|
||||
}
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
|
||||
|
||||
return asset;
|
||||
}
|
||||
): Promise<AssetMediaResponseDto> {}
|
||||
|
||||
private requireQuota(auth: AuthDto, size: number) {
|
||||
if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Updateable } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||
@@ -11,6 +11,7 @@ import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences }
|
||||
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
@@ -230,6 +231,11 @@ export class UserService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AssetCreate' })
|
||||
async onAssetCreate({ asset, file }: ArgOf<'AssetCreate'>) {
|
||||
await this.userRepository.updateUsage(asset.ownerId, file.size);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask })
|
||||
async handleUserSyncUsage(): Promise<JobStatus> {
|
||||
await this.userRepository.syncUsage();
|
||||
|
||||
@@ -76,12 +76,6 @@ export const isAssetChecksumConstraint = (error: unknown) => {
|
||||
return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum';
|
||||
};
|
||||
|
||||
export const STACK_PRIMARY_CONSTRAINT = 'stack_primaryAssetId_uq';
|
||||
|
||||
export const isStackPrimaryConstraint = (error: unknown) => {
|
||||
return (error as PostgresError)?.constraint_name === STACK_PRIMARY_CONSTRAINT;
|
||||
};
|
||||
|
||||
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
||||
}
|
||||
|
||||
+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>
|
||||
|
||||
@@ -127,7 +127,7 @@ export class EditManager {
|
||||
|
||||
try {
|
||||
// Setup the websocket listener before sending the edit request
|
||||
const editCompleted = waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000);
|
||||
const editCompleted = waitForWebsocketEvent('AssetEditReadyV2', (event) => event.asset.id === assetId, 10_000);
|
||||
|
||||
await (edits.length === 0
|
||||
? removeAssetEdits({ id: assetId })
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type NotificationDto,
|
||||
type ServerVersionResponseDto,
|
||||
type SyncAssetEditV1,
|
||||
type SyncAssetV1,
|
||||
type SyncAssetV2,
|
||||
} from '@immich/sdk';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { get, writable } from 'svelte/store';
|
||||
@@ -41,7 +41,7 @@ export interface Events {
|
||||
AppRestartV1: (event: AppRestartEvent) => void;
|
||||
|
||||
MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void;
|
||||
AssetEditReadyV1: (data: { asset: SyncAssetV1; edit: SyncAssetEditV1[] }) => void;
|
||||
AssetEditReadyV2: (data: { asset: SyncAssetV2; edit: SyncAssetEditV1[] }) => void;
|
||||
}
|
||||
|
||||
const websocket: Socket<Events> = io({
|
||||
|
||||
+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