Compare commits

..

14 Commits

Author SHA1 Message Date
Ben Beckford cff8065e5f chore: update core plugin header 2026-06-25 10:47:09 -07:00
Ben Beckford 961ab7b150 chore: clean up webhook plugin method 2026-06-25 08:10:00 -07:00
Ben Beckford 1037fcc07e chore: update workflow method wrapper type 2026-06-24 21:40:27 -07:00
Ben Beckford db9dc73006 Merge branch 'main' into feat/workflow-webhooks 2026-06-24 21:37:17 -07:00
Ben Beckford c80303d4d5 feat(server): allow plugins to specify allowed hostnames 2026-06-24 21:17:45 -07:00
Yaros 4099fa6b4a fix(mobile): app doesn't exit full-screen mode (#29301)
* fix(mobile): app doesn't exit full-screen mode

* chore: rename restoreSystemUI to restoreEdgeToEdge
2026-06-24 20:48:01 -05:00
Daniel Dietzler 9751530af8 feat: plugin wrapper type safety (#29300) 2026-06-24 15:22:35 -04:00
Daniel Dietzler 0931a19c5c fix: run test suite for plugin changes (#29311) 2026-06-24 16:29:46 +00:00
Alex 08b2e2c0b5 fix(docs): Revert v3 bump (#29310)
Revert "fix(docsc): v3 bump (#29246)"

This reverts commit dc7d57ff9a.
2026-06-24 11:18:37 -05:00
Santo Shakil e5b50a55a4 fix(mobile): blank notifications page after enabling notifications (#29232)
the old notification toggles were removed in a cleanup, so once notifications were enabled the page had nothing left and went blank. show a "notifications enabled" status tile with a shortcut to the system notification settings instead.
2026-06-24 09:15:28 -05:00
Ben Beckford 11f61f23ba Merge branch 'main' into feat/workflow-webhooks 2026-06-23 13:00:07 -07:00
Ben Beckford 226fab849c chore: use extism http in workflow webhook method 2026-06-23 12:58:59 -07:00
Ben Beckford d39bd2e6cc feat: support PUT in webhook action 2026-06-23 11:14:58 -07:00
Ben Beckford e4cf79263b feat: webhook workflow action 2026-06-22 00:01:04 -07:00
53 changed files with 321 additions and 851 deletions
+2
View File
@@ -45,6 +45,8 @@ jobs:
- 'server/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
- 'packages/plugin-core/**'
- 'packages/plugin-sdk/**'
cli:
- 'packages/cli/**'
- 'packages/sdk/**'
+1 -1
View File
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v3
IMMICH_VERSION=v2
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+1 -1
View File
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
+1 -1
View File
@@ -29,7 +29,7 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
+3
View File
@@ -1507,6 +1507,9 @@
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_enabled_list_tile_content": "Immich uses notifications for background backup. Manage them in your device settings.",
"notification_enabled_list_tile_open_button": "Open settings",
"notification_enabled_list_tile_title": "Notifications enabled",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
@@ -13,10 +13,6 @@ class DriftMemoryService {
return _repository.getAll(ownerId);
}
Future<List<DriftMemory>> getAllMemories(String ownerId) {
return _repository.getAll(ownerId, onlyToday: false);
}
Future<DriftMemory?> get(String memoryId) {
return _repository.get(memoryId);
}
@@ -9,7 +9,10 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftMemoryRepository(this._db) : super(_db);
Future<List<DriftMemory>> getAll(String ownerId, {bool onlyToday = true}) async {
Future<List<DriftMemory>> getAll(String ownerId) async {
final now = DateTime.now();
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
final query =
_db.select(_db.memoryEntity).join([
innerJoin(_db.memoryAssetEntity, _db.memoryAssetEntity.memoryId.equalsExp(_db.memoryEntity.id)),
@@ -21,17 +24,10 @@ class DriftMemoryRepository extends DriftDatabaseRepository {
),
])
..where(_db.memoryEntity.ownerId.equals(ownerId))
..where(_db.memoryEntity.deletedAt.isNull());
if (onlyToday) {
final now = DateTime.now();
final localUtc = DateTime.utc(now.year, now.month, now.day, 0, 0, 0);
query.where(_db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc));
query.where(_db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc));
}
query.orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
..where(_db.memoryEntity.deletedAt.isNull())
..where(_db.memoryEntity.showAt.isNull() | _db.memoryEntity.showAt.isSmallerOrEqualValue(localUtc))
..where(_db.memoryEntity.hideAt.isNull() | _db.memoryEntity.hideAt.isBiggerOrEqualValue(localUtc))
..orderBy([OrderingTerm.desc(_db.memoryEntity.memoryAt), OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]);
final rows = await query.get();
if (rows.isEmpty) {
+1 -1
View File
@@ -112,7 +112,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) {
}
if (index == kPhotoTabIndex) {
ref.invalidate(driftMemoryLaneProvider);
ref.invalidate(driftMemoryFutureProvider);
}
if (router.activeIndex != kSearchTabIndex && index == kSearchTabIndex) {
@@ -11,7 +11,7 @@ class MainTimelinePage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasMemories = ref.watch(driftMemoryLaneProvider.select((state) => state.value?.isNotEmpty ?? false));
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
topSliverWidgetHeight: hasMemories ? 200 : 0,
@@ -7,12 +7,9 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -137,12 +134,7 @@ class _CollectionCards extends StatelessWidget {
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
_PeopleCollectionCard(),
_PlacesCollectionCard(),
_LocalAlbumsCollectionCard(),
_MemoriesCollectionCard(),
],
children: [_PeopleCollectionCard(), _PlacesCollectionCard(), _LocalAlbumsCollectionCard()],
),
),
);
@@ -336,76 +328,6 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
}
}
class _MemoriesCollectionCard extends ConsumerWidget {
const _MemoriesCollectionCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final memories = ref.watch(driftAllMemoriesProvider);
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const DriftMemoryListRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: memories.widgetWhen(
onLoading: () => const Center(child: CircularProgressIndicator()),
onData: (memories) {
return GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: memories.take(4).map((memory) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: Thumbnail.remote(
remoteId: memory.assets[0].id,
thumbhash: memory.assets[0].thumbHash ?? "",
fit: BoxFit.cover,
),
);
}).toList(),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'memories'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
@visibleForTesting
final sharedWithPartnerProvider = StreamProvider.autoDispose<Iterable<Partner>>((ref) {
final currentUser = ref.watch(currentUserProvider);
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
@@ -49,7 +50,7 @@ class DriftMemoryPage extends HookConsumerWidget {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
return () {
// Clean up to normal edge to edge when we are done
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
restoreEdgeToEdge();
};
});
@@ -328,7 +329,7 @@ class DriftMemoryPage extends HookConsumerWidget {
// turn off full screen mode here
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
context.maybePop();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
restoreEdgeToEdge();
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
@@ -1,104 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage()
class DriftMemoryListPage extends ConsumerStatefulWidget {
const DriftMemoryListPage({super.key});
@override
ConsumerState<DriftMemoryListPage> createState() => _DriftMemoryListPageState();
}
class _DriftMemoryListPageState extends ConsumerState<DriftMemoryListPage> {
bool _onlyFavorites = false;
@override
Widget build(BuildContext context) {
final memories = ref.watch(driftAllMemoriesProvider);
return LayoutBuilder(
builder: (context, constraints) {
return Scaffold(
appBar: AppBar(
title: Text('memories'.tr()),
actions: [
IconButton(
icon: Icon(_onlyFavorites ? Icons.favorite : Icons.favorite_outline),
onPressed: () {
setState(() => _onlyFavorites = !_onlyFavorites);
},
),
],
),
body: SafeArea(
child: memories.when(
data: (memories) {
if (_onlyFavorites) {
memories = memories.where((memory) => memory.isSaved).toList();
}
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: constraints.maxWidth > 600 ? 4 : 2,
childAspectRatio: 0.5625,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
padding: const EdgeInsets.all(16),
itemCount: memories.length,
itemBuilder: (context, index) => GestureDetector(
onTap: () {
if (memories[index].assets.isNotEmpty) {
DriftMemoryPage.setMemory(ref, memories[index]);
}
context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index));
},
child: Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken),
child: AbsorbPointer(
child: Thumbnail.remote(
remoteId: memories[index].assets[0].id,
thumbhash: memories[index].assets[0].thumbHash ?? "",
fit: BoxFit.cover,
),
),
),
),
Positioned(
bottom: 16,
left: 16,
child: Text(
DateFormat.yMMMMd().format(memories[index].memoryAt),
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 15),
),
),
if (memories[index].isSaved)
const Positioned(
top: 16,
right: 16,
child: Icon(Icons.favorite, color: Colors.white, size: 24),
),
],
),
),
);
},
error: (error, stack) => const Text("Error loading memories"),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
},
);
}
}
@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -76,7 +77,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
_pageController.dispose();
_crossfadeController.dispose();
unawaited(WakelockPlus.disable());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
unawaited(restoreEdgeToEdge());
super.dispose();
}
@@ -255,7 +256,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
void _onTapUp() async {
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
await (_showAppBar ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive) : restoreEdgeToEdge());
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@RoutePage()
@@ -128,7 +129,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_reloadSubscription?.cancel();
_stackChildrenKeepAlive?.close();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
unawaited(restoreEdgeToEdge());
super.dispose();
}
@@ -251,10 +252,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
final immersive = !controls || (CurrentPlatform.isIOS && details);
unawaited(immersive ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) : restoreEdgeToEdge());
}
@override
@@ -14,7 +14,7 @@ class DriftMemoryLane extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneProvider = ref.watch(driftMemoryLaneProvider);
final memoryLaneProvider = ref.watch(driftMemoryFutureProvider);
final memories = memoryLaneProvider.value ?? const [];
if (memories.isEmpty) {
return const SizedBox.shrink();
@@ -15,7 +15,7 @@ final driftMemoryServiceProvider = Provider<DriftMemoryService>(
(ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)),
);
final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final driftMemoryFutureProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
if (userId == null || !enabled) {
return const [];
@@ -29,13 +29,3 @@ final driftMemoryLaneProvider = FutureProvider.autoDispose<List<DriftMemory>>((r
final service = ref.watch(driftMemoryServiceProvider);
return service.getMemoryLane(userId);
});
final driftAllMemoriesProvider = FutureProvider.autoDispose<List<DriftMemory>>((ref) {
final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true)));
if (userId == null || !enabled) {
return const [];
}
final service = ref.watch(driftMemoryServiceProvider);
return service.getAllMemories(userId);
});
-2
View File
@@ -52,7 +52,6 @@ import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart';
import 'package:immich_mobile/presentation/pages/drift_map.page.dart';
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/pages/drift_memory_list.page.dart';
import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
@@ -192,7 +191,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftMemoryListRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),
-16
View File
@@ -754,22 +754,6 @@ class DriftMapRouteArgs {
int get hashCode => key.hashCode ^ initialLocation.hashCode;
}
/// generated route for
/// [DriftMemoryListPage]
class DriftMemoryListRoute extends PageRouteInfo<void> {
const DriftMemoryListRoute({List<PageRouteInfo>? children})
: super(DriftMemoryListRoute.name, initialChildren: children);
static const String name = 'DriftMemoryListRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftMemoryListPage();
},
);
}
/// generated route for
/// [DriftMemoryPage]
class DriftMemoryRoute extends PageRouteInfo<DriftMemoryRouteArgs> {
+14
View File
@@ -0,0 +1,14 @@
import 'dart:async';
import 'package:flutter/services.dart';
/// Restore the system bars and return to edge-to-edge layout.
///
/// On Android 15+/API 36 edge-to-edge is enforced, so calling
/// setEnabledSystemUIMode(edgeToEdge) does NOT re-show bars that an immersive
/// mode (immersive / immersiveSticky) previously hid. Explicitly request all
/// overlays first, then return to edge-to-edge layout.
Future<void> restoreEdgeToEdge() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
@@ -48,6 +48,14 @@ class NotificationSetting extends HookConsumerWidget {
showPermissionsDialog();
}
}),
)
else
SettingsButtonListTile(
icon: Icons.notifications_active_outlined,
title: 'notification_enabled_list_tile_title'.tr(),
subtileText: 'notification_enabled_list_tile_content'.tr(),
buttonText: 'notification_enabled_list_tile_open_button'.tr(),
onButtonTap: () => openAppSettings(),
),
];
-1
View File
@@ -482,7 +482,6 @@ 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)
-1
View File
@@ -203,7 +203,6 @@ 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';
+5 -20
View File
@@ -265,9 +265,6 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
@@ -295,9 +292,6 @@ 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));
}
@@ -337,9 +331,6 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
@@ -443,9 +434,6 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
@@ -473,9 +461,6 @@ 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));
}
@@ -515,9 +500,6 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
@@ -531,8 +513,11 @@ 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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemorySearchResponseDto',) as MemorySearchResponseDto;
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<MemoryResponseDto>') as List)
.cast<MemoryResponseDto>()
.toList(growable: false);
}
return null;
}
-2
View File
@@ -451,8 +451,6 @@ 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':
-120
View File
@@ -1,120 +0,0 @@
//
// 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',
};
}
+4 -49
View File
@@ -7171,17 +7171,6 @@
"$ref": "#/components/schemas/MemorySearchOrder"
}
},
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"type": "integer"
}
},
{
"name": "size",
"required": false,
@@ -7207,7 +7196,10 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemorySearchResponseDto"
"items": {
"$ref": "#/components/schemas/MemoryResponseDto"
},
"type": "array"
}
}
},
@@ -7348,17 +7340,6 @@
"$ref": "#/components/schemas/MemorySearchOrder"
}
},
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"type": "integer"
}
},
{
"name": "size",
"required": false,
@@ -19974,32 +19955,6 @@
],
"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": {
+45 -1
View File
@@ -5,6 +5,7 @@
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasmPath": "dist/plugin.wasm",
"allowedHosts": ["*"],
"templates": [
{
"name": "screenshots-smart-album",
@@ -222,7 +223,16 @@
"name": "assetLock",
"title": "Move to locked folder",
"description": "Change visibility to locked",
"types": ["AssetV1"]
"types": ["AssetV1"],
"schema": {
"properties": {
"inverse": {
"title": "Inverse",
"description": "When true will unarchive any archived assets",
"type": "boolean"
}
}
}
},
{
"name": "assetTimeline",
@@ -291,6 +301,40 @@
"required": ["albumIds"]
}
},
{
"name": "webhook",
"title": "Trigger Webhook",
"description": "POST/PUT event data to any URL",
"types": ["AssetV1"],
"hostFunctions": true,
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "URL",
"description": "Event data will be PUT/POSTed to this URL as a JSON object"
},
"headerName": {
"type": "string",
"title": "Header name",
"description": "The name of an additional header to include with the request (e.g. authentication)"
},
"headerValue": {
"type": "string",
"title": "Header value",
"description": "The value of the additional header"
},
"method": {
"type": "string",
"title": "Method",
"description": "The HTTP method to use in the request",
"enum": ["POST", "PUT"]
}
},
"required": ["url"]
}
},
{
"name": "noop1",
"title": "DEV: Nested properties",
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "tsc --noEmit && node esbuild.js",
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
+2 -1
View File
@@ -22,6 +22,7 @@ declare module 'main' {
export function assetArchive(): I32;
export function assetLock(): I32;
export function assetTimeline(): I32;
export function assetTrash(): I32;
// export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
export function webhook(): I32;
}
+37 -27
View File
@@ -1,13 +1,11 @@
import { wrapper } from '@immich/plugin-sdk';
import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type manifestType from '../dist/manifest';
const wrapper = getWrapper<manifestType>();
type AssetFileFilterConfig = {
pattern: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
caseSensitive?: boolean;
};
export const assetFileFilter = () => {
return wrapper<WorkflowType.AssetV1, AssetFileFilterConfig>(({ data, config }) => {
return wrapper<'assetFileFilter'>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data;
@@ -43,7 +41,7 @@ export const assetFileFilter = () => {
};
export const assetMissingTimeZoneFilter = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
@@ -51,13 +49,7 @@ export const assetMissingTimeZoneFilter = () => {
};
export const assetLocationFilter = () => {
return wrapper<
WorkflowType.AssetV1,
{
region?: { country?: string; state?: string; city?: string };
coordinate?: { latitude?: string; longitude?: string; radius?: number };
}
>(({ config, data }) => {
return wrapper<'assetLocationFilter'>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
@@ -96,13 +88,13 @@ export const assetLocationFilter = () => {
};
export const assetTypeFilter = () => {
return wrapper<WorkflowType.AssetV1, { allowedTypes: AssetTypeEnum[] }>(({ config, data }) => {
return wrapper<'assetTypeFilter'>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
};
export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetFavorite'>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
@@ -115,13 +107,13 @@ export const assetFavorite = () => {
};
export const assetVisibility = () => {
return wrapper<WorkflowType.AssetV1, { visibility: AssetVisibility }>(({ config }) => ({
changes: { asset: { visibility: config.visibility } },
return wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
}));
};
export const assetArchive = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetArchive'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
@@ -135,7 +127,7 @@ export const assetArchive = () => {
};
export const assetLock = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
return wrapper<'assetLock'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
@@ -148,13 +140,13 @@ export const assetLock = () => {
});
};
export const assetTrash = () => {
// TODO use trash/untrash host functions
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
// export const assetTrash = () => {
// // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// };
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
@@ -181,3 +173,21 @@ export const assetAddToAlbums = () => {
return {};
});
};
export const webhook = () => {
return wrapper<'webhook'>(({ config, data }) => {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (config.headerName && config.headerValue) {
headers.set(config.headerName, config.headerValue);
}
fetch(config.url, {
method: config.method ?? 'POST',
body: JSON.stringify(data),
headers,
});
return {};
});
};
+1 -1
View File
@@ -4,7 +4,7 @@
"declaration": true,
"emitDeclarationOnly": true,
"esModuleInterop": true, // Enables compatibility with Babel-style module imports
"lib": ["es2020"], // Specify a list of library files to be included in the compilation
"lib": ["es2020", "DOM"], // Specify a list of library files to be included in the compilation
"module": "nodenext", // Specify module code generation
"moduleResolution": "nodenext",
"noEmit": true, // Do not emit outputs (no .js or .d.ts files)
+95 -44
View File
@@ -1,53 +1,104 @@
import type { WorkflowType } from '@immich/sdk';
import { hostFunctions } from 'src/host-functions.js';
import type {
ConfigValue,
WorkflowEventPayload,
WorkflowResponse,
WorkflowStepConfig,
} from 'src/types.js';
export const wrapper = <
T extends WorkflowType,
TConfig extends ConfigValue = ConfigValue,
>(
fn: (
payload: WorkflowEventPayload<T, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<T> | undefined,
) => {
const input = Host.inputString();
try {
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
type Property = {
type: 'string' | 'boolean' | 'number';
array?: boolean;
enum?: string[];
} & {
type: 'object';
properties: { [K: string]: Property };
required?: string[];
};
type RequiredProperties<
Properties extends { [K: string]: unknown },
Required extends string[] | undefined,
RequiredKeys extends string = Required extends undefined
? never
: NonNullable<Required>[number],
> = {
properties: Pick<Properties, RequiredKeys> &
Partial<Omit<Properties, RequiredKeys>>;
};
type GetConfigType<T extends Property> = 'enum' extends keyof T
? NonNullable<T['enum']>[number]
: T['type'] extends 'boolean'
? boolean
: T['type'] extends 'number'
? number
: T['type'] extends 'string'
? string
: T['type'] extends 'object'
? ConfigValue<T>
: never;
type ConfigValue<
T extends { properties: { [K: string]: Property }; required?: string[] },
Properties extends { [K: string]: Property } = T['properties'],
> = T extends never
? never
: RequiredProperties<
{
[K in keyof Properties]: Properties[K]['array'] extends true
? Array<GetConfigType<Properties[K]>>
: GetConfigType<Properties[K]>;
},
'required' extends keyof T ? T['required'] : undefined
>['properties'];
export const getWrapper =
<T extends Record<string, any>>() =>
<
K extends T['methods'][number]['name'],
L extends WorkflowType = (T['methods'][number] & {
name: K;
})['types'][number],
TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>,
>(
fn: (
payload: WorkflowEventPayload<L, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<L> | undefined,
) => {
const input = Host.inputString();
try {
const payload = JSON.parse(input) as WorkflowEventPayload<K, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
};
+3 -14
View File
@@ -1350,13 +1350,6 @@ 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[];
@@ -4977,24 +4970,22 @@ export function reverseGeocode({ lat, lon }: {
/**
* Retrieve memories
*/
export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $type }: {
export function searchMemories({ $for, isSaved, isTrashed, order, 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: MemorySearchResponseDto;
data: MemoryResponseDto[];
}>(`/memories${QS.query(QS.explode({
"for": $for,
isSaved,
isTrashed,
order,
page,
size,
"type": $type
}))}`, {
@@ -5019,12 +5010,11 @@ export function createMemory({ memoryCreateDto }: {
/**
* Retrieve memories statistics
*/
export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size, $type }: {
export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: {
$for?: string;
isSaved?: boolean;
isTrashed?: boolean;
order?: MemorySearchOrder;
page?: number;
size?: number;
$type?: MemoryType;
}, opts?: Oazapfts.RequestOpts) {
@@ -5036,7 +5026,6 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size
isSaved,
isTrashed,
order,
page,
size,
"type": $type
}))}`, {
+1 -2
View File
@@ -7,7 +7,6 @@ import {
MemoryCreateDto,
MemoryResponseDto,
MemorySearchDto,
MemorySearchResponseDto,
MemoryStatisticsResponseDto,
MemoryUpdateDto,
} from 'src/dtos/memory.dto';
@@ -29,7 +28,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<MemorySearchResponseDto> {
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryResponseDto[]> {
return this.service.search(auth, dto);
}
-10
View File
@@ -14,7 +14,6 @@ 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' });
@@ -76,20 +75,11 @@ 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 {
+1
View File
@@ -58,6 +58,7 @@ const PluginManifestSchema = z
wasmPath: z.string().min(1).describe('WASM file path'),
author: z.string().min(1).describe('Plugin author'),
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
allowedHosts: z.array(z.string()).optional().default([]).describe('Hostnames the plugin can access'),
templates: z
.array(PluginManifestTemplateSchema)
.optional()
+5
View File
@@ -6,6 +6,7 @@ select
"plugin"."name",
"plugin"."version",
"plugin"."wasmBytes",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -36,6 +37,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -72,6 +74,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -108,6 +111,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -144,6 +148,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
+3 -7
View File
@@ -9,7 +9,6 @@ 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 {
@@ -58,8 +57,8 @@ export class MemoryRepository implements IBulkAsset {
{ params: [DummyValue.UUID, {}] },
{ name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] },
)
async search(ownerId: string, dto: MemorySearchDto) {
const items = await this.searchBuilder(ownerId, dto)
search(ownerId: string, dto: MemorySearchDto) {
return this.searchBuilder(ownerId, dto)
.select((eb) =>
jsonArrayFrom(
eb
@@ -90,11 +89,8 @@ 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! + 1))
.$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!))
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size!))
.execute();
return paginationHelper(items, dto.size ?? items.length);
}
@GenerateSql({ params: [DummyValue.UUID] })
+7 -1
View File
@@ -20,6 +20,7 @@ export type PluginHostFunction = (callContext: CallContext, input: bigint) => Pr
export type PluginLoadOptions = {
runInWorker?: boolean;
functions?: Record<string, PluginHostFunction>;
allowedHosts?: string[];
};
export type PluginMethodSearchResponse = {
@@ -60,6 +61,7 @@ export class PluginRepository {
'plugin.name',
'plugin.version',
'plugin.wasmBytes',
'plugin.allowedHosts',
jsonArrayFrom(
eb
.selectFrom('plugin_method')
@@ -82,6 +84,7 @@ export class PluginRepository {
'plugin.createdAt',
'plugin.updatedAt',
'plugin.templates',
'plugin.allowedHosts',
jsonArrayFrom(
eb
.selectFrom('plugin_method')
@@ -159,6 +162,7 @@ export class PluginRepository {
wasmBytes: eb.ref('excluded.wasmBytes'),
templates: eb.ref('excluded.templates'),
sha256hash: eb.ref('excluded.sha256hash'),
allowedHosts: eb.ref('excluded.allowedHosts'),
})),
)
.returning(['id', 'name'])
@@ -202,7 +206,7 @@ export class PluginRepository {
});
}
async load({ key, label, wasmBytes }: PluginLoad, { runInWorker, functions }: PluginLoadOptions) {
async load({ key, label, wasmBytes }: PluginLoad, { runInWorker, functions, allowedHosts }: PluginLoadOptions) {
const data = new Uint8Array(wasmBytes.buffer, wasmBytes.byteOffset, wasmBytes.byteLength);
const logger = LoggingRepository.create(`Plugin:${label}`);
const pool = createPool<ExtismPlugin>(
@@ -216,6 +220,7 @@ export class PluginRepository {
functions: {
'extism:host/user': functions ?? {},
},
allowedHosts,
logger: {
trace: (message) => logger.verbose(message),
info: (message) => logger.log(message),
@@ -224,6 +229,7 @@ export class PluginRepository {
error: (message) => logger.error(message),
} as Console,
logLevel: asExtismLogLevel(logger.getLogLevel()),
enableWasiOutput: true,
},
),
destroy: (plugin) => plugin.close(),
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin" ADD "allowedHosts" character varying[] NOT NULL DEFAULT '{}';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin" DROP COLUMN "allowedHosts";`.execute(db);
}
+3
View File
@@ -43,6 +43,9 @@ export class PluginTable {
@Column({ type: 'bytea' })
sha256hash!: Buffer;
@Column({ type: 'character varying', default: [], array: true })
allowedHosts!: Generated<string[]>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
+6 -13
View File
@@ -34,28 +34,21 @@ 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({
items: [getForMemory(memory1), getForMemory(memory2)],
hasNextPage: false,
});
mocks.memory.statistics.mockResolvedValue({ total: 2 });
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toMatchObject({
items: expect.arrayContaining([
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
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({ items: [], hasNextPage: false });
mocks.memory.statistics.mockResolvedValue({ total: 0 });
await expect(sut.search(factory.auth(), {})).resolves.toMatchObject({ items: [], hasNextPage: false, total: 0 });
mocks.memory.search.mockResolvedValue([]);
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
});
});
+4 -8
View File
@@ -71,14 +71,10 @@ export class MemoryService extends BaseService {
}
async search(auth: AuthDto, dto: MemorySearchDto) {
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,
};
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));
}
statistics(auth: AuthDto, dto: MemorySearchDto) {
@@ -90,7 +90,7 @@ export class WorkflowExecutionService extends BaseService {
};
const plugins = await this.pluginRepository.getForLoad();
for (const { id, name, version, wasmBytes, methods } of plugins) {
for (const { id, name, version, wasmBytes, methods, allowedHosts } of plugins) {
const method = methods.some(({ hostFunctions }) => !hostFunctions);
if (method) {
const label = `${name}@${version}`;
@@ -108,7 +108,7 @@ export class WorkflowExecutionService extends BaseService {
const label = `${name}@${version}/worker`;
const key = this.getPluginKey({ id, hostFunctions: true });
try {
await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: true, functions });
await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: true, functions, allowedHosts });
this.logger.log(`Loaded plugin with host functions: ${label}`);
} catch (error) {
this.logger.error(`Unable to load plugin with host functions ${label} (${id})`, error);
@@ -214,6 +214,7 @@ export class WorkflowExecutionService extends BaseService {
author: manifest.author,
version: manifest.version,
templates: manifest.templates,
allowedHosts: manifest.allowedHosts,
wasmBytes,
sha256hash,
},
@@ -133,8 +133,8 @@ describe(MemoryService.name, () => {
await sut.onMemoriesCreate();
const memories = await memoryRepo.search(user.id, {});
expect(memories.items.length).toBe(1);
expect(memories.items[0]).toEqual(
expect(memories.length).toBe(1);
expect(memories[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.items.length).toBe(1);
expect(memories.items[0]).toEqual(
expect(memories.length).toBe(1);
expect(memories[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.items.length).toBe(1);
expect(memories.length).toBe(1);
await sut.onMemoriesCreate();
const memoriesAfter = await memoryRepo.search(user.id, {});
expect(memoriesAfter.items.length).toBe(1);
expect(memoriesAfter.length).toBe(1);
});
});
+8 -77
View File
@@ -1,16 +1,9 @@
import {
deleteMemory,
type MemoryResponseDto,
removeMemoryAssets,
searchMemories,
updateMemory,
MemorySearchOrder,
MemoryType,
} from '@immich/sdk';
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } 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 = {
@@ -27,31 +20,10 @@ 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 | undefined;
constructor() {
this.#filters = undefined;
this.#hasNextPage = true;
this.#page = 1;
this.#total = $state(undefined);
eventManager.on({
AuthLogout: () => this.clearCache(),
AuthUserLoaded: () => this.initialize(),
@@ -65,16 +37,6 @@ class MemoryManager {
this.scheduleHourlyRefresh();
}
get filters() {
return this.#filters;
}
set filters(filters) {
this.#filters = filters;
this.clearCache();
void this.loadNextPage();
}
ready() {
return this.initialize();
}
@@ -155,47 +117,22 @@ 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.#total = undefined;
this.memories = [];
}
private initialize() {
if (!this.#loading) {
this.#loading = this.load(this.#page++);
this.#loading = this.load();
}
return this.#loading;
}
private async load(page: number) {
if (this.#filters !== undefined) {
const { items, hasNextPage, total } = await searchMemories({ size: PAGE_SIZE, ...this.#filters, page });
this.memories.push(...items);
this.#hasNextPage = hasNextPage;
this.#total = total;
}
private async load() {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
this.memories = memories.filter((memory) => memory.assets.length > 0);
}
private scheduleHourlyRefresh() {
@@ -209,18 +146,12 @@ class MemoryManager {
const initialDelay = nextEvent.diff(now).as('milliseconds');
setTimeout(() => {
if (this.#page <= 2) {
this.clearCache();
this.loadNextPage();
}
this.#loading = this.load();
// Schedule subsequent events hourly
setInterval(
() => {
if (this.#page <= 2) {
this.clearCache();
this.loadNextPage();
}
this.#loading = this.load();
},
60 * 60 * 1000,
);
+3 -8
View File
@@ -20,13 +20,12 @@ 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, locale } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { convertBCP47, langs } from '$lib/utils/i18n';
@@ -367,13 +366,9 @@ 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) {
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('years_ago', { values: { years: now.getFullYear() - memory.data.year } });
}
return $t('unknown');
+6 -29
View File
@@ -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, memoryLaneTitle } from '$lib/utils';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Icon, ImageCarousel } from '@immich/ui';
import { Icon } from '@immich/ui';
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -28,22 +28,13 @@
return targetField?.items || [];
};
let places = $derived(getFieldItems(data.explore, 'exifInfo.city'));
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
let recents = $derived(
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 }),
})),
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
);
let people = $state(data.response.people);
let hasPeople = $derived(data.people.total > 0);
let hasPeople = $derived(data.response.total > 0);
const onPersonThumbnailReady = ({ id }: { id: string }) => {
for (const person of people) {
@@ -133,20 +124,6 @@
</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">
+4 -12
View File
@@ -1,24 +1,16 @@
import { getAllPeople, getExploreData, MemorySearchOrder } from '@immich/sdk';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { getAllPeople, getExploreData } from '@immich/sdk';
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);
memoryManager.filters = { size: 12, order: MemorySearchOrder.Desc };
const [explore, people] = await Promise.all([
getExploreData(),
getAllPeople({ withHidden: false }),
memoryManager.ready(),
]);
const [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]);
const $t = await getFormatter();
return {
explore,
people,
memories: memoryManager.memories,
items,
response,
meta: {
title: $t('explore'),
},
@@ -1,127 +1,5 @@
<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, LoadingSpinner } 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';
<script>
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);
}
});
const rotation = () => {
const classes = [
'rotate-[-2.5deg]',
'-rotate-2',
'rotate-[-1.5deg]',
'-rotate-1',
'rotate-[-0.5deg]',
'rotate-0',
'rotate-[0.5deg]',
'rotate-1',
'rotate-[1.5deg]',
'rotate-2',
'rotate-[2.5deg]',
];
return classes[Math.round(Math.random() * classes.length)];
};
</script>
{#if page.url.searchParams.has(QueryParameter.ID)}
<MemoryViewer />
{:else}
<UserPageLayout
title={data.meta.title}
description={memoryManager.total === undefined ? undefined : `(${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-3 gap-7 lg:grid-cols-4 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={`relative rounded-md bg-gray-50 p-2 pb-0 shadow-md transition-all hover:scale-102 hover:rotate-0 hover:shadow-lg sm:p-5 sm:pb-0 dark:bg-gray-800 ${rotation()}`}
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="32" class="text-red-400" />
</div>
{/if}
<img
src={getAssetMediaUrl({ id: memory.assets[0].id })}
alt={$getAltText(toTimelineAsset(memory.assets[0]))}
class="aspect-square object-cover brightness-75"
loading="lazy"
/>
<p class="my-2 text-center text-sm font-medium text-ellipsis capitalize hover:cursor-pointer sm:my-5">
{$memoryLaneTitle(memory)}
</p>
</a>
{/each}
</div>
{:else if memoryManager.total === undefined}
<div class="flex items-center justify-center py-16">
<LoadingSpinner size="giant" />
</div>
{/if}
</UserPageLayout>
{/if}
<MemoryViewer />
@@ -1,6 +1,3 @@
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';
@@ -9,19 +6,10 @@ 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('memories'),
title: $t('memory'),
},
};
}) satisfies PageLoad;
@@ -82,7 +82,6 @@
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) {
@@ -107,7 +106,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(previousPage);
const handleEscape = async () => goto(Route.photos());
const handleSelectAll = () =>
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
@@ -250,7 +249,7 @@
const init = (target: Page | NavigationTarget | null) => {
if (memoryManager.memories.length === 0) {
return handlePromiseError(goto(previousPage));
return handlePromiseError(goto(Route.photos()));
}
current = loadFromParams(target);
@@ -282,10 +281,6 @@
};
afterNavigate(({ from, to }) => {
if (from?.url !== null && !from?.url.searchParams.has(QueryParameter.ID)) {
previousPage = from!.url.toString();
}
memoryManager.ready().then(
() => {
let target;
@@ -386,7 +381,7 @@
icon={mdiClose}
aria-label={$t('close')}
size="large"
onclick={() => goto(previousPage)}
onclick={() => goto(Route.photos())}
/>
<p class="text-lg">
{$memoryLaneTitle(current.memory)}
@@ -33,14 +33,12 @@
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;
@@ -92,10 +90,6 @@
src: getAssetMediaUrl({ id: memory.assets[0].id }),
})),
);
if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) {
memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) };
}
</script>
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} scrollbar={false}>