Compare commits

..

14 Commits

Author SHA1 Message Date
Alex Tran 9b31ca6e75 feat: workflow step card with drag-n-drop ghost target 2026-05-29 09:40:18 -05:00
pneuly a838167f11 fix(ml): pass model_root_dir to OcrOptions for RapidOCR compatibility (#28610)
* fix(ml): pass model_root_dir to OcrOptions for RapidOCR compatibility

Fix a TypeError (Path(None)) when the OCR model is invoked, caused by an upstream change in RapidOCR v3.8.1 (RapidAI/RapidOCR@8ea9626).
RapidOCR now internally calls `Path(cfg.get("model_root_dir"))`. Since `model_root_dir` was missing from `OcrOptions`, it evaluated to `None` and triggered a `TypeError: argument should be a str or an os.PathLike`.
This fix adds the missing `model_root_dir` argument to prevent the error.
Ref: #28331

* fix(ml-test): update OCR tests for RapidOCR schema change

* chore(ml-test): remove unused `cache_dir` parameter from `TextRecognizer`

* Revert "chore(ml-test): remove unused `cache_dir` parameter from `TextRecognizer`"

This reverts commit 007ad7b3f2.

* fix(ml): use self.cache_dir for model_root_dir in OcrOptions
2026-05-28 22:54:04 -04:00
Mert b189fc571c fix: make ts a peer dependency for swagger (#28677)
make ts a peer dependency
2026-05-28 22:04:25 +00:00
Jason Rasmussen 96923f6115 refactor: plugin sdk types (#28674) 2026-05-28 22:04:15 +00:00
shenlong 0d6cce4a5b fix: api repositories using stale endpoint (#28667)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 16:44:11 -05:00
shenlong 55947cb227 refactor: drop metadata scope (#28668)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-05-28 16:42:59 -05:00
Jason Rasmussen 8783180cf3 refactor: plugin manifest (#28673) 2026-05-28 17:23:49 -04:00
Jason Rasmussen 134c0d4dfb feat: search by album name and id (#28672) 2026-05-28 17:01:47 -04:00
Alex aecf8ec88b fix: timeline scroll flicker (#28653)
* test: fix scroll flicker

* lint
2026-05-28 08:20:54 -05:00
Daniel Dietzler bcff1d42b0 chore: migrate more make targets (#28663) 2026-05-28 08:33:57 -04:00
Min Idzelis 1bd367bd51 refactor(web): replace per-asset viewport proximity with day-tier active indices (#28597) 2026-05-28 11:44:18 +02:00
Daniel Dietzler 725f266b81 chore: migrate more make targets to mise (#28651) 2026-05-28 11:31:02 +02:00
Daniel Dietzler d08e3de207 fix: e2e linting (#28659) 2026-05-28 11:12:26 +02:00
Timon 26714f6bfe fix(server): prevent locked assets from leaking to partners (#28652)
* fix(server): prevent locked assets from leaking to partners

* fix tests
2026-05-27 17:33:49 -04:00
63 changed files with 757 additions and 355 deletions
+15 -24
View File
@@ -1,46 +1,46 @@
dev: dev:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans @printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n" >&2 && exit 1
dev-down: dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans @printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n" >&2 && exit 1
dev-update: dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans @printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n" >&2 && exit 1
dev-scale: dev-scale:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans @printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n" >&2 && exit 1
dev-docs: dev-docs:
npm --prefix docs run start npm --prefix docs run start
.PHONY: e2e .PHONY: e2e
e2e: e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans @printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n" >&2 && exit 1
e2e-dev: e2e-dev:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans @printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n" >&2 && exit 1
e2e-update: e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans @printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n" >&2 && exit 1
e2e-down: e2e-down:
docker compose -f ./e2e/docker-compose.yml down --remove-orphans @printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n" >&2 && exit 1
prod: prod:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans @printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n" >&2 && exit 1
prod-down: prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans @printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n" >&2 && exit 1
prod-scale: prod-scale:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans @printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n" >&2 && exit 1
.PHONY: open-api .PHONY: open-api
open-api: open-api:
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n"\n\n >&2 && exit 1 @printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n" >&2 && exit 1
sql: sql:
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n"\n\n >&2 && exit 1 @printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n" >&2 && exit 1
renovate: renovate:
@@ -52,16 +52,7 @@ renovate:
MODULES = e2e server web cli sdk docs .github MODULES = e2e server web cli sdk docs .github
test-e2e: test-e2e:
docker compose -f ./e2e/docker-compose.yml build @printf "This command has been removed. Please use:\n\n mise //e2e:test # or mise //e2e:test-web for web tests, respectively\n\n" >&2 && exit 1
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
clean: clean:
find . -name "node_modules" -type d -prune -exec rm -rf {} + @printf "This command has been removed. Please use:\n\n mise clean # or mise //:clean from another directory\n\n" >&2 && exit 1
find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
+16 -1
View File
@@ -1,11 +1,21 @@
[tasks.install] [tasks.install]
run = "pnpm install --filter immich-e2e --frozen-lockfile" run = "pnpm install --filter immich-e2e --frozen-lockfile"
[tasks.build]
dir = "{{ config_root }}"
run = "docker compose build"
[tasks.test] [tasks.test]
depends = ["//e2e:build", "//e2e:ci-setup"]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "vitest --run" run = "vitest --run"
[tasks.playwright-install]
env._.path = "./node_modules/.bin"
run = "playwright install"
[tasks."test-web"] [tasks."test-web"]
depends = ["//e2e:build", "//e2e:ci-setup", "//e2e:playwright-install"]
env._.path = "./node_modules/.bin" env._.path = "./node_modules/.bin"
run = "playwright test" run = "playwright test"
@@ -30,7 +40,12 @@ run = "tsc --noEmit"
[tasks.ci-setup] [tasks.ci-setup]
depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"] depends = [
"//:sdk:install",
"//:sdk:build",
"//packages/cli:install",
"//packages/cli:build",
]
run = { task = ":install" } run = { task = ":install" }
@@ -55,8 +55,8 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
result.duration.push(asset.duration); result.duration.push(asset.duration);
result.projectionType.push(asset.projectionType); result.projectionType.push(asset.projectionType);
result.livePhotoVideoId.push(asset.livePhotoVideoId); result.livePhotoVideoId.push(asset.livePhotoVideoId);
result.city.push(asset.city); result.city?.push(asset.city);
result.country.push(asset.country); result.country?.push(asset.country);
result.visibility.push(asset.visibility); result.visibility.push(asset.visibility);
} }
+1
View File
@@ -2233,6 +2233,7 @@
"slideshow_repeat": "Repeat slideshow", "slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings", "slideshow_settings": "Slideshow settings",
"smart_album": "Smart album",
"sort_albums_by": "Sort albums by...", "sort_albums_by": "Sort albums by...",
"sort_created": "Date created", "sort_created": "Date created",
"sort_items": "Number of items", "sort_items": "Number of items",
@@ -64,6 +64,7 @@ class TextRecognizer(InferenceModel):
rec_batch_num=max_batch_size if max_batch_size else 6, rec_batch_num=max_batch_size if max_batch_size else 6,
rec_img_shape=(3, 48, 320), rec_img_shape=(3, 48, 320),
lang_type=self.language, lang_type=self.language,
model_root_dir=self.cache_dir,
) )
) )
return session return session
+18 -3
View File
@@ -1028,7 +1028,12 @@ class TestOcr:
text_recognizer.load() text_recognizer.load()
rapid_recognizer.assert_called_once_with( rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320)) OcrOptions(
session=ort_session.return_value,
rec_batch_num=6,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
) )
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None: def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
@@ -1041,7 +1046,12 @@ class TestOcr:
text_recognizer.load() text_recognizer.load()
rapid_recognizer.assert_called_once_with( rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320)) OcrOptions(
session=ort_session.return_value,
rec_batch_num=4,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
) )
def test_ignore_other_custom_max_batch_size( def test_ignore_other_custom_max_batch_size(
@@ -1056,7 +1066,12 @@ class TestOcr:
text_recognizer.load() text_recognizer.load()
rapid_recognizer.assert_called_once_with( rapid_recognizer.assert_called_once_with(
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320)) OcrOptions(
session=ort_session.return_value,
rec_batch_num=6,
rec_img_shape=(3, 48, 320),
model_root_dir=text_recognizer.cache_dir,
)
) )
+79 -2
View File
@@ -54,8 +54,8 @@ lockfile = true
[tasks.plugins] [tasks.plugins]
run = [ run = [
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile", "pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build", "pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core build",
] ]
[tasks.open-api-typescript] [tasks.open-api-typescript]
@@ -84,6 +84,72 @@ run = [
dir = "server" dir = "server"
run = "node ./dist/bin/sync-sql.js" run = "node ./dist/bin/sync-sql.js"
# TODO dev, prod, and e2e should be de-duplicated by using env but for some reason I ran into issues
[tasks.dev]
depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
depends_post = "//:dev-down"
[tasks.dev-update]
run = { task = "//:dev", args = ["--build", "-V"] }
[tasks.dev-scale]
run = { task = "//:dev", args = ["--build", "-V", "--scale immich-server=3"] }
[tasks.dev-down]
dir = "docker"
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
[tasks.prod]
depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.prod.yml up --build --remove-orphans"
depends_post = "//:prod-down"
[tasks.prod-scale]
run = { task = "//:prod", args = [
"--build",
"-V",
"--scale immich-server=3",
"--scale immich-microservices",
] }
[tasks.prod-down]
dir = "docker"
run = "docker compose -f ./docker-compose.prod.yml down --remove-orphans"
[tasks.e2e]
depends = "//:plugins"
dir = "e2e"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.yml up --remove-orphans"
depends_post = "//:e2e-down"
[tasks.e2e-dev]
depends = "//:plugins"
dir = "e2e"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
depends_post = "//:e2e-dev-down"
[tasks.e2e-update]
run = { task = "//:e2e", args = ["--build", '-V'] }
[tasks.e2e-down]
dir = "e2e"
run = "docker compose -f ./docker-compose.yml down --remove-orphans"
[tasks.e2e-dev-down]
dir = "e2e"
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
# SDK tasks # SDK tasks
[tasks."sdk:install"] [tasks."sdk:install"]
dir = "packages/sdk" dir = "packages/sdk"
@@ -99,3 +165,14 @@ run = "pnpm format"
[tasks."i18n:format-fix"] [tasks."i18n:format-fix"]
run = "pnpm format:fix" run = "pnpm format:fix"
[tasks.clean]
run = [
"find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
"find . -name 'dist' -type d -prune -exec rm -rf '{}' +",
"find . -name 'build' -type d -prune -exec rm -rf '{}' +",
"find . -name '.svelte-kit' -type d -prune -exec rm -rf '{}' +",
"find . -name 'coverage' -type d -prune -exec rm -rf '{}' +",
"find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' +",
{ task = "//:*-down" },
]
@@ -143,13 +143,8 @@ class AppConfig {
}) })
as T; as T;
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> entries) { factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> overrides) =>
var config = const AppConfig(); overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
for (final MapEntry(key: key, value: value) in entries.entries) {
config = config.write(key, value);
}
return config;
}
AppConfig write<T extends Object>(MetadataKey<T> key, T value) { AppConfig write<T extends Object>(MetadataKey<T> key, T value) {
return switch (key) { return switch (key) {
+7 -18
View File
@@ -7,13 +7,6 @@ import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
enum MetadataScope {
user, // keys with this scope are deleted on logout
system;
const MetadataScope();
}
enum MetadataKey<T extends Object> { enum MetadataKey<T extends Object> {
// Theme // Theme
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)), themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
@@ -32,14 +25,11 @@ enum MetadataKey<T extends Object> {
viewerTapToNavigate<bool>(), viewerTapToNavigate<bool>(),
// Network // Network
networkAutoEndpointSwitching<bool>(scope: .system), networkAutoEndpointSwitching<bool>(),
networkPreferredWifiName<String>(scope: .system), networkPreferredWifiName<String>(),
networkLocalEndpoint<String>(scope: .system), networkLocalEndpoint<String>(),
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)), networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>( networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
scope: .system,
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
),
// Album // Album
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)), albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
@@ -60,7 +50,7 @@ enum MetadataKey<T extends Object> {
timelineStorageIndicator<bool>(), timelineStorageIndicator<bool>(),
// Log // Log
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)), logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
// Map // Map
mapShowFavoriteOnly<bool>(), mapShowFavoriteOnly<bool>(),
@@ -83,10 +73,9 @@ enum MetadataKey<T extends Object> {
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)), slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)); slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
final MetadataScope scope;
final _MetadataCodec<T>? _codecOverride; final _MetadataCodec<T>? _codecOverride;
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec; const MetadataKey({_MetadataCodec<T>? codec}) : _codecOverride = codec;
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T); _MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
@@ -34,21 +34,33 @@ class MetadataRepository extends DriftDatabaseRepository {
Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get()); Future<void> refresh() async => _applyOverrides(await _db.select(_db.metadataEntity).get());
Future<void> clear(Iterable<MetadataKey> keys) async {
if (keys.isEmpty) {
return;
}
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.metadataEntity)..where((row) => row.key.isIn(names))).go();
for (final key in keys) {
_appConfig = _appConfig.write(key, defaultConfig.read(key));
}
}
Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async { Future<void> write<T extends Object, U extends T>(MetadataKey<T> key, U value) async {
if (value == _appConfig.read(key)) { if (value == _appConfig.read(key)) {
return; return;
} }
if (value == defaultConfig.read(key)) { if (value == defaultConfig.read(key)) {
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go(); return clear([key]);
} else {
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
} }
await _db
.into(_db.metadataEntity)
.insertOnConflictUpdate(
MetadataEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
);
_appConfig = _appConfig.write(key, value); _appConfig = _appConfig.write(key, value);
} }
+2 -2
View File
@@ -13,7 +13,7 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class ApiService { class ApiService {
late ApiClient _apiClient; final ApiClient _apiClient = ApiClient(basePath: '');
late UsersApi usersApi; late UsersApi usersApi;
late AuthenticationApi authenticationApi; late AuthenticationApi authenticationApi;
@@ -54,7 +54,7 @@ class ApiService {
} }
setEndpoint(String endpoint) { setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint); _apiClient.basePath = endpoint;
_apiClient.client = NetworkRepository.client; _apiClient.client = NetworkRepository.client;
usersApi = UsersApi(_apiClient); usersApi = UsersApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient);
+7 -1
View File
@@ -110,7 +110,7 @@ class AuthService {
/// - Authentication repository data /// - Authentication repository data
/// - Current user information /// - Current user information
/// - Access token /// - Access token
/// - Asset ETag /// - Server-specific endpoint configuration
/// ///
/// All deletions are executed in parallel using [Future.wait]. /// All deletions are executed in parallel using [Future.wait].
Future<void> clearLocalData() async { Future<void> clearLocalData() async {
@@ -120,6 +120,12 @@ class AuthService {
_authRepository.clearLocalData(), _authRepository.clearLocalData(),
Store.delete(StoreKey.currentUser), Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken), Store.delete(StoreKey.accessToken),
MetadataRepository.instance.clear(const [
.networkAutoEndpointSwitching,
.networkPreferredWifiName,
.networkLocalEndpoint,
.networkExternalEndpointList,
]),
]); ]);
} }
+21 -3
View File
@@ -508,12 +508,18 @@ class AlbumsApi {
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters) /// Filter albums containing this asset ID (ignores other parameters)
/// ///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned: /// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter /// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
/// ///
/// * [bool] isShared: /// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter /// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? isOwned, bool? isShared, }) async { ///
/// * [String] name:
/// Album name (exact match)
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/albums'; final apiPath = r'/albums';
@@ -527,12 +533,18 @@ class AlbumsApi {
if (assetId != null) { if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId)); queryParams.addAll(_queryParams('', 'assetId', assetId));
} }
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (isOwned != null) { if (isOwned != null) {
queryParams.addAll(_queryParams('', 'isOwned', isOwned)); queryParams.addAll(_queryParams('', 'isOwned', isOwned));
} }
if (isShared != null) { if (isShared != null) {
queryParams.addAll(_queryParams('', 'isShared', isShared)); queryParams.addAll(_queryParams('', 'isShared', isShared));
} }
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
const contentTypes = <String>[]; const contentTypes = <String>[];
@@ -557,13 +569,19 @@ class AlbumsApi {
/// * [String] assetId: /// * [String] assetId:
/// Filter albums containing this asset ID (ignores other parameters) /// Filter albums containing this asset ID (ignores other parameters)
/// ///
/// * [String] id:
/// Album ID
///
/// * [bool] isOwned: /// * [bool] isOwned:
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter /// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
/// ///
/// * [bool] isShared: /// * [bool] isShared:
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter /// Filter by shared status: true = only shared, false = not shared, undefined = no filter
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? isOwned, bool? isShared, }) async { ///
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, isOwned: isOwned, isShared: isShared, ); /// * [String] name:
/// Album name (exact match)
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, id: id, isOwned: isOwned, isShared: isShared, name: name, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
+1 -1
View File
@@ -13,7 +13,7 @@ part of openapi.api;
class ApiClient { class ApiClient {
ApiClient({this.basePath = '/api', this.authentication,}); ApiClient({this.basePath = '/api', this.authentication,});
final String basePath; String basePath;
final Authentication? authentication; final Authentication? authentication;
var _client = Client(); var _client = Client();
+3 -3
View File
@@ -77,7 +77,7 @@ class JobName {
static const versionCheck = JobName._(r'VersionCheck'); static const versionCheck = JobName._(r'VersionCheck');
static const ocrQueueAll = JobName._(r'OcrQueueAll'); static const ocrQueueAll = JobName._(r'OcrQueueAll');
static const ocr = JobName._(r'Ocr'); static const ocr = JobName._(r'Ocr');
static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate'); static const workflowAssetTrigger = JobName._(r'WorkflowAssetTrigger');
/// List of all possible values in this [enum][JobName]. /// List of all possible values in this [enum][JobName].
static const values = <JobName>[ static const values = <JobName>[
@@ -135,7 +135,7 @@ class JobName {
versionCheck, versionCheck,
ocrQueueAll, ocrQueueAll,
ocr, ocr,
workflowAssetCreate, workflowAssetTrigger,
]; ];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@@ -228,7 +228,7 @@ class JobNameTypeTransformer {
case r'VersionCheck': return JobName.versionCheck; case r'VersionCheck': return JobName.versionCheck;
case r'OcrQueueAll': return JobName.ocrQueueAll; case r'OcrQueueAll': return JobName.ocrQueueAll;
case r'Ocr': return JobName.ocr; case r'Ocr': return JobName.ocr;
case r'WorkflowAssetCreate': return JobName.workflowAssetCreate; case r'WorkflowAssetTrigger': return JobName.workflowAssetTrigger;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');
+14 -3
View File
@@ -18,6 +18,7 @@ class PluginTemplateResponseDto {
this.steps = const [], this.steps = const [],
required this.title, required this.title,
required this.trigger, required this.trigger,
this.uiHints = const [],
}); });
/// Template description /// Template description
@@ -34,13 +35,17 @@ class PluginTemplateResponseDto {
WorkflowTrigger trigger; WorkflowTrigger trigger;
/// Ui hints, for example \"smart-album\"
List<String> uiHints;
@override @override
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto && bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
other.description == description && other.description == description &&
other.key == key && other.key == key &&
_deepEquality.equals(other.steps, steps) && _deepEquality.equals(other.steps, steps) &&
other.title == title && other.title == title &&
other.trigger == trigger; other.trigger == trigger &&
_deepEquality.equals(other.uiHints, uiHints);
@override @override
int get hashCode => int get hashCode =>
@@ -49,10 +54,11 @@ class PluginTemplateResponseDto {
(key.hashCode) + (key.hashCode) +
(steps.hashCode) + (steps.hashCode) +
(title.hashCode) + (title.hashCode) +
(trigger.hashCode); (trigger.hashCode) +
(uiHints.hashCode);
@override @override
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]'; String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger, uiHints=$uiHints]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -61,6 +67,7 @@ class PluginTemplateResponseDto {
json[r'steps'] = this.steps; json[r'steps'] = this.steps;
json[r'title'] = this.title; json[r'title'] = this.title;
json[r'trigger'] = this.trigger; json[r'trigger'] = this.trigger;
json[r'uiHints'] = this.uiHints;
return json; return json;
} }
@@ -78,6 +85,9 @@ class PluginTemplateResponseDto {
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']), steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
title: mapValueOfType<String>(json, r'title')!, title: mapValueOfType<String>(json, r'title')!,
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
uiHints: json[r'uiHints'] is Iterable
? (json[r'uiHints'] as Iterable).cast<String>().toList(growable: false)
: const [],
); );
} }
return null; return null;
@@ -130,6 +140,7 @@ class PluginTemplateResponseDto {
'steps', 'steps',
'title', 'title',
'trigger', 'trigger',
'uiHints',
}; };
} }
+30 -2
View File
@@ -1627,6 +1627,17 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "id",
"required": false,
"in": "query",
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
},
{ {
"name": "isOwned", "name": "isOwned",
"required": false, "required": false,
@@ -1644,6 +1655,15 @@
"schema": { "schema": {
"type": "boolean" "type": "boolean"
} }
},
{
"name": "name",
"required": false,
"in": "query",
"description": "Album name (exact match)",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -18151,7 +18171,7 @@
"VersionCheck", "VersionCheck",
"OcrQueueAll", "OcrQueueAll",
"Ocr", "Ocr",
"WorkflowAssetCreate" "WorkflowAssetTrigger"
], ],
"type": "string" "type": "string"
}, },
@@ -20199,6 +20219,13 @@
"trigger": { "trigger": {
"$ref": "#/components/schemas/WorkflowTrigger", "$ref": "#/components/schemas/WorkflowTrigger",
"description": "Workflow trigger" "description": "Workflow trigger"
},
"uiHints": {
"description": "Ui hints, for example \"smart-album\"",
"items": {
"type": "string"
},
"type": "array"
} }
}, },
"required": [ "required": [
@@ -20206,7 +20233,8 @@
"key", "key",
"steps", "steps",
"title", "title",
"trigger" "trigger",
"uiHints"
], ],
"type": "object" "type": "object"
}, },
+9
View File
@@ -1,3 +1,12 @@
@@ -13,7 +13,7 @@
class ApiClient {
ApiClient({this.basePath = '/api', this.authentication,});
- final String basePath;
+ String basePath;
final Authentication? authentication;
var _client = Client();
@@ -143,19 +143,19 @@ @@ -143,19 +143,19 @@
); );
} }
+15 -14
View File
@@ -20,19 +20,20 @@
"caseSensitive": false "caseSensitive": false
} }
}, },
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumIds": []
}
},
{ {
"method": "immich-plugin-core#assetArchive", "method": "immich-plugin-core#assetArchive",
"config": { "config": {
"inverse": false "inverse": false
} }
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumIds": []
}
} }
] ],
"uiHints": ["SmartAlbum"]
} }
], ],
"methods": [ "methods": [
@@ -65,7 +66,7 @@
}, },
"required": ["pattern"] "required": ["pattern"]
}, },
"uiHints": ["filter"] "uiHints": ["Filter"]
}, },
{ {
"name": "filterFileType", "name": "filterFileType",
@@ -85,7 +86,7 @@
}, },
"required": ["fileTypes"] "required": ["fileTypes"]
}, },
"uiHints": ["filter"] "uiHints": ["Filter"]
}, },
{ {
"name": "filterPerson", "name": "filterPerson",
@@ -99,7 +100,7 @@
"array": true, "array": true,
"title": "Person IDs", "title": "Person IDs",
"description": "List of person to match", "description": "List of person to match",
"uiHint": "personI" "uiHint": "personId"
}, },
"matchAny": { "matchAny": {
"type": "boolean", "type": "boolean",
@@ -110,7 +111,7 @@
}, },
"required": ["personIds"] "required": ["personIds"]
}, },
"uiHints": ["filter"] "uiHints": ["Filter"]
}, },
{ {
"name": "assetArchive", "name": "assetArchive",
@@ -187,7 +188,7 @@
"title": "Album IDs", "title": "Album IDs",
"array": true, "array": true,
"description": "Target album IDs", "description": "Target album IDs",
"uiHint": "albumId" "uiHint": "AlbumId"
} }
}, },
"required": ["albumIds"] "required": ["albumIds"]
@@ -272,14 +273,14 @@
"type": "string", "type": "string",
"title": "Album ID", "title": "Album ID",
"description": "Target album ID", "description": "Target album ID",
"uiHint": "albumId" "uiHint": "AlbumId"
}, },
"albumIds": { "albumIds": {
"type": "string", "type": "string",
"title": "Album IDs", "title": "Album IDs",
"description": "Target album IDs", "description": "Target album IDs",
"array": true, "array": true,
"uiHint": "albumId" "uiHint": "AlbumId"
} }
} }
} }
+1 -1
View File
@@ -93,7 +93,7 @@ export const assetTrash = () => {
changes: { changes: {
asset: config.inverse asset: config.inverse
? { deletedAt: null, status: AssetStatus.Active } ? { deletedAt: null, status: AssetStatus.Active }
: { deletedAt: new Date(), status: AssetStatus.Trashed }, : { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed },
}, },
})); }));
}; };
+1
View File
@@ -27,6 +27,7 @@
"packageManager": "pnpm@10.33.4", "packageManager": "pnpm@10.33.4",
"devDependencies": { "devDependencies": {
"@extism/js-pdk": "^1.1.1", "@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*",
"@types/node": "^24.12.4", "@types/node": "^24.12.4",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"tsc-alias": "^1.8.16", "tsc-alias": "^1.8.16",
+8 -1
View File
@@ -1,3 +1,6 @@
import { type BulkIdResponseDto, type BulkIdsDto } from '@immich/sdk';
// keep in sync with plugin-core/src/index.d.ts';
declare module 'extism:host' { declare module 'extism:host' {
interface user { interface user {
albumAddAssets(ptr: PTR): I64; albumAddAssets(ptr: PTR): I64;
@@ -45,7 +48,11 @@ type AlbumsToAssets = {
export const hostFunctions = (authToken: string) => ({ export const hostFunctions = (authToken: string) => ({
albumAddAssets: (albumId: string, assetIds: string[]) => albumAddAssets: (albumId: string, assetIds: string[]) =>
call('albumAddAssets', authToken, [albumId, { ids: assetIds }]), call<[string, BulkIdsDto], BulkIdResponseDto[]>(
'albumAddAssets',
authToken,
[albumId, { ids: assetIds }],
),
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) => addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]), call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
}); });
+9 -9
View File
@@ -68,19 +68,19 @@ export type AssetV1 = {
ownerId: string; ownerId: string;
type: AssetType; type: AssetType;
originalPath: string; originalPath: string;
fileCreatedAt: Date; fileCreatedAt: string;
fileModifiedAt: Date; fileModifiedAt: string;
isFavorite: boolean; isFavorite: boolean;
checksum: Buffer; // sha1 checksum checksum: Buffer; // sha1 checksum
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
updatedAt: Date; updatedAt: string;
createdAt: Date; createdAt: string;
originalFileName: string; originalFileName: string;
isOffline: boolean; isOffline: boolean;
libraryId: string | null; libraryId: string | null;
isExternal: boolean; isExternal: boolean;
deletedAt: Date | null; deletedAt: string | null;
localDateTime: Date; localDateTime: string;
stackId: string | null; stackId: string | null;
duplicateId: string | null; duplicateId: string | null;
status: AssetStatus; status: AssetStatus;
@@ -93,8 +93,8 @@ export type AssetV1 = {
exifImageHeight: number | null; exifImageHeight: number | null;
fileSizeInByte: number | null; fileSizeInByte: number | null;
orientation: string | null; orientation: string | null;
dateTimeOriginal: Date | null; dateTimeOriginal: string | null;
modifyDate: Date | null; modifyDate: string | null;
lensModel: string | null; lensModel: string | null;
fNumber: number | null; fNumber: number | null;
focalLength: number | null; focalLength: number | null;
@@ -116,7 +116,7 @@ export type AssetV1 = {
autoStackId: string | null; autoStackId: string | null;
rating: number | null; rating: number | null;
tags: string[] | null; tags: string[] | null;
updatedAt: Date | null; updatedAt: string | null;
} | null; } | null;
}; };
}; };
+9 -3
View File
@@ -1535,6 +1535,8 @@ export type PluginTemplateResponseDto = {
title: string; title: string;
/** Workflow trigger */ /** Workflow trigger */
trigger: WorkflowTrigger; trigger: WorkflowTrigger;
/** Ui hints, for example "smart-album" */
uiHints: string[];
}; };
export type QueueResponseDto = { export type QueueResponseDto = {
/** Whether the queue is paused */ /** Whether the queue is paused */
@@ -3597,18 +3599,22 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
/** /**
* List all albums * List all albums
*/ */
export function getAllAlbums({ assetId, isOwned, isShared }: { export function getAllAlbums({ assetId, id, isOwned, isShared, name }: {
assetId?: string; assetId?: string;
id?: string;
isOwned?: boolean; isOwned?: boolean;
isShared?: boolean; isShared?: boolean;
name?: string;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AlbumResponseDto[]; data: AlbumResponseDto[];
}>(`/albums${QS.query(QS.explode({ }>(`/albums${QS.query(QS.explode({
assetId, assetId,
id,
isOwned, isOwned,
isShared isShared,
name
}))}`, { }))}`, {
...opts ...opts
})); }));
@@ -7140,7 +7146,7 @@ export enum JobName {
VersionCheck = "VersionCheck", VersionCheck = "VersionCheck",
OcrQueueAll = "OcrQueueAll", OcrQueueAll = "OcrQueueAll",
Ocr = "Ocr", Ocr = "Ocr",
WorkflowAssetCreate = "WorkflowAssetCreate" WorkflowAssetTrigger = "WorkflowAssetTrigger"
} }
export enum SearchSuggestionType { export enum SearchSuggestionType {
Country = "country", Country = "country",
+11 -6
View File
@@ -10,7 +10,7 @@ overrides:
sharp: ^0.34.5 sharp: ^0.34.5
webpackbar: ^7.0.0 webpackbar: ^7.0.0
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54= packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k=
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA= pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
@@ -332,6 +332,9 @@ importers:
'@extism/js-pdk': '@extism/js-pdk':
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1 version: 1.1.1
'@immich/sdk':
specifier: workspace:*
version: link:../sdk
'@types/node': '@types/node':
specifier: ^24.12.4 specifier: ^24.12.4
version: 24.12.4 version: 24.12.4
@@ -389,7 +392,7 @@ importers:
version: 6.1.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) version: 6.1.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
'@nestjs/swagger': '@nestjs/swagger':
specifier: ^11.4.2 specifier: ^11.4.2
version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2) version: 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)
'@nestjs/websockets': '@nestjs/websockets':
specifier: ^11.0.4 specifier: ^11.0.4
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-socket.io@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) 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-socket.io@11.1.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -536,7 +539,7 @@ importers:
version: 8.0.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) version: 8.0.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)
nestjs-zod: nestjs-zod:
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6) version: 5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6)
nodemailer: nodemailer:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.0.7 version: 8.0.7
@@ -3795,6 +3798,7 @@ packages:
class-transformer: '*' class-transformer: '*'
class-validator: '*' class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0 reflect-metadata: ^0.1.12 || ^0.2.0
typescript: '*'
peerDependenciesMeta: peerDependenciesMeta:
'@fastify/static': '@fastify/static':
optional: true optional: true
@@ -16570,7 +16574,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - chokidar
'@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)': '@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)':
dependencies: dependencies:
'@microsoft/tsdoc': 0.16.0 '@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -16581,6 +16585,7 @@ snapshots:
path-to-regexp: 8.4.2 path-to-regexp: 8.4.2
reflect-metadata: 0.2.2 reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.6 swagger-ui-dist: 5.32.6
typescript: 6.0.3
'@nestjs/testing@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)': '@nestjs/testing@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)':
dependencies: dependencies:
@@ -23229,14 +23234,14 @@ snapshots:
'@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1) '@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1)
tslib: 2.8.1 tslib: 2.8.1
nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6): nestjs-zod@5.4.0(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/swagger@11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3))(rxjs@7.8.2)(zod@4.3.6):
dependencies: dependencies:
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
deepmerge: 4.3.1 deepmerge: 4.3.1
rxjs: 7.8.2 rxjs: 7.8.2
zod: 4.3.6 zod: 4.3.6
optionalDependencies: optionalDependencies:
'@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2) '@nestjs/swagger': 11.4.3(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(reflect-metadata@0.2.2)(typescript@6.0.3)
next-tick@1.1.0: {} next-tick@1.1.0: {}
+3
View File
@@ -60,6 +60,9 @@ packageExtensions:
dependencies: dependencies:
node-addon-api: '*' node-addon-api: '*'
node-gyp: '*' node-gyp: '*'
'@nestjs/swagger':
peerDependencies:
typescript: '*'
dedupePeerDependents: false dedupePeerDependents: false
preferWorkspacePackages: true preferWorkspacePackages: true
injectWorkspacePackages: true injectWorkspacePackages: true
+4 -2
View File
@@ -13,14 +13,15 @@ FROM builder AS server
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY ./server ./server/ COPY ./server ./server/
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/ COPY ./packages/plugin-sdk ./packages/plugin-sdk/
RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --filter @immich/plugin-sdk --frozen-lockfile build && \ SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
FROM builder AS web FROM builder AS web
@@ -66,6 +67,7 @@ ENV MISE_DISABLE_TOOLS=flutter
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install mise install
COPY ./packages/sdk ./packages/sdk/
COPY ./packages/plugin-core ./packages/plugin-core/ COPY ./packages/plugin-core ./packages/plugin-core/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/ COPY ./packages/plugin-sdk ./packages/plugin-sdk/
+1
View File
@@ -68,6 +68,7 @@ run = [
[tasks.ci-medium] [tasks.ci-medium]
run = [ run = [
{ task = ":install" }, { task = ":install" },
{ task = "//:plugins" },
{ task = "//packages/plugin-core:build" }, { task = "//packages/plugin-core:build" },
{ task = ":test-medium --run" }, { task = ":test-medium --run" },
] ]
+2
View File
@@ -65,6 +65,8 @@ const UpdateAlbumSchema = z
const GetAlbumsSchema = z const GetAlbumsSchema = z
.object({ .object({
id: z.uuidv4().optional().describe('Album ID'),
name: z.string().optional().describe('Album name (exact match)'),
isOwned: stringToBool isOwned: stringToBool
.optional() .optional()
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'), .describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
+1
View File
@@ -38,6 +38,7 @@ const PluginManifestTemplateSchema = z
description: z.string().min(1).describe('Template description'), description: z.string().min(1).describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'), trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'), steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).optional().default([]).describe('Ui hints, for example "smart-album"'),
}) })
.meta({ id: 'PluginManifestTemplateDto' }); .meta({ id: 'PluginManifestTemplateDto' });
+3
View File
@@ -58,6 +58,7 @@ const PluginTemplateResponseSchema = z
description: z.string().describe('Template description'), description: z.string().describe('Template description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger'), trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'), steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
uiHints: z.array(z.string()).describe('Ui hints, for example "smart-album"'),
}) })
.meta({ id: 'PluginTemplateResponseDto' }); .meta({ id: 'PluginTemplateResponseDto' });
@@ -91,6 +92,7 @@ export type PluginTemplate = {
config?: Record<string, unknown> | null; config?: Record<string, unknown> | null;
enabled?: boolean; enabled?: boolean;
}>; }>;
uiHints: string[];
}; };
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => { export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
@@ -104,6 +106,7 @@ export const mapTemplate = (plugin: { name: string }, template: PluginTemplate):
config: step.config ?? null, config: step.config ?? null,
enabled: step.enabled, enabled: step.enabled,
})), })),
uiHints: template.uiHints ?? [],
}; };
}; };
+1 -1
View File
@@ -866,7 +866,7 @@ export enum JobName {
Ocr = 'Ocr', Ocr = 'Ocr',
// Workflow // Workflow
WorkflowAssetCreate = 'WorkflowAssetCreate', WorkflowAssetTrigger = 'WorkflowAssetTrigger',
} }
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' }); export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
+6 -1
View File
@@ -209,11 +209,16 @@ export class AlbumRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] }) @GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
getAll(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<MapAlbumDto[]> { getAll(
ownerId: string,
options: { id?: string; isOwned?: boolean; isShared?: boolean; name?: string } = {},
): Promise<MapAlbumDto[]> {
return this.buildAlbumBaseQuery(ownerId, options) return this.buildAlbumBaseQuery(ownerId, options)
.selectAll('album') .selectAll('album')
.select(withAlbumUsers(ownerId)) .select(withAlbumUsers(ownerId))
.select(withSharedLink) .select(withSharedLink)
.$if(!!options.id, (qb) => qb.where('album.id', '=', options.id!))
.$if(!!options.name, (qb) => qb.where('album.albumName', '=', options.name!))
.orderBy('album.createdAt', 'desc') .orderBy('album.createdAt', 'desc')
.execute(); .execute();
} }
@@ -45,10 +45,10 @@ export class WorkflowRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
search(dto: WorkflowSearchDto & { ownerId?: string }) { search(dto: WorkflowSearchDto & { userId?: string }) {
return this.queryBuilder() return this.queryBuilder()
.$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!)) .$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!))
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!)) .$if(!!dto.userId, (qb) => qb.where('ownerId', '=', dto.userId!))
.$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!))
.orderBy('createdAt', 'desc') .orderBy('createdAt', 'desc')
+2 -5
View File
@@ -37,15 +37,12 @@ export class AlbumService extends BaseService {
}; };
} }
async getAll( async getAll({ user: { id: ownerId } }: AuthDto, { assetId, ...rest }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
{ user: { id: ownerId } }: AuthDto,
{ assetId, isOwned, isShared }: GetAlbumsDto,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
const albums = assetId const albums = assetId
? await this.albumRepository.getByAssetId(ownerId, assetId) ? await this.albumRepository.getByAssetId(ownerId, assetId)
: await this.albumRepository.getAll(ownerId, { isOwned, isShared }); : await this.albumRepository.getAll(ownerId, rest);
if (albums.length === 0) { if (albums.length === 0) {
return []; return [];
+9 -5
View File
@@ -75,7 +75,7 @@ export class SearchService extends BaseService {
const page = dto.page ?? 1; const page = dto.page ?? 1;
const size = dto.size || 250; const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth); const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const { hasNextPage, items } = await this.searchRepository.searchMetadata( const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size }, { page, size },
{ {
@@ -103,7 +103,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth); requireElevatedPermission(auth);
} }
const userIds = await this.getUserIdsToSearch(auth); const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds }); const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth })); return items.map((item) => mapAsset(item, { auth }));
} }
@@ -113,7 +113,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth); requireElevatedPermission(auth);
} }
const userIds = await this.getUserIdsToSearch(auth); const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds }); const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth })); return items.map((item) => mapAsset(item, { auth }));
} }
@@ -128,7 +128,7 @@ export class SearchService extends BaseService {
throw new BadRequestException('Smart search is not enabled'); throw new BadRequestException('Smart search is not enabled');
} }
const userIds = this.getUserIdsToSearch(auth); const userIds = this.getUserIdsToSearch(auth, dto.visibility);
let embedding; let embedding;
if (dto.query) { if (dto.query) {
const key = machineLearning.clip.modelName + dto.query + dto.language; const key = machineLearning.clip.modelName + dto.query + dto.language;
@@ -202,7 +202,11 @@ export class SearchService extends BaseService {
} }
} }
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> { private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> {
// Locked assets are personal. Never include partner IDs, regardless of A's elevated session.
if (visibility === AssetVisibility.Locked) {
return [auth.user.id];
}
const partnerIds = await getMyPartnerIds({ const partnerIds = await getMyPartnerIds({
userId: auth.user.id, userId: auth.user.id,
repository: this.partnerRepository, repository: this.partnerRepository,
@@ -204,5 +204,16 @@ describe(TimelineService.name, () => {
}), }),
).rejects.toThrow(BadRequestException); ).rejects.toThrow(BadRequestException);
}); });
it('should throw an error if withPartners is true and visibility is locked', async () => {
await expect(
sut.getTimeBucket(authStub.adminWithElevatedPermission, {
timeBucket: 'bucket',
visibility: AssetVisibility.Locked,
withPartners: true,
userId: authStub.adminWithElevatedPermission.user.id,
}),
).rejects.toThrow(BadRequestException);
});
}); });
}); });
+3 -2
View File
@@ -71,13 +71,14 @@ export class TimelineService extends BaseService {
} }
if (dto.withPartners) { if (dto.withPartners) {
const requestedLocked = dto.visibility === AssetVisibility.Locked;
const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined; const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
const requestedTrash = dto.isTrashed === true; const requestedTrash = dto.isTrashed === true;
if (requestedArchived || requestedFavorite || requestedTrash) { if (requestedLocked || requestedArchived || requestedFavorite || requestedTrash) {
throw new BadRequestException( throw new BadRequestException(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
} }
} }
@@ -1,9 +1,8 @@
import { CurrentPlugin } from '@extism/extism'; import { CurrentPlugin } from '@extism/extism';
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk'; import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common'; import { HttpException, UnauthorizedException } from '@nestjs/common';
import _ from 'lodash';
import { join } from 'node:path'; import { join } from 'node:path';
import { OnEvent, OnJob } from 'src/decorators'; import { DummyValue, OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto'; import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@@ -21,6 +20,7 @@ import {
} from 'src/enum'; } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service'; import { AlbumService } from 'src/services/album.service';
import { AssetService } from 'src/services/asset.service';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types'; import { JobOf } from 'src/types';
@@ -32,9 +32,11 @@ const dummy = () => {
type ExecuteOptions<T extends WorkflowType> = { type ExecuteOptions<T extends WorkflowType> = {
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>; read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
write: (changes: WorkflowChanges<T>) => Promise<void>; write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
}; };
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
export class WorkflowExecutionService extends BaseService { export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string; private jwtSecret!: string;
@@ -62,7 +64,6 @@ export class WorkflowExecutionService extends BaseService {
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) => const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args), albumService.addAssets(authDto, ...args),
); );
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) => const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args), albumService.addAssetsToAlbums(authDto, ...args),
); );
@@ -247,20 +248,25 @@ export class WorkflowExecutionService extends BaseService {
} }
@OnEvent({ name: 'AssetCreate' }) @OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) { onAssetCreate({ asset: { ownerId: userId, id: assetId } }: ArgOf<'AssetCreate'>) {
const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate }; return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate });
const items = await this.workflowRepository.search(dto); }
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
items.map((workflow) => ({ items.map((workflow) => ({
name: JobName.WorkflowAssetCreate, name: JobName.WorkflowAssetTrigger,
data: { workflowId: workflow.id, assetId: asset.id }, data: { workflowId: workflow.id, assetId, trigger },
})), })),
); );
} }
@OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow }) @OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
handleAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) { handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
return this.execute(workflowId, (type) => { return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
switch (type) { switch (type) {
case WorkflowType.AssetV1: { case WorkflowType.AssetV1: {
return { return {
@@ -271,19 +277,16 @@ export class WorkflowExecutionService extends BaseService {
authUserId: asset.ownerId, authUserId: asset.ownerId,
}; };
}, },
write: async (changes) => { write: async (auth, changes) => {
if (changes.asset) { const asset = changes.asset;
await this.assetRepository.update({ if (!asset) {
id: assetId, return;
..._.omitBy(
{
isFavorite: changes.asset?.isFavorite,
visibility: changes.asset?.visibility,
},
_.isUndefined,
),
});
} }
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
});
}, },
} satisfies ExecuteOptions<typeof type>; } satisfies ExecuteOptions<typeof type>;
} }
@@ -301,7 +304,19 @@ export class WorkflowExecutionService extends BaseService {
} }
// TODO infer from steps // TODO infer from steps
const type = 'AssetV1' as T; let type: T | undefined;
for (const targetType of Object.values(WorkflowType)) {
const missing = workflow.steps.some((step) => !step.types.includes(targetType));
if (!missing) {
type = targetType as unknown as T;
break;
}
}
if (!type) {
throw new Error('Unable to infer workflow event type from steps');
}
const handler = getHandler(type); const handler = getHandler(type);
if (!handler) { if (!handler) {
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`); this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
@@ -337,7 +352,18 @@ export class WorkflowExecutionService extends BaseService {
payload, payload,
); );
if (result?.changes) { if (result?.changes) {
await write(result.changes); await write(
{
user: {
id: readResult.authUserId,
},
session: {
id: DummyValue.UUID,
hasElevatedPermission: true,
},
} as AuthDto,
result.changes,
);
({ data } = await read(type)); ({ data } = await read(type));
} }
+1 -1
View File
@@ -23,7 +23,7 @@ export class WorkflowService extends BaseService {
} }
async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> { async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id }); const workflows = await this.workflowRepository.search({ ...dto, userId: auth.user.id });
return workflows.map((workflow) => mapWorkflow(workflow)); return workflows.map((workflow) => mapWorkflow(workflow));
} }
+1 -1
View File
@@ -404,7 +404,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob } | { name: JobName.Ocr; data: IEntityJob }
// Workflow // Workflow
| { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } } | { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
// Editor // Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }; | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
+7
View File
@@ -41,6 +41,13 @@ export const authStub = {
id: 'token-id', id: 'token-id',
} as AuthSession, } as AuthSession,
}), }),
adminWithElevatedPermission: Object.freeze<AuthDto>({
user: authUser.admin,
session: {
id: 'token-id-elevated',
hasElevatedPermission: true,
} as AuthSession,
}),
adminSharedLink: Object.freeze({ adminSharedLink: Object.freeze({
user: authUser.admin, user: authUser.admin,
sharedLink: { sharedLink: {
@@ -51,13 +51,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive }); const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive });
await expect(response1).rejects.toBeInstanceOf(BadRequestException); await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow( await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
const response2 = sut.getTimeBuckets(auth, { withPartners: true }); const response2 = sut.getTimeBuckets(auth, { withPartners: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException); await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow( await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
}); });
@@ -67,13 +67,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false }); const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false });
await expect(response1).rejects.toBeInstanceOf(BadRequestException); await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow( await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true }); const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException); await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow( await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
}); });
@@ -83,7 +83,7 @@ describe(TimelineService.name, () => {
const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true }); const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true });
await expect(response).rejects.toBeInstanceOf(BadRequestException); await expect(response).rejects.toBeInstanceOf(BadRequestException);
await expect(response).rejects.toThrow( await expect(response).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets', 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
); );
}); });
@@ -137,7 +137,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive' }], steps: [{ method: 'immich-plugin-core#assetArchive' }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Archive, visibility: AssetVisibility.Archive,
@@ -154,7 +154,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }], steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
@@ -173,7 +173,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock' }], steps: [{ method: 'immich-plugin-core#assetLock' }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Locked, visibility: AssetVisibility.Locked,
@@ -190,7 +190,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }], steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
@@ -209,7 +209,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite' }], steps: [{ method: 'immich-plugin-core#assetFavorite' }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
}); });
@@ -224,7 +224,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }], steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false }); await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
}); });
@@ -242,7 +242,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }], steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
}); });
@@ -261,7 +261,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }], steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id);
await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id);
@@ -279,7 +279,7 @@ describe('core plugin', () => {
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }], steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
}); });
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy(); await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id); await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
}); });
@@ -0,0 +1,65 @@
import type { Attachment } from 'svelte/attachments';
const EDGE_ZONE = 72;
const MAX_SCROLL_SPEED = 22;
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
let node = element.parentElement;
while (node) {
const overflowY = getComputedStyle(node).overflowY;
if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return null;
};
export function dragAutoScroll(isActive: () => boolean): Attachment {
return (node) => {
const element = node as HTMLElement;
let scrollContainer: HTMLElement | null = null;
let pointerY = -1;
let frame: number | null = null;
const trackPointer = (event: DragEvent) => {
pointerY = event.clientY;
};
const tick = () => {
if (scrollContainer && pointerY >= 0) {
const { top, bottom } = scrollContainer.getBoundingClientRect();
let delta = 0;
if (pointerY < top + EDGE_ZONE) {
delta = -MAX_SCROLL_SPEED * Math.min(1, (top + EDGE_ZONE - pointerY) / EDGE_ZONE);
} else if (pointerY > bottom - EDGE_ZONE) {
delta = MAX_SCROLL_SPEED * Math.min(1, (pointerY - (bottom - EDGE_ZONE)) / EDGE_ZONE);
}
if (delta !== 0) {
scrollContainer.scrollBy(0, delta);
}
}
frame = requestAnimationFrame(tick);
};
$effect(() => {
if (!isActive()) {
return;
}
scrollContainer = findScrollContainer(element);
pointerY = -1;
globalThis.addEventListener('dragover', trackPointer);
frame = requestAnimationFrame(tick);
return () => {
globalThis.removeEventListener('dragover', trackPointer);
if (frame !== null) {
cancelAnimationFrame(frame);
frame = null;
}
scrollContainer = null;
};
});
};
}
@@ -64,7 +64,7 @@
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} /> <Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
{/each} {/each}
</div> </div>
{:else if schema.uiHint === 'albumId'} {:else if schema.uiHint === 'AlbumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} /> <SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array} {:else if schema.enum && schema.array}
<Field {label} {description}> <Field {label} {description}>
@@ -205,11 +205,7 @@
</script> </script>
<div <div
class={[ class={['group flex overflow-hidden focus-visible:outline-none', backgroundColorClass, { 'rounded-xl': selected }]}
'group flex overflow-hidden transition-[background-color,border-radius] focus-visible:outline-none',
backgroundColorClass,
{ 'rounded-xl': selected },
]}
style:width="{width}px" style:width="{width}px"
style:height="{height}px" style:height="{height}px"
onmouseenter={onMouseEnter} onmouseenter={onMouseEnter}
@@ -249,16 +245,8 @@
]} ]}
> >
<ImageThumbnail <ImageThumbnail
class={[ class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
'absolute transition-[border-radius] group-focus-visible:rounded-lg', brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
{ 'rounded-xl': selected },
imageClass,
]}
brokenAssetClass={[
'z-1 absolute group-focus-visible:rounded-lg transition-[border-radius]',
{ 'rounded-xl': selected },
brokenAssetClass,
]}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })} url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)} altText={$getAltText(asset)}
widthStyle="{width}px" widthStyle="{width}px"
@@ -30,14 +30,11 @@
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 viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)} {#each viewerAssets as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!} {@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!} {@const asset = viewerAsset.asset!}
+1 -1
View File
@@ -100,7 +100,7 @@
<AssetLayout <AssetLayout
{manager} {manager}
viewerAssets={timelineDay.viewerAssets} viewerAssets={timelineDay.activeViewerAssets}
height={timelineDay.height} height={timelineDay.height}
width={timelineDay.width} width={timelineDay.width}
{customThumbnailLayout} {customThumbnailLayout}
@@ -53,25 +53,3 @@ export function updateTimelineMonthViewportProximity(timelineManager: TimelineMa
timelineManager.clearDeferredLayout(month); timelineManager.clearDeferredLayout(month);
} }
} }
export function calculateViewerAssetViewportProximity(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
) {
const headerHeight = timelineManager.headerHeight;
return calculateViewportProximity(
positionTop,
positionTop + positionHeight,
timelineManager.visibleWindow.top - headerHeight,
timelineManager.visibleWindow.bottom + headerHeight,
);
}
export function calculateViewerAssetIsInOrNearViewport(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
) {
return isInOrNearViewport(calculateViewerAssetViewportProximity(timelineManager, positionTop, positionHeight));
}
@@ -1,12 +1,31 @@
import { AssetOrder, AssetOrderBy } from '@immich/sdk'; import { AssetOrder, AssetOrderBy } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils'; import type { CommonLayoutOptions, CommonPosition } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util'; import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { TimelineMonth } from './timeline-month.svelte'; import type { TimelineMonth } from './timeline-month.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types'; import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
function lowerBound(assets: ViewerAsset[], target: number, key: (pos: CommonPosition) => number): number {
let lo = 0;
let hi = assets.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (key(assets[mid].position!) < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
export class TimelineDay { export class TimelineDay {
readonly timelineMonth: TimelineMonth; readonly timelineMonth: TimelineMonth;
readonly index: number; readonly index: number;
@@ -18,12 +37,15 @@ export class TimelineDay {
height = $state(0); height = $state(0);
width = $state(0); width = $state(0);
// Assets in or near the viewport; active assets should be added to the DOM.
activeViewerAssets: ViewerAsset[] = $state([]);
isInOrNearViewport = $state(false);
#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;
@@ -149,18 +171,32 @@ export class TimelineDay {
for (let i = 0; i < this.viewerAssets.length; i++) { for (let i = 0; i < this.viewerAssets.length; i++) {
this.viewerAssets[i].position = geometry.getPosition(i); this.viewerAssets[i].position = geometry.getPosition(i);
} }
this.updateAssetBoundaries();
}
updateAssetBoundaries() {
const manager = this.timelineMonth.timelineManager;
const visibleWindow = manager.visibleWindow;
if (this.viewerAssets.length === 0 || !this.viewerAssets[0].position) {
this.activeViewerAssets = [];
this.isInOrNearViewport = false;
return;
}
const dayOffset = this.absoluteTimelineDayTop;
const headerHeight = manager.headerHeight;
const expandedTop = visibleWindow.top - headerHeight - INTERSECTION_EXPAND_TOP - dayOffset;
const expandedBottom = visibleWindow.bottom + headerHeight + INTERSECTION_EXPAND_BOTTOM - dayOffset;
const first = lowerBound(this.viewerAssets, expandedTop, (p) => p.top + p.height);
const last = lowerBound(this.viewerAssets, expandedBottom, (p) => p.top) - 1;
const hasActive = last >= first && first < this.viewerAssets.length;
this.activeViewerAssets = hasActive ? this.viewerAssets.slice(first, last + 1) : [];
this.isInOrNearViewport = hasActive;
} }
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;
}
} }
@@ -214,6 +214,11 @@ export class TimelineManager extends VirtualScrollManager {
for (const month of this.months) { for (const month of this.months) {
updateTimelineMonthViewportProximity(this, month); updateTimelineMonthViewportProximity(this, month);
if (month.isInOrNearViewport && month.isLoaded) {
for (const day of month.timelineDays) {
day.updateAssetBoundaries();
}
}
} }
const month = this.months.find((month) => month.isInViewport); const month = this.months.find((month) => month.isInViewport);
@@ -29,8 +29,7 @@ import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './typ
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
export class TimelineMonth { export class TimelineMonth {
#isInOrNearViewport = $state(false); #viewportProximity: ViewportProximity = $state(ViewportProximity.FarFromViewport);
#isInViewport = $state(false);
isLoaded: boolean = $state(false); isLoaded: boolean = $state(false);
timelineDays: TimelineDay[] = $state([]); timelineDays: TimelineDay[] = $state([]);
readonly timelineManager: TimelineManager; readonly timelineManager: TimelineManager;
@@ -86,28 +85,24 @@ export class TimelineMonth {
} }
set viewportProximity(newValue: ViewportProximity) { set viewportProximity(newValue: ViewportProximity) {
const isInOrNearViewport = isInOrNearViewportUtil(newValue); const old = this.#viewportProximity;
if (this.#isInOrNearViewport !== isInOrNearViewport) { if (old === newValue) {
this.#isInOrNearViewport = isInOrNearViewport; return;
if (isInOrNearViewport) {
void this.timelineManager.loadTimelineMonth(this.yearMonth);
} else {
this.cancel();
}
} }
this.#viewportProximity = newValue;
const isInViewport = isInViewportUtil(newValue); if (isInOrNearViewportUtil(newValue)) {
if (this.#isInViewport !== isInViewport) { void this.timelineManager.loadTimelineMonth(this.yearMonth);
this.#isInViewport = isInViewport; } else {
this.cancel();
} }
} }
get isInOrNearViewport() { get isInOrNearViewport() {
return this.#isInOrNearViewport; return isInOrNearViewportUtil(this.#viewportProximity);
} }
get isInViewport() { get isInViewport() {
return this.#isInViewport; return isInViewportUtil(this.#viewportProximity);
} }
get lastTimelineDay() { get lastTimelineDay() {
@@ -259,7 +254,7 @@ export class TimelineMonth {
addContext.newTimelineDays.add(timelineDay); addContext.newTimelineDays.add(timelineDay);
} }
const viewerAsset = new ViewerAsset(timelineDay, timelineAsset); const viewerAsset = new ViewerAsset(timelineAsset);
timelineDay.viewerAssets.push(viewerAsset); timelineDay.viewerAssets.push(viewerAsset);
addContext.changedTimelineDays.add(timelineDay); addContext.changedTimelineDays.add(timelineDay);
} }
@@ -1,32 +1,12 @@
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import { calculateViewerAssetIsInOrNearViewport } from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
export class ViewerAsset { export class ViewerAsset {
readonly #group: TimelineDay;
#isInOrNearViewport = $derived.by(() => {
if (!this.position) {
return false;
}
const store = this.#group.timelineMonth.timelineManager;
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
return calculateViewerAssetIsInOrNearViewport(store, positionTop, this.position.height);
});
get isInOrNearViewport() {
return this.#isInOrNearViewport;
}
position: CommonPosition | undefined = $state.raw(); position: CommonPosition | undefined = $state.raw();
asset: TimelineAsset = $state() as TimelineAsset; asset: TimelineAsset = $state() as TimelineAsset;
id: string = $derived(this.asset.id); id: string = $derived(this.asset.id);
constructor(group: TimelineDay, asset: TimelineAsset) { constructor(asset: TimelineAsset) {
this.#group = group;
this.asset = asset; this.asset = asset;
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@
<div class="grow text-start"> <div class="grow text-start">
<Text fontWeight="medium" class="flex items-center gap-1" <Text fontWeight="medium" class="flex items-center gap-1"
>{method.title} >{method.title}
{#if method.uiHints.includes('filter')} {#if method.uiHints.includes('Filter')}
<Badge size="tiny" color="info" title={$t('plugin_method_filter_type_description')} <Badge size="tiny" color="info" title={$t('plugin_method_filter_type_description')}
>{$t('plugin_method_filter_type')}</Badge >{$t('plugin_method_filter_type')}</Badge
> >
@@ -1,37 +0,0 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import { type WorkflowResponseDto } from '@immich/sdk';
import { FormModal, ListButton, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
workflow: WorkflowResponseDto;
onClose: () => void;
};
const { workflow, onClose }: Props = $props();
let selected = $state(pluginManager.getTrigger(workflow.trigger));
const onSubmit = async () => {
const success = await handleUpdateWorkflow(workflow.id, { trigger: selected.trigger });
if (success) {
onClose();
}
};
</script>
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
<div class="flex flex-col gap-2">
{#each pluginManager.triggers as item (item.trigger)}
<ListButton selected={item.trigger === selected.trigger} onclick={() => (selected = item)}>
<div class="grow text-start">
<Text fontWeight="medium">{getTriggerName($t, item.trigger)}</Text>
<Text size="tiny" color="muted">{getTriggerDescription($t, item.trigger)}</Text>
</div>
</ListButton>
{/each}
</div>
</FormModal>
@@ -2,7 +2,7 @@
import { pluginManager } from '$lib/managers/plugin-manager.svelte'; import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleCreateWorkflow } from '$lib/services/workflow.service'; import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { type PluginTemplateResponseDto } from '@immich/sdk'; import { type PluginTemplateResponseDto } from '@immich/sdk';
import { FormModal, Icon, ListButton, Text } from '@immich/ui'; import { Badge, FormModal, Icon, ListButton, Text } from '@immich/ui';
import { mdiFlashOutline } from '@mdi/js'; import { mdiFlashOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -59,6 +59,11 @@
<Text fontWeight="medium">{template.title}</Text> <Text fontWeight="medium">{template.title}</Text>
<Text size="tiny" color="muted">{template.description}</Text> <Text size="tiny" color="muted">{template.description}</Text>
</div> </div>
{#if template.uiHints.includes('SmartAlbum')}
<div class="shrink-0">
<Badge size="small">{$t('smart_album')}</Badge>
</div>
{/if}
</div> </div>
</ListButton> </ListButton>
{/each} {/each}
+2 -2
View File
@@ -26,7 +26,7 @@ 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 WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte'; import WorkflowTemplatePickerModal from '$lib/modals/WorkflowTemplatePickerModal.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { copyToClipboard, downloadJson } from '$lib/utils'; import { copyToClipboard, downloadJson } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
@@ -50,7 +50,7 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
const UseTemplate: ActionItem = { const UseTemplate: ActionItem = {
title: $t('browse_templates'), title: $t('browse_templates'),
icon: mdiFileDocumentMultipleOutline, icon: mdiFileDocumentMultipleOutline,
onAction: () => modalManager.show(WorkflowTemplatePicker, {}), onAction: () => modalManager.show(WorkflowTemplatePickerModal, {}),
}; };
return { Create, UseTemplate }; return { Create, UseTemplate };
+1 -1
View File
@@ -104,7 +104,7 @@ export type JSONSchemaProperty = {
array?: boolean; array?: boolean;
properties?: Record<string, JSONSchemaProperty>; properties?: Record<string, JSONSchemaProperty>;
required?: string[]; required?: string[];
uiHint?: 'albumId' | 'assetId' | 'personId'; uiHint?: 'AlbumId' | 'AssetId' | 'PersonId';
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate, goto, invalidate } from '$app/navigation'; import { beforeNavigate, goto, invalidate } from '$app/navigation';
import { dragAutoScroll } from '$lib/attachments/drag-auto-scroll.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte'; import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte'; import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
@@ -7,7 +8,7 @@
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service'; import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow'; import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk'; import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
import { import {
ActionBar, ActionBar,
AppShell, AppShell,
@@ -43,7 +44,10 @@
mdiPlus, mdiPlus,
} from '@mdi/js'; } from '@mdi/js';
import { cloneDeep, isEqual } from 'lodash-es'; import { cloneDeep, isEqual } from 'lodash-es';
import { flip } from 'svelte/animate';
import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { createListReorder, GHOST_KEY, type ReorderEntry } from './list-reorder.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte'; import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte'; import WorkflowStepCard from './WorkflowStepCard.svelte';
@@ -69,6 +73,11 @@
let isSaving = $state(false); let isSaving = $state(false);
let editMode = $state<EditMode>('visual'); let editMode = $state<EditMode>('visual');
const reorder = createListReorder(
() => steps,
(next) => (steps = next),
);
const workflowSummary = $derived({ name, description, trigger, steps }); const workflowSummary = $derived({ name, description, trigger, steps });
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps }); const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
@@ -106,19 +115,6 @@
} }
}; };
const handleDrop = (index: number, event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
const from = Number(event.dataTransfer.getData('text/plain'));
const next = [...steps];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
steps = next;
};
const handleDeleteStep = async (index: number) => { const handleDeleteStep = async (index: number) => {
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') }); const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
if (confirmed) { if (confirmed) {
@@ -344,17 +340,51 @@
</CardHeader> </CardHeader>
</Card> </Card>
{#each steps as step, index (step.method + index)} <div class="hidden" aria-hidden="true" {@attach dragAutoScroll(() => reorder.isDragging)}></div>
{#snippet stepCard(entry: ReorderEntry<WorkflowStepDto>)}
<WorkflowStepCard <WorkflowStepCard
{step} step={entry.item}
{index} index={entry.index}
position={entry.index + 1}
isGhost={entry.isGhost}
isSource={entry.isSource}
isDragging={reorder.isDragging}
onEdit={handleEditStep} onEdit={handleEditStep}
onDelete={handleDeleteStep} onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep} onInsertBefore={handleInsertStep}
onDrop={handleDrop} onDragStart={reorder.start}
onDragOver={reorder.over}
onDragEnd={reorder.end}
onDrop={reorder.drop}
/> />
{/snippet}
{#each reorder.entries as entry (entry.isGhost ? GHOST_KEY : entry.item)}
<div class="w-full" animate:flip={{ duration: 200 }}>
{#if entry.isGhost}
<div transition:fade={{ duration: 120 }}>{@render stepCard(entry)}</div>
{:else}
{@render stepCard(entry)}
{/if}
</div>
{/each} {/each}
{#if reorder.isDragging}
<div
class="-mt-4 min-h-12 w-full"
role="listitem"
ondragover={(event) => {
event.preventDefault();
reorder.toEnd();
}}
ondrop={(event) => {
event.preventDefault();
reorder.drop();
}}
></div>
{/if}
<Button <Button
size="small" size="small"
fullWidth fullWidth
@@ -17,23 +17,59 @@
type Props = { type Props = {
step: WorkflowStepDto; step: WorkflowStepDto;
index: number; index: number;
position: number;
isGhost: boolean;
isSource: boolean;
isDragging: boolean;
onEdit: (index: number) => void; onEdit: (index: number) => void;
onDelete: (index: number) => void; onDelete: (index: number) => void;
onInsertBefore: (index: number) => void; onInsertBefore: (index: number) => void;
onDrop: (index: number, event: DragEvent) => void; onDragStart: (index: number) => void;
onDragOver: (index: number, after: boolean) => void;
onDragEnd: () => void;
onDrop: () => void;
}; };
let { step, index, onEdit, onDelete, onInsertBefore, onDrop }: Props = $props(); let {
step,
index,
position,
isGhost,
isSource,
isDragging,
onEdit,
onDelete,
onInsertBefore,
onDragStart,
onDragOver,
onDragEnd,
onDrop,
}: Props = $props();
const method = $derived(pluginManager.getMethod(step.method)); const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('filter') ?? false); const isFilter = $derived(method?.uiHints?.includes('Filter') ?? false);
const configEntries = $derived( const configEntries = $derived(
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''), Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
); );
let dragImage = $state<Element>(); let dragImage = $state<Element>();
let isDropTarget = $state(false);
let hoverDrag = $state(false); let hoverDrag = $state(false);
const cardStateClass = $derived.by(() => {
if (isGhost) {
return 'pointer-events-none border-2 border-dashed border-primary bg-primary-50/40 shadow-lg';
}
if (isSource) {
return 'border-dashed border-primary-300 bg-primary-50/20';
}
if (hoverDrag) {
return 'border-dashed border-primary';
}
return '';
});
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input); const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
const formatConfigValue = (value: unknown): string => { const formatConfigValue = (value: unknown): string => {
@@ -75,7 +111,7 @@
target: document.body, target: document.body,
props: { props: {
description: method?.description, description: method?.description,
isFilter: method?.uiHints?.includes('filter') ?? false, isFilter: method?.uiHints?.includes('Filter') ?? false,
label: step ? pluginManager.getMethodLabel(step.method) : '', label: step ? pluginManager.getMethodLabel(step.method) : '',
stepNumber: index + 1, stepNumber: index + 1,
}, },
@@ -83,31 +119,31 @@
dragImage = document.body.querySelector('#workflow-step-drag-image')!; dragImage = document.body.querySelector('#workflow-step-drag-image')!;
event.dataTransfer.setDragImage(dragImage, 16, 22); event.dataTransfer.setDragImage(dragImage, 16, 22);
onDragStart(index);
}; };
const handleDrop = (index: number, event: DragEvent) => { const handleDrop = (event: DragEvent) => {
if (!event.dataTransfer) {
return;
}
event.preventDefault(); event.preventDefault();
onDrop();
};
const from = Number(event.dataTransfer.getData('text/plain')); const handleDragOver = (event: DragEvent & { currentTarget: HTMLElement }) => {
if (from === index) { event.preventDefault();
if (isGhost) {
return; return;
} }
onDrop(index, event); const rect = event.currentTarget.getBoundingClientRect();
}; const after = event.clientY > rect.top + rect.height / 2;
onDragOver(index, after);
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
isDropTarget = true;
}; };
const handleDragEnd = () => { const handleDragEnd = () => {
dragImage?.remove(); dragImage?.remove();
dragImage = undefined; dragImage = undefined;
isDropTarget = false; hoverDrag = false;
onDragEnd();
}; };
</script> </script>
@@ -118,6 +154,7 @@
<button <button
type="button" type="button"
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50" class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
class:hidden={isDragging}
aria-label={$t('add_step')} aria-label={$t('add_step')}
title={$t('add_step')} title={$t('add_step')}
onclick={() => onInsertBefore(index)} onclick={() => onInsertBefore(index)}
@@ -129,20 +166,12 @@
<div <div
class="w-full transition-all" class="w-full transition-all"
class:opacity-40={!!dragImage} class:opacity-50={isSource}
class:scale-[0.99]={!!dragImage}
ondragover={handleDragOver} ondragover={handleDragOver}
ondragleave={() => (isDropTarget = false)} ondrop={handleDrop}
ondrop={(event) => handleDrop(index, event)}
role="listitem" role="listitem"
> >
<Card <Card class="shadow-none transition-colors {cardStateClass}">
class="shadow-none transition-colors {isDropTarget
? 'border-primary ring-2 ring-primary-200'
: hoverDrag
? 'border-dashed border-primary'
: ''}"
>
<CardHeader> <CardHeader>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -171,7 +200,9 @@
</div> </div>
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<CardTitle class="truncate"> <CardTitle class="truncate">
<span class="mr-1 font-bold text-light-500">{index + 1}</span> {#if !isGhost}
<span class="mr-1 font-bold text-light-500">{position}</span>
{/if}
{pluginManager.getMethodLabel(step.method)} {pluginManager.getMethodLabel(step.method)}
</CardTitle> </CardTitle>
{#if method?.description} {#if method?.description}
@@ -67,7 +67,7 @@
for (const [i, step] of workflow.steps.entries()) { for (const [i, step] of workflow.steps.entries()) {
const method = pluginManager.getMethod(step.method); const method = pluginManager.getMethod(step.method);
const isFilter = method?.uiHints?.includes('filter') ?? false; const isFilter = method?.uiHints?.includes('Filter') ?? false;
const type = isFilter ? $t('filter') : $t('action'); const type = isFilter ? $t('filter') : $t('action');
const label = pluginManager.getMethodLabel(step.method); const label = pluginManager.getMethodLabel(step.method);
lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`); lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`);
@@ -0,0 +1,78 @@
export const GHOST_KEY = 'reorder-ghost';
export type ReorderEntry<T> = {
item: T;
index: number;
isGhost: boolean;
isSource: boolean;
};
export function createListReorder<T>(getItems: () => T[], setItems: (items: T[]) => void) {
let draggingIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
const entries = $derived.by<ReorderEntry<T>[]>(() => {
const items = getItems();
const list: ReorderEntry<T>[] = items.map((item, index) => ({
item,
index,
isGhost: false,
isSource: index === draggingIndex,
}));
if (
draggingIndex !== null &&
dropIndex !== null &&
dropIndex !== draggingIndex &&
dropIndex !== draggingIndex + 1
) {
list.splice(dropIndex, 0, { item: items[draggingIndex], index: draggingIndex, isGhost: true, isSource: false });
}
return list;
});
return {
get isDragging() {
return draggingIndex !== null;
},
get entries() {
return entries;
},
start(index: number) {
draggingIndex = index;
dropIndex = index;
},
over(index: number, after: boolean) {
if (draggingIndex === null) {
return;
}
dropIndex = Math.max(0, Math.min(index + (after ? 1 : 0), getItems().length));
},
toEnd() {
if (draggingIndex !== null) {
dropIndex = getItems().length;
}
},
end() {
draggingIndex = null;
dropIndex = null;
},
drop() {
if (draggingIndex === null || dropIndex === null) {
return;
}
const target = dropIndex > draggingIndex ? dropIndex - 1 : dropIndex;
if (target !== draggingIndex) {
const next = [...getItems()];
const [moved] = next.splice(draggingIndex, 1);
next.splice(target, 0, moved);
setItems(next);
}
draggingIndex = null;
dropIndex = null;
},
};
}