mirror of
https://github.com/immich-app/immich.git
synced 2026-05-29 19:12:32 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b31ca6e75 | |||
| a838167f11 | |||
| b189fc571c | |||
| 96923f6115 | |||
| 0d6cce4a5b | |||
| 55947cb227 | |||
| 8783180cf3 | |||
| 134c0d4dfb | |||
| aecf8ec88b | |||
| bcff1d42b0 | |||
| 1bd367bd51 | |||
| 725f266b81 | |||
| d08e3de207 | |||
| 26714f6bfe |
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+21
-3
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-1
@@ -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();
|
||||||
|
|||||||
Generated
+3
-3
@@ -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
@@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+11
-6
@@ -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: {}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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' });
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
Vendored
+7
@@ -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!}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
+6
-1
@@ -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}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user