mirror of
https://github.com/immich-app/immich.git
synced 2026-05-27 10:02:31 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b05fd7240a | |||
| 8682be4774 | |||
| dc66892ca1 | |||
| 53a24783f5 | |||
| 0546bc900c | |||
| 7c25bcc0a7 | |||
| 7905853639 | |||
| 073dcc1fbe | |||
| ccdaa4223c | |||
| 5386b62dc4 | |||
| 9733fa4872 | |||
| 3b34c53092 | |||
| fd7ddfef54 | |||
| 0975b1599c | |||
| 78ac0ade01 |
@@ -231,7 +231,7 @@ jobs:
|
|||||||
run: mise //mobile:codegen:pigeon
|
run: mise //mobile:codegen:pigeon
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@6aaa311d81eba98ae12eaffbcb63296ace0efcde # v1.307.0
|
uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.3'
|
ruby-version: '3.3'
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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).
|
# 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)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- 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.
|
# ℹ️ 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
|
# 📚 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
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@@ -154,33 +154,33 @@ Redis (Sentinel) URL example JSON before encoding:
|
|||||||
|
|
||||||
## Machine Learning
|
## Machine Learning
|
||||||
|
|
||||||
| Variable | Description | Default | Containers |
|
| 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` | 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_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_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_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_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_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_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_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_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__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__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__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__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__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_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` | 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_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_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_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__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_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` | 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_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_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 |
|
| `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.
|
\*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,
|
force: false,
|
||||||
ids: [assetToTrash.id],
|
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 page.getByText('Trash', { exact: true }).click();
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
|
||||||
@@ -676,7 +676,7 @@ test.describe('Timeline', () => {
|
|||||||
ids: [assetToArchive.id],
|
ids: [assetToArchive.id],
|
||||||
});
|
});
|
||||||
await thumbnailUtils.expectThumbnailIsArchive(page, 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 page.getByRole('link').getByText('Archive').click();
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
|
||||||
@@ -823,7 +823,7 @@ test.describe('Timeline', () => {
|
|||||||
});
|
});
|
||||||
// ensure thumbnail still exists and has favorite icon
|
// ensure thumbnail still exists and has favorite icon
|
||||||
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
|
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 page.getByRole('link').getByText('Favorites').click();
|
||||||
await timelineUtils.waitForTimelineLoad(page);
|
await timelineUtils.waitForTimelineLoad(page);
|
||||||
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
|
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
|
||||||
|
|||||||
@@ -698,6 +698,7 @@
|
|||||||
"birthdate_saved": "Date of birth saved successfully",
|
"birthdate_saved": "Date of birth saved successfully",
|
||||||
"birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
|
"birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
|
||||||
"blurred_background": "Blurred background",
|
"blurred_background": "Blurred background",
|
||||||
|
"browse_templates": "Browse templates",
|
||||||
"bugs_and_feature_requests": "Bugs & Feature Requests",
|
"bugs_and_feature_requests": "Bugs & Feature Requests",
|
||||||
"build": "Build",
|
"build": "Build",
|
||||||
"build_image": "Build Image",
|
"build_image": "Build Image",
|
||||||
@@ -839,6 +840,7 @@
|
|||||||
"copy_error": "Copy error",
|
"copy_error": "Copy error",
|
||||||
"copy_file_path": "Copy file path",
|
"copy_file_path": "Copy file path",
|
||||||
"copy_image": "Copy Image",
|
"copy_image": "Copy Image",
|
||||||
|
"copy_json": "Copy JSON",
|
||||||
"copy_link": "Copy link",
|
"copy_link": "Copy link",
|
||||||
"copy_link_to_clipboard": "Copy link to clipboard",
|
"copy_link_to_clipboard": "Copy link to clipboard",
|
||||||
"copy_password": "Copy password",
|
"copy_password": "Copy password",
|
||||||
@@ -976,7 +978,10 @@
|
|||||||
"downloading_asset_filename": "Downloading asset {filename}",
|
"downloading_asset_filename": "Downloading asset {filename}",
|
||||||
"downloading_from_icloud": "Downloading from iCloud",
|
"downloading_from_icloud": "Downloading from iCloud",
|
||||||
"downloading_media": "Downloading media",
|
"downloading_media": "Downloading media",
|
||||||
|
"drag_to_reorder": "Drag to reorder",
|
||||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"duplicate_workflow": "Duplicate workflow",
|
||||||
"duplicates": "Duplicates",
|
"duplicates": "Duplicates",
|
||||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
|
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
@@ -2254,6 +2259,7 @@
|
|||||||
"step_delete_confirm": "Are you sure you want to delete this step?",
|
"step_delete_confirm": "Are you sure you want to delete this step?",
|
||||||
"step_details": "Step details",
|
"step_details": "Step details",
|
||||||
"steps": "Steps",
|
"steps": "Steps",
|
||||||
|
"steps_count": "{count, plural, one {# step} other {# steps}}",
|
||||||
"stop_casting": "Stop casting",
|
"stop_casting": "Stop casting",
|
||||||
"stop_motion_photo": "Stop Motion Photo",
|
"stop_motion_photo": "Stop Motion Photo",
|
||||||
"stop_photo_sharing": "Stop sharing your photos?",
|
"stop_photo_sharing": "Stop sharing your photos?",
|
||||||
@@ -2415,6 +2421,7 @@
|
|||||||
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
||||||
"use_current_connection": "Use current connection",
|
"use_current_connection": "Use current connection",
|
||||||
"use_custom_date_range": "Use custom date range instead",
|
"use_custom_date_range": "Use custom date range instead",
|
||||||
|
"use_template": "Use template",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"user_has_been_deleted": "This user has been deleted.",
|
"user_has_been_deleted": "This user has been deleted.",
|
||||||
"user_id": "User ID",
|
"user_id": "User ID",
|
||||||
@@ -2476,6 +2483,7 @@
|
|||||||
"week": "Week",
|
"week": "Week",
|
||||||
"welcome": "Welcome",
|
"welcome": "Welcome",
|
||||||
"welcome_to_immich": "Welcome to Immich",
|
"welcome_to_immich": "Welcome to Immich",
|
||||||
|
"when": "When",
|
||||||
"width": "Width",
|
"width": "Width",
|
||||||
"wifi_name": "Wi-Fi Name",
|
"wifi_name": "Wi-Fi Name",
|
||||||
"workflow": "Workflow",
|
"workflow": "Workflow",
|
||||||
@@ -2488,6 +2496,7 @@
|
|||||||
"workflow_name": "Workflow name",
|
"workflow_name": "Workflow name",
|
||||||
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
||||||
"workflow_summary": "Workflow summary",
|
"workflow_summary": "Workflow summary",
|
||||||
|
"workflow_templates": "Workflow templates",
|
||||||
"workflow_update_success": "Workflow updated successfully",
|
"workflow_update_success": "Workflow updated successfully",
|
||||||
"workflow_updated": "Workflow updated",
|
"workflow_updated": "Workflow updated",
|
||||||
"workflows": "Workflows",
|
"workflows": "Workflows",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
from socket import socket
|
from socket import socket
|
||||||
|
|
||||||
from gunicorn.arbiter import Arbiter
|
from gunicorn.arbiter import Arbiter
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.logging import RichHandler
|
from rich.logging import RichHandler
|
||||||
@@ -42,6 +42,10 @@ class MaxBatchSize(BaseModel):
|
|||||||
ocr: int | None = None
|
ocr: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def default_worker_timeout() -> int:
|
||||||
|
return 900 if os.environ.get("DEVICE") == "rocm" else 300
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_prefix="MACHINE_LEARNING_",
|
env_prefix="MACHINE_LEARNING_",
|
||||||
@@ -54,7 +58,7 @@ class Settings(BaseSettings):
|
|||||||
model_ttl: int = 300
|
model_ttl: int = 300
|
||||||
model_ttl_poll_s: int = 10
|
model_ttl_poll_s: int = 10
|
||||||
workers: int = 1
|
workers: int = 1
|
||||||
worker_timeout: int = 300
|
worker_timeout: int = Field(default_factory=default_worker_timeout)
|
||||||
http_keepalive_timeout_s: int = 2
|
http_keepalive_timeout_s: int = 2
|
||||||
test_full: bool = False
|
test_full: bool = False
|
||||||
request_threads: int = os.cpu_count() or 4
|
request_threads: int = os.cpu_count() or 4
|
||||||
|
|||||||
@@ -89,4 +89,10 @@ class FaceRecognizer(InferenceModel):
|
|||||||
@property
|
@property
|
||||||
def _batch_size_default(self) -> int | None:
|
def _batch_size_default(self) -> int | None:
|
||||||
providers = ort.get_available_providers()
|
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 __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -12,6 +13,37 @@ from immich_ml.schemas import ModelPrecision, SessionNode
|
|||||||
|
|
||||||
from ..config import log, settings
|
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:
|
class OrtSession:
|
||||||
session: ort.InferenceSession
|
session: ort.InferenceSession
|
||||||
@@ -48,7 +80,21 @@ class OrtSession:
|
|||||||
input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]],
|
input_feed: dict[str, NDArray[np.float32]] | dict[str, NDArray[np.int32]],
|
||||||
run_options: Any = None,
|
run_options: Any = None,
|
||||||
) -> list[NDArray[np.float32]]:
|
) -> 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
|
return outputs
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ dependencies = [
|
|||||||
"fastapi>=0.95.2,<1.0",
|
"fastapi>=0.95.2,<1.0",
|
||||||
"gunicorn>=21.1.0",
|
"gunicorn>=21.1.0",
|
||||||
"huggingface-hub>=1.0,<2.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",
|
"numpy>=2.4.0,<3.0",
|
||||||
"opencv-python-headless>=4.7.0.72,<5.0",
|
"opencv-python-headless>=4.7.0.72,<5.0",
|
||||||
"orjson>=3.9.5",
|
"orjson>=3.9.5",
|
||||||
|
|||||||
@@ -35,7 +35,37 @@ from immich_ml.sessions.ort import OrtSession
|
|||||||
from immich_ml.sessions.rknn import RknnSession, run_inference
|
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:
|
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:
|
def test_sets_default_cache_dir(self) -> None:
|
||||||
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
|
encoder = OpenClipTextualEncoder("ViT-B-32__openai")
|
||||||
|
|
||||||
@@ -413,6 +443,52 @@ class TestOrtSession:
|
|||||||
|
|
||||||
assert sess_options is session.sess_options
|
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:
|
class TestAnnSession:
|
||||||
def test_creates_ann_session(self, ann_session: mock.Mock, info: mock.Mock) -> None:
|
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.load.assert_not_called()
|
||||||
onnx.save.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:
|
def test_set_custom_max_batch_size(self, mocker: MockerFixture) -> None:
|
||||||
mocker.patch.object(settings, "max_batch_size", MaxBatchSize(facial_recognition=2))
|
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 = "fastapi", specifier = ">=0.95.2,<1.0" },
|
||||||
{ name = "gunicorn", specifier = ">=21.1.0" },
|
{ name = "gunicorn", specifier = ">=21.1.0" },
|
||||||
{ name = "huggingface-hub", specifier = ">=1.0,<2.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 = "numpy", specifier = ">=2.4.0,<3.0" },
|
||||||
{ name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" },
|
{ name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.23.2,<2" },
|
||||||
{ name = "onnxruntime", marker = "extra == 'cpu'", 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 }
|
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
|
||||||
run = [
|
run = [
|
||||||
{ task = "//:plugins" },
|
{ task = "//:plugins" },
|
||||||
{ task = "//server:build" },
|
|
||||||
{ task = "//server:install" },
|
{ task = "//server:install" },
|
||||||
{ task = "//server:build" },
|
{ task = "//server:build" },
|
||||||
{ task = "//server:sync-open-api" },
|
{ task = "//server:sync-open-api" },
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import app.alextran.immich.images.LocalImageApi
|
|||||||
import app.alextran.immich.images.LocalImagesImpl
|
import app.alextran.immich.images.LocalImagesImpl
|
||||||
import app.alextran.immich.images.RemoteImageApi
|
import app.alextran.immich.images.RemoteImageApi
|
||||||
import app.alextran.immich.images.RemoteImagesImpl
|
import app.alextran.immich.images.RemoteImagesImpl
|
||||||
|
import app.alextran.immich.permission.PermissionApi
|
||||||
|
import app.alextran.immich.permission.PermissionApiImpl
|
||||||
import app.alextran.immich.sync.NativeSyncApi
|
import app.alextran.immich.sync.NativeSyncApi
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||||
@@ -44,7 +46,9 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
} else {
|
} else {
|
||||||
NativeSyncApiImpl30(ctx)
|
NativeSyncApiImpl30(ctx)
|
||||||
}
|
}
|
||||||
|
val permissionApiImpl = PermissionApiImpl(ctx)
|
||||||
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
||||||
|
PermissionApi.setUp(messenger, permissionApiImpl)
|
||||||
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
|
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
|
||||||
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
|
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
|
||||||
|
|
||||||
@@ -53,6 +57,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
|
|
||||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||||
|
flutterEngine.plugins.add(permissionApiImpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
||||||
@@ -60,6 +65,8 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
||||||
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
||||||
nativeApi?.detachFromEngine()
|
nativeApi?.detachFromEngine()
|
||||||
|
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
|
||||||
|
permissionApi?.detachFromEngine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
package app.alextran.immich.permission
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
|
|
||||||
|
class ManageMediaPermissionDelegate(
|
||||||
|
context: Context,
|
||||||
|
private val requestCode: Int = 1003,
|
||||||
|
) : PluginRegistry.ActivityResultListener {
|
||||||
|
private val ctx = context.applicationContext
|
||||||
|
private var activityBinding: ActivityPluginBinding? = null
|
||||||
|
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
|
||||||
|
|
||||||
|
fun hasManageMediaPermission(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaStore.canManageMedia(ctx)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
if (hasManageMediaPermission()) {
|
||||||
|
callback(Result.success(true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openManageMediaPermissionSettings(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
openManageMediaPermissionSettings(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openManageMediaPermissionSettings(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
callback(Result.success(false))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val activity = activityBinding?.activity
|
||||||
|
if (activity == null) {
|
||||||
|
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingResult = callback
|
||||||
|
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA).apply {
|
||||||
|
data = "package:${activity.packageName}".toUri()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
activity.startActivityForResult(intent, requestCode)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pendingResult = null
|
||||||
|
callback(
|
||||||
|
Result.failure(
|
||||||
|
FlutterError("ACTIVITY_LAUNCH_FAILED", "Failed to launch MANAGE_MEDIA settings", e.toString())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
activityBinding = binding
|
||||||
|
binding.addActivityResultListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDetachedFromActivity() {
|
||||||
|
failPending("ACTIVITY_DETACHED", "Activity detached before MANAGE_MEDIA result")
|
||||||
|
activityBinding?.removeActivityResultListener(this)
|
||||||
|
activityBinding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||||
|
if (requestCode == this.requestCode) {
|
||||||
|
val callback = pendingResult
|
||||||
|
pendingResult = null
|
||||||
|
callback?.invoke(Result.success(hasManageMediaPermission()))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failPending(code: String, message: String) {
|
||||||
|
val callback = pendingResult ?: return
|
||||||
|
pendingResult = null
|
||||||
|
callback(Result.failure(FlutterError(code, message, null)))
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||||
|
|
||||||
|
package app.alextran.immich.permission
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import io.flutter.plugin.common.BasicMessageChannel
|
||||||
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
import io.flutter.plugin.common.MessageCodec
|
||||||
|
import io.flutter.plugin.common.StandardMethodCodec
|
||||||
|
import io.flutter.plugin.common.StandardMessageCodec
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
private object PermissionApiPigeonUtils {
|
||||||
|
|
||||||
|
fun wrapResult(result: Any?): List<Any?> {
|
||||||
|
return listOf(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun wrapError(exception: Throwable): List<Any?> {
|
||||||
|
return if (exception is FlutterError) {
|
||||||
|
listOf(
|
||||||
|
exception.code,
|
||||||
|
exception.message,
|
||||||
|
exception.details
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
exception.javaClass.simpleName,
|
||||||
|
exception.toString(),
|
||||||
|
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||||
|
* @property code The error code.
|
||||||
|
* @property message The error message.
|
||||||
|
* @property details The error details. Must be a datatype supported by the api codec.
|
||||||
|
*/
|
||||||
|
class FlutterError (
|
||||||
|
val code: String,
|
||||||
|
override val message: String? = null,
|
||||||
|
val details: Any? = null
|
||||||
|
) : RuntimeException()
|
||||||
|
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
|
||||||
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
|
return super.readValueOfType(type, buffer)
|
||||||
|
}
|
||||||
|
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||||
|
super.writeValue(stream, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
|
interface PermissionApi {
|
||||||
|
fun hasManageMediaPermission(): Boolean
|
||||||
|
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||||
|
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The codec used by PermissionApi. */
|
||||||
|
val codec: MessageCodec<Any?> by lazy {
|
||||||
|
PermissionApiPigeonCodec()
|
||||||
|
}
|
||||||
|
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
|
||||||
|
@JvmOverloads
|
||||||
|
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||||
|
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
val wrapped: List<Any?> = try {
|
||||||
|
listOf(api.hasManageMediaPermission())
|
||||||
|
} catch (exception: Throwable) {
|
||||||
|
PermissionApiPigeonUtils.wrapError(exception)
|
||||||
|
}
|
||||||
|
reply.reply(wrapped)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
api.requestManageMediaPermission{ result: Result<Boolean> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(PermissionApiPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { _, reply ->
|
||||||
|
api.manageMediaPermission{ result: Result<Boolean> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(PermissionApiPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package app.alextran.immich.permission
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.alextran.immich.core.ImmichPlugin
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
|
|
||||||
|
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
|
||||||
|
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
|
||||||
|
|
||||||
|
override fun hasManageMediaPermission(): Boolean =
|
||||||
|
manageMediaPermissionDelegate.hasManageMediaPermission()
|
||||||
|
|
||||||
|
override fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
manageMediaPermissionDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
|
||||||
|
manageMediaPermissionDelegate.manageMediaPermission { completeWhenActive(callback, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
manageMediaPermissionDelegate.onAttachedToActivity(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
|
manageMediaPermissionDelegate.onDetachedFromActivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
|
manageMediaPermissionDelegate.onAttachedToActivity(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivity() {
|
||||||
|
manageMediaPermissionDelegate.onDetachedFromActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package app.alextran.immich.sync
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
|
import io.flutter.plugin.common.PluginRegistry
|
||||||
|
|
||||||
|
class MediaTrashDelegate(
|
||||||
|
context: Context,
|
||||||
|
private val trashRequestCode: Int = 1002,
|
||||||
|
) : PluginRegistry.ActivityResultListener {
|
||||||
|
private val ctx = context.applicationContext
|
||||||
|
private var activityBinding: ActivityPluginBinding? = null
|
||||||
|
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
|
||||||
|
|
||||||
|
private fun hasManageMediaPermission(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
MediaStore.canManageMedia(ctx)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || !hasManageMediaPermission()) {
|
||||||
|
callback(Result.failure(FlutterError("PERMISSION_DENIED", "Media permission required", null)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val id = mediaId.toLongOrNull()
|
||||||
|
if (id == null) {
|
||||||
|
callback(Result.failure(FlutterError("INVALID_ID", "The file id is not a valid number: $mediaId", null)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInTrash(id)) {
|
||||||
|
callback(Result.failure(FlutterError("TRASH_NOT_FOUND", "Item with id=$id not found in trash", null)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreUri(ContentUris.withAppendedId(contentUriForType(type.toInt()), id), callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun restoreUri(
|
||||||
|
contentUri: Uri,
|
||||||
|
callback: (Result<Boolean>) -> Unit,
|
||||||
|
) {
|
||||||
|
val activity = activityBinding?.activity
|
||||||
|
if (activity == null) {
|
||||||
|
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pendingIntent = MediaStore.createTrashRequest(ctx.contentResolver, listOf(contentUri), false)
|
||||||
|
pendingResult = callback
|
||||||
|
activity.startIntentSenderForResult(
|
||||||
|
pendingIntent.intentSender,
|
||||||
|
trashRequestCode,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
pendingResult = null
|
||||||
|
callback(
|
||||||
|
Result.failure(
|
||||||
|
FlutterError("TRASH_ERROR", "Error creating or starting trash request", e.toString())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun isInTrash(id: Long): Boolean {
|
||||||
|
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
|
val args = Bundle().apply {
|
||||||
|
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
|
||||||
|
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
|
||||||
|
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||||
|
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
|
||||||
|
}
|
||||||
|
return ctx.contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
|
||||||
|
?.use { it.moveToFirst() } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contentUriForType(type: Int): Uri =
|
||||||
|
when (type) {
|
||||||
|
// Same order as AssetType from Dart.
|
||||||
|
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
|
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
|
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
|
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
activityBinding = binding
|
||||||
|
binding.addActivityResultListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDetachedFromActivity() {
|
||||||
|
failPending("ACTIVITY_DETACHED", "Activity detached before trash result")
|
||||||
|
activityBinding?.removeActivityResultListener(this)
|
||||||
|
activityBinding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||||
|
if (requestCode == trashRequestCode) {
|
||||||
|
val callback = pendingResult
|
||||||
|
pendingResult = null
|
||||||
|
callback?.invoke(Result.success(resultCode == Activity.RESULT_OK))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failPending(code: String, message: String) {
|
||||||
|
val callback = pendingResult ?: return
|
||||||
|
pendingResult = null
|
||||||
|
callback(Result.failure(FlutterError(code, message, null)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -553,6 +553,7 @@ interface NativeSyncApi {
|
|||||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||||
fun cancelHashing()
|
fun cancelHashing()
|
||||||
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
|
||||||
|
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
|
||||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -747,6 +748,27 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val mediaIdArg = args[0] as String
|
||||||
|
val typeArg = args[1] as Long
|
||||||
|
api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result<Boolean> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
run {
|
run {
|
||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import com.bumptech.glide.Glide
|
|||||||
import com.bumptech.glide.load.ImageHeaderParser
|
import com.bumptech.glide.load.ImageHeaderParser
|
||||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -39,10 +41,11 @@ sealed class AssetResult {
|
|||||||
private const val TAG = "NativeSyncApiImplBase"
|
private const val TAG = "NativeSyncApiImplBase"
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
@SuppressLint("InlinedApi")
|
||||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
private var hashTask: Job? = null
|
private var hashTask: Job? = null
|
||||||
|
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
|
||||||
@@ -448,6 +451,26 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
hashTask = null
|
hashTask = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
|
||||||
|
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
|
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivityForConfigChanges() {
|
||||||
|
mediaTrashDelegate.onDetachedFromActivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||||
|
mediaTrashDelegate.onAttachedToActivity(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromActivity() {
|
||||||
|
mediaTrashDelegate.onDetachedFromActivity()
|
||||||
|
}
|
||||||
|
|
||||||
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
|
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
|
||||||
@Suppress("unused", "UNUSED_PARAMETER")
|
@Suppress("unused", "UNUSED_PARAMETER")
|
||||||
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
||||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
||||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
|
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
|
||||||
|
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
|
||||||
|
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
|
||||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
|
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
|
||||||
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
|
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
|
||||||
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
@@ -105,6 +107,8 @@
|
|||||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
||||||
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
|
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
|
||||||
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
|
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
|
||||||
|
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
|
||||||
|
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
|
||||||
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
|
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
|
||||||
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -283,6 +287,7 @@
|
|||||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
|
B2EE00052E72CA15008B6CA7 /* Permission */,
|
||||||
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
|
||||||
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
|
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||||
@@ -317,6 +322,15 @@
|
|||||||
path = Connectivity;
|
path = Connectivity;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
B2EE00052E72CA15008B6CA7 /* Permission */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */,
|
||||||
|
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */,
|
||||||
|
);
|
||||||
|
path = Permission;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -619,6 +633,8 @@
|
|||||||
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
|
||||||
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
|
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
|
||||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
||||||
|
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
|
||||||
|
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
|
||||||
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
|
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
|
||||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
||||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import native_video_player
|
|||||||
|
|
||||||
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
|
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
|
||||||
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||||
|
PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
|
||||||
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
|
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
|
||||||
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
|
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
|
||||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
|
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import Flutter
|
||||||
|
#elseif os(macOS)
|
||||||
|
import FlutterMacOS
|
||||||
|
#else
|
||||||
|
#error("Unsupported platform.")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private func wrapResult(_ result: Any?) -> [Any?] {
|
||||||
|
return [result]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wrapError(_ error: Any) -> [Any?] {
|
||||||
|
if let pigeonError = error as? PigeonError {
|
||||||
|
return [
|
||||||
|
pigeonError.code,
|
||||||
|
pigeonError.message,
|
||||||
|
pigeonError.details,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if let flutterError = error as? FlutterError {
|
||||||
|
return [
|
||||||
|
flutterError.code,
|
||||||
|
flutterError.message,
|
||||||
|
flutterError.details,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
"\(error)",
|
||||||
|
"\(Swift.type(of: error))",
|
||||||
|
"Stacktrace: \(Thread.callStackSymbols)",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isNullish(_ value: Any?) -> Bool {
|
||||||
|
return value is NSNull || value == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nilOrValue<T>(_ value: Any?) -> T? {
|
||||||
|
if value is NSNull { return nil }
|
||||||
|
return value as! T?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
|
protocol PermissionApi {
|
||||||
|
func hasManageMediaPermission() throws -> Bool
|
||||||
|
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||||
|
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
|
class PermissionApiSetup {
|
||||||
|
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
|
||||||
|
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
|
||||||
|
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
|
||||||
|
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||||
|
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||||
|
do {
|
||||||
|
let result = try api.hasManageMediaPermission()
|
||||||
|
reply(wrapResult(result))
|
||||||
|
} catch {
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasManageMediaPermissionChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
requestManageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||||
|
api.requestManageMediaPermission { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let res):
|
||||||
|
reply(wrapResult(res))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
requestManageMediaPermissionChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
manageMediaPermissionChannel.setMessageHandler { _, reply in
|
||||||
|
api.manageMediaPermission { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let res):
|
||||||
|
reply(wrapResult(res))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
manageMediaPermissionChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class PermissionApiImpl: PermissionApi {
|
||||||
|
func hasManageMediaPermission() throws -> Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||||
|
completion(.success(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||||
|
completion(.success(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+19
@@ -537,6 +537,7 @@ protocol NativeSyncApi {
|
|||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||||
func cancelHashing() throws
|
func cancelHashing() throws
|
||||||
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
func getTrashedAssets() throws -> [String: [PlatformAsset]]
|
||||||
|
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
|
||||||
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,6 +722,24 @@ class NativeSyncApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
getTrashedAssetsChannel.setMessageHandler(nil)
|
getTrashedAssetsChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
restoreFromTrashByIdChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let mediaIdArg = args[0] as! String
|
||||||
|
let typeArg = args[1] as! Int64
|
||||||
|
api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let res):
|
||||||
|
reply(wrapResult(res))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
restoreFromTrashByIdChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
let getCloudIdForAssetIdsChannel = taskQueue == nil
|
let getCloudIdForAssetIdsChannel = taskQueue == nil
|
||||||
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
|
|||||||
@@ -382,6 +382,10 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
|
||||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||||
|
completion(.success(false))
|
||||||
|
}
|
||||||
|
|
||||||
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
|
||||||
// Ensure to actually getting all assets for the Recents album
|
// Ensure to actually getting all assets for the Recents album
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
import 'package:immich_mobile/utils/datetime_helpers.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -23,29 +23,29 @@ class LocalSyncService {
|
|||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final NativeSyncApi _nativeSyncApi;
|
final NativeSyncApi _nativeSyncApi;
|
||||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
final LocalFilesManagerRepository _localFilesManager;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
final StorageRepository _storageRepository;
|
final IPermissionRepository _permissionRepository;
|
||||||
final Logger _log = Logger("DeviceSyncService");
|
final Logger _log = Logger("DeviceSyncService");
|
||||||
|
|
||||||
LocalSyncService({
|
LocalSyncService({
|
||||||
required DriftLocalAlbumRepository localAlbumRepository,
|
required DriftLocalAlbumRepository localAlbumRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
required LocalFilesManagerRepository localFilesManager,
|
required AssetMediaRepository assetMediaRepository,
|
||||||
required StorageRepository storageRepository,
|
required IPermissionRepository permissionRepository,
|
||||||
required NativeSyncApi nativeSyncApi,
|
required NativeSyncApi nativeSyncApi,
|
||||||
}) : _localAlbumRepository = localAlbumRepository,
|
}) : _localAlbumRepository = localAlbumRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
_localFilesManager = localFilesManager,
|
_assetMediaRepository = assetMediaRepository,
|
||||||
_storageRepository = storageRepository,
|
_permissionRepository = permissionRepository,
|
||||||
_nativeSyncApi = nativeSyncApi;
|
_nativeSyncApi = nativeSyncApi;
|
||||||
|
|
||||||
Future<void> sync({bool full = false}) async {
|
Future<void> sync({bool full = false}) async {
|
||||||
final Stopwatch stopwatch = Stopwatch()..start();
|
final Stopwatch stopwatch = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
final hasPermission = await _permissionRepository.hasManageMediaPermission();
|
||||||
if (hasPermission) {
|
if (hasPermission) {
|
||||||
await _syncTrashedAssets();
|
await _syncTrashedAssets();
|
||||||
} else {
|
} else {
|
||||||
@@ -373,7 +373,7 @@ class LocalSyncService {
|
|||||||
|
|
||||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||||
if (assetsToRestore.isNotEmpty) {
|
if (assetsToRestore.isNotEmpty) {
|
||||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||||
} else {
|
} else {
|
||||||
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
_log.info("syncTrashedAssets, No remote assets found for restoration");
|
||||||
@@ -381,15 +381,15 @@ class LocalSyncService {
|
|||||||
|
|
||||||
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
|
||||||
if (localAssetsToTrash.isNotEmpty) {
|
if (localAssetsToTrash.isNotEmpty) {
|
||||||
final mediaUrls = await Future.wait(
|
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||||
localAssetsToTrash.values
|
_log.info("Moving to trash ${localIds.join(", ")} assets");
|
||||||
.expand((e) => e)
|
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
if (movedIds.isNotEmpty) {
|
||||||
);
|
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||||
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
)..removeWhere((_, assets) => assets.isEmpty);
|
||||||
if (result) {
|
|
||||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/utils/semver.dart';
|
import 'package:immich_mobile/utils/semver.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -34,8 +34,8 @@ class SyncStreamService {
|
|||||||
final SyncStreamRepository _syncStreamRepository;
|
final SyncStreamRepository _syncStreamRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
|
||||||
final LocalFilesManagerRepository _localFilesManager;
|
final AssetMediaRepository _assetMediaRepository;
|
||||||
final StorageRepository _storageRepository;
|
final IPermissionRepository _permissionRepository;
|
||||||
final SyncMigrationRepository _syncMigrationRepository;
|
final SyncMigrationRepository _syncMigrationRepository;
|
||||||
final ApiService _api;
|
final ApiService _api;
|
||||||
final bool Function()? _cancelChecker;
|
final bool Function()? _cancelChecker;
|
||||||
@@ -45,8 +45,8 @@ class SyncStreamService {
|
|||||||
required SyncStreamRepository syncStreamRepository,
|
required SyncStreamRepository syncStreamRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
|
||||||
required LocalFilesManagerRepository localFilesManager,
|
required AssetMediaRepository assetMediaRepository,
|
||||||
required StorageRepository storageRepository,
|
required IPermissionRepository permissionRepository,
|
||||||
required SyncMigrationRepository syncMigrationRepository,
|
required SyncMigrationRepository syncMigrationRepository,
|
||||||
required ApiService api,
|
required ApiService api,
|
||||||
bool Function()? cancelChecker,
|
bool Function()? cancelChecker,
|
||||||
@@ -54,8 +54,8 @@ class SyncStreamService {
|
|||||||
_syncStreamRepository = syncStreamRepository,
|
_syncStreamRepository = syncStreamRepository,
|
||||||
_localAssetRepository = localAssetRepository,
|
_localAssetRepository = localAssetRepository,
|
||||||
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
_trashedLocalAssetRepository = trashedLocalAssetRepository,
|
||||||
_localFilesManager = localFilesManager,
|
_assetMediaRepository = assetMediaRepository,
|
||||||
_storageRepository = storageRepository,
|
_permissionRepository = permissionRepository,
|
||||||
_syncMigrationRepository = syncMigrationRepository,
|
_syncMigrationRepository = syncMigrationRepository,
|
||||||
_api = api,
|
_api = api,
|
||||||
_cancelChecker = cancelChecker;
|
_cancelChecker = cancelChecker;
|
||||||
@@ -500,22 +500,22 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
||||||
final mediaUrls = await Future.wait(
|
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
|
||||||
localAssetsToTrash.values
|
_logger.info("Moving to trash ${localIds.join(", ")} assets");
|
||||||
.expand((e) => e)
|
final movedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
if (movedIds.isNotEmpty) {
|
||||||
);
|
final movedAssetsByAlbum = localAssetsToTrash.map(
|
||||||
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
|
||||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
)..removeWhere((_, assets) => assets.isEmpty);
|
||||||
if (result) {
|
|
||||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyRemoteRestoreToLocal() async {
|
Future<void> _applyRemoteRestoreToLocal() async {
|
||||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||||
if (assetsToRestore.isNotEmpty) {
|
if (assetsToRestore.isNotEmpty) {
|
||||||
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
|
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
|
||||||
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
|
||||||
} else {
|
} else {
|
||||||
_logger.info("No remote assets found for restoration");
|
_logger.info("No remote assets found for restoration");
|
||||||
@@ -523,7 +523,7 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
|
||||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
||||||
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -533,7 +533,7 @@ class SyncStreamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
|
||||||
if (!(await _localFilesManager.hasManageMediaPermission())) {
|
if (!(await _permissionRepository.hasManageMediaPermission())) {
|
||||||
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -654,6 +654,25 @@ class NativeSyncApi {
|
|||||||
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mediaId, type]);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: false,
|
||||||
|
);
|
||||||
|
return pigeonVar_replyValue! as bool;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
|
||||||
|
// See also: https://pub.dev/packages/pigeon
|
||||||
|
// ignore_for_file: unused_import, unused_shown_name
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data' show Float64List, Int32List, Int64List;
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
|
||||||
|
|
||||||
|
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
|
||||||
|
if (replyList == null) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'channel-error',
|
||||||
|
message: 'Unable to establish connection on channel: "$channelName".',
|
||||||
|
);
|
||||||
|
} else if (replyList.length > 1) {
|
||||||
|
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
|
||||||
|
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: 'null-error',
|
||||||
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return replyList.firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PigeonCodec extends StandardMessageCodec {
|
||||||
|
const _PigeonCodec();
|
||||||
|
@override
|
||||||
|
void writeValue(WriteBuffer buffer, Object? value) {
|
||||||
|
if (value is int) {
|
||||||
|
buffer.putUint8(4);
|
||||||
|
buffer.putInt64(value);
|
||||||
|
} else {
|
||||||
|
super.writeValue(buffer, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||||
|
switch (type) {
|
||||||
|
default:
|
||||||
|
return super.readValueOfType(type, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PermissionApi {
|
||||||
|
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
|
||||||
|
/// available for dependency injection. If it is left null, the default
|
||||||
|
/// BinaryMessenger will be used which routes to the host platform.
|
||||||
|
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
|
||||||
|
: pigeonVar_binaryMessenger = binaryMessenger,
|
||||||
|
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
|
||||||
|
final BinaryMessenger? pigeonVar_binaryMessenger;
|
||||||
|
|
||||||
|
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
|
||||||
|
|
||||||
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
|
Future<bool> hasManageMediaPermission() async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: false,
|
||||||
|
);
|
||||||
|
return pigeonVar_replyValue! as bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> requestManageMediaPermission() async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: false,
|
||||||
|
);
|
||||||
|
return pigeonVar_replyValue! as bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> manageMediaPermission() async {
|
||||||
|
final pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$pigeonVar_messageChannelSuffix';
|
||||||
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||||
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
pigeonVar_replyList,
|
||||||
|
pigeonVar_channelName,
|
||||||
|
isNullValid: false,
|
||||||
|
);
|
||||||
|
return pigeonVar_replyValue! as bool;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
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/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/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.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/action.service.dart';
|
||||||
import 'package:immich_mobile/services/download.service.dart';
|
import 'package:immich_mobile/services/download.service.dart';
|
||||||
import 'package:immich_mobile/services/foreground_upload.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:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.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');
|
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
|
||||||
}
|
}
|
||||||
|
|
||||||
final completer = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
|
Future<void> editReady;
|
||||||
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
|
if (ref.read(serverInfoProvider).serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) {
|
||||||
return eventAsset?.id == ids.first;
|
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV2", (dynamic data) {
|
||||||
}, const Duration(seconds: 10));
|
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 {
|
try {
|
||||||
await _service.applyEdits(ids.first, edits);
|
await _service.applyEdits(ids.first, edits);
|
||||||
await completer;
|
await editReady;
|
||||||
return const ActionResult(count: 1, success: true);
|
return const ActionResult(count: 1, success: true);
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Failed to apply edits to assets', error, stack);
|
_logger.severe('Failed to apply edits to assets', error, stack);
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
|
|||||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
|
||||||
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
import 'package:immich_mobile/platform/local_image_api.g.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/network_api.g.dart';
|
import 'package:immich_mobile/platform/network_api.g.dart';
|
||||||
|
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||||
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||||
|
|
||||||
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
|
||||||
@@ -16,6 +17,8 @@ final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService
|
|||||||
|
|
||||||
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||||
|
|
||||||
|
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
|
||||||
|
|
||||||
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
|
||||||
|
|
||||||
final localImageApi = LocalImageApi();
|
final localImageApi = LocalImageApi();
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.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/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
|
|
||||||
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ final syncStreamServiceProvider = Provider(
|
|||||||
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||||
storageRepository: ref.watch(storageRepositoryProvider),
|
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||||
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
|
||||||
api: ref.watch(apiServiceProvider),
|
api: ref.watch(apiServiceProvider),
|
||||||
cancelChecker: ref.watch(cancellationProvider),
|
cancelChecker: ref.watch(cancellationProvider),
|
||||||
@@ -39,8 +39,8 @@ final localSyncServiceProvider = Provider(
|
|||||||
localAlbumRepository: ref.watch(localAlbumRepository),
|
localAlbumRepository: ref.watch(localAlbumRepository),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
|
||||||
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
|
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
|
||||||
storageRepository: ref.watch(storageRepositoryProvider),
|
permissionRepository: ref.watch(permissionRepositoryProvider),
|
||||||
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
nativeSyncApi: ref.watch(nativeSyncApiProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,19 +8,24 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/response_extensions.dart';
|
import 'package:immich_mobile/extensions/response_extensions.dart';
|
||||||
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
|
final assetMediaRepositoryProvider = Provider(
|
||||||
|
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
class AssetMediaRepository {
|
class AssetMediaRepository {
|
||||||
final AssetApiRepository _assetApiRepository;
|
final AssetApiRepository _assetApiRepository;
|
||||||
|
final NativeSyncApi _nativeSyncApi;
|
||||||
static final Logger _log = Logger("AssetMediaRepository");
|
static final Logger _log = Logger("AssetMediaRepository");
|
||||||
|
|
||||||
const AssetMediaRepository(this._assetApiRepository);
|
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
|
||||||
|
|
||||||
Future<bool> _androidSupportsTrash() async {
|
Future<bool> _androidSupportsTrash() async {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
@@ -45,6 +50,27 @@ class AssetMediaRepository {
|
|||||||
return PhotoManager.editor.deleteWithIds(ids);
|
return PhotoManager.editor.deleteWithIds(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _restoreFromTrashById(String mediaId, int type) async {
|
||||||
|
try {
|
||||||
|
return await _nativeSyncApi.restoreFromTrashById(mediaId, type);
|
||||||
|
} catch (e, s) {
|
||||||
|
_log.warning('Error restore file from trash by Id', e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
||||||
|
final restoredIds = <String>[];
|
||||||
|
for (final asset in assets) {
|
||||||
|
_log.info("Restoring from trash, localId: ${asset.id}, checksum: ${asset.checksum}");
|
||||||
|
final result = await _restoreFromTrashById(asset.id, asset.type.index);
|
||||||
|
if (result) {
|
||||||
|
restoredIds.add(asset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return restoredIds;
|
||||||
|
}
|
||||||
|
|
||||||
Future<AssetEntity?> get(String id) async {
|
Future<AssetEntity?> get(String id) async {
|
||||||
final entity = await AssetEntity.fromId(id);
|
final entity = await AssetEntity.fromId(id);
|
||||||
return entity;
|
return entity;
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:immich_mobile/services/local_files_manager.service.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
final localFilesManagerRepositoryProvider = Provider(
|
|
||||||
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
|
|
||||||
);
|
|
||||||
|
|
||||||
class LocalFilesManagerRepository {
|
|
||||||
LocalFilesManagerRepository(this._service);
|
|
||||||
|
|
||||||
final Logger _logger = Logger('LocalFilesManagerRepo');
|
|
||||||
final LocalFilesManagerService _service;
|
|
||||||
|
|
||||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
|
||||||
return await _service.moveToTrash(mediaUrls);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
|
||||||
return await _service.restoreFromTrash(fileName, type);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> requestManageMediaPermission() async {
|
|
||||||
return await _service.requestManageMediaPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> hasManageMediaPermission() async {
|
|
||||||
return await _service.hasManageMediaPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> manageMediaPermission() async {
|
|
||||||
return await _service.manageMediaPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
|
|
||||||
final restoredIds = <String>[];
|
|
||||||
for (final asset in assets) {
|
|
||||||
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
|
|
||||||
try {
|
|
||||||
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
|
|
||||||
if (result) {
|
|
||||||
restoredIds.add(asset.id);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
_logger.warning("Restoring failure: $e");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return restoredIds;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/platform/permission_api.g.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
final permissionRepositoryProvider = Provider((_) {
|
final permissionRepositoryProvider = Provider((ref) {
|
||||||
return const PermissionRepository();
|
return PermissionRepository(ref.watch(permissionApiProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
class PermissionRepository implements IPermissionRepository {
|
class PermissionRepository implements IPermissionRepository {
|
||||||
const PermissionRepository();
|
final PermissionApi _permissionApi;
|
||||||
|
|
||||||
|
const PermissionRepository(this._permissionApi);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> hasLocationWhenInUsePermission() {
|
Future<bool> hasLocationWhenInUsePermission() {
|
||||||
@@ -34,6 +38,21 @@ class PermissionRepository implements IPermissionRepository {
|
|||||||
Future<bool> openSettings() {
|
Future<bool> openSettings() {
|
||||||
return openAppSettings();
|
return openAppSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> hasManageMediaPermission() {
|
||||||
|
return _permissionApi.hasManageMediaPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestManageMediaPermission() {
|
||||||
|
return _permissionApi.requestManageMediaPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> manageMediaPermission() {
|
||||||
|
return _permissionApi.manageMediaPermission();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract interface class IPermissionRepository {
|
abstract interface class IPermissionRepository {
|
||||||
@@ -42,4 +61,7 @@ abstract interface class IPermissionRepository {
|
|||||||
Future<bool> hasLocationAlwaysPermission();
|
Future<bool> hasLocationAlwaysPermission();
|
||||||
Future<bool> requestLocationAlwaysPermission();
|
Future<bool> requestLocationAlwaysPermission();
|
||||||
Future<bool> openSettings();
|
Future<bool> openSettings();
|
||||||
|
Future<bool> hasManageMediaPermission();
|
||||||
|
Future<bool> requestManageMediaPermission();
|
||||||
|
Future<bool> manageMediaPermission();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref) => const LocalFilesManagerService());
|
|
||||||
|
|
||||||
class LocalFilesManagerService {
|
|
||||||
const LocalFilesManagerService();
|
|
||||||
|
|
||||||
static final Logger _logger = Logger('LocalFilesManager');
|
|
||||||
static const MethodChannel _channel = MethodChannel('file_trash');
|
|
||||||
|
|
||||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error moving file to trash', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('restoreFromTrash', {'fileName': fileName, 'type': type});
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error restore file from trash', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> restoreFromTrashById(String mediaId, int type) async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type});
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error restore file from trash by Id', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> requestManageMediaPermission() async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('requestManageMediaPermission');
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error requesting manage media permission', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> hasManageMediaPermission() async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('hasManageMediaPermission');
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error requesting manage media permission state', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> manageMediaPermission() async {
|
|
||||||
try {
|
|
||||||
return await _channel.invokeMethod('manageMediaPermission');
|
|
||||||
} catch (e, s) {
|
|
||||||
_logger.warning('Error requesting manage media permission settings', e, s);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/provider_utils.dart';
|
import 'package:immich_mobile/utils/provider_utils.dart';
|
||||||
import 'package:immich_mobile/utils/url_helper.dart';
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
@@ -193,7 +193,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getManageMediaPermission() async {
|
getManageMediaPermission() async {
|
||||||
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
|
final hasPermission = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -224,7 +224,7 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
unawaited(ref.read(permissionRepositoryProvider).requestManageMediaPermission());
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -139,8 +139,6 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
|
|
||||||
PhotoViewHeroAttributes? get heroAttributes => widget.heroAttributes;
|
PhotoViewHeroAttributes? get heroAttributes => widget.heroAttributes;
|
||||||
|
|
||||||
late ScaleBoundaries cachedScaleBoundaries = widget.scaleBoundaries;
|
|
||||||
|
|
||||||
void handleScaleAnimation() {
|
void handleScaleAnimation() {
|
||||||
scale = _scaleAnimation!.value;
|
scale = _scaleAnimation!.value;
|
||||||
}
|
}
|
||||||
@@ -303,7 +301,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
controller.scaleAnimationBuilder(_animateControllerScale);
|
controller.scaleAnimationBuilder(_animateControllerScale);
|
||||||
controller.rotationAnimationBuilder(_animateControllerRotation);
|
controller.rotationAnimationBuilder(_animateControllerRotation);
|
||||||
|
|
||||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
_updateScaleBoundaries();
|
||||||
|
|
||||||
_scaleAnimationController = AnimationController(vsync: this)
|
_scaleAnimationController = AnimationController(vsync: this)
|
||||||
..addListener(handleScaleAnimation)
|
..addListener(handleScaleAnimation)
|
||||||
@@ -334,14 +332,29 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
widget.onTapDown?.call(context, details, controller.value);
|
widget.onTapDown?.call(context, details, controller.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _updateScaleBoundaries() {
|
||||||
Widget build(BuildContext context) {
|
final prev = controller.scaleBoundaries;
|
||||||
// Check if we need a recalc on the scale
|
if (prev == widget.scaleBoundaries) {
|
||||||
if (widget.scaleBoundaries != cachedScaleBoundaries) {
|
return;
|
||||||
markNeedsScaleRecalc = true;
|
|
||||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
return StreamBuilder(
|
return StreamBuilder(
|
||||||
stream: controller.outputStateStream,
|
stream: controller.outputStateStream,
|
||||||
initialData: controller.prevValue,
|
initialData: controller.prevValue,
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
|||||||
_lastStack = null;
|
_lastStack = null;
|
||||||
|
|
||||||
_didLoadSynchronously = synchronousCall;
|
_didLoadSynchronously = synchronousCall;
|
||||||
widget.controller.scaleBoundaries = scaleBoundaries;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
|
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||||
@@ -57,9 +57,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
() async {
|
() async {
|
||||||
isManageMediaSupported.value = await checkAndroidVersion();
|
isManageMediaSupported.value = await checkAndroidVersion();
|
||||||
if (isManageMediaSupported.value) {
|
if (isManageMediaSupported.value) {
|
||||||
manageMediaAndroidPermission.value = await ref
|
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
|
||||||
.read(localFilesManagerRepositoryProvider)
|
|
||||||
.hasManageMediaPermission();
|
|
||||||
}
|
}
|
||||||
}();
|
}();
|
||||||
return null;
|
return null;
|
||||||
@@ -82,7 +80,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
if (value) {
|
if (value) {
|
||||||
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
|
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
|
||||||
manageLocalMediaAndroid.value = result;
|
manageLocalMediaAndroid.value = result;
|
||||||
manageMediaAndroidPermission.value = result;
|
manageMediaAndroidPermission.value = result;
|
||||||
}
|
}
|
||||||
@@ -96,7 +94,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
? const Color.fromARGB(255, 243, 188, 106)
|
? const Color.fromARGB(255, 243, 188, 106)
|
||||||
: null,
|
: null,
|
||||||
onActionTap: () async {
|
onActionTap: () async {
|
||||||
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
|
final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
|
||||||
manageMediaAndroidPermission.value = result;
|
manageMediaAndroidPermission.value = result;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Generated
+3
@@ -206,6 +206,7 @@ Class | Method | HTTP request | Description
|
|||||||
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
||||||
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
||||||
*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods
|
*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods
|
||||||
|
*PluginsApi* | [**searchPluginTemplates**](doc//PluginsApi.md#searchplugintemplates) | **GET** /plugins/templates | Retrieve workflow templates
|
||||||
*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **GET** /plugins | List all plugins
|
*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **GET** /plugins | List all plugins
|
||||||
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
|
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
|
||||||
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
|
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
|
||||||
@@ -491,6 +492,8 @@ Class | Method | HTTP request | Description
|
|||||||
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
||||||
- [PluginMethodResponseDto](doc//PluginMethodResponseDto.md)
|
- [PluginMethodResponseDto](doc//PluginMethodResponseDto.md)
|
||||||
- [PluginResponseDto](doc//PluginResponseDto.md)
|
- [PluginResponseDto](doc//PluginResponseDto.md)
|
||||||
|
- [PluginTemplateResponseDto](doc//PluginTemplateResponseDto.md)
|
||||||
|
- [PluginTemplateStepResponseDto](doc//PluginTemplateStepResponseDto.md)
|
||||||
- [PurchaseResponse](doc//PurchaseResponse.md)
|
- [PurchaseResponse](doc//PurchaseResponse.md)
|
||||||
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
||||||
- [QueueCommand](doc//QueueCommand.md)
|
- [QueueCommand](doc//QueueCommand.md)
|
||||||
|
|||||||
Generated
+2
@@ -237,6 +237,8 @@ part 'model/pin_code_setup_dto.dart';
|
|||||||
part 'model/places_response_dto.dart';
|
part 'model/places_response_dto.dart';
|
||||||
part 'model/plugin_method_response_dto.dart';
|
part 'model/plugin_method_response_dto.dart';
|
||||||
part 'model/plugin_response_dto.dart';
|
part 'model/plugin_response_dto.dart';
|
||||||
|
part 'model/plugin_template_response_dto.dart';
|
||||||
|
part 'model/plugin_template_step_response_dto.dart';
|
||||||
part 'model/purchase_response.dart';
|
part 'model/purchase_response.dart';
|
||||||
part 'model/purchase_update.dart';
|
part 'model/purchase_update.dart';
|
||||||
part 'model/queue_command.dart';
|
part 'model/queue_command.dart';
|
||||||
|
|||||||
Generated
+51
@@ -204,6 +204,57 @@ class PluginsApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieve workflow templates
|
||||||
|
///
|
||||||
|
/// Retrieve workflow templates provided by installed plugins
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
Future<Response> searchPluginTemplatesWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/plugins/templates';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve workflow templates
|
||||||
|
///
|
||||||
|
/// Retrieve workflow templates provided by installed plugins
|
||||||
|
Future<List<PluginTemplateResponseDto>?> searchPluginTemplates() async {
|
||||||
|
final response = await searchPluginTemplatesWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
final responseBody = await _decodeBodyBytes(response);
|
||||||
|
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTemplateResponseDto>') as List)
|
||||||
|
.cast<PluginTemplateResponseDto>()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// List all plugins
|
/// List all plugins
|
||||||
///
|
///
|
||||||
/// Retrieve a list of plugins available to the authenticated user.
|
/// Retrieve a list of plugins available to the authenticated user.
|
||||||
|
|||||||
Generated
+4
@@ -520,6 +520,10 @@ class ApiClient {
|
|||||||
return PluginMethodResponseDto.fromJson(value);
|
return PluginMethodResponseDto.fromJson(value);
|
||||||
case 'PluginResponseDto':
|
case 'PluginResponseDto':
|
||||||
return PluginResponseDto.fromJson(value);
|
return PluginResponseDto.fromJson(value);
|
||||||
|
case 'PluginTemplateResponseDto':
|
||||||
|
return PluginTemplateResponseDto.fromJson(value);
|
||||||
|
case 'PluginTemplateStepResponseDto':
|
||||||
|
return PluginTemplateStepResponseDto.fromJson(value);
|
||||||
case 'PurchaseResponse':
|
case 'PurchaseResponse':
|
||||||
return PurchaseResponse.fromJson(value);
|
return PurchaseResponse.fromJson(value);
|
||||||
case 'PurchaseUpdate':
|
case 'PurchaseUpdate':
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class PluginTemplateResponseDto {
|
||||||
|
/// Returns a new [PluginTemplateResponseDto] instance.
|
||||||
|
PluginTemplateResponseDto({
|
||||||
|
required this.description,
|
||||||
|
required this.key,
|
||||||
|
this.steps = const [],
|
||||||
|
required this.title,
|
||||||
|
required this.trigger,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Template description
|
||||||
|
String description;
|
||||||
|
|
||||||
|
/// Template key (unique across all templates)
|
||||||
|
String key;
|
||||||
|
|
||||||
|
/// Workflow steps
|
||||||
|
List<PluginTemplateStepResponseDto> steps;
|
||||||
|
|
||||||
|
/// Template title
|
||||||
|
String title;
|
||||||
|
|
||||||
|
WorkflowTrigger trigger;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
|
||||||
|
other.description == description &&
|
||||||
|
other.key == key &&
|
||||||
|
_deepEquality.equals(other.steps, steps) &&
|
||||||
|
other.title == title &&
|
||||||
|
other.trigger == trigger;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(description.hashCode) +
|
||||||
|
(key.hashCode) +
|
||||||
|
(steps.hashCode) +
|
||||||
|
(title.hashCode) +
|
||||||
|
(trigger.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'description'] = this.description;
|
||||||
|
json[r'key'] = this.key;
|
||||||
|
json[r'steps'] = this.steps;
|
||||||
|
json[r'title'] = this.title;
|
||||||
|
json[r'trigger'] = this.trigger;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [PluginTemplateResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static PluginTemplateResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "PluginTemplateResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return PluginTemplateResponseDto(
|
||||||
|
description: mapValueOfType<String>(json, r'description')!,
|
||||||
|
key: mapValueOfType<String>(json, r'key')!,
|
||||||
|
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
|
||||||
|
title: mapValueOfType<String>(json, r'title')!,
|
||||||
|
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PluginTemplateResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <PluginTemplateResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = PluginTemplateResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, PluginTemplateResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, PluginTemplateResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = PluginTemplateResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of PluginTemplateResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<PluginTemplateResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<PluginTemplateResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = PluginTemplateResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'description',
|
||||||
|
'key',
|
||||||
|
'steps',
|
||||||
|
'title',
|
||||||
|
'trigger',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class PluginTemplateStepResponseDto {
|
||||||
|
/// Returns a new [PluginTemplateStepResponseDto] instance.
|
||||||
|
PluginTemplateStepResponseDto({
|
||||||
|
this.config = const {},
|
||||||
|
this.enabled,
|
||||||
|
required this.method,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Step configuration
|
||||||
|
Map<String, Object>? config;
|
||||||
|
|
||||||
|
/// Whether the step is enabled
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
bool? enabled;
|
||||||
|
|
||||||
|
/// Step plugin method
|
||||||
|
String method;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateStepResponseDto &&
|
||||||
|
_deepEquality.equals(other.config, config) &&
|
||||||
|
other.enabled == enabled &&
|
||||||
|
other.method == method;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(config == null ? 0 : config!.hashCode) +
|
||||||
|
(enabled == null ? 0 : enabled!.hashCode) +
|
||||||
|
(method.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PluginTemplateStepResponseDto[config=$config, enabled=$enabled, method=$method]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
if (this.config != null) {
|
||||||
|
json[r'config'] = this.config;
|
||||||
|
} else {
|
||||||
|
// json[r'config'] = null;
|
||||||
|
}
|
||||||
|
if (this.enabled != null) {
|
||||||
|
json[r'enabled'] = this.enabled;
|
||||||
|
} else {
|
||||||
|
// json[r'enabled'] = null;
|
||||||
|
}
|
||||||
|
json[r'method'] = this.method;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [PluginTemplateStepResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static PluginTemplateStepResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "PluginTemplateStepResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return PluginTemplateStepResponseDto(
|
||||||
|
config: mapCastOfType<String, Object>(json, r'config'),
|
||||||
|
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||||
|
method: mapValueOfType<String>(json, r'method')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PluginTemplateStepResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <PluginTemplateStepResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = PluginTemplateStepResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, PluginTemplateStepResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, PluginTemplateStepResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = PluginTemplateStepResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of PluginTemplateStepResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<PluginTemplateStepResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<PluginTemplateStepResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = PluginTemplateStepResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'config',
|
||||||
|
'method',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,14 +11,7 @@ import 'package:pigeon/pigeon.dart';
|
|||||||
dartPackageName: 'immich_mobile',
|
dartPackageName: 'immich_mobile',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
enum PlatformAssetPlaybackStyle {
|
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||||
unknown,
|
|
||||||
image,
|
|
||||||
video,
|
|
||||||
imageAnimated,
|
|
||||||
livePhoto,
|
|
||||||
videoLooping,
|
|
||||||
}
|
|
||||||
|
|
||||||
class PlatformAsset {
|
class PlatformAsset {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -142,6 +135,9 @@ abstract class NativeSyncApi {
|
|||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
Map<String, List<PlatformAsset>> getTrashedAssets();
|
Map<String, List<PlatformAsset>> getTrashedAssets();
|
||||||
|
|
||||||
|
@async
|
||||||
|
bool restoreFromTrashById(String mediaId, int type);
|
||||||
|
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:pigeon/pigeon.dart';
|
||||||
|
|
||||||
|
@ConfigurePigeon(
|
||||||
|
PigeonOptions(
|
||||||
|
dartOut: 'lib/platform/permission_api.g.dart',
|
||||||
|
swiftOut: 'ios/Runner/Permission/PermissionApi.g.swift',
|
||||||
|
swiftOptions: SwiftOptions(),
|
||||||
|
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
|
||||||
|
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
|
||||||
|
dartOptions: DartOptions(),
|
||||||
|
dartPackageName: 'immich_mobile',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@HostApi()
|
||||||
|
abstract class PermissionApi {
|
||||||
|
bool hasManageMediaPermission();
|
||||||
|
|
||||||
|
@async
|
||||||
|
bool requestManageMediaPermission();
|
||||||
|
|
||||||
|
@async
|
||||||
|
bool manageMediaPermission();
|
||||||
|
}
|
||||||
@@ -10,17 +10,15 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.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/platform/native_sync_api.g.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
import '../../domain/service.mock.dart';
|
import '../../domain/service.mock.dart';
|
||||||
import '../../fixtures/asset.stub.dart';
|
import '../../fixtures/asset.stub.dart';
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
import '../../mocks/asset_entity.mock.dart';
|
|
||||||
import '../../repository.mocks.dart';
|
import '../../repository.mocks.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -28,8 +26,8 @@ void main() {
|
|||||||
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
late DriftLocalAlbumRepository mockLocalAlbumRepository;
|
||||||
late DriftLocalAssetRepository mockLocalAssetRepository;
|
late DriftLocalAssetRepository mockLocalAssetRepository;
|
||||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
|
||||||
late LocalFilesManagerRepository mockLocalFilesManager;
|
late AssetMediaRepository mockAssetMediaRepository;
|
||||||
late StorageRepository mockStorageRepository;
|
late MockPermissionRepository mockPermissionRepository;
|
||||||
late MockNativeSyncApi mockNativeSyncApi;
|
late MockNativeSyncApi mockNativeSyncApi;
|
||||||
late Drift db;
|
late Drift db;
|
||||||
|
|
||||||
@@ -51,8 +49,8 @@ void main() {
|
|||||||
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
mockLocalAlbumRepository = MockLocalAlbumRepository();
|
||||||
mockLocalAssetRepository = MockLocalAssetRepository();
|
mockLocalAssetRepository = MockLocalAssetRepository();
|
||||||
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
|
||||||
mockLocalFilesManager = MockLocalFilesManagerRepository();
|
mockAssetMediaRepository = MockAssetMediaRepository();
|
||||||
mockStorageRepository = MockStorageRepository();
|
mockPermissionRepository = MockPermissionRepository();
|
||||||
mockNativeSyncApi = MockNativeSyncApi();
|
mockNativeSyncApi = MockNativeSyncApi();
|
||||||
|
|
||||||
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
|
||||||
@@ -65,25 +63,28 @@ void main() {
|
|||||||
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
|
||||||
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||||
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
|
||||||
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
|
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
|
||||||
|
final ids = invocation.positionalArguments.first as List<String>;
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
|
||||||
sut = LocalSyncService(
|
sut = LocalSyncService(
|
||||||
localAlbumRepository: mockLocalAlbumRepository,
|
localAlbumRepository: mockLocalAlbumRepository,
|
||||||
localAssetRepository: mockLocalAssetRepository,
|
localAssetRepository: mockLocalAssetRepository,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
|
||||||
localFilesManager: mockLocalFilesManager,
|
assetMediaRepository: mockAssetMediaRepository,
|
||||||
storageRepository: mockStorageRepository,
|
permissionRepository: mockPermissionRepository,
|
||||||
nativeSyncApi: mockNativeSyncApi,
|
nativeSyncApi: mockNativeSyncApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('LocalSyncService - syncTrashedAssets gating', () {
|
group('LocalSyncService - syncTrashedAssets gating', () {
|
||||||
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ void main() {
|
|||||||
|
|
||||||
test('skips syncTrashedAssets when store flag disabled', () async {
|
test('skips syncTrashedAssets when store flag disabled', () async {
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ void main() {
|
|||||||
|
|
||||||
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
|
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ void main() {
|
|||||||
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
|
||||||
|
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||||
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
|
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
|
||||||
|
|
||||||
await sut.sync();
|
await sut.sync();
|
||||||
|
|
||||||
@@ -131,13 +132,13 @@ void main() {
|
|||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
orientation: 0,
|
orientation: 0,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||||
);
|
);
|
||||||
|
|
||||||
final assetsToRestore = [LocalAssetStub.image1];
|
final assetsToRestore = [LocalAssetStub.image1];
|
||||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
|
||||||
final restoredIds = ['image1'];
|
final restoredIds = ['image1'];
|
||||||
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
when(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||||
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||||
expect(requested, orderedEquals(assetsToRestore));
|
expect(requested, orderedEquals(assetsToRestore));
|
||||||
return restoredIds;
|
return restoredIds;
|
||||||
@@ -150,10 +151,6 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final assetEntity = MockAssetEntity();
|
|
||||||
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
|
|
||||||
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
|
|
||||||
|
|
||||||
await sut.processTrashedAssets({
|
await sut.processTrashedAssets({
|
||||||
'album-a': [platformAsset],
|
'album-a': [platformAsset],
|
||||||
});
|
});
|
||||||
@@ -168,12 +165,11 @@ void main() {
|
|||||||
expect(trashedEntry.asset.name, platformAsset.name);
|
expect(trashedEntry.asset.name, platformAsset.name);
|
||||||
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
|
||||||
|
|
||||||
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
|
verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1);
|
||||||
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
|
||||||
|
|
||||||
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
|
final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List<String>;
|
||||||
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
|
expect(moveArgs, ['local-trash']);
|
||||||
expect(moveArgs, ['content://local-trash']);
|
|
||||||
final trashArgs =
|
final trashArgs =
|
||||||
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||||
as Map<String, List<LocalAsset>>;
|
as Map<String, List<LocalAsset>>;
|
||||||
@@ -181,6 +177,26 @@ void main() {
|
|||||||
expect(trashArgs['album-a'], [localAssetToTrash]);
|
expect(trashArgs['album-a'], [localAssetToTrash]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('records only local assets that were moved to device trash', () async {
|
||||||
|
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
|
||||||
|
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
|
||||||
|
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
|
||||||
|
(_) async => {
|
||||||
|
'album-a': [movedAsset],
|
||||||
|
'album-b': [skippedAsset],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
|
||||||
|
|
||||||
|
await sut.processTrashedAssets({});
|
||||||
|
|
||||||
|
final trashArgs =
|
||||||
|
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
|
||||||
|
as Map<String, List<LocalAsset>>;
|
||||||
|
expect(trashArgs.keys, ['album-a']);
|
||||||
|
expect(trashArgs['album-a'], [movedAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
test('does not attempt restore when repository has no assets to restore', () async {
|
test('does not attempt restore when repository has no assets to restore', () async {
|
||||||
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
|
||||||
|
|
||||||
@@ -190,7 +206,7 @@ void main() {
|
|||||||
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
|
||||||
as Iterable<TrashedAsset>;
|
as Iterable<TrashedAsset>;
|
||||||
expect(trashedSnapshot, isEmpty);
|
expect(trashedSnapshot, isEmpty);
|
||||||
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
|
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
|
||||||
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -199,7 +215,7 @@ void main() {
|
|||||||
|
|
||||||
await sut.processTrashedAssets({});
|
await sut.processTrashedAssets({});
|
||||||
|
|
||||||
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
|
verifyNever(() => mockAssetMediaRepository.deleteAll(any()));
|
||||||
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -215,7 +231,7 @@ void main() {
|
|||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
createdAt: 1700000000,
|
createdAt: 1700000000,
|
||||||
updatedAt: 1732000000,
|
updatedAt: 1732000000,
|
||||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||||
);
|
);
|
||||||
|
|
||||||
final localAsset = platformAsset.toLocalAsset();
|
final localAsset = platformAsset.toLocalAsset();
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
|||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/utils/semver.dart';
|
import 'package:immich_mobile/utils/semver.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
@@ -26,7 +25,6 @@ import '../../api.mocks.dart';
|
|||||||
import '../../fixtures/asset.stub.dart';
|
import '../../fixtures/asset.stub.dart';
|
||||||
import '../../fixtures/sync_stream.stub.dart';
|
import '../../fixtures/sync_stream.stub.dart';
|
||||||
import '../../infrastructure/repository.mock.dart';
|
import '../../infrastructure/repository.mock.dart';
|
||||||
import '../../mocks/asset_entity.mock.dart';
|
|
||||||
import '../../repository.mocks.dart';
|
import '../../repository.mocks.dart';
|
||||||
import '../../service.mocks.dart';
|
import '../../service.mocks.dart';
|
||||||
|
|
||||||
@@ -52,8 +50,8 @@ void main() {
|
|||||||
late SyncApiRepository mockSyncApiRepo;
|
late SyncApiRepository mockSyncApiRepo;
|
||||||
late DriftLocalAssetRepository mockLocalAssetRepo;
|
late DriftLocalAssetRepository mockLocalAssetRepo;
|
||||||
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
|
||||||
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
|
late AssetMediaRepository mockAssetMediaRepo;
|
||||||
late StorageRepository mockStorageRepo;
|
late MockPermissionRepository mockPermissionRepo;
|
||||||
late MockApiService mockApi;
|
late MockApiService mockApi;
|
||||||
late MockServerApi mockServerApi;
|
late MockServerApi mockServerApi;
|
||||||
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
late MockSyncMigrationRepository mockSyncMigrationRepo;
|
||||||
@@ -86,8 +84,8 @@ void main() {
|
|||||||
mockSyncApiRepo = MockSyncApiRepository();
|
mockSyncApiRepo = MockSyncApiRepository();
|
||||||
mockLocalAssetRepo = MockLocalAssetRepository();
|
mockLocalAssetRepo = MockLocalAssetRepository();
|
||||||
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
|
||||||
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
|
mockAssetMediaRepo = MockAssetMediaRepository();
|
||||||
mockStorageRepo = MockStorageRepository();
|
mockPermissionRepo = MockPermissionRepository();
|
||||||
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
|
||||||
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
|
||||||
mockApi = MockApiService();
|
mockApi = MockApiService();
|
||||||
@@ -159,8 +157,8 @@ void main() {
|
|||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
localAssetRepository: mockLocalAssetRepo,
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
localFilesManager: mockLocalFilesManagerRepo,
|
assetMediaRepository: mockAssetMediaRepo,
|
||||||
storageRepository: mockStorageRepo,
|
permissionRepository: mockPermissionRepo,
|
||||||
api: mockApi,
|
api: mockApi,
|
||||||
syncMigrationRepository: mockSyncMigrationRepo,
|
syncMigrationRepository: mockSyncMigrationRepo,
|
||||||
);
|
);
|
||||||
@@ -170,10 +168,12 @@ void main() {
|
|||||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
|
||||||
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
|
||||||
hasManageMediaPermission = false;
|
hasManageMediaPermission = false;
|
||||||
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
|
||||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
|
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
final ids = invocation.positionalArguments.first as List<String>;
|
||||||
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
|
return ids;
|
||||||
|
});
|
||||||
|
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
|
||||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,8 +241,8 @@ void main() {
|
|||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
localAssetRepository: mockLocalAssetRepo,
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
localFilesManager: mockLocalFilesManagerRepo,
|
assetMediaRepository: mockAssetMediaRepo,
|
||||||
storageRepository: mockStorageRepo,
|
permissionRepository: mockPermissionRepo,
|
||||||
cancelChecker: cancellationChecker.call,
|
cancelChecker: cancellationChecker.call,
|
||||||
api: mockApi,
|
api: mockApi,
|
||||||
syncMigrationRepository: mockSyncMigrationRepo,
|
syncMigrationRepository: mockSyncMigrationRepo,
|
||||||
@@ -282,8 +282,8 @@ void main() {
|
|||||||
syncStreamRepository: mockSyncStreamRepo,
|
syncStreamRepository: mockSyncStreamRepo,
|
||||||
localAssetRepository: mockLocalAssetRepo,
|
localAssetRepository: mockLocalAssetRepo,
|
||||||
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
|
||||||
localFilesManager: mockLocalFilesManagerRepo,
|
assetMediaRepository: mockAssetMediaRepo,
|
||||||
storageRepository: mockStorageRepo,
|
permissionRepository: mockPermissionRepo,
|
||||||
cancelChecker: cancellationChecker.call,
|
cancelChecker: cancellationChecker.call,
|
||||||
api: mockApi,
|
api: mockApi,
|
||||||
syncMigrationRepository: mockSyncMigrationRepo,
|
syncMigrationRepository: mockSyncMigrationRepo,
|
||||||
@@ -424,18 +424,10 @@ void main() {
|
|||||||
return assetsByAlbum;
|
return assetsByAlbum;
|
||||||
});
|
});
|
||||||
|
|
||||||
final localEntity = MockAssetEntity();
|
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
|
||||||
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
|
final ids = invocation.positionalArguments.first as List<String>;
|
||||||
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
|
expect(ids, unorderedEquals(['local-only', 'merged-local']));
|
||||||
|
return ids;
|
||||||
final mergedEntity = MockAssetEntity();
|
|
||||||
when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local');
|
|
||||||
when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity);
|
|
||||||
|
|
||||||
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async {
|
|
||||||
final urls = invocation.positionalArguments.first as List<String>;
|
|
||||||
expect(urls, unorderedEquals(['content://local-only', 'content://merged-local']));
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final events = [
|
final events = [
|
||||||
@@ -461,10 +453,51 @@ void main() {
|
|||||||
|
|
||||||
await simulateEvents(events);
|
await simulateEvents(events);
|
||||||
|
|
||||||
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
|
final trashArgs =
|
||||||
|
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
|
||||||
|
as Map<String, List<LocalAsset>>;
|
||||||
|
expect(trashArgs.keys, unorderedEquals(['album-a', 'album-b']));
|
||||||
|
expect(trashArgs['album-a'], [localAsset]);
|
||||||
|
expect(trashArgs['album-b'], [mergedAsset]);
|
||||||
|
verify(() => mockAssetMediaRepo.deleteAll(any())).called(1);
|
||||||
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
|
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("records only assets that were moved to device trash", () async {
|
||||||
|
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
|
||||||
|
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
|
||||||
|
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer(
|
||||||
|
(_) async => {
|
||||||
|
'album-a': [movedAsset],
|
||||||
|
'album-b': [skippedAsset],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
|
||||||
|
|
||||||
|
final events = [
|
||||||
|
SyncStreamStub.assetTrashed(
|
||||||
|
id: 'remote-moved',
|
||||||
|
checksum: movedAsset.checksum!,
|
||||||
|
ack: 'asset-remote-moved',
|
||||||
|
trashedAt: DateTime(2025, 5, 1),
|
||||||
|
),
|
||||||
|
SyncStreamStub.assetTrashed(
|
||||||
|
id: 'remote-skipped',
|
||||||
|
checksum: skippedAsset.checksum!,
|
||||||
|
ack: 'asset-remote-skipped',
|
||||||
|
trashedAt: DateTime(2025, 5, 2),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await simulateEvents(events);
|
||||||
|
|
||||||
|
final trashArgs =
|
||||||
|
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
|
||||||
|
as Map<String, List<LocalAsset>>;
|
||||||
|
expect(trashArgs.keys, ['album-a']);
|
||||||
|
expect(trashArgs['album-a'], [movedAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
test("skips device trashing when no local assets match the remote trash payload", () async {
|
test("skips device trashing when no local assets match the remote trash payload", () async {
|
||||||
final events = [
|
final events = [
|
||||||
SyncStreamStub.assetTrashed(
|
SyncStreamStub.assetTrashed(
|
||||||
@@ -478,7 +511,7 @@ void main() {
|
|||||||
await simulateEvents(events);
|
await simulateEvents(events);
|
||||||
|
|
||||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -494,7 +527,7 @@ void main() {
|
|||||||
await simulateEvents(events);
|
await simulateEvents(events);
|
||||||
|
|
||||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
|
||||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -505,7 +538,7 @@ void main() {
|
|||||||
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
|
||||||
|
|
||||||
final restoredIds = ['trashed-1'];
|
final restoredIds = ['trashed-1'];
|
||||||
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
|
||||||
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
|
||||||
expect(requestedAssets, orderedEquals(trashedAssets));
|
expect(requestedAssets, orderedEquals(trashedAssets));
|
||||||
return restoredIds;
|
return restoredIds;
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
|||||||
import 'package:immich_mobile/repositories/auth.repository.dart';
|
import 'package:immich_mobile/repositories/auth.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
import 'package:immich_mobile/repositories/auth_api.repository.dart';
|
||||||
import 'package:immich_mobile/domain/services/tag.service.dart';
|
import 'package:immich_mobile/domain/services/tag.service.dart';
|
||||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
|
||||||
|
|
||||||
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
|
||||||
|
|
||||||
|
class MockPermissionRepository extends Mock implements IPermissionRepository {}
|
||||||
|
|
||||||
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
|
||||||
|
|
||||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||||
|
|
||||||
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
|
|
||||||
|
|
||||||
class MockTagService extends Mock implements TagService {}
|
class MockTagService extends Mock implements TagService {}
|
||||||
|
|||||||
@@ -8818,6 +8818,50 @@
|
|||||||
"x-immich-permission": "plugin.read"
|
"x-immich-permission": "plugin.read"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/plugins/templates": {
|
||||||
|
"get": {
|
||||||
|
"description": "Retrieve workflow templates provided by installed plugins",
|
||||||
|
"operationId": "searchPluginTemplates",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PluginTemplateResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Retrieve workflow templates",
|
||||||
|
"tags": [
|
||||||
|
"Plugins"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v3.0.0",
|
||||||
|
"state": "Added"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "plugin.read"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/plugins/{id}": {
|
"/plugins/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Retrieve information about a specific plugin by its ID.",
|
"description": "Retrieve information about a specific plugin by its ID.",
|
||||||
@@ -20131,6 +20175,64 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PluginTemplateResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"description": {
|
||||||
|
"description": "Template description",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"description": "Template key (unique across all templates)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"description": "Workflow steps",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PluginTemplateStepResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"description": "Template title",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"trigger": {
|
||||||
|
"$ref": "#/components/schemas/WorkflowTrigger",
|
||||||
|
"description": "Workflow trigger"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"description",
|
||||||
|
"key",
|
||||||
|
"steps",
|
||||||
|
"title",
|
||||||
|
"trigger"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"PluginTemplateStepResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"config": {
|
||||||
|
"additionalProperties": {},
|
||||||
|
"description": "Step configuration",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"description": "Whether the step is enabled",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"method": {
|
||||||
|
"description": "Step plugin method",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"config",
|
||||||
|
"method"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"PurchaseResponse": {
|
"PurchaseResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"hideBuyButtonUntil": {
|
"hideBuyButtonUntil": {
|
||||||
@@ -20793,7 +20895,14 @@
|
|||||||
"description": "Total number of matching assets",
|
"description": "Total number of matching assets",
|
||||||
"maximum": 9007199254740991,
|
"maximum": 9007199254740991,
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"type": "integer"
|
"type": "integer",
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v3.0.0",
|
||||||
|
"state": "Deprecated"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-state": "Deprecated"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -5,6 +5,36 @@
|
|||||||
"description": "Core workflow capabilities for Immich",
|
"description": "Core workflow capabilities for Immich",
|
||||||
"author": "Immich Team",
|
"author": "Immich Team",
|
||||||
"wasmPath": "dist/plugin.wasm",
|
"wasmPath": "dist/plugin.wasm",
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"name": "auto-archive-screenshots",
|
||||||
|
"title": "Auto-archive screenshots",
|
||||||
|
"description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album",
|
||||||
|
"trigger": "AssetCreate",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"method": "immich-plugin-core#assetFileFilter",
|
||||||
|
"config": {
|
||||||
|
"pattern": "screenshot",
|
||||||
|
"matchType": "contains",
|
||||||
|
"caseSensitive": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "immich-plugin-core#assetAddToAlbums",
|
||||||
|
"config": {
|
||||||
|
"albumIds": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "immich-plugin-core#assetArchive",
|
||||||
|
"config": {
|
||||||
|
"inverse": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"methods": [
|
"methods": [
|
||||||
{
|
{
|
||||||
"name": "assetFileFilter",
|
"name": "assetFileFilter",
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ export const assetTrash = () => {
|
|||||||
|
|
||||||
export const assetAddToAlbums = () => {
|
export const assetAddToAlbums = () => {
|
||||||
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => {
|
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => {
|
||||||
|
if (config.albumIds.length === 0) {
|
||||||
|
// noop
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
if (config.albumIds.length === 1) {
|
if (config.albumIds.length === 1) {
|
||||||
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
|
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -1514,6 +1514,28 @@ export type PluginResponseDto = {
|
|||||||
/** Plugin version */
|
/** Plugin version */
|
||||||
version: string;
|
version: string;
|
||||||
};
|
};
|
||||||
|
export type PluginTemplateStepResponseDto = {
|
||||||
|
/** Step configuration */
|
||||||
|
config: {
|
||||||
|
[key: string]: any;
|
||||||
|
} | null;
|
||||||
|
/** Whether the step is enabled */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Step plugin method */
|
||||||
|
method: string;
|
||||||
|
};
|
||||||
|
export type PluginTemplateResponseDto = {
|
||||||
|
/** Template description */
|
||||||
|
description: string;
|
||||||
|
/** Template key (unique across all templates) */
|
||||||
|
key: string;
|
||||||
|
/** Workflow steps */
|
||||||
|
steps: PluginTemplateStepResponseDto[];
|
||||||
|
/** Template title */
|
||||||
|
title: string;
|
||||||
|
/** Workflow trigger */
|
||||||
|
trigger: WorkflowTrigger;
|
||||||
|
};
|
||||||
export type QueueResponseDto = {
|
export type QueueResponseDto = {
|
||||||
/** Whether the queue is paused */
|
/** Whether the queue is paused */
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
@@ -5242,6 +5264,17 @@ export function searchPluginMethods({ description, enabled, id, name, pluginName
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Retrieve workflow templates
|
||||||
|
*/
|
||||||
|
export function searchPluginTemplates(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: PluginTemplateResponseDto[];
|
||||||
|
}>("/plugins/templates", {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Retrieve a plugin
|
* Retrieve a plugin
|
||||||
*/
|
*/
|
||||||
|
|||||||
Generated
+34
-28
@@ -609,7 +609,7 @@ importers:
|
|||||||
version: 10.0.1(eslint@10.4.0(jiti@2.7.0))
|
version: 10.0.1(eslint@10.4.0(jiti@2.7.0))
|
||||||
'@nestjs/cli':
|
'@nestjs/cli':
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.2
|
||||||
version: 11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)
|
version: 11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.22))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)
|
||||||
'@nestjs/schematics':
|
'@nestjs/schematics':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)
|
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)
|
||||||
@@ -618,7 +618,7 @@ importers:
|
|||||||
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)
|
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)
|
||||||
'@swc/core':
|
'@swc/core':
|
||||||
specifier: ^1.4.14
|
specifier: ^1.4.14
|
||||||
version: 1.15.33(@swc/helpers@0.5.21)
|
version: 1.15.33(@swc/helpers@0.5.22)
|
||||||
'@types/archiver':
|
'@types/archiver':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.0
|
version: 7.0.0
|
||||||
@@ -738,7 +738,7 @@ importers:
|
|||||||
version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
|
version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
|
||||||
unplugin-swc:
|
unplugin-swc:
|
||||||
specifier: ^1.4.5
|
specifier: ^1.4.5
|
||||||
version: 1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.21))(rollup@4.60.4)
|
version: 1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.22))(rollup@4.60.4)
|
||||||
vite-tsconfig-paths:
|
vite-tsconfig-paths:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.1.1(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))
|
version: 6.1.1(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))
|
||||||
@@ -758,8 +758,8 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../packages/sdk
|
version: link:../packages/sdk
|
||||||
'@immich/ui':
|
'@immich/ui':
|
||||||
specifier: ^0.77.0
|
specifier: ^0.79.2
|
||||||
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))
|
version: 0.79.2(@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':
|
'@mapbox/mapbox-gl-rtl-text':
|
||||||
specifier: 0.4.0
|
specifier: 0.4.0
|
||||||
version: 0.4.0
|
version: 0.4.0
|
||||||
@@ -1691,6 +1691,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7':
|
||||||
|
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -3204,8 +3208,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@immich/ui@0.77.3':
|
'@immich/ui@0.79.2':
|
||||||
resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==}
|
resolution: {integrity: sha512-tnpYhYHrjrFJK18QglRMzPUtHv6q5V6tW38HiAraQJBv7MCg+yaJDrdF8omM2L5F311FGlv1PZLJYvmR4e49PA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@sveltejs/kit': ^2.13.0
|
'@sveltejs/kit': ^2.13.0
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
@@ -4982,8 +4986,8 @@ packages:
|
|||||||
'@swc/counter@0.1.3':
|
'@swc/counter@0.1.3':
|
||||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.21':
|
'@swc/helpers@0.5.22':
|
||||||
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
resolution: {integrity: sha512-/e2Ly3Docn9kYByap6TV4oquJ3wQuz3c+kC74riqtkwU9CwTMeuj6t2rW+bRr4pyOx/CYQM4wr0RgaKQwGEz0A==}
|
||||||
|
|
||||||
'@swc/types@0.1.26':
|
'@swc/types@0.1.26':
|
||||||
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
|
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
|
||||||
@@ -13787,6 +13791,8 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/runtime@7.29.2': {}
|
'@babel/runtime@7.29.2': {}
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.7': {}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
@@ -15879,7 +15885,7 @@ snapshots:
|
|||||||
pg-connection-string: 2.13.0
|
pg-connection-string: 2.13.0
|
||||||
postgres: 3.4.9
|
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.2(@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:
|
dependencies:
|
||||||
'@internationalized/date': 3.12.1
|
'@internationalized/date': 3.12.1
|
||||||
'@mdi/js': 7.4.47
|
'@mdi/js': 7.4.47
|
||||||
@@ -16035,7 +16041,7 @@ snapshots:
|
|||||||
|
|
||||||
'@internationalized/date@3.12.1':
|
'@internationalized/date@3.12.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/helpers': 0.5.21
|
'@swc/helpers': 0.5.22
|
||||||
|
|
||||||
'@ioredis/commands@1.5.1': {}
|
'@ioredis/commands@1.5.1': {}
|
||||||
|
|
||||||
@@ -16438,7 +16444,7 @@ snapshots:
|
|||||||
bullmq: 5.76.10
|
bullmq: 5.76.10
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@nestjs/cli@11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)':
|
'@nestjs/cli@11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.22))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
||||||
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
|
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
|
||||||
@@ -16449,17 +16455,17 @@ snapshots:
|
|||||||
chokidar: 4.0.3
|
chokidar: 4.0.3
|
||||||
cli-table3: 0.6.5
|
cli-table3: 0.6.5
|
||||||
commander: 4.1.1
|
commander: 4.1.1
|
||||||
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0))
|
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0))
|
||||||
glob: 13.0.6
|
glob: 13.0.6
|
||||||
node-emoji: 1.11.0
|
node-emoji: 1.11.0
|
||||||
ora: 5.4.1
|
ora: 5.4.1
|
||||||
tsconfig-paths: 4.2.0
|
tsconfig-paths: 4.2.0
|
||||||
tsconfig-paths-webpack-plugin: 4.2.0
|
tsconfig-paths-webpack-plugin: 4.2.0
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
|
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||||
webpack-node-externals: 3.0.0
|
webpack-node-externals: 3.0.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
|
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@minify-html/node'
|
- '@minify-html/node'
|
||||||
- '@swc/css'
|
- '@swc/css'
|
||||||
@@ -17438,7 +17444,7 @@ snapshots:
|
|||||||
|
|
||||||
'@slorber/react-helmet-async@1.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
'@slorber/react-helmet-async@1.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.2
|
'@babel/runtime': 7.29.7
|
||||||
invariant: 2.2.4
|
invariant: 2.2.4
|
||||||
prop-types: 15.8.1
|
prop-types: 15.8.1
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
@@ -17647,7 +17653,7 @@ snapshots:
|
|||||||
'@swc/core-win32-x64-msvc@1.15.33':
|
'@swc/core-win32-x64-msvc@1.15.33':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@swc/core@1.15.33(@swc/helpers@0.5.21)':
|
'@swc/core@1.15.33(@swc/helpers@0.5.22)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@swc/counter': 0.1.3
|
'@swc/counter': 0.1.3
|
||||||
'@swc/types': 0.1.26
|
'@swc/types': 0.1.26
|
||||||
@@ -17664,11 +17670,11 @@ snapshots:
|
|||||||
'@swc/core-win32-arm64-msvc': 1.15.33
|
'@swc/core-win32-arm64-msvc': 1.15.33
|
||||||
'@swc/core-win32-ia32-msvc': 1.15.33
|
'@swc/core-win32-ia32-msvc': 1.15.33
|
||||||
'@swc/core-win32-x64-msvc': 1.15.33
|
'@swc/core-win32-x64-msvc': 1.15.33
|
||||||
'@swc/helpers': 0.5.21
|
'@swc/helpers': 0.5.22
|
||||||
|
|
||||||
'@swc/counter@0.1.3': {}
|
'@swc/counter@0.1.3': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.21':
|
'@swc/helpers@0.5.22':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
@@ -21084,7 +21090,7 @@ snapshots:
|
|||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
@@ -21099,7 +21105,7 @@ snapshots:
|
|||||||
semver: 7.8.0
|
semver: 7.8.0
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
|
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
form-data-encoder@2.1.4: {}
|
form-data-encoder@2.1.4: {}
|
||||||
|
|
||||||
@@ -25761,15 +25767,15 @@ snapshots:
|
|||||||
- bare-abort-controller
|
- bare-abort-controller
|
||||||
- react-native-b4a
|
- react-native-b4a
|
||||||
|
|
||||||
terser-webpack-plugin@5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
terser-webpack-plugin@5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
jest-worker: 27.5.1
|
jest-worker: 27.5.1
|
||||||
schema-utils: 4.3.3
|
schema-utils: 4.3.3
|
||||||
terser: 5.47.1
|
terser: 5.47.1
|
||||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
|
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
|
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
|
||||||
esbuild: 0.28.0
|
esbuild: 0.28.0
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
|
|
||||||
@@ -26176,10 +26182,10 @@ snapshots:
|
|||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|
||||||
unplugin-swc@1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.21))(rollup@4.60.4):
|
unplugin-swc@1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.22))(rollup@4.60.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.4)
|
'@rollup/pluginutils': 5.3.0(rollup@4.60.4)
|
||||||
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
|
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
|
||||||
load-tsconfig: 0.2.5
|
load-tsconfig: 0.2.5
|
||||||
unplugin: 2.3.11
|
unplugin: 2.3.11
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -26578,7 +26584,7 @@ snapshots:
|
|||||||
|
|
||||||
webpack-virtual-modules@0.6.2: {}
|
webpack-virtual-modules@0.6.2: {}
|
||||||
|
|
||||||
webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0):
|
webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint-scope': 3.7.7
|
'@types/eslint-scope': 3.7.7
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
@@ -26602,7 +26608,7 @@ snapshots:
|
|||||||
neo-async: 2.6.2
|
neo-async: 2.6.2
|
||||||
schema-utils: 4.3.3
|
schema-utils: 4.3.3
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
terser-webpack-plugin: 5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0))
|
terser-webpack-plugin: 5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0))
|
||||||
watchpack: 2.5.1
|
watchpack: 2.5.1
|
||||||
webpack-sources: 3.4.1
|
webpack-sources: 3.4.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|||||||
+2
-2
@@ -88,8 +88,8 @@ ENV NODE_ENV=production \
|
|||||||
COPY --from=server /output/server-pruned ./server
|
COPY --from=server /output/server-pruned ./server
|
||||||
COPY --from=web /usr/src/app/web/build /build/www
|
COPY --from=web /usr/src/app/web/build /build/www
|
||||||
COPY --from=cli /output/cli-pruned ./cli
|
COPY --from=cli /output/cli-pruned ./cli
|
||||||
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-core-plugin/dist
|
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist
|
||||||
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-core-plugin/manifest.json
|
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json
|
||||||
RUN ln -s ../../cli/bin/immich server/bin/immich
|
RUN ln -s ../../cli/bin/immich server/bin/immich
|
||||||
COPY LICENSE /licenses/LICENSE.txt
|
COPY LICENSE /licenses/LICENSE.txt
|
||||||
COPY LICENSE /LICENSE
|
COPY LICENSE /LICENSE
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PluginMethodSearchDto,
|
PluginMethodSearchDto,
|
||||||
PluginResponseDto,
|
PluginResponseDto,
|
||||||
PluginSearchDto,
|
PluginSearchDto,
|
||||||
|
PluginTemplateResponseDto,
|
||||||
} from 'src/dtos/plugin.dto';
|
} from 'src/dtos/plugin.dto';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { Authenticated } from 'src/middleware/auth.guard';
|
import { Authenticated } from 'src/middleware/auth.guard';
|
||||||
@@ -39,6 +40,17 @@ export class PluginController {
|
|||||||
return this.service.searchMethods(dto);
|
return this.service.searchMethods(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('templates')
|
||||||
|
@Authenticated({ permission: Permission.PluginRead })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Retrieve workflow templates',
|
||||||
|
description: 'Retrieve workflow templates provided by installed plugins',
|
||||||
|
history: HistoryBuilder.v3(),
|
||||||
|
})
|
||||||
|
searchPluginTemplates(): Promise<PluginTemplateResponseDto[]> {
|
||||||
|
return this.service.searchTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Authenticated({ permission: Permission.PluginRead })
|
@Authenticated({ permission: Permission.PluginRead })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { JsonSchemaSchema } from 'src/dtos/json-schema.dto';
|
import { JsonSchemaSchema } from 'src/dtos/json-schema.dto';
|
||||||
import { WorkflowTypeSchema } from 'src/enum';
|
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/;
|
const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/;
|
||||||
@@ -23,6 +23,24 @@ const PluginManifestMethodSchema = z
|
|||||||
})
|
})
|
||||||
.meta({ id: 'PluginManifestMethodDto' });
|
.meta({ id: 'PluginManifestMethodDto' });
|
||||||
|
|
||||||
|
const PluginManifestTemplateStepSchema = z
|
||||||
|
.object({
|
||||||
|
method: z.string().min(1).describe('Step plugin method (pluginName#methodName)'),
|
||||||
|
config: z.record(z.string(), z.unknown()).nullable().optional().describe('Step configuration'),
|
||||||
|
enabled: z.boolean().optional().describe('Whether the step is enabled'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'PluginManifestTemplateStepDto' });
|
||||||
|
|
||||||
|
const PluginManifestTemplateSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1).describe('Template name (must be unique within the manifest)'),
|
||||||
|
title: z.string().min(1).describe('Template title'),
|
||||||
|
description: z.string().min(1).describe('Template description'),
|
||||||
|
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
|
||||||
|
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'PluginManifestTemplateDto' });
|
||||||
|
|
||||||
const PluginManifestSchema = z
|
const PluginManifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -39,6 +57,14 @@ const PluginManifestSchema = z
|
|||||||
wasmPath: z.string().min(1).describe('WASM file path'),
|
wasmPath: z.string().min(1).describe('WASM file path'),
|
||||||
author: z.string().min(1).describe('Plugin author'),
|
author: z.string().min(1).describe('Plugin author'),
|
||||||
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
|
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
|
||||||
|
templates: z
|
||||||
|
.array(PluginManifestTemplateSchema)
|
||||||
|
.optional()
|
||||||
|
.default([])
|
||||||
|
.refine((templates) => new Set(templates.map((t) => t.name)).size === templates.length, {
|
||||||
|
error: 'Template names must be unique within the manifest',
|
||||||
|
})
|
||||||
|
.describe('Workflow templates'),
|
||||||
})
|
})
|
||||||
.meta({ id: 'PluginManifestDto' });
|
.meta({ id: 'PluginManifestDto' });
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
|
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
|
||||||
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
||||||
import { asMethodString } from 'src/utils/workflow';
|
import { asPluginKey } from 'src/utils/workflow';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
const PluginSearchSchema = z
|
const PluginSearchSchema = z
|
||||||
@@ -43,6 +43,24 @@ const PluginResponseSchema = z
|
|||||||
})
|
})
|
||||||
.meta({ id: 'PluginResponseDto' });
|
.meta({ id: 'PluginResponseDto' });
|
||||||
|
|
||||||
|
const PluginTemplateStepResponseSchema = z
|
||||||
|
.object({
|
||||||
|
method: z.string().describe('Step plugin method'),
|
||||||
|
config: z.record(z.string(), z.unknown()).nullable().describe('Step configuration'),
|
||||||
|
enabled: z.boolean().optional().describe('Whether the step is enabled'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'PluginTemplateStepResponseDto' });
|
||||||
|
|
||||||
|
const PluginTemplateResponseSchema = z
|
||||||
|
.object({
|
||||||
|
key: z.string().describe('Template key (unique across all templates)'),
|
||||||
|
title: z.string().describe('Template title'),
|
||||||
|
description: z.string().describe('Template description'),
|
||||||
|
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
|
||||||
|
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
|
||||||
|
})
|
||||||
|
.meta({ id: 'PluginTemplateResponseDto' });
|
||||||
|
|
||||||
const PluginMethodSearchSchema = z
|
const PluginMethodSearchSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.uuidv4().optional().describe('Plugin method ID'),
|
id: z.uuidv4().optional().describe('Plugin method ID'),
|
||||||
@@ -61,6 +79,33 @@ export class PluginSearchDto extends createZodDto(PluginSearchSchema) {}
|
|||||||
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
|
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
|
||||||
export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {}
|
export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {}
|
||||||
export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {}
|
export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {}
|
||||||
|
export class PluginTemplateResponseDto extends createZodDto(PluginTemplateResponseSchema) {}
|
||||||
|
|
||||||
|
export type PluginTemplate = {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
trigger: WorkflowTrigger;
|
||||||
|
steps: Array<{
|
||||||
|
method: string;
|
||||||
|
config?: Record<string, unknown> | null;
|
||||||
|
enabled?: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
|
||||||
|
return {
|
||||||
|
key: asPluginKey({ pluginName: plugin.name, name: template.name }),
|
||||||
|
title: template.title,
|
||||||
|
description: template.description,
|
||||||
|
trigger: template.trigger,
|
||||||
|
steps: template.steps.map((step) => ({
|
||||||
|
method: step.method,
|
||||||
|
config: step.config ?? null,
|
||||||
|
enabled: step.enabled,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type Plugin = {
|
type Plugin = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -101,7 +146,7 @@ export function mapPlugin(plugin: Plugin): PluginResponseDto {
|
|||||||
|
|
||||||
export const mapMethod = (method: PluginMethod): PluginMethodResponseDto => {
|
export const mapMethod = (method: PluginMethod): PluginMethodResponseDto => {
|
||||||
return {
|
return {
|
||||||
key: asMethodString({ pluginName: method.pluginName, methodName: method.name }),
|
key: asPluginKey({ pluginName: method.pluginName, name: method.name }),
|
||||||
name: method.name,
|
name: method.name,
|
||||||
title: method.title,
|
title: method.title,
|
||||||
hostFunctions: method.hostFunctions,
|
hostFunctions: method.hostFunctions,
|
||||||
|
|||||||
@@ -186,7 +186,11 @@ const SearchAlbumResponseSchema = z
|
|||||||
|
|
||||||
const SearchAssetResponseSchema = z
|
const SearchAssetResponseSchema = z
|
||||||
.object({
|
.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'),
|
count: z.int().min(0).describe('Number of assets in this page'),
|
||||||
items: z.array(AssetResponseSchema),
|
items: z.array(AssetResponseSchema),
|
||||||
facets: z.array(SearchFacetResponseSchema),
|
facets: z.array(SearchFacetResponseSchema),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ select
|
|||||||
"plugin"."version",
|
"plugin"."version",
|
||||||
"plugin"."createdAt",
|
"plugin"."createdAt",
|
||||||
"plugin"."updatedAt",
|
"plugin"."updatedAt",
|
||||||
|
"plugin"."templates",
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
coalesce(json_agg(agg), '[]')
|
coalesce(json_agg(agg), '[]')
|
||||||
@@ -60,6 +61,42 @@ from
|
|||||||
order by
|
order by
|
||||||
"plugin"."name"
|
"plugin"."name"
|
||||||
|
|
||||||
|
-- PluginRepository.getByHash
|
||||||
|
select
|
||||||
|
"plugin"."id",
|
||||||
|
"plugin"."name",
|
||||||
|
"plugin"."title",
|
||||||
|
"plugin"."description",
|
||||||
|
"plugin"."author",
|
||||||
|
"plugin"."version",
|
||||||
|
"plugin"."createdAt",
|
||||||
|
"plugin"."updatedAt",
|
||||||
|
"plugin"."templates",
|
||||||
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"plugin_method"."name",
|
||||||
|
"plugin_method"."title",
|
||||||
|
"plugin_method"."description",
|
||||||
|
"plugin_method"."types",
|
||||||
|
"plugin_method"."schema",
|
||||||
|
"plugin_method"."hostFunctions",
|
||||||
|
"plugin_method"."uiHints",
|
||||||
|
"plugin"."name" as "pluginName"
|
||||||
|
from
|
||||||
|
"plugin_method"
|
||||||
|
where
|
||||||
|
"plugin_method"."pluginId" = "plugin"."id"
|
||||||
|
) as agg
|
||||||
|
) as "methods"
|
||||||
|
from
|
||||||
|
"plugin"
|
||||||
|
where
|
||||||
|
"plugin"."sha256hash" = $1
|
||||||
|
|
||||||
-- PluginRepository.getByName
|
-- PluginRepository.getByName
|
||||||
select
|
select
|
||||||
"plugin"."id",
|
"plugin"."id",
|
||||||
@@ -70,6 +107,7 @@ select
|
|||||||
"plugin"."version",
|
"plugin"."version",
|
||||||
"plugin"."createdAt",
|
"plugin"."createdAt",
|
||||||
"plugin"."updatedAt",
|
"plugin"."updatedAt",
|
||||||
|
"plugin"."templates",
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
coalesce(json_agg(agg), '[]')
|
coalesce(json_agg(agg), '[]')
|
||||||
@@ -105,6 +143,7 @@ select
|
|||||||
"plugin"."version",
|
"plugin"."version",
|
||||||
"plugin"."createdAt",
|
"plugin"."createdAt",
|
||||||
"plugin"."updatedAt",
|
"plugin"."updatedAt",
|
||||||
|
"plugin"."templates",
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
coalesce(json_agg(agg), '[]')
|
coalesce(json_agg(agg), '[]')
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ export class DatabaseRepository {
|
|||||||
columns: { ignoreExtra: true },
|
columns: { ignoreExtra: true },
|
||||||
functions: { ignoreExtra: false },
|
functions: { ignoreExtra: false },
|
||||||
parameters: { ignoreExtra: true },
|
parameters: { ignoreExtra: true },
|
||||||
|
extensions: { ignoreExtra: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return drift;
|
return drift;
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export class PluginRepository {
|
|||||||
'plugin.version',
|
'plugin.version',
|
||||||
'plugin.createdAt',
|
'plugin.createdAt',
|
||||||
'plugin.updatedAt',
|
'plugin.updatedAt',
|
||||||
|
'plugin.templates',
|
||||||
jsonArrayFrom(
|
jsonArrayFrom(
|
||||||
eb
|
eb
|
||||||
.selectFrom('plugin_method')
|
.selectFrom('plugin_method')
|
||||||
@@ -102,6 +103,11 @@ export class PluginRepository {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.STRING] })
|
||||||
|
getByHash(hash: Buffer) {
|
||||||
|
return this.queryBuilder().where('plugin.sha256hash', '=', hash).executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.STRING] })
|
||||||
getByName(name: string) {
|
getByName(name: string) {
|
||||||
return this.queryBuilder().where('plugin.name', '=', name).executeTakeFirst();
|
return this.queryBuilder().where('plugin.name', '=', name).executeTakeFirst();
|
||||||
@@ -151,6 +157,8 @@ export class PluginRepository {
|
|||||||
author: eb.ref('excluded.author'),
|
author: eb.ref('excluded.author'),
|
||||||
version: eb.ref('excluded.version'),
|
version: eb.ref('excluded.version'),
|
||||||
wasmBytes: eb.ref('excluded.wasmBytes'),
|
wasmBytes: eb.ref('excluded.wasmBytes'),
|
||||||
|
templates: eb.ref('excluded.templates'),
|
||||||
|
sha256hash: eb.ref('excluded.sha256hash'),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.returning(['id', 'name'])
|
.returning(['id', 'name'])
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export class WorkflowRepository {
|
|||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
search(dto: WorkflowSearchDto & { ownerId?: string }) {
|
search(dto: WorkflowSearchDto & { ownerId?: string }) {
|
||||||
return this.queryBuilder()
|
return this.queryBuilder()
|
||||||
|
.$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!))
|
||||||
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
|
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
|
||||||
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
|
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
|
||||||
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
|
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "plugin" ADD "templates" jsonb NOT NULL DEFAULT '[]';`.execute(db);
|
||||||
|
await sql`ALTER TABLE "plugin" ADD "sha256hash" bytea NOT NULL DEFAULT decode('20464b37ad726d03d878d38d873c40a52d1fdfb754feda956ebb464afd689e2f', 'hex');`.execute(db);
|
||||||
|
await sql`ALTER TABLE "plugin" ALTER COLUMN "sha256hash" DROP DEFAULT;`.execute(db);
|
||||||
|
await sql`ALTER TABLE "plugin" ALTER COLUMN "templates" DROP DEFAULT;`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "plugin" DROP COLUMN "templates";`.execute(db);
|
||||||
|
await sql`ALTER TABLE "plugin" DROP COLUMN "sha256hash";`.execute(db);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Unique,
|
Unique,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from '@immich/sql-tools';
|
} from '@immich/sql-tools';
|
||||||
|
import { PluginTemplate } from 'src/dtos/plugin.dto';
|
||||||
|
|
||||||
@Unique({ columns: ['name', 'version'] })
|
@Unique({ columns: ['name', 'version'] })
|
||||||
@Table('plugin')
|
@Table('plugin')
|
||||||
@@ -36,6 +37,12 @@ export class PluginTable {
|
|||||||
@Column({ type: 'bytea' })
|
@Column({ type: 'bytea' })
|
||||||
wasmBytes!: Buffer;
|
wasmBytes!: Buffer;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb' })
|
||||||
|
templates!: PluginTemplate[];
|
||||||
|
|
||||||
|
@Column({ type: 'bytea' })
|
||||||
|
sha256hash!: Buffer;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Generated<Timestamp>;
|
createdAt!: Generated<Timestamp>;
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
|||||||
import {
|
import {
|
||||||
mapMethod,
|
mapMethod,
|
||||||
mapPlugin,
|
mapPlugin,
|
||||||
|
mapTemplate,
|
||||||
PluginMethodResponseDto,
|
PluginMethodResponseDto,
|
||||||
PluginMethodSearchDto,
|
PluginMethodSearchDto,
|
||||||
PluginResponseDto,
|
PluginResponseDto,
|
||||||
PluginSearchDto,
|
PluginSearchDto,
|
||||||
|
PluginTemplateResponseDto,
|
||||||
} from 'src/dtos/plugin.dto';
|
} from 'src/dtos/plugin.dto';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { isMethodCompatible } from 'src/utils/workflow';
|
import { isMethodCompatible } from 'src/utils/workflow';
|
||||||
@@ -31,4 +33,9 @@ export class PluginService extends BaseService {
|
|||||||
.filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger))
|
.filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger))
|
||||||
.map((method) => mapMethod(method));
|
.map((method) => mapMethod(method));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchTemplates(): Promise<PluginTemplateResponseDto[]> {
|
||||||
|
const plugins = await this.pluginRepository.search();
|
||||||
|
return plugins.flatMap((plugin) => plugin.templates.map((template) => mapTemplate(plugin, template)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
|||||||
import {
|
import {
|
||||||
BootstrapEventPriority,
|
BootstrapEventPriority,
|
||||||
DatabaseLock,
|
DatabaseLock,
|
||||||
|
ImmichEnvironment,
|
||||||
ImmichWorker,
|
ImmichWorker,
|
||||||
JobName,
|
JobName,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
@@ -43,8 +44,8 @@ export class WorkflowExecutionService extends BaseService {
|
|||||||
// TODO avoid importing plugins in each worker
|
// TODO avoid importing plugins in each worker
|
||||||
// Can this use system metadata similar to geocoding?
|
// Can this use system metadata similar to geocoding?
|
||||||
|
|
||||||
const { resourcePaths, plugins } = this.configRepository.getEnv();
|
const { environment, resourcePaths, plugins } = this.configRepository.getEnv();
|
||||||
await this.importFolder(resourcePaths.corePlugin, { force: true });
|
await this.importFolder(resourcePaths.corePlugin, { force: environment === ImmichEnvironment.Development });
|
||||||
|
|
||||||
if (plugins.external.allow && plugins.external.installFolder) {
|
if (plugins.external.allow && plugins.external.installFolder) {
|
||||||
await this.importFolders(plugins.external.installFolder);
|
await this.importFolders(plugins.external.installFolder);
|
||||||
@@ -166,7 +167,19 @@ export class WorkflowExecutionService extends BaseService {
|
|||||||
private async importFolder(folder: string, options?: { force?: boolean }) {
|
private async importFolder(folder: string, options?: { force?: boolean }) {
|
||||||
try {
|
try {
|
||||||
const manifestPath = join(folder, 'manifest.json');
|
const manifestPath = join(folder, 'manifest.json');
|
||||||
const dto = await this.storageRepository.readJsonFile(manifestPath);
|
const bytes = await this.storageRepository.readFile(manifestPath);
|
||||||
|
const contents = bytes.toString('utf8');
|
||||||
|
const sha256hash = this.cryptoRepository.hashSha256(contents) as Buffer;
|
||||||
|
|
||||||
|
if (!options?.force) {
|
||||||
|
const match = await this.pluginRepository.getByHash(sha256hash);
|
||||||
|
if (match) {
|
||||||
|
this.logger.log(`Plugin up to date (name=${match.name}@${match.version}, hash=${sha256hash.toString('hex')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = JSON.parse(contents);
|
||||||
const result = PluginManifestDto.schema.safeParse(dto);
|
const result = PluginManifestDto.schema.safeParse(dto);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const issues = result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n');
|
const issues = result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n');
|
||||||
@@ -176,22 +189,21 @@ export class WorkflowExecutionService extends BaseService {
|
|||||||
const manifest = result.data;
|
const manifest = result.data;
|
||||||
|
|
||||||
const existing = await this.pluginRepository.getByName(manifest.name);
|
const existing = await this.pluginRepository.getByName(manifest.name);
|
||||||
if (existing && existing.version === manifest.version && options?.force !== true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasmPath = `${folder}/${manifest.wasmPath}`;
|
const wasmPath = `${folder}/${manifest.wasmPath}`;
|
||||||
const wasmBytes = await this.storageRepository.readFile(wasmPath);
|
const wasmBytes = await this.storageRepository.readFile(wasmPath);
|
||||||
|
|
||||||
const plugin = await this.pluginRepository.upsert(
|
const plugin = await this.pluginRepository.upsert(
|
||||||
{
|
{
|
||||||
|
// NOTE: new properties here need to be added to the on conflict clause in the repository
|
||||||
enabled: true,
|
enabled: true,
|
||||||
name: manifest.name,
|
name: manifest.name,
|
||||||
title: manifest.title,
|
title: manifest.title,
|
||||||
description: manifest.description,
|
description: manifest.description,
|
||||||
author: manifest.author,
|
author: manifest.author,
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
|
templates: manifest.templates,
|
||||||
wasmBytes,
|
wasmBytes,
|
||||||
|
sha256hash,
|
||||||
},
|
},
|
||||||
manifest.methods,
|
manifest.methods,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export const resolveMethod = (methods: PluginMethodSearchResponse[], method: str
|
|||||||
return methods.find((method) => method.pluginName === pluginName && method.name === methodName);
|
return methods.find((method) => method.pluginName === pluginName && method.name === methodName);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const asMethodString = (method: { pluginName: string; methodName: string }) => {
|
export const asPluginKey = (method: { pluginName: string; name: string }) => {
|
||||||
return `${method.pluginName}#${method.methodName}`;
|
return `${method.pluginName}#${method.name}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const METHOD_REGEX = /^(?<name>[^@#\s]+)(?:@(?<version>[^#\s]*))?#(?<method>[^@#\s]+)$/;
|
const METHOD_REGEX = /^(?<name>[^@#\s]+)(?:@(?<version>[^#\s]*))?#(?<method>[^@#\s]+)$/;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getKyselyDB } from 'test/utils';
|
|||||||
let defaultDatabase: Kysely<DB>;
|
let defaultDatabase: Kysely<DB>;
|
||||||
|
|
||||||
const wasmBytes = Buffer.from('some-wasm-binary-data');
|
const wasmBytes = Buffer.from('some-wasm-binary-data');
|
||||||
|
const sha256hash = Buffer.from('some-manifest-hash');
|
||||||
|
|
||||||
const setup = (db?: Kysely<DB>) => {
|
const setup = (db?: Kysely<DB>) => {
|
||||||
return newMediumService(PluginService, {
|
return newMediumService(PluginService, {
|
||||||
@@ -46,7 +47,9 @@ describe(PluginService.name, () => {
|
|||||||
description: 'A test plugin',
|
description: 'A test plugin',
|
||||||
author: 'Test Author',
|
author: 'Test Author',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
templates: [],
|
||||||
wasmBytes,
|
wasmBytes,
|
||||||
|
sha256hash,
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -75,7 +78,9 @@ describe(PluginService.name, () => {
|
|||||||
description: 'A plugin with multiple methods',
|
description: 'A plugin with multiple methods',
|
||||||
author: 'Test Author',
|
author: 'Test Author',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
templates: [],
|
||||||
wasmBytes,
|
wasmBytes,
|
||||||
|
sha256hash,
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -130,7 +135,9 @@ describe(PluginService.name, () => {
|
|||||||
description: 'First plugin',
|
description: 'First plugin',
|
||||||
author: 'Author 1',
|
author: 'Author 1',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
templates: [],
|
||||||
wasmBytes,
|
wasmBytes,
|
||||||
|
sha256hash,
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -150,7 +157,9 @@ describe(PluginService.name, () => {
|
|||||||
description: 'Second plugin',
|
description: 'Second plugin',
|
||||||
author: 'Author 2',
|
author: 'Author 2',
|
||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
|
templates: [],
|
||||||
wasmBytes,
|
wasmBytes,
|
||||||
|
sha256hash,
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -183,7 +192,9 @@ describe(PluginService.name, () => {
|
|||||||
description: 'Plugin with multiple methods',
|
description: 'Plugin with multiple methods',
|
||||||
author: 'Test Author',
|
author: 'Test Author',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
templates: [],
|
||||||
wasmBytes,
|
wasmBytes,
|
||||||
|
sha256hash,
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -242,6 +253,8 @@ describe(PluginService.name, () => {
|
|||||||
description: 'A single plugin',
|
description: 'A single plugin',
|
||||||
author: 'Test Author',
|
author: 'Test Author',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
templates: [],
|
||||||
|
sha256hash,
|
||||||
wasmBytes,
|
wasmBytes,
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const setup = (db?: Kysely<DB>) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const wasmBytes = Buffer.from('random-wasm-bytes');
|
const wasmBytes = Buffer.from('random-wasm-bytes');
|
||||||
|
const sha256hash = Buffer.from('some-manifest-hash');
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
defaultDatabase = await getKyselyDB();
|
defaultDatabase = await getKyselyDB();
|
||||||
@@ -41,7 +42,9 @@ describe(WorkflowService.name, () => {
|
|||||||
description: 'A test core plugin for workflow tests',
|
description: 'A test core plugin for workflow tests',
|
||||||
author: 'Test Author',
|
author: 'Test Author',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
templates: [],
|
||||||
wasmBytes,
|
wasmBytes,
|
||||||
|
sha256hash,
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@
|
|||||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||||
"@immich/justified-layout-wasm": "^0.4.3",
|
"@immich/justified-layout-wasm": "^0.4.3",
|
||||||
"@immich/sdk": "workspace:*",
|
"@immich/sdk": "workspace:*",
|
||||||
"@immich/ui": "^0.77.0",
|
"@immich/ui": "^0.79.2",
|
||||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
|
|||||||
+145
-1
@@ -1,17 +1,35 @@
|
|||||||
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
|
import { defaultProvider, screencastManager, themeManager, ThemePreference, type ActionItem } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiAccountMultipleOutline,
|
mdiAccountMultipleOutline,
|
||||||
|
mdiAccountOutline,
|
||||||
|
mdiArchiveArrowDownOutline,
|
||||||
mdiBookshelf,
|
mdiBookshelf,
|
||||||
mdiCog,
|
mdiCog,
|
||||||
|
mdiContentDuplicate,
|
||||||
|
mdiCrosshairsGps,
|
||||||
|
mdiFolderOutline,
|
||||||
|
mdiHeartOutline,
|
||||||
|
mdiImageAlbum,
|
||||||
|
mdiImageMultipleOutline,
|
||||||
|
mdiImageSizeSelectLarge,
|
||||||
mdiKeyboard,
|
mdiKeyboard,
|
||||||
|
mdiLink,
|
||||||
|
mdiLockOutline,
|
||||||
|
mdiMagnify,
|
||||||
|
mdiMapOutline,
|
||||||
mdiServer,
|
mdiServer,
|
||||||
|
mdiStateMachine,
|
||||||
mdiSync,
|
mdiSync,
|
||||||
|
mdiTagMultipleOutline,
|
||||||
mdiThemeLightDark,
|
mdiThemeLightDark,
|
||||||
|
mdiToolboxOutline,
|
||||||
|
mdiTrashCanOutline,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import type { MessageFormatter } from 'svelte-i18n';
|
import type { MessageFormatter } from 'svelte-i18n';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard } from '$lib/utils';
|
||||||
|
|
||||||
@@ -49,7 +67,133 @@ export const getPagesProvider = ($t: MessageFormatter) => {
|
|||||||
},
|
},
|
||||||
].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin }));
|
].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 = () => {
|
const getMyImmichLink = () => {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
<ControlAppBar showBackButton={false}>
|
<ControlAppBar>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||||
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<header>
|
<header>
|
||||||
<ControlAppBar showBackButton={false}>
|
<ControlAppBar>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||||
<Logo variant="inline" />
|
<Logo variant="inline" />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
|
import { getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { IconButton, Logo, toastManager } from '@immich/ui';
|
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 { t } from 'svelte-i18n';
|
||||||
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
|
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
|
||||||
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
|
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}>
|
<ControlAppBar>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
|
||||||
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
|
||||||
|
|||||||
@@ -1,97 +1,49 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { ControlBar, ControlBarContent, ControlBarHeader, ControlBarOverflow, ControlBarTitle } from '@immich/ui';
|
||||||
import { IconButton } from '@immich/ui';
|
|
||||||
import { mdiClose } from '@mdi/js';
|
import { mdiClose } from '@mdi/js';
|
||||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
import { fly } from 'svelte/transition';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
showBackButton?: boolean;
|
|
||||||
backIcon?: string;
|
backIcon?: string;
|
||||||
tailwindClasses?: string;
|
class?: ClassValue;
|
||||||
forceDark?: boolean;
|
|
||||||
multiRow?: boolean;
|
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
title?: Snippet | string;
|
||||||
leading?: Snippet;
|
leading?: Snippet;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
trailing?: Snippet;
|
trailing?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { backIcon = mdiClose, class: className = '', onClose, title, leading, children, trailing }: Props = $props();
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full bg-transparent">
|
<div class="absolute top-0 w-full bg-transparent p-2" id="control-bar">
|
||||||
<nav
|
<ControlBar closeIcon={backIcon} {onClose} shape="round" class={className}>
|
||||||
id="asset-selection-app-bar"
|
{#if title || leading}
|
||||||
class={[
|
<ControlBarHeader>
|
||||||
'grid',
|
{#if title}
|
||||||
multiRow && 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]',
|
<ControlBarTitle>
|
||||||
!multiRow && 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]',
|
{#if typeof title === 'string'}
|
||||||
'justify-between lg:grid-cols-[25%_50%_25%]',
|
{title}
|
||||||
appBarBorder,
|
{:else}
|
||||||
'm-2 place-items-center rounded-full p-2 transition-all max-md:p-0',
|
{@render title()}
|
||||||
tailwindClasses,
|
{/if}
|
||||||
forceDark ? 'bg-immich-dark-gray! text-white' : 'bg-light-50 dark:bg-immich-dark-gray',
|
</ControlBarTitle>
|
||||||
]}
|
{/if}
|
||||||
>
|
{@render leading?.()}
|
||||||
<div class="flex place-items-center justify-self-start sm:gap-6 dark:text-immich-dark-fg {forceDark ? 'dark' : ''}">
|
</ControlBarHeader>
|
||||||
{#if showBackButton}
|
{/if}
|
||||||
<IconButton
|
|
||||||
aria-label={$t('close')}
|
|
||||||
onclick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
shape="round"
|
|
||||||
variant="ghost"
|
|
||||||
icon={backIcon}
|
|
||||||
size="large"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{@render leading?.()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full">
|
{#if children}
|
||||||
{@render children?.()}
|
<ControlBarContent>
|
||||||
</div>
|
{@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">
|
{#if trailing}
|
||||||
{@render trailing?.()}
|
<ControlBarOverflow>
|
||||||
</div>
|
{@render trailing()}
|
||||||
</nav>
|
</ControlBarOverflow>
|
||||||
|
{/if}
|
||||||
|
</ControlBar>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
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 { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
@@ -31,11 +30,14 @@
|
|||||||
|
|
||||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- Image grid -->
|
<!-- Image grid -->
|
||||||
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
|
<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 position = viewerAsset.position!}
|
||||||
{@const asset = viewerAsset.asset!}
|
{@const asset = viewerAsset.asset!}
|
||||||
|
|
||||||
|
|||||||
@@ -7,19 +7,18 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
forceDark?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let { children, forceDark }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
|
||||||
const onClose = () => assetMultiSelectManager.clear();
|
const onClose = () => assetMultiSelectManager.clear();
|
||||||
|
|
||||||
const assets = $derived(assetMultiSelectManager.assets);
|
const assets = $derived(assetMultiSelectManager.assets);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ControlAppBar {onClose} {forceDark} backIcon={mdiClose} tailwindClasses="bg-white shadow-md">
|
<ControlAppBar {onClose} backIcon={mdiClose}>
|
||||||
{#snippet leading()}
|
{#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="block sm:hidden">{assets.length}</p>
|
||||||
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
|
<p class="hidden sm:block">{$t('selected_count', { values: { count: assets.length } })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export class EditManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Setup the websocket listener before sending the edit request
|
// 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
|
await (edits.length === 0
|
||||||
? removeAssetEdits({ id: assetId })
|
? removeAssetEdits({ id: assetId })
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
getWorkflowTriggers,
|
getWorkflowTriggers,
|
||||||
searchPluginMethods,
|
searchPluginMethods,
|
||||||
|
searchPluginTemplates,
|
||||||
WorkflowTrigger,
|
WorkflowTrigger,
|
||||||
type PluginMethodResponseDto,
|
type PluginMethodResponseDto,
|
||||||
|
type PluginTemplateResponseDto,
|
||||||
type WorkflowTriggerResponseDto,
|
type WorkflowTriggerResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
@@ -16,6 +18,7 @@ class PluginManager {
|
|||||||
#methodMap = new SvelteMap<string, PluginMethodResponseDto>();
|
#methodMap = new SvelteMap<string, PluginMethodResponseDto>();
|
||||||
#methods = $state<PluginMethodResponseDto[]>([]);
|
#methods = $state<PluginMethodResponseDto[]>([]);
|
||||||
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
|
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
|
||||||
|
#templates = $state<PluginTemplateResponseDto[]>([]);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
eventManager.on({
|
eventManager.on({
|
||||||
@@ -33,6 +36,10 @@ class PluginManager {
|
|||||||
return this.#triggers;
|
return this.#triggers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get templates() {
|
||||||
|
return this.#templates;
|
||||||
|
}
|
||||||
|
|
||||||
ready() {
|
ready() {
|
||||||
return this.initialize();
|
return this.initialize();
|
||||||
}
|
}
|
||||||
@@ -70,7 +77,11 @@ class PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async load() {
|
private async load() {
|
||||||
const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]);
|
const [methods, triggers, templates] = await Promise.all([
|
||||||
|
searchPluginMethods({}),
|
||||||
|
getWorkflowTriggers(),
|
||||||
|
searchPluginTemplates(),
|
||||||
|
]);
|
||||||
|
|
||||||
this.#methods = methods;
|
this.#methods = methods;
|
||||||
for (const method of this.#methods) {
|
for (const method of this.#methods) {
|
||||||
@@ -78,6 +89,7 @@ class PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.#triggers = triggers;
|
this.#triggers = triggers;
|
||||||
|
this.#templates = templates;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ export class TimelineDay {
|
|||||||
|
|
||||||
height = $state(0);
|
height = $state(0);
|
||||||
width = $state(0);
|
width = $state(0);
|
||||||
isInOrNearViewport = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.isInOrNearViewport));
|
|
||||||
|
|
||||||
#top: number = $state(0);
|
#top: number = $state(0);
|
||||||
#start: number = $state(0);
|
#start: number = $state(0);
|
||||||
#row = $state(0);
|
#row = $state(0);
|
||||||
#col = $state(0);
|
#col = $state(0);
|
||||||
#deferredLayout = false;
|
#deferredLayout = false;
|
||||||
|
#lastInOrNearViewport = -1;
|
||||||
|
|
||||||
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
|
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
|
||||||
this.index = index;
|
this.index = index;
|
||||||
@@ -154,4 +154,13 @@ export class TimelineDay {
|
|||||||
get absoluteTimelineDayTop() {
|
get absoluteTimelineDayTop() {
|
||||||
return this.timelineMonth.top + this.#top;
|
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();
|
const { trigger, selectedKey, onClose }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BasicModal title={$t('add_step')} {onClose}>
|
<BasicModal title={$t('add_step')} {onClose} size="medium">
|
||||||
{#await searchPluginMethods({ trigger })}
|
{#await searchPluginMethods({ trigger })}
|
||||||
<div class="flex w-full place-content-center place-items-center">
|
<div class="flex w-full place-content-center place-items-center">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if method}
|
{#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="flex items-center justify-between gap-2">
|
||||||
<div class="grow text-start">
|
<div class="grow text-start">
|
||||||
<Text fontWeight="medium">{method.title}</Text>
|
<Text fontWeight="medium">{method.title}</Text>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { Route } from '$lib/route';
|
||||||
|
import { handleCreateWorkflow } from '$lib/services/workflow.service';
|
||||||
|
import { WorkflowTrigger, type WorkflowResponseDto } from '@immich/sdk';
|
||||||
|
import { Field, FormModal, Input, Textarea, VStack } from '@immich/ui';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workflow: WorkflowResponseDto;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { workflow, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let name = $state(workflow.name ?? '');
|
||||||
|
let description = $state(workflow.description ?? '');
|
||||||
|
let trigger = $state<WorkflowTrigger>(workflow.trigger);
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
const response = await handleCreateWorkflow({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
trigger,
|
||||||
|
steps: workflow.steps,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
await goto(Route.viewWorkflow({ id: response.id }));
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormModal
|
||||||
|
title={$t('duplicate_workflow')}
|
||||||
|
{onClose}
|
||||||
|
{onSubmit}
|
||||||
|
disabled={!name || !trigger}
|
||||||
|
size="medium"
|
||||||
|
submitText={$t('create')}
|
||||||
|
>
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Field label={$t('name')} required>
|
||||||
|
<Input placeholder={$t('workflow_name')} bind:value={name} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={$t('description')}>
|
||||||
|
<Textarea grow placeholder={$t('workflow_description')} bind:value={description} />
|
||||||
|
</Field>
|
||||||
|
</VStack>
|
||||||
|
</FormModal>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if method}
|
{#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="flex items-center justify-between gap-2">
|
||||||
<div class="grow text-start">
|
<div class="grow text-start">
|
||||||
<Text fontWeight="medium">{method.title}</Text>
|
<Text fontWeight="medium">{method.title}</Text>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||||
|
import { handleCreateWorkflow } from '$lib/services/workflow.service';
|
||||||
|
import { type PluginTemplateResponseDto } from '@immich/sdk';
|
||||||
|
import { FormModal, Icon, ListButton, Text } from '@immich/ui';
|
||||||
|
import { mdiFlashOutline } from '@mdi/js';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { onClose }: Props = $props();
|
||||||
|
|
||||||
|
let selected = $state<PluginTemplateResponseDto>();
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await handleCreateWorkflow({
|
||||||
|
trigger: selected.trigger,
|
||||||
|
steps: selected.steps,
|
||||||
|
name: selected.title,
|
||||||
|
description: selected.description,
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (template: PluginTemplateResponseDto) => selected?.key === template.key;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormModal
|
||||||
|
title={$t('workflow_templates')}
|
||||||
|
{onClose}
|
||||||
|
{onSubmit}
|
||||||
|
disabled={!selected}
|
||||||
|
size="medium"
|
||||||
|
submitText={$t('use_template')}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each pluginManager.templates as template (template.key)}
|
||||||
|
<ListButton
|
||||||
|
selected={isSelected(template)}
|
||||||
|
onclick={() => (selected = isSelected(template) ? undefined : template)}
|
||||||
|
>
|
||||||
|
<div class="flex w-full items-center gap-3 text-start">
|
||||||
|
<div
|
||||||
|
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary"
|
||||||
|
>
|
||||||
|
<Icon icon={mdiFlashOutline} size="18" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 grow">
|
||||||
|
<Text fontWeight="medium">{template.title}</Text>
|
||||||
|
<Text size="tiny" color="muted">{template.description}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ListButton>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
const onSubmit = () => onClose(selected);
|
const onSubmit = () => onClose(selected);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
|
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="medium">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each pluginManager.triggers as item (item.trigger)}
|
{#each pluginManager.triggers as item (item.trigger)}
|
||||||
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
|
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
|
|||||||
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
||||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||||
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
|
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
|
||||||
import { downloadUrl } from '$lib/utils/asset-utils';
|
import { downloadUrl } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { toastManager, type ActionItem } from '@immich/ui';
|
|||||||
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
|
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import type { MessageFormatter } from 'svelte-i18n';
|
import type { MessageFormatter } from 'svelte-i18n';
|
||||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard, downloadJson } from '$lib/utils';
|
||||||
import { downloadBlob } from '$lib/utils/asset-utils';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
|
||||||
@@ -19,7 +17,7 @@ export const getSystemConfigActions = (
|
|||||||
title: $t('copy_to_clipboard'),
|
title: $t('copy_to_clipboard'),
|
||||||
description: $t('admin.copy_config_to_clipboard_description'),
|
description: $t('admin.copy_config_to_clipboard_description'),
|
||||||
icon: mdiContentCopy,
|
icon: mdiContentCopy,
|
||||||
onAction: () => handleCopyToClipboard(config),
|
onAction: () => copyToClipboard(config),
|
||||||
shortcuts: { shift: true, key: 'c' },
|
shortcuts: { shift: true, key: 'c' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,7 +25,7 @@ export const getSystemConfigActions = (
|
|||||||
title: $t('export_as_json'),
|
title: $t('export_as_json'),
|
||||||
description: $t('admin.export_config_as_json_description'),
|
description: $t('admin.export_config_as_json_description'),
|
||||||
icon: mdiDownload,
|
icon: mdiDownload,
|
||||||
onAction: () => handleDownloadConfig(config),
|
onAction: () => downloadJson(config, 'immich-config.json'),
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ shift: true, key: 's' },
|
{ shift: true, key: 's' },
|
||||||
{ shift: true, key: 'd' },
|
{ shift: true, key: 'd' },
|
||||||
@@ -65,31 +63,6 @@ export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793
|
|
||||||
const jsonReplacer = (_key: string, value: unknown) =>
|
|
||||||
value instanceof Object && !Array.isArray(value)
|
|
||||||
? Object.keys(value)
|
|
||||||
.sort()
|
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce
|
|
||||||
.reduce((sorted: { [key: string]: unknown }, key) => {
|
|
||||||
sorted[key] = (value as { [key: string]: unknown })[key];
|
|
||||||
return sorted;
|
|
||||||
}, {})
|
|
||||||
: value;
|
|
||||||
|
|
||||||
export const handleCopyToClipboard = async (config: SystemConfigDto) => {
|
|
||||||
await copyToClipboard(JSON.stringify(config, jsonReplacer, 2));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleDownloadConfig = (config: SystemConfigDto) => {
|
|
||||||
const blob = new Blob([JSON.stringify(config, jsonReplacer, 2)], { type: 'application/json' });
|
|
||||||
const downloadKey = 'immich-config.json';
|
|
||||||
downloadManager.add(downloadKey, blob.size);
|
|
||||||
downloadManager.update(downloadKey, blob.size);
|
|
||||||
downloadBlob(blob, downloadKey);
|
|
||||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const handleUploadConfig = () => {
|
export const handleUploadConfig = () => {
|
||||||
const input = globalThis.document.createElement('input');
|
const input = globalThis.document.createElement('input');
|
||||||
input.setAttribute('type', 'file');
|
input.setAttribute('type', 'file');
|
||||||
|
|||||||
@@ -10,11 +10,25 @@ import {
|
|||||||
type WorkflowUpdateDto,
|
type WorkflowUpdateDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||||
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
|
import {
|
||||||
|
mdiCodeJson,
|
||||||
|
mdiContentCopy,
|
||||||
|
mdiContentDuplicate,
|
||||||
|
mdiDeleteOutline,
|
||||||
|
mdiDownload,
|
||||||
|
mdiFileDocumentMultipleOutline,
|
||||||
|
mdiPause,
|
||||||
|
mdiPencil,
|
||||||
|
mdiPlay,
|
||||||
|
mdiPlus,
|
||||||
|
} from '@mdi/js';
|
||||||
import type { MessageFormatter } from 'svelte-i18n';
|
import type { MessageFormatter } from 'svelte-i18n';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
|
||||||
|
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
|
import { copyToClipboard, downloadJson } from '$lib/utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
|
|
||||||
@@ -33,17 +47,63 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { Create };
|
const UseTemplate: ActionItem = {
|
||||||
|
title: $t('browse_templates'),
|
||||||
|
icon: mdiFileDocumentMultipleOutline,
|
||||||
|
onAction: () => modalManager.show(WorkflowTemplatePicker, {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { Create, UseTemplate };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
|
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
|
||||||
const ToggleEnabled: ActionItem = {
|
const ToggleEnabled: ActionItem = {
|
||||||
title: workflow.enabled ? $t('disable') : $t('enable'),
|
title: workflow.enabled ? $t('disable') : $t('enable'),
|
||||||
icon: workflow.enabled ? mdiPause : mdiPlay,
|
icon: workflow.enabled ? mdiPause : mdiPlay,
|
||||||
color: workflow.enabled ? 'danger' : 'primary',
|
|
||||||
onAction: () => handleUpdateWorkflow(workflow.id, { enabled: !workflow.enabled }),
|
onAction: () => handleUpdateWorkflow(workflow.id, { enabled: !workflow.enabled }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CopyJson: ActionItem = {
|
||||||
|
title: $t('copy_json'),
|
||||||
|
icon: mdiContentCopy,
|
||||||
|
onAction: () =>
|
||||||
|
copyToClipboard(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: workflow.name,
|
||||||
|
description: workflow.description,
|
||||||
|
enabled: workflow.enabled,
|
||||||
|
trigger: workflow.trigger,
|
||||||
|
steps: workflow.steps,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const Download: ActionItem = {
|
||||||
|
title: $t('download'),
|
||||||
|
icon: mdiDownload,
|
||||||
|
onAction: () =>
|
||||||
|
downloadJson(
|
||||||
|
{
|
||||||
|
name: workflow.name,
|
||||||
|
description: workflow.description,
|
||||||
|
enabled: workflow.enabled,
|
||||||
|
trigger: workflow.trigger,
|
||||||
|
steps: workflow.steps,
|
||||||
|
},
|
||||||
|
'workflow.json',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const Duplicate: ActionItem = {
|
||||||
|
title: $t('duplicate'),
|
||||||
|
icon: mdiContentDuplicate,
|
||||||
|
onAction: async () => modalManager.show(WorkflowDuplicateModal, { workflow }),
|
||||||
|
};
|
||||||
|
|
||||||
const Edit: ActionItem = {
|
const Edit: ActionItem = {
|
||||||
title: $t('edit'),
|
title: $t('edit'),
|
||||||
icon: mdiPencil,
|
icon: mdiPencil,
|
||||||
@@ -52,14 +112,12 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
|
|||||||
|
|
||||||
const Delete: ActionItem = {
|
const Delete: ActionItem = {
|
||||||
title: $t('delete'),
|
title: $t('delete'),
|
||||||
icon: mdiDelete,
|
icon: mdiDeleteOutline,
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
onAction: async () => {
|
onAction: () => handleDeleteWorkflow(workflow),
|
||||||
await handleDeleteWorkflow(workflow);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ToggleEnabled, Edit, Delete };
|
return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getWorkflowShowSchemaAction = (
|
export const getWorkflowShowSchemaAction = (
|
||||||
@@ -72,12 +130,14 @@ export const getWorkflowShowSchemaAction = (
|
|||||||
onAction: onToggle,
|
onAction: onToggle,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
|
export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
|
||||||
const $t = await getFormatter();
|
const $t = await getFormatter();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await createWorkflow({ workflowCreateDto: dto });
|
const response = await createWorkflow({ workflowCreateDto: dto });
|
||||||
eventManager.emit('WorkflowCreate', response);
|
eventManager.emit('WorkflowCreate', response);
|
||||||
|
toastManager.success();
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_create'));
|
handleError(error, $t('errors.unable_to_create'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
type ServerVersionResponseDto,
|
type ServerVersionResponseDto,
|
||||||
type SyncAssetEditV1,
|
type SyncAssetEditV1,
|
||||||
type SyncAssetV1,
|
type SyncAssetV2,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
import { get, writable } from 'svelte/store';
|
import { get, writable } from 'svelte/store';
|
||||||
@@ -41,7 +41,7 @@ export interface Events {
|
|||||||
AppRestartV1: (event: AppRestartEvent) => void;
|
AppRestartV1: (event: AppRestartEvent) => void;
|
||||||
|
|
||||||
MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void;
|
MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void;
|
||||||
AssetEditReadyV1: (data: { asset: SyncAssetV1; edit: SyncAssetEditV1[] }) => void;
|
AssetEditReadyV2: (data: { asset: SyncAssetV2; edit: SyncAssetEditV1[] }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const websocket: Socket<Events> = io({
|
const websocket: Socket<Events> = io({
|
||||||
|
|||||||
+39
-2
@@ -24,6 +24,7 @@ import { init, register, t } from 'svelte-i18n';
|
|||||||
import { derived, get } from 'svelte/store';
|
import { derived, get } from 'svelte/store';
|
||||||
import { defaultLang, locales } from '$lib/constants';
|
import { defaultLang, locales } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||||
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
@@ -249,17 +250,53 @@ export const getProfileImageUrl = (user: UserResponseDto) =>
|
|||||||
export const getPeopleThumbnailUrl = (person: PersonResponseDto, updatedAt?: string) =>
|
export const getPeopleThumbnailUrl = (person: PersonResponseDto, updatedAt?: string) =>
|
||||||
createUrl(getPeopleThumbnailPath(person.id), { updatedAt: updatedAt ?? person.updatedAt });
|
createUrl(getPeopleThumbnailPath(person.id), { updatedAt: updatedAt ?? person.updatedAt });
|
||||||
|
|
||||||
export const copyToClipboard = async (secret: string) => {
|
export const copyToClipboard = async (secret: string | unknown) => {
|
||||||
const $t = get(t);
|
const $t = get(t);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(secret);
|
const value = typeof secret === 'string' ? secret : JSON.stringify(secret, jsonReplacer, 2);
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
toastManager.info($t('copied_to_clipboard'));
|
toastManager.info($t('copied_to_clipboard'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_copy_to_clipboard'));
|
handleError(error, $t('errors.unable_to_copy_to_clipboard'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793
|
||||||
|
const jsonReplacer = (_key: string, value: unknown) =>
|
||||||
|
value instanceof Object && !Array.isArray(value)
|
||||||
|
? Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
// eslint-disable-next-line unicorn/no-array-reduce
|
||||||
|
.reduce((sorted: { [key: string]: unknown }, key) => {
|
||||||
|
sorted[key] = (value as { [key: string]: unknown })[key];
|
||||||
|
return sorted;
|
||||||
|
}, {})
|
||||||
|
: value;
|
||||||
|
|
||||||
|
export const downloadUrl = (url: string, filename: string) => {
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
|
||||||
|
document.body.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadBlob = (data: Blob, filename: string) => downloadUrl(URL.createObjectURL(data), filename);
|
||||||
|
|
||||||
|
export const downloadJson = (data: unknown, filename: string) => {
|
||||||
|
const blob = new Blob([JSON.stringify(data, jsonReplacer, 2)], { type: 'application/json' });
|
||||||
|
const downloadKey = filename;
|
||||||
|
downloadManager.add(downloadKey, blob.size);
|
||||||
|
downloadManager.update(downloadKey, blob.size);
|
||||||
|
downloadBlob(blob, downloadKey);
|
||||||
|
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
export const oauth = {
|
export const oauth = {
|
||||||
isCallback: (location: Location) => {
|
isCallback: (location: Location) => {
|
||||||
const search = location.search;
|
const search = location.search;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
|
|||||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { downloadRequest, withError } from '$lib/utils';
|
import { downloadBlob, downloadRequest, withError } from '$lib/utils';
|
||||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
import { getFormatter } from '$lib/utils/i18n';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
@@ -73,32 +73,6 @@ export const removeTag = async ({
|
|||||||
return assetIds;
|
return assetIds;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const downloadBlob = (data: Blob, filename: string) => {
|
|
||||||
const url = URL.createObjectURL(data);
|
|
||||||
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
|
|
||||||
document.body.append(anchor);
|
|
||||||
anchor.click();
|
|
||||||
anchor.remove();
|
|
||||||
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadUrl = (url: string, filename: string) => {
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
|
|
||||||
document.body.append(anchor);
|
|
||||||
anchor.click();
|
|
||||||
anchor.remove();
|
|
||||||
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const downloadArchive = async (fileName: string, options: Omit<DownloadInfoDto, 'archiveSize'>) => {
|
export const downloadArchive = async (fileName: string, options: Omit<DownloadInfoDto, 'archiveSize'>) => {
|
||||||
const archiveSize = authManager.authenticated ? authManager.preferences.download.archiveSize : undefined;
|
const archiveSize = authManager.authenticated ? authManager.preferences.download.archiveSize : undefined;
|
||||||
const dto = { ...options, archiveSize };
|
const dto = { ...options, archiveSize };
|
||||||
|
|||||||
+3
-3
@@ -1,10 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto, invalidate, onNavigate } from '$app/navigation';
|
import { goto, invalidate, onNavigate } from '$app/navigation';
|
||||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||||
import AlbumDescription from './AlbumDescription.svelte';
|
|
||||||
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
|
import AlbumMap from '$lib/components/album-page/AlbumMap.svelte';
|
||||||
import AlbumSummary from '$lib/components/album-page/AlbumSummary.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 ActivityStatus from '$lib/components/asset-viewer/ActivityStatus.svelte';
|
||||||
import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte';
|
import ActivityViewer from '$lib/components/asset-viewer/ActivityViewer.svelte';
|
||||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||||
@@ -78,6 +76,8 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import AlbumDescription from './AlbumDescription.svelte';
|
||||||
|
import AlbumTitle from './AlbumTitle.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
@@ -499,7 +499,7 @@
|
|||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
{#if viewMode === AlbumPageViewMode.VIEW}
|
{#if viewMode === AlbumPageViewMode.VIEW}
|
||||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.albums())}>
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<ActionButton action={Cast} />
|
<ActionButton action={Cast} />
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
import { afterNavigate, goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
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 ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.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 GalleryViewer from '$lib/components/shared-components/gallery-viewer/GalleryViewer.svelte';
|
||||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||||
@@ -37,6 +34,7 @@
|
|||||||
mdiChevronLeft,
|
mdiChevronLeft,
|
||||||
mdiChevronRight,
|
mdiChevronRight,
|
||||||
mdiChevronUp,
|
mdiChevronUp,
|
||||||
|
mdiClose,
|
||||||
mdiDotsVertical,
|
mdiDotsVertical,
|
||||||
mdiHeart,
|
mdiHeart,
|
||||||
mdiHeartOutline,
|
mdiHeartOutline,
|
||||||
@@ -54,6 +52,8 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { Attachment } from 'svelte/attachments';
|
import type { Attachment } from 'svelte/attachments';
|
||||||
import { Tween } from 'svelte/motion';
|
import { Tween } from 'svelte/motion';
|
||||||
|
import MemoryPhotoViewer from './MemoryPhotoViewer.svelte';
|
||||||
|
import MemoryVideoViewer from './MemoryVideoViewer.svelte';
|
||||||
|
|
||||||
let memoryGallery: HTMLElement | undefined = $state();
|
let memoryGallery: HTMLElement | undefined = $state();
|
||||||
let memoryWrapper: HTMLElement | undefined = $state();
|
let memoryWrapper: HTMLElement | undefined = $state();
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
|
|
||||||
{#if assetMultiSelectManager.selectionActive}
|
{#if assetMultiSelectManager.selectionActive}
|
||||||
<div class="dark sticky top-0 z-1">
|
<div class="dark sticky top-0 z-1">
|
||||||
<AssetSelectControlBar forceDark>
|
<AssetSelectControlBar>
|
||||||
{@const Actions = getAssetBulkActions($t)}
|
{@const Actions = getAssetBulkActions($t)}
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -365,22 +365,31 @@
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
id="memory-viewer"
|
id="memory-viewer"
|
||||||
class="w-full bg-immich-dark-gray"
|
class="dark w-full bg-immich-dark-gray text-white"
|
||||||
bind:this={memoryWrapper}
|
bind:this={memoryWrapper}
|
||||||
bind:clientHeight={viewport.height}
|
bind:clientHeight={viewport.height}
|
||||||
bind:clientWidth={viewport.width}
|
bind:clientWidth={viewport.width}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
<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">
|
||||||
{#snippet leading()}
|
{#if current}
|
||||||
{#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">
|
<p class="text-lg">
|
||||||
{$memoryLaneTitle(current.memory)}
|
{$memoryLaneTitle(current.memory)}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
</div>
|
||||||
{/snippet}
|
{/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
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -438,7 +447,7 @@
|
|||||||
</media-mute-button>
|
</media-mute-button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</ControlAppBar>
|
</div>
|
||||||
|
|
||||||
{#if galleryInView}
|
{#if galleryInView}
|
||||||
<div
|
<div
|
||||||
@@ -462,7 +471,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Viewer -->
|
<!-- 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
|
<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)]"
|
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 />
|
<DownloadAction />
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
|
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(Route.sharing())}>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
|
<p class="whitespace-nowrap text-immich-fg dark:text-immich-dark-fg">
|
||||||
{$t('partner_list_user_photos', { values: { user: data.partner.name } })}
|
{$t('partner_list_user_photos', { values: { user: data.partner.name } })}
|
||||||
|
|||||||
+4
-4
@@ -5,9 +5,6 @@
|
|||||||
import { listNavigation } from '$lib/actions/list-navigation';
|
import { listNavigation } from '$lib/actions/list-navigation';
|
||||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
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 OnEvents from '$lib/components/OnEvents.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
|
||||||
@@ -54,6 +51,9 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import EditNameInput from './EditNameInput.svelte';
|
||||||
|
import MergeFaceSelector from './MergeFaceSelector.svelte';
|
||||||
|
import UnmergeFaceSelector from './UnmergeFaceSelector.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{:else}
|
{:else}
|
||||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
||||||
<ControlAppBar showBackButton backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
<ControlAppBar backIcon={mdiArrowLeft} onClose={() => goto(previousRoute)}>
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<ContextMenuButton
|
<ContextMenuButton
|
||||||
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
|
items={[SelectFeaturePhoto, HidePerson, ShowPerson, SetDateOfBirth, Merge, Favorite, Unfavorite]}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user