mirror of
https://github.com/immich-app/immich.git
synced 2026-05-29 11:02:38 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f1b8e1d9b | |||
| 6e78d6e131 |
@@ -2233,7 +2233,6 @@
|
||||
"slideshow_repeat": "Repeat slideshow",
|
||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||
"slideshow_settings": "Slideshow settings",
|
||||
"smart_album": "Smart album",
|
||||
"sort_albums_by": "Sort albums by...",
|
||||
"sort_created": "Date created",
|
||||
"sort_items": "Number of items",
|
||||
|
||||
@@ -64,7 +64,6 @@ class TextRecognizer(InferenceModel):
|
||||
rec_batch_num=max_batch_size if max_batch_size else 6,
|
||||
rec_img_shape=(3, 48, 320),
|
||||
lang_type=self.language,
|
||||
model_root_dir=self.cache_dir,
|
||||
)
|
||||
)
|
||||
return session
|
||||
|
||||
@@ -1028,12 +1028,7 @@ class TestOcr:
|
||||
text_recognizer.load()
|
||||
|
||||
rapid_recognizer.assert_called_once_with(
|
||||
OcrOptions(
|
||||
session=ort_session.return_value,
|
||||
rec_batch_num=6,
|
||||
rec_img_shape=(3, 48, 320),
|
||||
model_root_dir=text_recognizer.cache_dir,
|
||||
)
|
||||
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
|
||||
)
|
||||
|
||||
def test_set_custom_max_batch_size(self, ort_session: mock.Mock, path: mock.Mock, mocker: MockerFixture) -> None:
|
||||
@@ -1046,12 +1041,7 @@ class TestOcr:
|
||||
text_recognizer.load()
|
||||
|
||||
rapid_recognizer.assert_called_once_with(
|
||||
OcrOptions(
|
||||
session=ort_session.return_value,
|
||||
rec_batch_num=4,
|
||||
rec_img_shape=(3, 48, 320),
|
||||
model_root_dir=text_recognizer.cache_dir,
|
||||
)
|
||||
OcrOptions(session=ort_session.return_value, rec_batch_num=4, rec_img_shape=(3, 48, 320))
|
||||
)
|
||||
|
||||
def test_ignore_other_custom_max_batch_size(
|
||||
@@ -1066,12 +1056,7 @@ class TestOcr:
|
||||
text_recognizer.load()
|
||||
|
||||
rapid_recognizer.assert_called_once_with(
|
||||
OcrOptions(
|
||||
session=ort_session.return_value,
|
||||
rec_batch_num=6,
|
||||
rec_img_shape=(3, 48, 320),
|
||||
model_root_dir=text_recognizer.cache_dir,
|
||||
)
|
||||
OcrOptions(session=ort_session.return_value, rec_batch_num=6, rec_img_shape=(3, 48, 320))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ lockfile = true
|
||||
|
||||
[tasks.plugins]
|
||||
run = [
|
||||
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
|
||||
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core build",
|
||||
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
|
||||
"pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build",
|
||||
]
|
||||
|
||||
[tasks.open-api-typescript]
|
||||
@@ -108,7 +108,7 @@ depends = "//:plugins"
|
||||
dir = "docker"
|
||||
interactive = true
|
||||
env = { COMPOSE_BAKE = true }
|
||||
run = "docker compose -f ./docker-compose.prod.yml up --build --remove-orphans"
|
||||
run = "docker compose -f ./docker-compose.prod.yml up --remove-orphans"
|
||||
depends_post = "//:prod-down"
|
||||
|
||||
[tasks.prod-scale]
|
||||
|
||||
@@ -143,8 +143,13 @@ class AppConfig {
|
||||
})
|
||||
as T;
|
||||
|
||||
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> overrides) =>
|
||||
overrides.entries.fold(const AppConfig(), (config, entry) => config.write(entry.key, entry.value));
|
||||
factory AppConfig.fromEntries(Map<MetadataKey<Object>, Object> entries) {
|
||||
var config = const AppConfig();
|
||||
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) {
|
||||
return switch (key) {
|
||||
|
||||
@@ -7,6 +7,13 @@ import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.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> {
|
||||
// Theme
|
||||
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
|
||||
@@ -25,11 +32,14 @@ enum MetadataKey<T extends Object> {
|
||||
viewerTapToNavigate<bool>(),
|
||||
|
||||
// Network
|
||||
networkAutoEndpointSwitching<bool>(),
|
||||
networkPreferredWifiName<String>(),
|
||||
networkLocalEndpoint<String>(),
|
||||
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
|
||||
networkAutoEndpointSwitching<bool>(scope: .system),
|
||||
networkPreferredWifiName<String>(scope: .system),
|
||||
networkLocalEndpoint<String>(scope: .system),
|
||||
networkExternalEndpointList<List<String>>(scope: .system, codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(
|
||||
scope: .system,
|
||||
codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string),
|
||||
),
|
||||
|
||||
// Album
|
||||
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
|
||||
@@ -50,7 +60,7 @@ enum MetadataKey<T extends Object> {
|
||||
timelineStorageIndicator<bool>(),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
|
||||
logLevel<LogLevel>(scope: .system, codec: _EnumCodec(LogLevel.values)),
|
||||
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
@@ -73,9 +83,10 @@ enum MetadataKey<T extends Object> {
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
||||
|
||||
final MetadataScope scope;
|
||||
final _MetadataCodec<T>? _codecOverride;
|
||||
|
||||
const MetadataKey({_MetadataCodec<T>? codec}) : _codecOverride = codec;
|
||||
const MetadataKey({this.scope = .user, _MetadataCodec<T>? codec}) : _codecOverride = codec;
|
||||
|
||||
_MetadataCodec<T> get _codec => _codecOverride ?? _MetadataCodec.forType(T);
|
||||
|
||||
|
||||
@@ -34,33 +34,21 @@ class MetadataRepository extends DriftDatabaseRepository {
|
||||
|
||||
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 {
|
||||
if (value == _appConfig.read(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value == defaultConfig.read(key)) {
|
||||
return clear([key]);
|
||||
await (_db.delete(_db.metadataEntity)..where((t) => t.key.equals(key.name))).go();
|
||||
} 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class ApiService {
|
||||
final ApiClient _apiClient = ApiClient(basePath: '');
|
||||
late ApiClient _apiClient;
|
||||
|
||||
late UsersApi usersApi;
|
||||
late AuthenticationApi authenticationApi;
|
||||
@@ -54,7 +54,7 @@ class ApiService {
|
||||
}
|
||||
|
||||
setEndpoint(String endpoint) {
|
||||
_apiClient.basePath = endpoint;
|
||||
_apiClient = ApiClient(basePath: endpoint);
|
||||
_apiClient.client = NetworkRepository.client;
|
||||
usersApi = UsersApi(_apiClient);
|
||||
authenticationApi = AuthenticationApi(_apiClient);
|
||||
|
||||
@@ -110,7 +110,7 @@ class AuthService {
|
||||
/// - Authentication repository data
|
||||
/// - Current user information
|
||||
/// - Access token
|
||||
/// - Server-specific endpoint configuration
|
||||
/// - Asset ETag
|
||||
///
|
||||
/// All deletions are executed in parallel using [Future.wait].
|
||||
Future<void> clearLocalData() async {
|
||||
@@ -120,12 +120,6 @@ class AuthService {
|
||||
_authRepository.clearLocalData(),
|
||||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
MetadataRepository.instance.clear(const [
|
||||
.networkAutoEndpointSwitching,
|
||||
.networkPreferredWifiName,
|
||||
.networkLocalEndpoint,
|
||||
.networkExternalEndpointList,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Generated
+1
@@ -450,6 +450,7 @@ Class | Method | HTTP request | Description
|
||||
- [MemoryCreateDto](doc//MemoryCreateDto.md)
|
||||
- [MemoryResponseDto](doc//MemoryResponseDto.md)
|
||||
- [MemorySearchOrder](doc//MemorySearchOrder.md)
|
||||
- [MemorySearchResponseDto](doc//MemorySearchResponseDto.md)
|
||||
- [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md)
|
||||
- [MemoryType](doc//MemoryType.md)
|
||||
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
|
||||
|
||||
Generated
+1
@@ -195,6 +195,7 @@ part 'model/memories_update.dart';
|
||||
part 'model/memory_create_dto.dart';
|
||||
part 'model/memory_response_dto.dart';
|
||||
part 'model/memory_search_order.dart';
|
||||
part 'model/memory_search_response_dto.dart';
|
||||
part 'model/memory_statistics_response_dto.dart';
|
||||
part 'model/memory_type.dart';
|
||||
part 'model/memory_update_dto.dart';
|
||||
|
||||
Generated
+3
-21
@@ -508,18 +508,12 @@ class AlbumsApi {
|
||||
/// * [String] assetId:
|
||||
/// Filter albums containing this asset ID (ignores other parameters)
|
||||
///
|
||||
/// * [String] id:
|
||||
/// Album ID
|
||||
///
|
||||
/// * [bool] isOwned:
|
||||
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
|
||||
///
|
||||
/// * [bool] isShared:
|
||||
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
|
||||
///
|
||||
/// * [String] name:
|
||||
/// Album name (exact match)
|
||||
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, String? id, bool? isOwned, bool? isShared, String? name, }) async {
|
||||
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? isOwned, bool? isShared, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/albums';
|
||||
|
||||
@@ -533,18 +527,12 @@ class AlbumsApi {
|
||||
if (assetId != null) {
|
||||
queryParams.addAll(_queryParams('', 'assetId', assetId));
|
||||
}
|
||||
if (id != null) {
|
||||
queryParams.addAll(_queryParams('', 'id', id));
|
||||
}
|
||||
if (isOwned != null) {
|
||||
queryParams.addAll(_queryParams('', 'isOwned', isOwned));
|
||||
}
|
||||
if (isShared != null) {
|
||||
queryParams.addAll(_queryParams('', 'isShared', isShared));
|
||||
}
|
||||
if (name != null) {
|
||||
queryParams.addAll(_queryParams('', 'name', name));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
@@ -569,19 +557,13 @@ class AlbumsApi {
|
||||
/// * [String] assetId:
|
||||
/// Filter albums containing this asset ID (ignores other parameters)
|
||||
///
|
||||
/// * [String] id:
|
||||
/// Album ID
|
||||
///
|
||||
/// * [bool] isOwned:
|
||||
/// Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter
|
||||
///
|
||||
/// * [bool] isShared:
|
||||
/// Filter by shared status: true = only shared, false = not shared, undefined = no filter
|
||||
///
|
||||
/// * [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, );
|
||||
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? isOwned, bool? isShared, }) async {
|
||||
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, isOwned: isOwned, isShared: isShared, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
Generated
+26
-11
@@ -261,11 +261,14 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
|
||||
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/memories/statistics';
|
||||
|
||||
@@ -288,6 +291,9 @@ class MemoriesApi {
|
||||
if (order != null) {
|
||||
queryParams.addAll(_queryParams('', 'order', order));
|
||||
}
|
||||
if (page != null) {
|
||||
queryParams.addAll(_queryParams('', 'page', page));
|
||||
}
|
||||
if (size != null) {
|
||||
queryParams.addAll(_queryParams('', 'size', size));
|
||||
}
|
||||
@@ -326,12 +332,15 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
|
||||
final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, );
|
||||
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async {
|
||||
final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -428,11 +437,14 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
|
||||
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/memories';
|
||||
|
||||
@@ -455,6 +467,9 @@ class MemoriesApi {
|
||||
if (order != null) {
|
||||
queryParams.addAll(_queryParams('', 'order', order));
|
||||
}
|
||||
if (page != null) {
|
||||
queryParams.addAll(_queryParams('', 'page', page));
|
||||
}
|
||||
if (size != null) {
|
||||
queryParams.addAll(_queryParams('', 'size', size));
|
||||
}
|
||||
@@ -493,12 +508,15 @@ class MemoriesApi {
|
||||
///
|
||||
/// * [MemorySearchOrder] order:
|
||||
///
|
||||
/// * [int] page:
|
||||
/// Page number
|
||||
///
|
||||
/// * [int] size:
|
||||
/// Number of memories to return
|
||||
///
|
||||
/// * [MemoryType] type:
|
||||
Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
|
||||
final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, );
|
||||
Future<MemorySearchResponseDto?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async {
|
||||
final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -506,11 +524,8 @@ class MemoriesApi {
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<MemoryResponseDto>') as List)
|
||||
.cast<MemoryResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemorySearchResponseDto',) as MemorySearchResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Generated
+3
-1
@@ -13,7 +13,7 @@ part of openapi.api;
|
||||
class ApiClient {
|
||||
ApiClient({this.basePath = '/api', this.authentication,});
|
||||
|
||||
String basePath;
|
||||
final String basePath;
|
||||
final Authentication? authentication;
|
||||
|
||||
var _client = Client();
|
||||
@@ -436,6 +436,8 @@ class ApiClient {
|
||||
return MemoryResponseDto.fromJson(value);
|
||||
case 'MemorySearchOrder':
|
||||
return MemorySearchOrderTypeTransformer().decode(value);
|
||||
case 'MemorySearchResponseDto':
|
||||
return MemorySearchResponseDto.fromJson(value);
|
||||
case 'MemoryStatisticsResponseDto':
|
||||
return MemoryStatisticsResponseDto.fromJson(value);
|
||||
case 'MemoryType':
|
||||
|
||||
Generated
+3
-3
@@ -77,7 +77,7 @@ class JobName {
|
||||
static const versionCheck = JobName._(r'VersionCheck');
|
||||
static const ocrQueueAll = JobName._(r'OcrQueueAll');
|
||||
static const ocr = JobName._(r'Ocr');
|
||||
static const workflowAssetTrigger = JobName._(r'WorkflowAssetTrigger');
|
||||
static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate');
|
||||
|
||||
/// List of all possible values in this [enum][JobName].
|
||||
static const values = <JobName>[
|
||||
@@ -135,7 +135,7 @@ class JobName {
|
||||
versionCheck,
|
||||
ocrQueueAll,
|
||||
ocr,
|
||||
workflowAssetTrigger,
|
||||
workflowAssetCreate,
|
||||
];
|
||||
|
||||
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
|
||||
@@ -228,7 +228,7 @@ class JobNameTypeTransformer {
|
||||
case r'VersionCheck': return JobName.versionCheck;
|
||||
case r'OcrQueueAll': return JobName.ocrQueueAll;
|
||||
case r'Ocr': return JobName.ocr;
|
||||
case r'WorkflowAssetTrigger': return JobName.workflowAssetTrigger;
|
||||
case r'WorkflowAssetCreate': return JobName.workflowAssetCreate;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class MemorySearchResponseDto {
|
||||
/// Returns a new [MemorySearchResponseDto] instance.
|
||||
MemorySearchResponseDto({
|
||||
required this.hasNextPage,
|
||||
this.items = const [],
|
||||
required this.total,
|
||||
});
|
||||
|
||||
/// Whether there are more pages
|
||||
bool hasNextPage;
|
||||
|
||||
List<MemoryResponseDto> items;
|
||||
|
||||
/// Total number of matching memories
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int total;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MemorySearchResponseDto &&
|
||||
other.hasNextPage == hasNextPage &&
|
||||
_deepEquality.equals(other.items, items) &&
|
||||
other.total == total;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(hasNextPage.hashCode) +
|
||||
(items.hashCode) +
|
||||
(total.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MemorySearchResponseDto[hasNextPage=$hasNextPage, items=$items, total=$total]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'hasNextPage'] = this.hasNextPage;
|
||||
json[r'items'] = this.items;
|
||||
json[r'total'] = this.total;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [MemorySearchResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static MemorySearchResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "MemorySearchResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MemorySearchResponseDto(
|
||||
hasNextPage: mapValueOfType<bool>(json, r'hasNextPage')!,
|
||||
items: MemoryResponseDto.listFromJson(json[r'items']),
|
||||
total: mapValueOfType<int>(json, r'total')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<MemorySearchResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <MemorySearchResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = MemorySearchResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, MemorySearchResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, MemorySearchResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = MemorySearchResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of MemorySearchResponseDto-objects as value to a dart map
|
||||
static Map<String, List<MemorySearchResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<MemorySearchResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = MemorySearchResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'hasNextPage',
|
||||
'items',
|
||||
'total',
|
||||
};
|
||||
}
|
||||
|
||||
+3
-14
@@ -18,7 +18,6 @@ class PluginTemplateResponseDto {
|
||||
this.steps = const [],
|
||||
required this.title,
|
||||
required this.trigger,
|
||||
this.uiHints = const [],
|
||||
});
|
||||
|
||||
/// Template description
|
||||
@@ -35,17 +34,13 @@ class PluginTemplateResponseDto {
|
||||
|
||||
WorkflowTrigger trigger;
|
||||
|
||||
/// Ui hints, for example \"smart-album\"
|
||||
List<String> uiHints;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
|
||||
other.description == description &&
|
||||
other.key == key &&
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.title == title &&
|
||||
other.trigger == trigger &&
|
||||
_deepEquality.equals(other.uiHints, uiHints);
|
||||
other.trigger == trigger;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
@@ -54,11 +49,10 @@ class PluginTemplateResponseDto {
|
||||
(key.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(title.hashCode) +
|
||||
(trigger.hashCode) +
|
||||
(uiHints.hashCode);
|
||||
(trigger.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger, uiHints=$uiHints]';
|
||||
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -67,7 +61,6 @@ class PluginTemplateResponseDto {
|
||||
json[r'steps'] = this.steps;
|
||||
json[r'title'] = this.title;
|
||||
json[r'trigger'] = this.trigger;
|
||||
json[r'uiHints'] = this.uiHints;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -85,9 +78,6 @@ class PluginTemplateResponseDto {
|
||||
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
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;
|
||||
@@ -140,7 +130,6 @@ class PluginTemplateResponseDto {
|
||||
'steps',
|
||||
'title',
|
||||
'trigger',
|
||||
'uiHints',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1627,17 +1627,6 @@
|
||||
"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",
|
||||
"required": false,
|
||||
@@ -1655,15 +1644,6 @@
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Album name (exact match)",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -6383,6 +6363,17 @@
|
||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Page number",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
@@ -6408,10 +6399,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MemoryResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
"$ref": "#/components/schemas/MemorySearchResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6552,6 +6540,17 @@
|
||||
"$ref": "#/components/schemas/MemorySearchOrder"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Page number",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
@@ -18171,7 +18170,7 @@
|
||||
"VersionCheck",
|
||||
"OcrQueueAll",
|
||||
"Ocr",
|
||||
"WorkflowAssetTrigger"
|
||||
"WorkflowAssetCreate"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18827,6 +18826,32 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MemorySearchResponseDto": {
|
||||
"properties": {
|
||||
"hasNextPage": {
|
||||
"description": "Whether there are more pages",
|
||||
"type": "boolean"
|
||||
},
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MemoryResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"total": {
|
||||
"description": "Total number of matching memories",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"hasNextPage",
|
||||
"items",
|
||||
"total"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MemoryStatisticsResponseDto": {
|
||||
"properties": {
|
||||
"total": {
|
||||
@@ -20219,13 +20244,6 @@
|
||||
"trigger": {
|
||||
"$ref": "#/components/schemas/WorkflowTrigger",
|
||||
"description": "Workflow trigger"
|
||||
},
|
||||
"uiHints": {
|
||||
"description": "Ui hints, for example \"smart-album\"",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20233,8 +20251,7 @@
|
||||
"key",
|
||||
"steps",
|
||||
"title",
|
||||
"trigger",
|
||||
"uiHints"
|
||||
"trigger"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
@@ -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 @@
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,20 +20,19 @@
|
||||
"caseSensitive": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetArchive",
|
||||
"config": {
|
||||
"inverse": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetAddToAlbums",
|
||||
"config": {
|
||||
"albumIds": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetArchive",
|
||||
"config": {
|
||||
"inverse": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"uiHints": ["SmartAlbum"]
|
||||
]
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
@@ -66,7 +65,7 @@
|
||||
},
|
||||
"required": ["pattern"]
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
"uiHints": ["filter"]
|
||||
},
|
||||
{
|
||||
"name": "filterFileType",
|
||||
@@ -86,7 +85,7 @@
|
||||
},
|
||||
"required": ["fileTypes"]
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
"uiHints": ["filter"]
|
||||
},
|
||||
{
|
||||
"name": "filterPerson",
|
||||
@@ -100,7 +99,7 @@
|
||||
"array": true,
|
||||
"title": "Person IDs",
|
||||
"description": "List of person to match",
|
||||
"uiHint": "personId"
|
||||
"uiHint": "personI"
|
||||
},
|
||||
"matchAny": {
|
||||
"type": "boolean",
|
||||
@@ -111,7 +110,7 @@
|
||||
},
|
||||
"required": ["personIds"]
|
||||
},
|
||||
"uiHints": ["Filter"]
|
||||
"uiHints": ["filter"]
|
||||
},
|
||||
{
|
||||
"name": "assetArchive",
|
||||
@@ -188,7 +187,7 @@
|
||||
"title": "Album IDs",
|
||||
"array": true,
|
||||
"description": "Target album IDs",
|
||||
"uiHint": "AlbumId"
|
||||
"uiHint": "albumId"
|
||||
}
|
||||
},
|
||||
"required": ["albumIds"]
|
||||
@@ -273,14 +272,14 @@
|
||||
"type": "string",
|
||||
"title": "Album ID",
|
||||
"description": "Target album ID",
|
||||
"uiHint": "AlbumId"
|
||||
"uiHint": "albumId"
|
||||
},
|
||||
"albumIds": {
|
||||
"type": "string",
|
||||
"title": "Album IDs",
|
||||
"description": "Target album IDs",
|
||||
"array": true,
|
||||
"uiHint": "AlbumId"
|
||||
"uiHint": "albumId"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export const assetTrash = () => {
|
||||
changes: {
|
||||
asset: config.inverse
|
||||
? { deletedAt: null, status: AssetStatus.Active }
|
||||
: { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed },
|
||||
: { deletedAt: new Date(), status: AssetStatus.Trashed },
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"devDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@types/node": "^24.12.4",
|
||||
"esbuild": "^0.28.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { type BulkIdResponseDto, type BulkIdsDto } from '@immich/sdk';
|
||||
|
||||
// keep in sync with plugin-core/src/index.d.ts';
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
albumAddAssets(ptr: PTR): I64;
|
||||
@@ -48,11 +45,7 @@ type AlbumsToAssets = {
|
||||
|
||||
export const hostFunctions = (authToken: string) => ({
|
||||
albumAddAssets: (albumId: string, assetIds: string[]) =>
|
||||
call<[string, BulkIdsDto], BulkIdResponseDto[]>(
|
||||
'albumAddAssets',
|
||||
authToken,
|
||||
[albumId, { ids: assetIds }],
|
||||
),
|
||||
call('albumAddAssets', authToken, [albumId, { ids: assetIds }]),
|
||||
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
||||
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
||||
});
|
||||
|
||||
@@ -68,19 +68,19 @@ export type AssetV1 = {
|
||||
ownerId: string;
|
||||
type: AssetType;
|
||||
originalPath: string;
|
||||
fileCreatedAt: string;
|
||||
fileModifiedAt: string;
|
||||
fileCreatedAt: Date;
|
||||
fileModifiedAt: Date;
|
||||
isFavorite: boolean;
|
||||
checksum: Buffer; // sha1 checksum
|
||||
livePhotoVideoId: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: Date;
|
||||
createdAt: Date;
|
||||
originalFileName: string;
|
||||
isOffline: boolean;
|
||||
libraryId: string | null;
|
||||
isExternal: boolean;
|
||||
deletedAt: string | null;
|
||||
localDateTime: string;
|
||||
deletedAt: Date | null;
|
||||
localDateTime: Date;
|
||||
stackId: string | null;
|
||||
duplicateId: string | null;
|
||||
status: AssetStatus;
|
||||
@@ -93,8 +93,8 @@ export type AssetV1 = {
|
||||
exifImageHeight: number | null;
|
||||
fileSizeInByte: number | null;
|
||||
orientation: string | null;
|
||||
dateTimeOriginal: string | null;
|
||||
modifyDate: string | null;
|
||||
dateTimeOriginal: Date | null;
|
||||
modifyDate: Date | null;
|
||||
lensModel: string | null;
|
||||
fNumber: number | null;
|
||||
focalLength: number | null;
|
||||
@@ -116,7 +116,7 @@ export type AssetV1 = {
|
||||
autoStackId: string | null;
|
||||
rating: number | null;
|
||||
tags: string[] | null;
|
||||
updatedAt: string | null;
|
||||
updatedAt: Date | null;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1316,6 +1316,13 @@ export type MemoryResponseDto = {
|
||||
/** Last update date */
|
||||
updatedAt: string;
|
||||
};
|
||||
export type MemorySearchResponseDto = {
|
||||
/** Whether there are more pages */
|
||||
hasNextPage: boolean;
|
||||
items: MemoryResponseDto[];
|
||||
/** Total number of matching memories */
|
||||
total: number;
|
||||
};
|
||||
export type MemoryCreateDto = {
|
||||
/** Asset IDs to associate with memory */
|
||||
assetIds?: string[];
|
||||
@@ -1535,8 +1542,6 @@ export type PluginTemplateResponseDto = {
|
||||
title: string;
|
||||
/** Workflow trigger */
|
||||
trigger: WorkflowTrigger;
|
||||
/** Ui hints, for example "smart-album" */
|
||||
uiHints: string[];
|
||||
};
|
||||
export type QueueResponseDto = {
|
||||
/** Whether the queue is paused */
|
||||
@@ -3599,22 +3604,18 @@ export function getUserStatisticsAdmin({ id, isFavorite, isTrashed, visibility }
|
||||
/**
|
||||
* List all albums
|
||||
*/
|
||||
export function getAllAlbums({ assetId, id, isOwned, isShared, name }: {
|
||||
export function getAllAlbums({ assetId, isOwned, isShared }: {
|
||||
assetId?: string;
|
||||
id?: string;
|
||||
isOwned?: boolean;
|
||||
isShared?: boolean;
|
||||
name?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AlbumResponseDto[];
|
||||
}>(`/albums${QS.query(QS.explode({
|
||||
assetId,
|
||||
id,
|
||||
isOwned,
|
||||
isShared,
|
||||
name
|
||||
isShared
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
@@ -4684,22 +4685,24 @@ export function reverseGeocode({ lat, lon }: {
|
||||
/**
|
||||
* Retrieve memories
|
||||
*/
|
||||
export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: {
|
||||
export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $type }: {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: MemoryResponseDto[];
|
||||
data: MemorySearchResponseDto;
|
||||
}>(`/memories${QS.query(QS.explode({
|
||||
"for": $for,
|
||||
isSaved,
|
||||
isTrashed,
|
||||
order,
|
||||
page,
|
||||
size,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
@@ -4724,11 +4727,12 @@ export function createMemory({ memoryCreateDto }: {
|
||||
/**
|
||||
* Retrieve memories statistics
|
||||
*/
|
||||
export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: {
|
||||
export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size, $type }: {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
@@ -4740,6 +4744,7 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ
|
||||
isSaved,
|
||||
isTrashed,
|
||||
order,
|
||||
page,
|
||||
size,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
@@ -7146,7 +7151,7 @@ export enum JobName {
|
||||
VersionCheck = "VersionCheck",
|
||||
OcrQueueAll = "OcrQueueAll",
|
||||
Ocr = "Ocr",
|
||||
WorkflowAssetTrigger = "WorkflowAssetTrigger"
|
||||
WorkflowAssetCreate = "WorkflowAssetCreate"
|
||||
}
|
||||
export enum SearchSuggestionType {
|
||||
Country = "country",
|
||||
|
||||
Generated
+6
-11
@@ -10,7 +10,7 @@ overrides:
|
||||
sharp: ^0.34.5
|
||||
webpackbar: ^7.0.0
|
||||
|
||||
packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k=
|
||||
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
|
||||
|
||||
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
|
||||
|
||||
@@ -332,9 +332,6 @@ importers:
|
||||
'@extism/js-pdk':
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1
|
||||
'@immich/sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../sdk
|
||||
'@types/node':
|
||||
specifier: ^24.12.4
|
||||
version: 24.12.4
|
||||
@@ -392,7 +389,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)
|
||||
'@nestjs/swagger':
|
||||
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)(typescript@6.0.3)
|
||||
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)
|
||||
'@nestjs/websockets':
|
||||
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)
|
||||
@@ -539,7 +536,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)
|
||||
nestjs-zod:
|
||||
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)(typescript@6.0.3))(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))(rxjs@7.8.2)(zod@4.3.6)
|
||||
nodemailer:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.7
|
||||
@@ -3798,7 +3795,6 @@ packages:
|
||||
class-transformer: '*'
|
||||
class-validator: '*'
|
||||
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
'@fastify/static':
|
||||
optional: true
|
||||
@@ -16574,7 +16570,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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)(typescript@6.0.3)':
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@microsoft/tsdoc': 0.16.0
|
||||
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -16585,7 +16581,6 @@ snapshots:
|
||||
path-to-regexp: 8.4.2
|
||||
reflect-metadata: 0.2.2
|
||||
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)':
|
||||
dependencies:
|
||||
@@ -23234,14 +23229,14 @@ snapshots:
|
||||
'@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.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)(typescript@6.0.3))(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))(rxjs@7.8.2)(zod@4.3.6):
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
deepmerge: 4.3.1
|
||||
rxjs: 7.8.2
|
||||
zod: 4.3.6
|
||||
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)(typescript@6.0.3)
|
||||
'@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)
|
||||
|
||||
next-tick@1.1.0: {}
|
||||
|
||||
|
||||
@@ -60,9 +60,6 @@ packageExtensions:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
'@nestjs/swagger':
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
dedupePeerDependents: false
|
||||
preferWorkspacePackages: true
|
||||
injectWorkspacePackages: true
|
||||
|
||||
+2
-4
@@ -13,15 +13,14 @@ FROM builder AS server
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./server ./server/
|
||||
COPY ./packages/sdk ./packages/sdk/
|
||||
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
|
||||
RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
|
||||
--mount=type=bind,source=package.json,target=package.json \
|
||||
--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-workspace.yaml,target=pnpm-workspace.yaml \
|
||||
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_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --filter @immich/plugin-sdk --frozen-lockfile build && \
|
||||
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
|
||||
|
||||
FROM builder AS web
|
||||
|
||||
@@ -67,7 +66,6 @@ ENV MISE_DISABLE_TOOLS=flutter
|
||||
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
mise install
|
||||
|
||||
COPY ./packages/sdk ./packages/sdk/
|
||||
COPY ./packages/plugin-core ./packages/plugin-core/
|
||||
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ run = [
|
||||
[tasks.ci-medium]
|
||||
run = [
|
||||
{ task = ":install" },
|
||||
{ task = "//:plugins" },
|
||||
{ task = "//packages/plugin-core:build" },
|
||||
{ task = ":test-medium --run" },
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MemoryCreateDto,
|
||||
MemoryResponseDto,
|
||||
MemorySearchDto,
|
||||
MemorySearchResponseDto,
|
||||
MemoryStatisticsResponseDto,
|
||||
MemoryUpdateDto,
|
||||
} from 'src/dtos/memory.dto';
|
||||
@@ -28,7 +29,7 @@ export class MemoryController {
|
||||
'Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryResponseDto[]> {
|
||||
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemorySearchResponseDto> {
|
||||
return this.service.search(auth, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,8 +65,6 @@ const UpdateAlbumSchema = z
|
||||
|
||||
const GetAlbumsSchema = z
|
||||
.object({
|
||||
id: z.uuidv4().optional().describe('Album ID'),
|
||||
name: z.string().optional().describe('Album name (exact match)'),
|
||||
isOwned: stringToBool
|
||||
.optional()
|
||||
.describe('Filter by ownership: true = only owned, false = only shared-with-me, undefined = no filter'),
|
||||
|
||||
@@ -14,6 +14,7 @@ const MemorySearchSchema = z
|
||||
isTrashed: stringToBool.optional().describe('Include trashed memories'),
|
||||
isSaved: stringToBool.optional().describe('Filter by saved status'),
|
||||
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),
|
||||
page: z.coerce.number().int().min(1).optional().describe('Page number'),
|
||||
order: AssetOrderWithRandomSchema.optional(),
|
||||
})
|
||||
.meta({ id: 'MemorySearchDto' });
|
||||
@@ -75,11 +76,20 @@ const MemoryResponseSchema = z
|
||||
})
|
||||
.meta({ id: 'MemoryResponseDto' });
|
||||
|
||||
const MemorySearchResponseSchema = z
|
||||
.object({
|
||||
total: z.int().min(0).describe('Total number of matching memories'),
|
||||
items: z.array(MemoryResponseSchema),
|
||||
hasNextPage: z.boolean().describe('Whether there are more pages'),
|
||||
})
|
||||
.meta({ id: 'MemorySearchResponseDto' });
|
||||
|
||||
export class MemorySearchDto extends createZodDto(MemorySearchSchema) {}
|
||||
export class MemoryUpdateDto extends createZodDto(MemoryUpdateSchema) {}
|
||||
export class MemoryCreateDto extends createZodDto(MemoryCreateSchema) {}
|
||||
export class MemoryStatisticsResponseDto extends createZodDto(MemoryStatisticsResponseSchema) {}
|
||||
export class MemoryResponseDto extends createZodDto(MemoryResponseSchema) {}
|
||||
export class MemorySearchResponseDto extends createZodDto(MemorySearchResponseSchema) {}
|
||||
|
||||
export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => {
|
||||
return {
|
||||
|
||||
@@ -38,7 +38,6 @@ const PluginManifestTemplateSchema = z
|
||||
description: z.string().min(1).describe('Template description'),
|
||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
|
||||
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
|
||||
uiHints: z.array(z.string()).optional().default([]).describe('Ui hints, for example "smart-album"'),
|
||||
})
|
||||
.meta({ id: 'PluginManifestTemplateDto' });
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ const PluginTemplateResponseSchema = z
|
||||
description: z.string().describe('Template description'),
|
||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
|
||||
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
|
||||
uiHints: z.array(z.string()).describe('Ui hints, for example "smart-album"'),
|
||||
})
|
||||
.meta({ id: 'PluginTemplateResponseDto' });
|
||||
|
||||
@@ -92,7 +91,6 @@ export type PluginTemplate = {
|
||||
config?: Record<string, unknown> | null;
|
||||
enabled?: boolean;
|
||||
}>;
|
||||
uiHints: string[];
|
||||
};
|
||||
|
||||
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
|
||||
@@ -106,7 +104,6 @@ export const mapTemplate = (plugin: { name: string }, template: PluginTemplate):
|
||||
config: step.config ?? null,
|
||||
enabled: step.enabled,
|
||||
})),
|
||||
uiHints: template.uiHints ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -866,7 +866,7 @@ export enum JobName {
|
||||
Ocr = 'Ocr',
|
||||
|
||||
// Workflow
|
||||
WorkflowAssetTrigger = 'WorkflowAssetTrigger',
|
||||
WorkflowAssetCreate = 'WorkflowAssetCreate',
|
||||
}
|
||||
|
||||
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
|
||||
|
||||
@@ -209,16 +209,11 @@ export class AlbumRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { isOwned: true, isShared: true }] })
|
||||
getAll(
|
||||
ownerId: string,
|
||||
options: { id?: string; isOwned?: boolean; isShared?: boolean; name?: string } = {},
|
||||
): Promise<MapAlbumDto[]> {
|
||||
getAll(ownerId: string, options: { isOwned?: boolean; isShared?: boolean } = {}): Promise<MapAlbumDto[]> {
|
||||
return this.buildAlbumBaseQuery(ownerId, options)
|
||||
.selectAll('album')
|
||||
.select(withAlbumUsers(ownerId))
|
||||
.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')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AssetOrderWithRandom, AssetVisibility } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { IBulkAsset } from 'src/types';
|
||||
import { paginationHelper } from 'src/utils/pagination';
|
||||
|
||||
@Injectable()
|
||||
export class MemoryRepository implements IBulkAsset {
|
||||
@@ -57,8 +58,8 @@ export class MemoryRepository implements IBulkAsset {
|
||||
{ params: [DummyValue.UUID, {}] },
|
||||
{ name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] },
|
||||
)
|
||||
search(ownerId: string, dto: MemorySearchDto) {
|
||||
return this.searchBuilder(ownerId, dto)
|
||||
async search(ownerId: string, dto: MemorySearchDto) {
|
||||
const items = await this.searchBuilder(ownerId, dto)
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
@@ -89,8 +90,11 @@ export class MemoryRepository implements IBulkAsset {
|
||||
? qb.orderBy(sql`RANDOM()`)
|
||||
: qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection),
|
||||
)
|
||||
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size!))
|
||||
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size! + 1))
|
||||
.$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!))
|
||||
.execute();
|
||||
|
||||
return paginationHelper(items, dto.size ?? items.length);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
|
||||
@@ -45,10 +45,10 @@ export class WorkflowRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
search(dto: WorkflowSearchDto & { userId?: string }) {
|
||||
search(dto: WorkflowSearchDto & { ownerId?: string }) {
|
||||
return this.queryBuilder()
|
||||
.$if(!!dto.id, (qb) => qb.where('id', '=', dto.id!))
|
||||
.$if(!!dto.userId, (qb) => qb.where('ownerId', '=', dto.userId!))
|
||||
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
|
||||
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
|
||||
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
|
||||
.orderBy('createdAt', 'desc')
|
||||
|
||||
@@ -37,12 +37,15 @@ export class AlbumService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, ...rest }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
async getAll(
|
||||
{ user: { id: ownerId } }: AuthDto,
|
||||
{ assetId, isOwned, isShared }: GetAlbumsDto,
|
||||
): Promise<AlbumResponseDto[]> {
|
||||
await this.albumRepository.updateThumbnails();
|
||||
|
||||
const albums = assetId
|
||||
? await this.albumRepository.getByAssetId(ownerId, assetId)
|
||||
: await this.albumRepository.getAll(ownerId, rest);
|
||||
: await this.albumRepository.getAll(ownerId, { isOwned, isShared });
|
||||
|
||||
if (albums.length === 0) {
|
||||
return [];
|
||||
|
||||
@@ -34,21 +34,28 @@ describe(MemoryService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
|
||||
const memory2 = MemoryFactory.create({ ownerId: userId });
|
||||
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
|
||||
mocks.memory.search.mockResolvedValue({
|
||||
items: [getForMemory(memory1), getForMemory(memory2)],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.memory.statistics.mockResolvedValue({ total: 2 });
|
||||
|
||||
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toMatchObject({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: memory1.id,
|
||||
assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
hasNextPage: false,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should map empty result', async () => {
|
||||
mocks.memory.search.mockResolvedValue([]);
|
||||
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
|
||||
mocks.memory.search.mockResolvedValue({ items: [], hasNextPage: false });
|
||||
mocks.memory.statistics.mockResolvedValue({ total: 0 });
|
||||
await expect(sut.search(factory.auth(), {})).resolves.toMatchObject({ items: [], hasNextPage: false, total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -71,10 +71,14 @@ export class MemoryService extends BaseService {
|
||||
}
|
||||
|
||||
async search(auth: AuthDto, dto: MemorySearchDto) {
|
||||
const memories = await this.memoryRepository.search(auth.user.id, dto);
|
||||
return memories
|
||||
.filter((memory: Memory) => memory.assets && memory.assets.length > 0)
|
||||
.map((memory: Memory) => mapMemory(memory, auth));
|
||||
const { items, hasNextPage } = await this.memoryRepository.search(auth.user.id, dto);
|
||||
const { total } = await this.memoryRepository.statistics(auth.user.id, dto);
|
||||
|
||||
return {
|
||||
total,
|
||||
items: items.map((memory: Memory) => mapMemory(memory, auth)),
|
||||
hasNextPage,
|
||||
};
|
||||
}
|
||||
|
||||
statistics(auth: AuthDto, dto: MemorySearchDto) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { CurrentPlugin } from '@extism/extism';
|
||||
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
|
||||
import { HttpException, UnauthorizedException } from '@nestjs/common';
|
||||
import _ from 'lodash';
|
||||
import { join } from 'node:path';
|
||||
import { DummyValue, OnEvent, OnJob } from 'src/decorators';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
} from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { AlbumService } from 'src/services/album.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobOf } from 'src/types';
|
||||
|
||||
@@ -32,11 +32,9 @@ const dummy = () => {
|
||||
|
||||
type ExecuteOptions<T extends WorkflowType> = {
|
||||
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
|
||||
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
|
||||
write: (changes: WorkflowChanges<T>) => Promise<void>;
|
||||
};
|
||||
|
||||
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
|
||||
|
||||
export class WorkflowExecutionService extends BaseService {
|
||||
private jwtSecret!: string;
|
||||
|
||||
@@ -64,6 +62,7 @@ export class WorkflowExecutionService extends BaseService {
|
||||
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
|
||||
albumService.addAssets(authDto, ...args),
|
||||
);
|
||||
|
||||
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
|
||||
albumService.addAssetsToAlbums(authDto, ...args),
|
||||
);
|
||||
@@ -248,25 +247,20 @@ export class WorkflowExecutionService extends BaseService {
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AssetCreate' })
|
||||
onAssetCreate({ asset: { ownerId: userId, id: assetId } }: ArgOf<'AssetCreate'>) {
|
||||
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetCreate });
|
||||
}
|
||||
|
||||
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
|
||||
const items = await this.workflowRepository.search({ userId, trigger });
|
||||
async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
|
||||
const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate };
|
||||
const items = await this.workflowRepository.search(dto);
|
||||
await this.jobRepository.queueAll(
|
||||
items.map((workflow) => ({
|
||||
name: JobName.WorkflowAssetTrigger,
|
||||
data: { workflowId: workflow.id, assetId, trigger },
|
||||
name: JobName.WorkflowAssetCreate,
|
||||
data: { workflowId: workflow.id, assetId: asset.id },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
|
||||
handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
|
||||
@OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow })
|
||||
handleAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) {
|
||||
return this.execute(workflowId, (type) => {
|
||||
const assetService = BaseService.create(AssetService, this);
|
||||
|
||||
switch (type) {
|
||||
case WorkflowType.AssetV1: {
|
||||
return {
|
||||
@@ -277,16 +271,19 @@ export class WorkflowExecutionService extends BaseService {
|
||||
authUserId: asset.ownerId,
|
||||
};
|
||||
},
|
||||
write: async (auth, changes) => {
|
||||
const asset = changes.asset;
|
||||
if (!asset) {
|
||||
return;
|
||||
write: async (changes) => {
|
||||
if (changes.asset) {
|
||||
await this.assetRepository.update({
|
||||
id: assetId,
|
||||
..._.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>;
|
||||
}
|
||||
@@ -304,19 +301,7 @@ export class WorkflowExecutionService extends BaseService {
|
||||
}
|
||||
|
||||
// TODO infer from steps
|
||||
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 type = 'AssetV1' as T;
|
||||
const handler = getHandler(type);
|
||||
if (!handler) {
|
||||
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
|
||||
@@ -352,18 +337,7 @@ export class WorkflowExecutionService extends BaseService {
|
||||
payload,
|
||||
);
|
||||
if (result?.changes) {
|
||||
await write(
|
||||
{
|
||||
user: {
|
||||
id: readResult.authUserId,
|
||||
},
|
||||
session: {
|
||||
id: DummyValue.UUID,
|
||||
hasElevatedPermission: true,
|
||||
},
|
||||
} as AuthDto,
|
||||
result.changes,
|
||||
);
|
||||
await write(result.changes);
|
||||
({ data } = await read(type));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export class WorkflowService extends BaseService {
|
||||
}
|
||||
|
||||
async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
|
||||
const workflows = await this.workflowRepository.search({ ...dto, userId: auth.user.id });
|
||||
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id });
|
||||
return workflows.map((workflow) => mapWorkflow(workflow));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -404,7 +404,7 @@ export type JobItem =
|
||||
| { name: JobName.Ocr; data: IEntityJob }
|
||||
|
||||
// Workflow
|
||||
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
|
||||
| { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } }
|
||||
|
||||
// Editor
|
||||
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
|
||||
|
||||
@@ -133,8 +133,8 @@ describe(MemoryService.name, () => {
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, {});
|
||||
expect(memories.length).toBe(1);
|
||||
expect(memories[0]).toEqual(
|
||||
expect(memories.items.length).toBe(1);
|
||||
expect(memories.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
createdAt: expect.any(Date),
|
||||
@@ -173,8 +173,8 @@ describe(MemoryService.name, () => {
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, {});
|
||||
expect(memories.length).toBe(1);
|
||||
expect(memories[0]).toEqual(
|
||||
expect(memories.items.length).toBe(1);
|
||||
expect(memories.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
createdAt: expect.any(Date),
|
||||
@@ -228,12 +228,12 @@ describe(MemoryService.name, () => {
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.id, {});
|
||||
expect(memories.length).toBe(1);
|
||||
expect(memories.items.length).toBe(1);
|
||||
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memoriesAfter = await memoryRepo.search(user.id, {});
|
||||
expect(memoriesAfter.length).toBe(1);
|
||||
expect(memoriesAfter.items.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('core plugin', () => {
|
||||
steps: [{ method: 'immich-plugin-core#assetArchive' }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
|
||||
visibility: AssetVisibility.Archive,
|
||||
@@ -154,7 +154,7 @@ describe('core plugin', () => {
|
||||
steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
|
||||
visibility: AssetVisibility.Timeline,
|
||||
@@ -173,7 +173,7 @@ describe('core plugin', () => {
|
||||
steps: [{ method: 'immich-plugin-core#assetLock' }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
|
||||
visibility: AssetVisibility.Locked,
|
||||
@@ -190,7 +190,7 @@ describe('core plugin', () => {
|
||||
steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
|
||||
visibility: AssetVisibility.Timeline,
|
||||
@@ -209,7 +209,7 @@ describe('core plugin', () => {
|
||||
steps: [{ method: 'immich-plugin-core#assetFavorite' }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
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 } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
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] } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
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] } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
await expect(ctx.sut.handleAssetCreate({ 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(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] } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
|
||||
|
||||
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
|
||||
});
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if schema.uiHint === 'AlbumId'}
|
||||
{:else if schema.uiHint === 'albumId'}
|
||||
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
|
||||
{:else if schema.enum && schema.array}
|
||||
<Field {label} {description}>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
|
||||
import {
|
||||
deleteMemory,
|
||||
type MemoryResponseDto,
|
||||
removeMemoryAssets,
|
||||
searchMemories,
|
||||
updateMemory,
|
||||
MemorySearchOrder,
|
||||
MemoryType,
|
||||
} from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
|
||||
type MemoryIndex = {
|
||||
@@ -20,10 +27,31 @@ export type MemoryAsset = MemoryIndex & {
|
||||
nextMemory?: MemoryResponseDto;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 250;
|
||||
|
||||
class MemoryManager {
|
||||
#loading: Promise<void> | undefined;
|
||||
#filters:
|
||||
| {
|
||||
$for?: string;
|
||||
isSaved?: boolean;
|
||||
isTrashed?: boolean;
|
||||
order?: MemorySearchOrder;
|
||||
page?: number;
|
||||
size?: number;
|
||||
$type?: MemoryType;
|
||||
}
|
||||
| undefined;
|
||||
#hasNextPage: boolean;
|
||||
#page: number;
|
||||
#total: number;
|
||||
|
||||
constructor() {
|
||||
this.#filters = undefined;
|
||||
this.#hasNextPage = true;
|
||||
this.#page = 1;
|
||||
this.#total = 0;
|
||||
|
||||
eventManager.on({
|
||||
AuthLogout: () => this.clearCache(),
|
||||
AuthUserLoaded: () => this.initialize(),
|
||||
@@ -37,6 +65,16 @@ class MemoryManager {
|
||||
this.scheduleHourlyRefresh();
|
||||
}
|
||||
|
||||
get filters() {
|
||||
return this.#filters;
|
||||
}
|
||||
|
||||
set filters(filters) {
|
||||
this.#filters = filters;
|
||||
this.clearCache();
|
||||
void this.loadNextPage();
|
||||
}
|
||||
|
||||
ready() {
|
||||
return this.initialize();
|
||||
}
|
||||
@@ -117,22 +155,46 @@ class MemoryManager {
|
||||
}
|
||||
}
|
||||
|
||||
loadNextPage() {
|
||||
if (this.#hasNextPage) {
|
||||
if (this.#loading === undefined) {
|
||||
this.#loading = this.load(this.#page++);
|
||||
} else {
|
||||
void this.#loading.then(() => (this.#loading = this.load(this.#page++)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get hasNextPage() {
|
||||
return this.#hasNextPage;
|
||||
}
|
||||
|
||||
get total() {
|
||||
return this.#total;
|
||||
}
|
||||
|
||||
private clearCache() {
|
||||
this.#loading = undefined;
|
||||
this.#hasNextPage = true;
|
||||
this.#page = 1;
|
||||
this.memories = [];
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
if (!this.#loading) {
|
||||
this.#loading = this.load();
|
||||
this.#loading = this.load(this.#page++);
|
||||
}
|
||||
|
||||
return this.#loading;
|
||||
}
|
||||
|
||||
private async load() {
|
||||
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
|
||||
this.memories = memories.filter((memory) => memory.assets.length > 0);
|
||||
private async load(page: number) {
|
||||
if (this.#filters !== undefined) {
|
||||
const { items, hasNextPage, total } = await searchMemories({ ...this.#filters, page, size: PAGE_SIZE });
|
||||
this.memories.push(...items);
|
||||
this.#hasNextPage = hasNextPage;
|
||||
this.#total = total;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleHourlyRefresh() {
|
||||
@@ -146,12 +208,14 @@ class MemoryManager {
|
||||
const initialDelay = nextEvent.diff(now).as('milliseconds');
|
||||
|
||||
setTimeout(() => {
|
||||
this.#loading = this.load();
|
||||
this.clearCache();
|
||||
this.#loading = this.load(0);
|
||||
|
||||
// Schedule subsequent events hourly
|
||||
setInterval(
|
||||
() => {
|
||||
this.#loading = this.load();
|
||||
this.clearCache();
|
||||
this.#loading = this.load(0);
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="grow text-start">
|
||||
<Text fontWeight="medium" class="flex items-center gap-1"
|
||||
>{method.title}
|
||||
{#if method.uiHints.includes('Filter')}
|
||||
{#if method.uiHints.includes('filter')}
|
||||
<Badge size="tiny" color="info" title={$t('plugin_method_filter_type_description')}
|
||||
>{$t('plugin_method_filter_type')}</Badge
|
||||
>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<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>
|
||||
+1
-6
@@ -2,7 +2,7 @@
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { handleCreateWorkflow } from '$lib/services/workflow.service';
|
||||
import { type PluginTemplateResponseDto } from '@immich/sdk';
|
||||
import { Badge, FormModal, Icon, ListButton, Text } from '@immich/ui';
|
||||
import { FormModal, Icon, ListButton, Text } from '@immich/ui';
|
||||
import { mdiFlashOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -59,11 +59,6 @@
|
||||
<Text fontWeight="medium">{template.title}</Text>
|
||||
<Text size="tiny" color="muted">{template.description}</Text>
|
||||
</div>
|
||||
{#if template.uiHints.includes('SmartAlbum')}
|
||||
<div class="shrink-0">
|
||||
<Badge size="small">{$t('smart_album')}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</ListButton>
|
||||
{/each}
|
||||
@@ -26,7 +26,7 @@ import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
|
||||
import WorkflowTemplatePickerModal from '$lib/modals/WorkflowTemplatePickerModal.svelte';
|
||||
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { copyToClipboard, downloadJson } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -50,7 +50,7 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
|
||||
const UseTemplate: ActionItem = {
|
||||
title: $t('browse_templates'),
|
||||
icon: mdiFileDocumentMultipleOutline,
|
||||
onAction: () => modalManager.show(WorkflowTemplatePickerModal, {}),
|
||||
onAction: () => modalManager.show(WorkflowTemplatePicker, {}),
|
||||
};
|
||||
|
||||
return { Create, UseTemplate };
|
||||
|
||||
@@ -104,7 +104,7 @@ export type JSONSchemaProperty = {
|
||||
array?: boolean;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
uiHint?: 'AlbumId' | 'AssetId' | 'PersonId';
|
||||
uiHint?: 'albumId' | 'assetId' | 'personId';
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -20,12 +20,13 @@ import {
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { init, register, t } from 'svelte-i18n';
|
||||
import { derived, get } from 'svelte/store';
|
||||
import { defaultLang, locales } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||
import { alwaysLoadOriginalFile, lang, locale } from '$lib/stores/preferences.store';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { langs } from '$lib/utils/i18n';
|
||||
@@ -358,9 +359,13 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
|
||||
|
||||
export const memoryLaneTitle = derived(t, ($t) => {
|
||||
return (memory: MemoryResponseDto) => {
|
||||
const now = new Date();
|
||||
if (memory.type === MemoryType.OnThisDay) {
|
||||
return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } });
|
||||
const now = new Date();
|
||||
const memoryDate = new Date(memory.memoryAt);
|
||||
|
||||
return memoryDate.getUTCDate() === now.getDate() && memoryDate.getUTCMonth() === now.getMonth()
|
||||
? $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } })
|
||||
: DateTime.fromJSDate(memoryDate).toLocaleString(DateTime.DATE_MED, { locale: get(locale) });
|
||||
}
|
||||
|
||||
return $t('unknown');
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { Icon, ImageCarousel } from '@immich/ui';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -28,13 +28,22 @@
|
||||
return targetField?.items || [];
|
||||
};
|
||||
|
||||
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
|
||||
let places = $derived(getFieldItems(data.explore, 'exifInfo.city'));
|
||||
let recents = $derived(
|
||||
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
|
||||
getFieldItems(data.explore, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
|
||||
);
|
||||
let people = $state(data.people.people);
|
||||
let memories = $derived(
|
||||
data.memories.map((memory) => ({
|
||||
id: memory.id,
|
||||
title: $memoryLaneTitle(memory),
|
||||
href: Route.memories({ id: memory.assets[0].id }),
|
||||
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
|
||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||
})),
|
||||
);
|
||||
let people = $state(data.response.people);
|
||||
|
||||
let hasPeople = $derived(data.response.total > 0);
|
||||
let hasPeople = $derived(data.people.total > 0);
|
||||
|
||||
const onPersonThumbnailReady = ({ id }: { id: string }) => {
|
||||
for (const person of people) {
|
||||
@@ -124,6 +133,20 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if memories.length > 0}
|
||||
<div class="mt-2 mb-6">
|
||||
<div class="flex justify-between">
|
||||
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('memories')}</p>
|
||||
<a
|
||||
href={Route.memories()}
|
||||
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
|
||||
draggable="false">{$t('view_all')}</a
|
||||
>
|
||||
</div>
|
||||
<ImageCarousel items={memories} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if recents.length > 0}
|
||||
<div class="mt-2 mb-6">
|
||||
<div class="flex justify-between">
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { getAllPeople, getExploreData } from '@immich/sdk';
|
||||
import { getAllPeople, getExploreData, MemorySearchOrder } from '@immich/sdk';
|
||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url);
|
||||
const [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]);
|
||||
memoryManager.filters = { size: 12, order: MemorySearchOrder.Desc };
|
||||
|
||||
const [explore, people] = await Promise.all([
|
||||
getExploreData(),
|
||||
getAllPeople({ withHidden: false }),
|
||||
memoryManager.ready(),
|
||||
]);
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
items,
|
||||
response,
|
||||
explore,
|
||||
people,
|
||||
memories: memoryManager.memories,
|
||||
meta: {
|
||||
title: $t('explore'),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,104 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { mdiHeartOutline, mdiHeart } from '@mdi/js';
|
||||
import { Button, Icon } from '@immich/ui';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { page } from '$app/state';
|
||||
import MemoryViewer from './MemoryViewer.svelte';
|
||||
import { QueryParameter } from '$lib/constants';
|
||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||
import { clearQueryParam, setQueryValue } from '$lib/utils/navigation';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
let onlyFavorites = $state(page.url.searchParams.get('favorites') === 'true');
|
||||
let lastElement: HTMLElement | undefined = $state();
|
||||
|
||||
const toggleFavorites = async () => {
|
||||
onlyFavorites = !onlyFavorites;
|
||||
memoryManager.filters = onlyFavorites ? { isSaved: true } : {};
|
||||
await memoryManager.ready();
|
||||
|
||||
if (onlyFavorites) {
|
||||
void setQueryValue('favorites', 'true');
|
||||
} else {
|
||||
void clearQueryParam('favorites', page.url);
|
||||
}
|
||||
};
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||
const entry = entries.find((entry) => entry.target === lastElement);
|
||||
if (entry?.isIntersecting && memoryManager.hasNextPage) {
|
||||
void memoryManager.loadNextPage();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (lastElement) {
|
||||
intersectionObserver.disconnect();
|
||||
intersectionObserver.observe(lastElement);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<MemoryViewer />
|
||||
{#if page.url.searchParams.has(QueryParameter.ID)}
|
||||
<MemoryViewer />
|
||||
{:else}
|
||||
<UserPageLayout title={data.meta.title} description={`(${memoryManager.total.toLocaleString($locale)})`}>
|
||||
{#snippet buttons()}
|
||||
<div class="flex place-items-center gap-2">
|
||||
<Button
|
||||
leadingIcon={mdiHeartOutline}
|
||||
size="small"
|
||||
variant={onlyFavorites ? 'filled' : 'ghost'}
|
||||
color="secondary"
|
||||
onclick={() => toggleFavorites()}>{$t('only_favorites')}</Button
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if memoryManager.memories.length > 0}
|
||||
<div class="grid w-full grid-cols-2 gap-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 2xl:grid-cols-7">
|
||||
{#each memoryManager.memories as memory, index (memory.id)}
|
||||
<a
|
||||
href={Route.memories({ id: memory.assets[0].id })}
|
||||
class="item-card relative inline-block aspect-video"
|
||||
bind:this={
|
||||
() => (index === memoryManager.memories.length - 1 ? lastElement : null),
|
||||
(e) => {
|
||||
if (index === memoryManager.memories.length - 1) {
|
||||
lastElement = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
{#if memory.isSaved}
|
||||
<div class="absolute inset-s-2 top-2 z-2">
|
||||
<Icon data-icon-favorite icon={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: memory.assets[0].id })}
|
||||
alt={$getAltText(toTimelineAsset(memory.assets[0]))}
|
||||
class="size-full object-cover brightness-75"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-2 w-full px-1 text-center text-sm font-medium text-ellipsis text-white capitalize backdrop-blur-[1px] hover:cursor-pointer"
|
||||
>
|
||||
{$memoryLaneTitle(memory)}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}{/if}
|
||||
</UserPageLayout>
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { QueryParameter } from '$lib/constants';
|
||||
import { memoryManager } from '$lib/managers/memory-manager.svelte';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
@@ -6,10 +9,19 @@ export const load = (async ({ url }) => {
|
||||
const user = await authenticate(url);
|
||||
const $t = await getFormatter();
|
||||
|
||||
const filters = url.searchParams.get('favorites') === 'true' ? { isSaved: true } : {};
|
||||
if (
|
||||
!(url.searchParams.has(QueryParameter.ID) && memoryManager.memories.length > 0) &&
|
||||
!isEqual(memoryManager.filters, filters)
|
||||
) {
|
||||
memoryManager.filters = filters;
|
||||
await memoryManager.ready();
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
meta: {
|
||||
title: $t('memory'),
|
||||
title: $t('memories'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
let progressBarController: Tween<number> | undefined = $state(undefined);
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||
let previousPage = $state(Route.memories());
|
||||
|
||||
const handleNavigate = async (asset?: { id: string }) => {
|
||||
if (assetViewerManager.isViewing) {
|
||||
@@ -106,7 +107,7 @@
|
||||
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
||||
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
||||
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
||||
const handleEscape = async () => goto(Route.photos());
|
||||
const handleEscape = async () => goto(previousPage);
|
||||
const handleSelectAll = () =>
|
||||
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
||||
|
||||
@@ -249,7 +250,7 @@
|
||||
|
||||
const init = (target: Page | NavigationTarget | null) => {
|
||||
if (memoryManager.memories.length === 0) {
|
||||
return handlePromiseError(goto(Route.photos()));
|
||||
return handlePromiseError(goto(previousPage));
|
||||
}
|
||||
|
||||
current = loadFromParams(target);
|
||||
@@ -281,6 +282,10 @@
|
||||
};
|
||||
|
||||
afterNavigate(({ from, to }) => {
|
||||
if (from?.url !== null && !from?.url.searchParams.has(QueryParameter.ID)) {
|
||||
previousPage = from!.url.toString();
|
||||
}
|
||||
|
||||
memoryManager.ready().then(
|
||||
() => {
|
||||
let target;
|
||||
@@ -381,7 +386,7 @@
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
size="large"
|
||||
onclick={() => goto(Route.photos())}
|
||||
onclick={() => goto(previousPage)}
|
||||
/>
|
||||
<p class="text-lg">
|
||||
{$memoryLaneTitle(current.memory)}
|
||||
|
||||
@@ -33,12 +33,14 @@
|
||||
type OnLink,
|
||||
type OnUnlink,
|
||||
} from '$lib/utils/actions';
|
||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
||||
import { mdiDotsVertical } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
@@ -81,6 +83,10 @@
|
||||
assetMultiSelectManager.clear();
|
||||
};
|
||||
|
||||
if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) {
|
||||
memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) };
|
||||
}
|
||||
|
||||
const items = $derived(
|
||||
memoryManager.memories.map((memory) => ({
|
||||
id: memory.id,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate, goto, invalidate } from '$app/navigation';
|
||||
import { dragAutoScroll } from '$lib/attachments/drag-auto-scroll.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
|
||||
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
|
||||
@@ -8,7 +7,7 @@
|
||||
import { Route } from '$lib/route';
|
||||
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
|
||||
import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk';
|
||||
import {
|
||||
ActionBar,
|
||||
AppShell,
|
||||
@@ -44,10 +43,7 @@
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { createListReorder, GHOST_KEY, type ReorderEntry } from './list-reorder.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
|
||||
import WorkflowStepCard from './WorkflowStepCard.svelte';
|
||||
@@ -73,11 +69,6 @@
|
||||
let isSaving = $state(false);
|
||||
let editMode = $state<EditMode>('visual');
|
||||
|
||||
const reorder = createListReorder(
|
||||
() => steps,
|
||||
(next) => (steps = next),
|
||||
);
|
||||
|
||||
const workflowSummary = $derived({ name, description, trigger, steps });
|
||||
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
|
||||
|
||||
@@ -115,6 +106,19 @@
|
||||
}
|
||||
};
|
||||
|
||||
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 confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
|
||||
if (confirmed) {
|
||||
@@ -340,51 +344,17 @@
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div class="hidden" aria-hidden="true" {@attach dragAutoScroll(() => reorder.isDragging)}></div>
|
||||
|
||||
{#snippet stepCard(entry: ReorderEntry<WorkflowStepDto>)}
|
||||
{#each steps as step, index (step.method + index)}
|
||||
<WorkflowStepCard
|
||||
step={entry.item}
|
||||
index={entry.index}
|
||||
position={entry.index + 1}
|
||||
isGhost={entry.isGhost}
|
||||
isSource={entry.isSource}
|
||||
isDragging={reorder.isDragging}
|
||||
{step}
|
||||
{index}
|
||||
onEdit={handleEditStep}
|
||||
onDelete={handleDeleteStep}
|
||||
onInsertBefore={handleInsertStep}
|
||||
onDragStart={reorder.start}
|
||||
onDragOver={reorder.over}
|
||||
onDragEnd={reorder.end}
|
||||
onDrop={reorder.drop}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
{/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}
|
||||
|
||||
{#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
|
||||
size="small"
|
||||
fullWidth
|
||||
|
||||
@@ -17,59 +17,23 @@
|
||||
type Props = {
|
||||
step: WorkflowStepDto;
|
||||
index: number;
|
||||
position: number;
|
||||
isGhost: boolean;
|
||||
isSource: boolean;
|
||||
isDragging: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
onInsertBefore: (index: number) => void;
|
||||
onDragStart: (index: number) => void;
|
||||
onDragOver: (index: number, after: boolean) => void;
|
||||
onDragEnd: () => void;
|
||||
onDrop: () => void;
|
||||
onDrop: (index: number, event: DragEvent) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
step,
|
||||
index,
|
||||
position,
|
||||
isGhost,
|
||||
isSource,
|
||||
isDragging,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onInsertBefore,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDrop,
|
||||
}: Props = $props();
|
||||
let { step, index, onEdit, onDelete, onInsertBefore, onDrop }: Props = $props();
|
||||
|
||||
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(
|
||||
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
|
||||
);
|
||||
let dragImage = $state<Element>();
|
||||
let isDropTarget = $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 formatConfigValue = (value: unknown): string => {
|
||||
@@ -111,7 +75,7 @@
|
||||
target: document.body,
|
||||
props: {
|
||||
description: method?.description,
|
||||
isFilter: method?.uiHints?.includes('Filter') ?? false,
|
||||
isFilter: method?.uiHints?.includes('filter') ?? false,
|
||||
label: step ? pluginManager.getMethodLabel(step.method) : '',
|
||||
stepNumber: index + 1,
|
||||
},
|
||||
@@ -119,31 +83,31 @@
|
||||
|
||||
dragImage = document.body.querySelector('#workflow-step-drag-image')!;
|
||||
event.dataTransfer.setDragImage(dragImage, 16, 22);
|
||||
|
||||
onDragStart(index);
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
const handleDrop = (index: number, event: DragEvent) => {
|
||||
if (!event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onDrop();
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent & { currentTarget: HTMLElement }) => {
|
||||
event.preventDefault();
|
||||
if (isGhost) {
|
||||
const from = Number(event.dataTransfer.getData('text/plain'));
|
||||
if (from === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const after = event.clientY > rect.top + rect.height / 2;
|
||||
onDragOver(index, after);
|
||||
onDrop(index, event);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDropTarget = true;
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
dragImage?.remove();
|
||||
dragImage = undefined;
|
||||
hoverDrag = false;
|
||||
onDragEnd();
|
||||
isDropTarget = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -154,7 +118,6 @@
|
||||
<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:hidden={isDragging}
|
||||
aria-label={$t('add_step')}
|
||||
title={$t('add_step')}
|
||||
onclick={() => onInsertBefore(index)}
|
||||
@@ -166,12 +129,20 @@
|
||||
|
||||
<div
|
||||
class="w-full transition-all"
|
||||
class:opacity-50={isSource}
|
||||
class:opacity-40={!!dragImage}
|
||||
class:scale-[0.99]={!!dragImage}
|
||||
ondragover={handleDragOver}
|
||||
ondrop={handleDrop}
|
||||
ondragleave={() => (isDropTarget = false)}
|
||||
ondrop={(event) => handleDrop(index, event)}
|
||||
role="listitem"
|
||||
>
|
||||
<Card class="shadow-none transition-colors {cardStateClass}">
|
||||
<Card
|
||||
class="shadow-none transition-colors {isDropTarget
|
||||
? 'border-primary ring-2 ring-primary-200'
|
||||
: hoverDrag
|
||||
? 'border-dashed border-primary'
|
||||
: ''}"
|
||||
>
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -200,9 +171,7 @@
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<CardTitle class="truncate">
|
||||
{#if !isGhost}
|
||||
<span class="mr-1 font-bold text-light-500">{position}</span>
|
||||
{/if}
|
||||
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
|
||||
{pluginManager.getMethodLabel(step.method)}
|
||||
</CardTitle>
|
||||
{#if method?.description}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
for (const [i, step] of workflow.steps.entries()) {
|
||||
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 label = pluginManager.getMethodLabel(step.method);
|
||||
lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`);
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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