mirror of
https://github.com/immich-app/immich.git
synced 2026-07-01 18:45:05 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa6218ff1b | |||
| 9cf3ef98aa | |||
| 29949bebe4 | |||
| d85e599ad9 | |||
| b16cc496b2 | |||
| 953ef5c047 | |||
| a876d4a9f1 | |||
| 688241a462 | |||
| cb1af3a8ec | |||
| 49a821b0d0 | |||
| 3a7034d25e |
@@ -716,7 +716,7 @@ jobs:
|
|||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
|
run: pnpm --filter immich install --frozen-lockfile
|
||||||
- name: Run API generation
|
- name: Run API generation
|
||||||
run: mise //:open-api
|
run: mise //:open-api
|
||||||
working-directory: open-api
|
working-directory: open-api
|
||||||
@@ -774,7 +774,7 @@ jobs:
|
|||||||
github_token: ${{ steps.token.outputs.token }}
|
github_token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Install server dependencies
|
- name: Install server dependencies
|
||||||
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Build plugins
|
- name: Build plugins
|
||||||
run: mise //:plugins
|
run: mise //:plugins
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ Under Email, enter the required details to connect with an SMTP server.
|
|||||||
|
|
||||||
You can use [this guide](/guides/smtp-gmail) to use Gmail's SMTP server.
|
You can use [this guide](/guides/smtp-gmail) to use Gmail's SMTP server.
|
||||||
|
|
||||||
|
You can use [this guide](/guides/smtp-microsoft365) to use Microsoft's SMTP server.
|
||||||
|
|
||||||
## User's notifications settings
|
## User's notifications settings
|
||||||
|
|
||||||
Users can manage their email notification settings from their account settings page on the web. They can choose to turn email notifications on or off for the following events:
|
Users can manage their email notification settings from their account settings page on the web. They can choose to turn email notifications on or off for the following events:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
# SMTP settings using Microsoft 365
|
||||||
|
|
||||||
|
This guide walks you through how to get the information you need to set up your Immich instance to send emails using Microsoft's SMTP server.
|
||||||
|
|
||||||
|
## Create an app password
|
||||||
|
|
||||||
|
You will need to generate an app password to use your Microsoft email in Immich. Depending on if you have a personal or business account, you can use https://go.microsoft.com/fwlink/?linkid=2274139 or https://myaccount.microsoft.com/securtiy-info respectively.
|
||||||
|
|
||||||
|
## Entering the SMTP credential in Immich
|
||||||
|
|
||||||
|
Entering your credential in Immich's email notification settings at `Administration -> Settings -> Notification Settings`
|
||||||
|
|
||||||
|
Host: smtp-mail.outlook.com
|
||||||
|
Port: 587
|
||||||
|
username: your mail address
|
||||||
|
Password: app password you created earlier
|
||||||
|
SMTPS: set it to disabled
|
||||||
|
|
||||||
|
<img src={require('./img/email-ms-settings.webp').default} width="80%" title="SMTP settings" />
|
||||||
+1
-1
@@ -48,7 +48,7 @@
|
|||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-organize-imports": "^4.0.0",
|
"prettier-plugin-organize-imports": "^4.0.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.35.2",
|
||||||
"socket.io-client": "^4.7.4",
|
"socket.io-client": "^4.7.4",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"typescript": "^6.0.0",
|
"typescript": "^6.0.0",
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ dir = "open-api"
|
|||||||
run = "bash ./bin/generate-dart-sdk.sh"
|
run = "bash ./bin/generate-dart-sdk.sh"
|
||||||
|
|
||||||
[tasks.open-api]
|
[tasks.open-api]
|
||||||
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
|
|
||||||
run = [
|
run = [
|
||||||
{ task = "//:plugins" },
|
{ task = "//:plugins" },
|
||||||
{ task = "//server:install" },
|
{ task = "//server:install" },
|
||||||
|
|||||||
@@ -3,33 +3,35 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
|
|
||||||
class AssetService {
|
class AssetService {
|
||||||
final RemoteAssetRepository _remoteAssetRepository;
|
final RemoteAssetRepository _remoteRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localRepository;
|
||||||
|
final AssetApiRepository _apiRepository;
|
||||||
|
|
||||||
const AssetService({required this._remoteAssetRepository, required this._localAssetRepository});
|
const AssetService({required this._remoteRepository, required this._localRepository, required this._apiRepository});
|
||||||
|
|
||||||
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
Future<BaseAsset?> getAsset(BaseAsset asset) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||||
return asset is LocalAsset ? _localAssetRepository.get(id) : _remoteAssetRepository.get(id);
|
return asset is LocalAsset ? _localRepository.get(id) : _remoteRepository.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||||
return asset is LocalAsset ? _localAssetRepository.watch(id) : _remoteAssetRepository.watch(id);
|
return asset is LocalAsset ? _localRepository.watch(id) : _remoteRepository.watch(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<LocalAsset?>> getLocalAssetsByChecksum(String checksum) {
|
Future<List<LocalAsset?>> getLocalAssetsByChecksum(String checksum) {
|
||||||
return _localAssetRepository.getByChecksum(checksum);
|
return _localRepository.getByChecksum(checksum);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
|
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
|
||||||
return _remoteAssetRepository.getByChecksum(checksum);
|
return _remoteRepository.getByChecksum(checksum);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<RemoteAsset?> getRemoteAsset(String id) {
|
Future<RemoteAsset?> getRemoteAsset(String id) {
|
||||||
return _remoteAssetRepository.get(id);
|
return _remoteRepository.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
|
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
|
||||||
@@ -37,7 +39,7 @@ class AssetService {
|
|||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
final stack = await _remoteAssetRepository.getStackChildren(asset);
|
final stack = await _remoteRepository.getStackChildren(asset);
|
||||||
// Include the primary asset in the stack as the first item
|
// Include the primary asset in the stack as the first item
|
||||||
return [asset, ...stack];
|
return [asset, ...stack];
|
||||||
}
|
}
|
||||||
@@ -48,22 +50,31 @@ class AssetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
|
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
|
||||||
return _remoteAssetRepository.getExif(id);
|
return _remoteRepository.getExif(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<(String, String)>> getPlaces(String userId) {
|
Future<List<(String, String)>> getPlaces(String userId) {
|
||||||
return _remoteAssetRepository.getPlaces(userId);
|
return _remoteRepository.getPlaces(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<(int local, int remote)> getAssetCounts() async {
|
Future<(int local, int remote)> getAssetCounts() async {
|
||||||
return (await _localAssetRepository.getCount(), await _remoteAssetRepository.getCount());
|
return (await _localRepository.getCount(), await _remoteRepository.getCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> getLocalHashedCount() {
|
Future<int> getLocalHashedCount() {
|
||||||
return _localAssetRepository.getHashedCount();
|
return _localRepository.getHashedCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
|
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
|
||||||
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
|
return _localRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateFavorite(List<String> remoteIds, bool isFavorite) async {
|
||||||
|
if (remoteIds.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _apiRepository.updateFavorite(remoteIds, isFavorite);
|
||||||
|
await _remoteRepository.updateFavorite(remoteIds, isFavorite);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
|
|
||||||
class ActionScope {
|
class ActionScope {
|
||||||
@@ -21,3 +22,11 @@ abstract class BaseAction {
|
|||||||
|
|
||||||
Future<void> onAction(ActionScope scope);
|
Future<void> onAction(ActionScope scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract class AssetAction<T extends BaseAsset> extends BaseAction {
|
||||||
|
final Iterable<BaseAsset> assets;
|
||||||
|
|
||||||
|
const AssetAction({required this.assets});
|
||||||
|
|
||||||
|
Iterable<T> filter(ActionScope scope) => assets.whereType<T>();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
|
class AssetDebugAction extends AssetAction<BaseAsset> {
|
||||||
|
const AssetDebugAction({required super.assets});
|
||||||
|
|
||||||
|
@override
|
||||||
|
IconData get icon => Icons.help_outline_rounded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String label(ActionScope scope) => scope.context.t.troubleshoot;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isVisible(ActionScope scope) =>
|
||||||
|
assets.length == 1 && scope.ref.watch(settingsProvider.notifier).get(.advancedTroubleshooting);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction(ActionScope scope) async =>
|
||||||
|
unawaited(scope.context.pushRoute(AssetTroubleshootRoute(asset: assets.first)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
|
class FavoriteAction extends AssetAction<RemoteAsset> {
|
||||||
|
final bool shouldFavorite;
|
||||||
|
|
||||||
|
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
|
||||||
|
|
||||||
|
@override
|
||||||
|
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Iterable<RemoteAsset> filter(ActionScope scope) => assets
|
||||||
|
.where(
|
||||||
|
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
|
||||||
|
)
|
||||||
|
.cast<RemoteAsset>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction(ActionScope scope) async {
|
||||||
|
final ActionScope(:ref) = scope;
|
||||||
|
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
||||||
|
|
||||||
|
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
|
||||||
|
final message = shouldFavorite
|
||||||
|
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
|
||||||
|
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
|
||||||
|
snackbar.success(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
|
||||||
|
class TimelineAction extends BaseAction {
|
||||||
|
final BaseAction action;
|
||||||
|
|
||||||
|
const TimelineAction({required this.action});
|
||||||
|
|
||||||
|
@override
|
||||||
|
IconData get icon => action.icon;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String label(ActionScope scope) => action.label(scope);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isVisible(ActionScope scope) => action.isVisible(scope);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction(ActionScope scope) async {
|
||||||
|
await action.onAction(scope);
|
||||||
|
scope.ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|
||||||
|
|
||||||
class AdvancedInfoActionButton extends ConsumerWidget {
|
|
||||||
final ActionSource source;
|
|
||||||
final bool iconOnly;
|
|
||||||
final bool menuItem;
|
|
||||||
|
|
||||||
const AdvancedInfoActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
unawaited(ref.read(actionProvider.notifier).troubleshoot(source, context));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return BaseActionButton(
|
|
||||||
maxWidth: 115.0,
|
|
||||||
iconData: Icons.help_outline_rounded,
|
|
||||||
label: "troubleshoot".t(context: context),
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
onPressed: () => _onTap(context, ref),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,7 @@ import 'package:immich_mobile/providers/routes.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
class ViewerKebabMenu extends ConsumerWidget {
|
class ViewerKebabMenu extends ConsumerWidget {
|
||||||
const ViewerKebabMenu({super.key, this.originalTheme});
|
const ViewerKebabMenu({super.key, this.originalTheme});
|
||||||
@@ -49,9 +50,9 @@ class ViewerKebabMenu extends ConsumerWidget {
|
|||||||
timelineOrigin: timelineOrigin,
|
timelineOrigin: timelineOrigin,
|
||||||
);
|
);
|
||||||
|
|
||||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
|
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
|
||||||
|
|
||||||
return MenuAnchor(
|
return ImmichMenu(
|
||||||
consumeOutsideTap: true,
|
consumeOutsideTap: true,
|
||||||
style: MenuStyle(
|
style: MenuStyle(
|
||||||
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||||
@@ -62,7 +63,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
|
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
|
||||||
),
|
),
|
||||||
menuChildren: [
|
children: [
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(minWidth: 150),
|
constraints: const BoxConstraints(minWidth: 150),
|
||||||
child: Theme(
|
child: Theme(
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
@@ -15,9 +14,9 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provid
|
|||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/timezone.dart';
|
import 'package:immich_mobile/utils/timezone.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
const ViewerTopAppBar({super.key});
|
const ViewerTopAppBar({super.key});
|
||||||
@@ -31,8 +30,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
|
|
||||||
final album = ref.watch(currentRemoteAlbumProvider);
|
final album = ref.watch(currentRemoteAlbumProvider);
|
||||||
|
|
||||||
final user = ref.watch(currentUserProvider);
|
|
||||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
|
||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
@@ -46,6 +43,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||||
|
|
||||||
final originalTheme = context.themeData;
|
final originalTheme = context.themeData;
|
||||||
|
final assetForAction = [asset];
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||||
@@ -63,10 +61,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)),
|
||||||
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
|
||||||
if (asset.hasRemote && isOwner && asset.isFavorite)
|
|
||||||
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
|
||||||
|
|
||||||
ViewerKebabMenu(originalTheme: originalTheme),
|
ViewerKebabMenu(originalTheme: originalTheme),
|
||||||
];
|
];
|
||||||
@@ -107,7 +102,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
leading: const _AppBarBackButton(),
|
leading: const _AppBarBackButton(),
|
||||||
middle: showingDetails ? null : _AssetInfoTitle(asset: asset),
|
middle: showingDetails ? null : _AssetInfoTitle(asset: asset),
|
||||||
trailing: !showingDetails && !isReadonlyModeEnabled
|
trailing: !showingDetails && !isReadonlyModeEnabled
|
||||||
? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions)
|
? ImmichColorOverride(
|
||||||
|
color: Colors.white,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: isInLockedView ? lockedViewActions : actions,
|
||||||
|
),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -106,65 +106,57 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
|||||||
borderRadius: const BorderRadius.all(Radius.circular(18.5)),
|
borderRadius: const BorderRadius.all(Radius.circular(18.5)),
|
||||||
color: context.colorScheme.surfaceContainerLow,
|
color: context.colorScheme.surfaceContainerLow,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Padding(
|
||||||
color: context.colorScheme.surfaceContainerLow,
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
|
child: Row(
|
||||||
child: InkWell(
|
children: [
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20.5)),
|
Container(
|
||||||
onTap: () => _onToggle(!_isEnabled),
|
padding: const EdgeInsets.all(8),
|
||||||
child: Padding(
|
decoration: BoxDecoration(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
shape: BoxShape.circle,
|
||||||
child: Row(
|
gradient: LinearGradient(
|
||||||
children: [
|
colors: [
|
||||||
Container(
|
context.primaryColor.withValues(alpha: 0.2),
|
||||||
padding: const EdgeInsets.all(8),
|
context.primaryColor.withValues(alpha: 0.1),
|
||||||
decoration: BoxDecoration(
|
],
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
context.primaryColor.withValues(alpha: 0.2),
|
|
||||||
context.primaryColor.withValues(alpha: 0.1),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: isProcessing
|
|
||||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: Icon(Icons.cloud_upload_outlined, color: context.primaryColor, size: 24),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
),
|
||||||
Expanded(
|
child: isProcessing
|
||||||
child: Column(
|
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
: Icon(Icons.cloud_upload_outlined, color: context.primaryColor, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Flexible(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
child: Text(
|
||||||
children: [
|
"enable_backup".t(context: context),
|
||||||
Flexible(
|
style: context.textTheme.titleMedium?.copyWith(
|
||||||
child: Text(
|
fontWeight: FontWeight.w600,
|
||||||
"enable_backup".t(context: context),
|
color: context.primaryColor,
|
||||||
style: context.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: context.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (errorCount > 0)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 2),
|
|
||||||
child: Text(
|
|
||||||
"upload_error_with_count".t(context: context, args: {'count': '$errorCount'}),
|
|
||||||
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
if (errorCount > 0)
|
||||||
Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)),
|
Padding(
|
||||||
],
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
"upload_error_with_count".t(context: context, args: {'count': '$errorCount'}),
|
||||||
|
style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Switch.adaptive(value: _isEnabled, onChanged: (value) => _onToggle(value)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
@@ -74,6 +76,9 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
|||||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
|
final actions = [FavoriteAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
initialChildSize: 0.25,
|
initialChildSize: 0.25,
|
||||||
@@ -84,7 +89,7 @@ class _ArchiveBottomSheetState extends ConsumerState<ArchiveBottomSheet> {
|
|||||||
if (multiselect.hasRemote) ...[
|
if (multiselect.hasRemote) ...[
|
||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
const UnArchiveActionButton(source: ActionSource.timeline),
|
const UnArchiveActionButton(source: ActionSource.timeline),
|
||||||
const FavoriteActionButton(source: ActionSource.timeline),
|
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import 'package:immich_mobile/constants/enums.dart';
|
|||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||||
@@ -15,7 +18,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
@@ -65,6 +67,9 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
ref.read(multiSelectProvider.notifier).reset();
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
|
final actions = [FavoriteAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
initialChildSize: 0.4,
|
initialChildSize: 0.4,
|
||||||
maxChildSize: 0.7,
|
maxChildSize: 0.7,
|
||||||
@@ -73,7 +78,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
const ShareActionButton(source: ActionSource.timeline),
|
const ShareActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasRemote) ...[
|
if (multiselect.hasRemote) ...[
|
||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
const UnFavoriteActionButton(source: ActionSource.timeline),
|
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
const ArchiveActionButton(source: ActionSource.timeline),
|
const ArchiveActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
||||||
isTrashEnable
|
isTrashEnable
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/bulk_tag_assets_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||||
@@ -24,7 +25,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_
|
|||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
@@ -56,7 +56,6 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final multiselect = ref.watch(multiSelectProvider);
|
final multiselect = ref.watch(multiSelectProvider);
|
||||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
|
|
||||||
final tagsEnabled = ref.watch(
|
final tagsEnabled = ref.watch(
|
||||||
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
|
userMetadataPreferencesProvider.select((value) => value.valueOrNull?.tagsEnabled ?? false),
|
||||||
);
|
);
|
||||||
@@ -84,6 +83,9 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
|
final actions = [AssetDebugAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
initialChildSize: widget.minChildSize ?? 0.15,
|
initialChildSize: widget.minChildSize ?? 0.15,
|
||||||
@@ -91,9 +93,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
maxChildSize: 0.85,
|
maxChildSize: 0.85,
|
||||||
shouldCloseOnMinExtent: false,
|
shouldCloseOnMinExtent: false,
|
||||||
actions: [
|
actions: [
|
||||||
if (multiselect.selectedAssets.length == 1 && advancedTroubleshooting) ...[
|
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
const AdvancedInfoActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
|
||||||
const ShareActionButton(source: ActionSource.timeline),
|
const ShareActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasRemote) ...[
|
if (multiselect.hasRemote) ...[
|
||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_date_time_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_location_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||||
@@ -83,6 +85,9 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final assets = multiselect.selectedAssets.toList(growable: false);
|
||||||
|
final actions = [FavoriteAction(assets: assets)];
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
controller: sheetController,
|
controller: sheetController,
|
||||||
initialChildSize: 0.22,
|
initialChildSize: 0.22,
|
||||||
@@ -96,7 +101,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
|
|
||||||
if (ownsAlbum) ...[
|
if (ownsAlbum) ...[
|
||||||
const ArchiveActionButton(source: ActionSource.timeline),
|
const ArchiveActionButton(source: ActionSource.timeline),
|
||||||
const FavoriteActionButton(source: ActionSource.timeline),
|
...actions.map((action) => ActionColumnButtonWidget(action: TimelineAction(action: action))),
|
||||||
],
|
],
|
||||||
const DownloadActionButton(source: ActionSource.timeline),
|
const DownloadActionButton(source: ActionSource.timeline),
|
||||||
if (ownsAlbum) ...[
|
if (ownsAlbum) ...[
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_asset.repositor
|
|||||||
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
|
|
||||||
final localAssetRepository = Provider<DriftLocalAssetRepository>(
|
final localAssetRepository = Provider<DriftLocalAssetRepository>(
|
||||||
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
|
(ref) => DriftLocalAssetRepository(ref.watch(driftProvider)),
|
||||||
@@ -20,8 +21,9 @@ final trashedLocalAssetRepository = Provider<DriftTrashedLocalAssetRepository>(
|
|||||||
|
|
||||||
final assetServiceProvider = Provider(
|
final assetServiceProvider = Provider(
|
||||||
(ref) => AssetService(
|
(ref) => AssetService(
|
||||||
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
|
remoteRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||||
localAssetRepository: ref.watch(localAssetRepository),
|
localRepository: ref.watch(localAssetRepository),
|
||||||
|
apiRepository: ref.watch(assetApiRepositoryProvider),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||||
@@ -185,18 +185,14 @@ enum ActionButtonType {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
ConsumerWidget buildButton(
|
Widget buildButton(
|
||||||
ActionButtonContext context, [
|
ActionButtonContext context, [
|
||||||
BuildContext? buildContext,
|
BuildContext? buildContext,
|
||||||
bool iconOnly = false,
|
bool iconOnly = false,
|
||||||
bool menuItem = false,
|
bool menuItem = false,
|
||||||
]) {
|
]) {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
ActionButtonType.advancedInfo => AdvancedInfoActionButton(
|
ActionButtonType.advancedInfo => ActionMenuItemWidget(action: AssetDebugAction(assets: [context.asset])),
|
||||||
source: context.source,
|
|
||||||
iconOnly: iconOnly,
|
|
||||||
menuItem: menuItem,
|
|
||||||
),
|
|
||||||
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.shareLink => ShareLinkActionButton(
|
ActionButtonType.shareLink => ShareLinkActionButton(
|
||||||
source: context.source,
|
source: context.source,
|
||||||
@@ -334,7 +330,7 @@ class ActionButtonBuilder {
|
|||||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
|
||||||
final visibleButtons = defaultViewerKebabMenuOrder
|
final visibleButtons = defaultViewerKebabMenuOrder
|
||||||
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
|
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -350,7 +346,7 @@ class ActionButtonBuilder {
|
|||||||
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
|
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
|
||||||
result.add(const Divider(height: 1));
|
result.add(const Divider(height: 1));
|
||||||
}
|
}
|
||||||
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
|
result.add(type.buildButton(context, buildContext, false, true));
|
||||||
lastGroup = type.kebabMenuGroup;
|
lastGroup = type.kebabMenuGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export 'src/color_override.dart';
|
||||||
export 'src/components/close_button.dart';
|
export 'src/components/close_button.dart';
|
||||||
export 'src/components/column_button.dart';
|
export 'src/components/column_button.dart';
|
||||||
export 'src/components/form.dart';
|
export 'src/components/form.dart';
|
||||||
|
|||||||
@@ -217,8 +217,8 @@ class MediumRepositoryContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<AssetFaceEntityData> newFace({String? assetId, String? personId, int? imageWidth, int? imageHeight}) {
|
Future<AssetFaceEntityData> newFace({String? assetId, String? personId, int? imageWidth, int? imageHeight}) {
|
||||||
imageWidth ??= TestUtils.randInt(999) + 1;
|
imageWidth ??= TestUtils.randInt(999) + 2;
|
||||||
imageHeight ??= TestUtils.randInt(999) + 1;
|
imageHeight ??= TestUtils.randInt(999) + 2;
|
||||||
|
|
||||||
final x1 = TestUtils.randInt(imageWidth - 1);
|
final x1 = TestUtils.randInt(imageWidth - 1);
|
||||||
final y1 = TestUtils.randInt(imageHeight - 1);
|
final y1 = TestUtils.randInt(imageHeight - 1);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class RepositoryMocks {
|
|||||||
class ServiceMocks {
|
class ServiceMocks {
|
||||||
final PartnerStub partner = PartnerStub(MockPartnerService());
|
final PartnerStub partner = PartnerStub(MockPartnerService());
|
||||||
final UserStub user = UserStub(MockUserService());
|
final UserStub user = UserStub(MockUserService());
|
||||||
|
final asset = AssetStub(MockAssetService());
|
||||||
|
|
||||||
ServiceMocks() {
|
ServiceMocks() {
|
||||||
resetAll();
|
resetAll();
|
||||||
@@ -43,8 +44,10 @@ class ServiceMocks {
|
|||||||
_registerFallbacks();
|
_registerFallbacks();
|
||||||
partner.reset();
|
partner.reset();
|
||||||
user.reset();
|
user.reset();
|
||||||
|
asset.reset();
|
||||||
_stubUserService();
|
_stubUserService();
|
||||||
_stubPartnerService();
|
_stubPartnerService();
|
||||||
|
_stubAssetService();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stubUserService() {
|
void _stubUserService() {
|
||||||
@@ -63,6 +66,10 @@ class ServiceMocks {
|
|||||||
when(partner.create).thenAnswer((_) async {});
|
when(partner.create).thenAnswer((_) async {});
|
||||||
when(partner.delete).thenAnswer((_) async {});
|
when(partner.delete).thenAnswer((_) async {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _stubAssetService() {
|
||||||
|
when(asset.updateFavorite).thenAnswer((_) async {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _registerFallbacks() {
|
void _registerFallbacks() {
|
||||||
@@ -119,3 +126,8 @@ extension type const UserStub(MockUserService service) implements Stub<MockUserS
|
|||||||
Future<String?> Function() get createProfileImage =>
|
Future<String?> Function() get createProfileImage =>
|
||||||
() => service.createProfileImage(any(), any());
|
() => service.createProfileImage(any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension type const AssetStub(MockAssetService service) implements Stub<MockAssetService> {
|
||||||
|
Future<void> Function() get updateFavorite =>
|
||||||
|
() => service.updateFavorite(any(), any());
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/asset_debug.action.dart';
|
||||||
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
|
import '../../factories/remote_asset_factory.dart';
|
||||||
|
import '../../presentation_context.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
context = await PresentationContext.create();
|
||||||
|
await StoreService.I.put(StoreKey.advancedTroubleshooting, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
context.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AssetDebugAction', () {
|
||||||
|
testWidgets('visible for a single asset when advanced troubleshooting is on', (tester) async {
|
||||||
|
await tester.pumpTestWidget(
|
||||||
|
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
|
||||||
|
overrides: context.overrides,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(ImmichIconButton), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hidden for multiple assets', (tester) async {
|
||||||
|
await tester.pumpTestWidget(
|
||||||
|
ActionIconButtonWidget(
|
||||||
|
action: AssetDebugAction(assets: [RemoteAssetFactory.create(), RemoteAssetFactory.create()]),
|
||||||
|
),
|
||||||
|
overrides: context.overrides,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(ImmichIconButton), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('hidden when advanced troubleshooting is off', (tester) async {
|
||||||
|
await StoreService.I.put(StoreKey.advancedTroubleshooting, false);
|
||||||
|
await tester.pumpTestWidget(
|
||||||
|
ActionIconButtonWidget(action: AssetDebugAction(assets: [RemoteAssetFactory.create()])),
|
||||||
|
overrides: context.overrides,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(ImmichIconButton), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/favorite.action.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../factories/remote_asset_factory.dart';
|
||||||
|
import '../../presentation_context.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
context = await PresentationContext.create();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
context.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Override> overrides() => [
|
||||||
|
...context.overrides,
|
||||||
|
assetServiceProvider.overrideWithValue(context.mocks.asset.service),
|
||||||
|
];
|
||||||
|
|
||||||
|
RemoteAsset owned({bool isFavorite = false}) =>
|
||||||
|
RemoteAssetFactory.create(ownerId: context.currentUser.id, isFavorite: isFavorite);
|
||||||
|
|
||||||
|
group('FavoriteAction', () {
|
||||||
|
testWidgets('favorites the eligible owned assets', (tester) async {
|
||||||
|
final asset = owned();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
|
||||||
|
|
||||||
|
verify(() => context.mocks.asset.service.updateFavorite([asset.id], true)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('unfavorite the eligible owned assets', (tester) async {
|
||||||
|
final asset = owned(isFavorite: true);
|
||||||
|
|
||||||
|
await tester.pumpTestAction(FavoriteAction(assets: [asset]), overrides: overrides());
|
||||||
|
|
||||||
|
verify(() => context.mocks.asset.service.updateFavorite([asset.id], false)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ignores assets owned by someone else', (tester) async {
|
||||||
|
final mine = owned();
|
||||||
|
final theirs = RemoteAssetFactory.create();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(FavoriteAction(assets: [mine, theirs]), overrides: overrides());
|
||||||
|
|
||||||
|
verify(() => context.mocks.asset.service.updateFavorite([mine.id], true)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('batches every eligible owned asset into a single call', (tester) async {
|
||||||
|
final first = owned();
|
||||||
|
final second = owned();
|
||||||
|
|
||||||
|
await tester.pumpTestAction(FavoriteAction(assets: [first, second]), overrides: overrides());
|
||||||
|
|
||||||
|
verify(() => context.mocks.asset.service.updateFavorite([first.id, second.id], true)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('skips owned assets already in the target state', (tester) async {
|
||||||
|
final stale = owned();
|
||||||
|
final alreadyFavorite = owned(isFavorite: true);
|
||||||
|
|
||||||
|
await tester.pumpTestAction(FavoriteAction(assets: [stale, alreadyFavorite]), overrides: overrides());
|
||||||
|
|
||||||
|
verify(() => context.mocks.asset.service.updateFavorite([stale.id], true)).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows a confirmation snackbar on success', (tester) async {
|
||||||
|
await tester.pumpTestAction(FavoriteAction(assets: [owned()]), overrides: overrides());
|
||||||
|
await tester.pumpUntilFound(find.byType(SnackBar));
|
||||||
|
|
||||||
|
expect(find.byType(SnackBar), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,32 +5,25 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
import 'package:immich_mobile/presentation/actions/partner.action.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
import '../../factories/user_factory.dart';
|
import '../../factories/user_factory.dart';
|
||||||
import '../../mocks.dart';
|
|
||||||
import '../../presentation_context.dart';
|
import '../../presentation_context.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late PresentationContext context;
|
late PresentationContext context;
|
||||||
late UserDto currentUser;
|
|
||||||
final mocks = ServiceMocks();
|
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
currentUser = UserFactory.createDto();
|
|
||||||
context = await PresentationContext.create();
|
context = await PresentationContext.create();
|
||||||
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() {
|
||||||
mocks.resetAll();
|
context.dispose();
|
||||||
await context.dispose();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
List<Override> overrides({List<User> candidates = const []}) => [
|
List<Override> overrides({List<User> candidates = const []}) => [
|
||||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(mocks.user.service)),
|
...context.overrides,
|
||||||
partnerServiceProvider.overrideWithValue(mocks.partner.service),
|
partnerServiceProvider.overrideWithValue(context.mocks.partner.service),
|
||||||
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
candidatesStateProvider.overrideWith((ref) => Stream<Iterable<User>>.value(candidates)),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -43,7 +36,9 @@ void main() {
|
|||||||
await tester.tap(find.text(candidate.name));
|
await tester.tap(find.text(candidate.name));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
verify(() => mocks.partner.service.create(sharedById: currentUser.id, sharedWithId: candidate.id)).called(1);
|
verify(
|
||||||
|
() => context.mocks.partner.service.create(sharedById: context.currentUser.id, sharedWithId: candidate.id),
|
||||||
|
).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
|
testWidgets('creates nothing when the selection dialog is dismissed', (tester) async {
|
||||||
@@ -51,7 +46,7 @@ void main() {
|
|||||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
|
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // dismiss without selecting
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
verifyNever(mocks.partner.create);
|
verifyNever(context.mocks.partner.create);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,7 +60,9 @@ void main() {
|
|||||||
await tester.tap(find.byType(TextButton).last); // confirm
|
await tester.tap(find.byType(TextButton).last); // confirm
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
verify(() => mocks.partner.service.delete(sharedById: currentUser.id, sharedWithId: partner.id)).called(1);
|
verify(
|
||||||
|
() => context.mocks.partner.service.delete(sharedById: context.currentUser.id, sharedWithId: partner.id),
|
||||||
|
).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
|
testWidgets('deletes nothing when the confirmation is cancelled', (tester) async {
|
||||||
@@ -77,7 +74,7 @@ void main() {
|
|||||||
await tester.tap(find.byType(TextButton).first); // cancel
|
await tester.tap(find.byType(TextButton).first); // cancel
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
verifyNever(mocks.partner.delete);
|
verifyNever(context.mocks.partner.delete);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/action.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/actions/timeline.action.dart';
|
||||||
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
|
||||||
|
import '../../factories/remote_asset_factory.dart';
|
||||||
|
import '../../presentation_context.dart';
|
||||||
|
|
||||||
|
class _FakeAction extends BaseAction {
|
||||||
|
_FakeAction({this.visible = true, this.error});
|
||||||
|
|
||||||
|
final bool visible;
|
||||||
|
final Object? error;
|
||||||
|
|
||||||
|
bool ran = false;
|
||||||
|
bool? selectionDuringOnAction;
|
||||||
|
|
||||||
|
@override
|
||||||
|
IconData get icon => Icons.bolt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String label(ActionScope scope) => 'fake';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isVisible(ActionScope scope) => visible;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onAction(ActionScope scope) async {
|
||||||
|
ran = true;
|
||||||
|
selectionDuringOnAction = scope.ref.read(multiSelectProvider).isEnabled;
|
||||||
|
if (error != null) {
|
||||||
|
throw error!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PresentationContext context;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
context = await PresentationContext.create();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
context.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Override> seededOverrides() => [
|
||||||
|
...context.overrides,
|
||||||
|
multiSelectProvider.overrideWith(
|
||||||
|
() => MultiSelectNotifier(
|
||||||
|
MultiSelectState(selectedAssets: {RemoteAssetFactory.create()}, lockedSelectionAssets: const {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<(ActionScope, ProviderContainer)> pumpScope(WidgetTester tester) async {
|
||||||
|
late ActionScope scope;
|
||||||
|
late ProviderContainer container;
|
||||||
|
await tester.pumpTestWidget(
|
||||||
|
Consumer(
|
||||||
|
builder: (innerContext, ref, _) {
|
||||||
|
scope = ActionScope(context: innerContext, ref: ref, authUser: context.currentUser);
|
||||||
|
container = ProviderScope.containerOf(innerContext, listen: false);
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
overrides: seededOverrides(),
|
||||||
|
);
|
||||||
|
return (scope, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
group('TimelineAction', () {
|
||||||
|
testWidgets('runs the wrapped action and then clears the selection', (tester) async {
|
||||||
|
final inner = _FakeAction();
|
||||||
|
final (scope, container) = await pumpScope(tester);
|
||||||
|
await TimelineAction(action: inner).onAction(scope);
|
||||||
|
|
||||||
|
expect(inner.ran, isTrue);
|
||||||
|
expect(inner.selectionDuringOnAction, isTrue, reason: 'reset must run after the inner action, not before');
|
||||||
|
expect(container.read(multiSelectProvider).isEnabled, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('rethrows and keeps the selection when the wrapped action throws', (tester) async {
|
||||||
|
final error = Exception('boom');
|
||||||
|
final inner = _FakeAction(error: error);
|
||||||
|
final (scope, container) = await pumpScope(tester);
|
||||||
|
|
||||||
|
await expectLater(TimelineAction(action: inner).onAction(scope), throwsA(same(error)));
|
||||||
|
|
||||||
|
expect(inner.ran, isTrue);
|
||||||
|
expect(container.read(multiSelectProvider).isEnabled, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('delegates visibility to the wrapped action', (tester) async {
|
||||||
|
await tester.pumpTestWidget(
|
||||||
|
ActionIconButtonWidget(action: TimelineAction(action: _FakeAction(visible: false))),
|
||||||
|
overrides: context.overrides,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(ActionIconButtonWidget), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.bolt), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ void main() {
|
|||||||
late PresentationContext context;
|
late PresentationContext context;
|
||||||
|
|
||||||
setUp(() async => context = await PresentationContext.create());
|
setUp(() async => context = await PresentationContext.create());
|
||||||
tearDown(() async => await context.dispose());
|
tearDown(() => context.dispose());
|
||||||
|
|
||||||
group('PartnerSharedByList', () {
|
group('PartnerSharedByList', () {
|
||||||
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
testWidgets('shows the empty-state add button when there are no partners', (tester) async {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import 'mocks.dart';
|
|||||||
|
|
||||||
class PresentationContext {
|
class PresentationContext {
|
||||||
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
|
PresentationContext._({required UserDto user}) : currentUser = user, mocks = ServiceMocks() {
|
||||||
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
|
setup();
|
||||||
}
|
}
|
||||||
|
|
||||||
static const String serverEndpoint = 'http://localhost:3000';
|
static const String serverEndpoint = 'http://localhost:3000';
|
||||||
@@ -46,10 +46,14 @@ class PresentationContext {
|
|||||||
return PresentationContext._(user: UserFactory.createDto());
|
return PresentationContext._(user: UserFactory.createDto());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
void setup() {
|
||||||
// TODO: Dispose the store and database after each test.
|
when(mocks.user.tryGetMyUser).thenReturn(currentUser);
|
||||||
// This is currently not possible because the store is a singleton and is used across tests.
|
}
|
||||||
// Refactor the store to be created per test to allow proper disposal.
|
|
||||||
|
void dispose() {
|
||||||
|
addTearDown(() {
|
||||||
|
mocks.resetAll();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +77,7 @@ extension PumpPresentationWidget on WidgetTester {
|
|||||||
localizationsDelegates: context.localizationDelegates,
|
localizationsDelegates: context.localizationDelegates,
|
||||||
supportedLocales: context.supportedLocales,
|
supportedLocales: context.supportedLocales,
|
||||||
locale: context.locale,
|
locale: context.locale,
|
||||||
home: Material(child: widget),
|
home: Scaffold(body: widget),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -83,10 +87,7 @@ extension PumpPresentationWidget on WidgetTester {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
|
Future<void> pumpTestAction(BaseAction action, {List<Override> overrides = const []}) async {
|
||||||
await pumpTestWidget(
|
await pumpTestWidget(ActionIconButtonWidget(action: action), overrides: overrides);
|
||||||
Scaffold(body: ActionIconButtonWidget(action: action)),
|
|
||||||
overrides: overrides,
|
|
||||||
);
|
|
||||||
await tap(find.byType(ImmichIconButton));
|
await tap(find.byType(ImmichIconButton));
|
||||||
await pump();
|
await pump();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16252,7 +16252,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Faces",
|
"name": "Faces",
|
||||||
"description": "A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually."
|
"description": "A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created manually."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Integrity (admin)",
|
"name": "Integrity (admin)",
|
||||||
|
|||||||
@@ -233,12 +233,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "assetTimeline",
|
|
||||||
"title": "Move to timeline",
|
|
||||||
"description": "Change visibility to timeline",
|
|
||||||
"types": ["AssetV1"]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "assetVisibility",
|
"name": "assetVisibility",
|
||||||
"title": "Update visibility",
|
"title": "Update visibility",
|
||||||
@@ -301,100 +295,38 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "noop1",
|
"name": "webhook",
|
||||||
"title": "DEV: Nested properties",
|
"title": "Trigger Webhook",
|
||||||
"description": "Example configuration with nested properties",
|
"description": "POST/PUT event data to any URL",
|
||||||
"types": ["AssetV1"],
|
"types": ["AssetV1"],
|
||||||
|
"hostFunctions": true,
|
||||||
|
"allowedHosts": ["*"],
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"number1": {
|
"url": {
|
||||||
"type": "number",
|
|
||||||
"title": "Number 1",
|
|
||||||
"description": "Basic number"
|
|
||||||
},
|
|
||||||
"number2": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Number 2",
|
|
||||||
"array": true,
|
|
||||||
"description": "List of numbers"
|
|
||||||
},
|
|
||||||
"string1": {
|
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "String 1",
|
"title": "URL",
|
||||||
"description": "Basic string"
|
"description": "Event data will be PUT/POSTed to this URL as a JSON object"
|
||||||
},
|
},
|
||||||
"string2": {
|
"headerName": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "String 2",
|
"title": "Header name",
|
||||||
"array": true,
|
"description": "The name of an additional header to include with the request (e.g. authentication)"
|
||||||
"description": "List of strings"
|
|
||||||
},
|
},
|
||||||
"string3": {
|
"headerValue": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "String 3",
|
"title": "Header value",
|
||||||
"enum": ["choice-1", "choice-2"],
|
"description": "The value of the additional header"
|
||||||
"description": "Select from a list"
|
|
||||||
},
|
},
|
||||||
"nested": {
|
"method": {
|
||||||
"type": "object",
|
"type": "string",
|
||||||
"title": "Nested",
|
"title": "Method",
|
||||||
"description": "Nested properties for nesting",
|
"description": "The HTTP method to use in the request",
|
||||||
"properties": {
|
"enum": ["POST", "PUT"]
|
||||||
"nested1": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Nested 1",
|
|
||||||
"description": "Nested string"
|
|
||||||
},
|
|
||||||
"nested2": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "Nested 2",
|
|
||||||
"description": "Nested number"
|
|
||||||
},
|
|
||||||
"nested3": {
|
|
||||||
"type": "object",
|
|
||||||
"title": "Nested 3",
|
|
||||||
"description": "Nested again",
|
|
||||||
"properties": {
|
|
||||||
"nested4": {
|
|
||||||
"type": "boolean",
|
|
||||||
"title": "Nested 4",
|
|
||||||
"description": "Nested, nested boolean"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
"required": ["url"]
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "noop2",
|
|
||||||
"title": "DEV: Album pickers",
|
|
||||||
"description": "Example configuration with album pickers",
|
|
||||||
"types": ["AssetV1"],
|
|
||||||
"schema": {
|
|
||||||
"properties": {
|
|
||||||
"albumId": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Album ID",
|
|
||||||
"description": "Target album ID",
|
|
||||||
"uiHint": {
|
|
||||||
"type": "AlbumId",
|
|
||||||
"order": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"albumIds": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Album IDs",
|
|
||||||
"description": "Target album IDs",
|
|
||||||
"array": true,
|
|
||||||
"uiHint": {
|
|
||||||
"type": "AlbumId",
|
|
||||||
"order": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm build:tsc && pnpm build:wasm",
|
"build": "pnpm build:tsc && pnpm build:wasm",
|
||||||
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
|
"build:tsc": "plugin-sdk prepareBuild && tsc --noEmit && node esbuild.js",
|
||||||
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
|
"build:wasm": "extism-js dist/index.js -i dist/index.d.ts -o dist/plugin.wasm"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
Vendored
-27
@@ -1,27 +0,0 @@
|
|||||||
// keep in sync with plugin-sdk/host-functions.ts';
|
|
||||||
declare module 'extism:host' {
|
|
||||||
interface user {
|
|
||||||
searchAlbums(ptr: PTR): I64;
|
|
||||||
createAlbum(ptr: PTR): I64;
|
|
||||||
addAssetsToAlbum(ptr: PTR): I64;
|
|
||||||
addAssetsToAlbums(ptr: PTR): I64;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// keep in sync with manifest.json
|
|
||||||
declare module 'main' {
|
|
||||||
// filters
|
|
||||||
export function assetFileFilter(): I32;
|
|
||||||
export function assetMissingTimeZoneFilter(): I32;
|
|
||||||
export function assetLocationFilter(): I32;
|
|
||||||
export function assetTypeFilter(): I32;
|
|
||||||
|
|
||||||
// updates
|
|
||||||
export function assetFavorite(): I32;
|
|
||||||
export function assetVisibility(): I32;
|
|
||||||
export function assetArchive(): I32;
|
|
||||||
export function assetLock(): I32;
|
|
||||||
export function assetTimeline(): I32;
|
|
||||||
// export function assetTrash(): I32;
|
|
||||||
export function assetAddToAlbums(): I32;
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,59 @@
|
|||||||
import { getWrapper } from '@immich/plugin-sdk';
|
import { wrapper } from '@immich/plugin-sdk';
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import type manifestType from '../dist/manifest';
|
import type { Manifest } from '../dist/index.d.ts';
|
||||||
|
|
||||||
const wrapper = getWrapper<manifestType>();
|
const methods = wrapper<Manifest>({
|
||||||
|
assetAddToAlbums: ({ config, data, functions }) => {
|
||||||
|
const assetId = data.asset.id;
|
||||||
|
|
||||||
export const assetFileFilter = () => {
|
if (config.albumIds.length === 0) {
|
||||||
return wrapper<'assetFileFilter'>(({ data, config }) => {
|
if (!config.albumName) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = functions.searchAlbums({ name: config.albumName });
|
||||||
|
if (!existing) {
|
||||||
|
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
|
||||||
|
config.albumIds.push(created.id);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
config.albumIds.push(existing.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.albumIds.length === 1) {
|
||||||
|
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
|
||||||
|
assetArchive: ({ config, data }) => {
|
||||||
|
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
|
||||||
|
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
|
||||||
|
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
|
||||||
|
assetFavorite: ({ config, data }) => {
|
||||||
|
const target = config.inverse ? false : true;
|
||||||
|
if (target !== data.asset.isFavorite) {
|
||||||
|
return {
|
||||||
|
changes: {
|
||||||
|
asset: { isFavorite: target },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
assetFileFilter: ({ data, config }) => {
|
||||||
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
||||||
|
|
||||||
const { asset } = data;
|
const { asset } = data;
|
||||||
@@ -37,19 +85,9 @@ export const assetFileFilter = () => {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
};
|
|
||||||
|
|
||||||
export const assetMissingTimeZoneFilter = () => {
|
assetLocationFilter: ({ config, data }) => {
|
||||||
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
|
|
||||||
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
|
|
||||||
const needsTimeZone = config.inverse ? true : false;
|
|
||||||
return { workflow: { continue: hasTimeZone === needsTimeZone } };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const assetLocationFilter = () => {
|
|
||||||
return wrapper<'assetLocationFilter'>(({ config, data }) => {
|
|
||||||
if (
|
if (
|
||||||
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
|
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
|
||||||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
|
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
|
||||||
@@ -84,50 +122,9 @@ export const assetLocationFilter = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
||||||
});
|
},
|
||||||
};
|
|
||||||
|
|
||||||
export const assetTypeFilter = () => {
|
assetLock: ({ config, data }) => {
|
||||||
return wrapper<'assetTypeFilter'>(({ config, data }) => {
|
|
||||||
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const assetFavorite = () => {
|
|
||||||
return wrapper<'assetFavorite'>(({ config, data }) => {
|
|
||||||
const target = config.inverse ? false : true;
|
|
||||||
if (target !== data.asset.isFavorite) {
|
|
||||||
return {
|
|
||||||
changes: {
|
|
||||||
asset: { isFavorite: target },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const assetVisibility = () => {
|
|
||||||
return wrapper<'assetVisibility'>(({ config }) => ({
|
|
||||||
changes: { asset: { visibility: config.visibility as AssetVisibility } },
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const assetArchive = () => {
|
|
||||||
return wrapper<'assetArchive'>(({ config, data }) => {
|
|
||||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
|
|
||||||
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
|
|
||||||
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const assetLock = () => {
|
|
||||||
return wrapper<'assetLock'>(({ config, data }) => {
|
|
||||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
|
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
|
||||||
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
|
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
|
||||||
}
|
}
|
||||||
@@ -137,39 +134,68 @@ export const assetLock = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
});
|
},
|
||||||
};
|
|
||||||
|
|
||||||
// export const assetTrash = () => {
|
assetMissingTimeZoneFilter: ({ config, data }) => {
|
||||||
// // TODO use trash/untrash host functions
|
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
|
||||||
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
|
const needsTimeZone = config.inverse ? true : false;
|
||||||
// };
|
return { workflow: { continue: hasTimeZone === needsTimeZone } };
|
||||||
|
},
|
||||||
|
|
||||||
export const assetAddToAlbums = () => {
|
assetTypeFilter: ({ config, data }) => {
|
||||||
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
|
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
|
||||||
const assetId = data.asset.id;
|
},
|
||||||
|
|
||||||
if (config.albumIds.length === 0) {
|
assetVisibility: ({ config }) => ({
|
||||||
if (!config.albumName) {
|
changes: { asset: { visibility: config.visibility as AssetVisibility } },
|
||||||
return {};
|
}),
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = functions.searchAlbums({ name: config.albumName });
|
webhook: ({ config, data, functions }) => {
|
||||||
if (!existing) {
|
const headers: Record<string, string> = {
|
||||||
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
|
'Content-Type': 'application/json',
|
||||||
config.albumIds.push(created.id);
|
};
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
config.albumIds.push(existing.id);
|
if (config.headerName && config.headerValue) {
|
||||||
|
headers[config.headerName] = config.headerValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.albumIds.length === 1) {
|
functions.httpRequest(config.url, {
|
||||||
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
|
method: config.method ?? 'POST',
|
||||||
return {};
|
body: JSON.stringify(data.asset),
|
||||||
}
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
|
|
||||||
return {};
|
return {};
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
assetAddToAlbums,
|
||||||
|
assetArchive,
|
||||||
|
assetFavorite,
|
||||||
|
assetFileFilter,
|
||||||
|
assetLocationFilter,
|
||||||
|
assetLock,
|
||||||
|
assetMissingTimeZoneFilter,
|
||||||
|
assetTypeFilter,
|
||||||
|
assetVisibility,
|
||||||
|
webhook,
|
||||||
|
|
||||||
|
// should be empty. ensures that every field is destructured
|
||||||
|
...rest
|
||||||
|
} = methods;
|
||||||
|
|
||||||
|
export {
|
||||||
|
assetAddToAlbums,
|
||||||
|
assetArchive,
|
||||||
|
assetFavorite,
|
||||||
|
assetFileFilter,
|
||||||
|
assetLocationFilter,
|
||||||
|
assetLock,
|
||||||
|
assetMissingTimeZoneFilter,
|
||||||
|
assetTypeFilter,
|
||||||
|
assetVisibility,
|
||||||
|
webhook,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
'All methods must be destructured and exported' satisfies string & typeof rest;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"declaration": true,
|
"declaration": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"esModuleInterop": true, // Enables compatibility with Babel-style module imports
|
"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
|
"module": "nodenext", // Specify module code generation
|
||||||
"moduleResolution": "nodenext",
|
"moduleResolution": "nodenext",
|
||||||
"noEmit": true, // Do not emit outputs (no .js or .d.ts files)
|
"noEmit": true, // Do not emit outputs (no .js or .d.ts files)
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"skipLibCheck": true, // Skip type checking of declaration files
|
"skipLibCheck": true, // Skip type checking of declaration files
|
||||||
"strict": true, // Enable all strict type-checking options
|
"strict": true, // Enable all strict type-checking options
|
||||||
"target": "es2020", // Specify ECMAScript target version
|
"target": "es2020", // Specify ECMAScript target version
|
||||||
"types": ["./src/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
|
"types": ["./dist/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules" // Exclude the node_modules directory
|
"node_modules" // Exclude the node_modules directory
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import esbuild from 'esbuild';
|
import esbuild from 'esbuild';
|
||||||
|
|
||||||
esbuild.build({
|
esbuild.build({
|
||||||
entryPoints: ['src/index.ts'],
|
entryPoints: ['src/index.ts', 'src/cli.ts'],
|
||||||
outdir: 'dist',
|
outdir: 'dist',
|
||||||
bundle: true,
|
bundle: true,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
minify: false,
|
minify: false,
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
|
platform: 'node',
|
||||||
target: ['es2020'],
|
target: ['es2020'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
"bin": {
|
||||||
|
"plugin-sdk": "./plugin-sdk.mjs"
|
||||||
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
@@ -35,5 +38,8 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@extism/js-pdk": "^1.1.1"
|
"@extism/js-pdk": "^1.1.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^15.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import "./dist/cli.js";
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { availableFunctions } from 'src/host-functions.js';
|
||||||
|
|
||||||
|
const program = new Command('plugin-sdk');
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('prepareBuild')
|
||||||
|
.description('Generate .d.ts file required for extism')
|
||||||
|
.argument(
|
||||||
|
'[manifest]',
|
||||||
|
"Path to the plugins's manifest file",
|
||||||
|
'manifest.json',
|
||||||
|
)
|
||||||
|
.option('-o --output', 'Output file for generated types', 'dist/index.d.ts')
|
||||||
|
.action((manifest: string, { output }) => {
|
||||||
|
const content = readFileSync(manifest, { encoding: 'utf-8' });
|
||||||
|
const methods = (
|
||||||
|
JSON.parse(content) as { methods: { name: string }[] }
|
||||||
|
).methods.map(({ name }) => name);
|
||||||
|
mkdirSync(dirname(output), { recursive: true });
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
output,
|
||||||
|
`
|
||||||
|
declare module 'extism:host' {
|
||||||
|
interface user {
|
||||||
|
${availableFunctions.map((functionName) => ` ${functionName}(ptr: PTR): I64;`).join('\n')}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'main' {
|
||||||
|
${methods.map((method) => ` export function ${method}(): I32;`).join('\n')}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Manifest = ${content};
|
||||||
|
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parse();
|
||||||
@@ -6,14 +6,11 @@ import {
|
|||||||
type CreateAlbumDto,
|
type CreateAlbumDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
|
|
||||||
// keep in sync with plugin-core/src/index.d.ts';
|
|
||||||
declare module 'extism:host' {
|
declare module 'extism:host' {
|
||||||
interface user {
|
interface user extends Record<
|
||||||
searchAlbums(ptr: PTR): I64;
|
(typeof availableFunctions)[number],
|
||||||
createAlbum(ptr: PTR): I64;
|
(ptr: PTR) => I64
|
||||||
addAssetsToAlbum(ptr: PTR): I64;
|
> {}
|
||||||
addAssetsToAlbums(ptr: PTR): I64;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlbumsToAssets = {
|
type AlbumsToAssets = {
|
||||||
@@ -33,6 +30,24 @@ type HostFunctionResult<T> =
|
|||||||
|
|
||||||
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
|
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
|
||||||
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
|
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
|
||||||
|
type HttpRequestOptions = {
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
};
|
||||||
|
type HttpResponse = {
|
||||||
|
ok: string;
|
||||||
|
status: number;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const availableFunctions = [
|
||||||
|
'searchAlbums',
|
||||||
|
'createAlbum',
|
||||||
|
'addAssetsToAlbum',
|
||||||
|
'addAssetsToAlbums',
|
||||||
|
'httpRequest',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const hostFunctions = (authToken: string) => {
|
export const hostFunctions = (authToken: string) => {
|
||||||
const host = Host.getFunctions();
|
const host = Host.getFunctions();
|
||||||
@@ -75,5 +90,11 @@ export const hostFunctions = (authToken: string) => {
|
|||||||
),
|
),
|
||||||
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
||||||
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
||||||
};
|
httpRequest: (url: string, options?: HttpRequestOptions) =>
|
||||||
|
call<[string, HttpRequestOptions | undefined], HttpResponse>(
|
||||||
|
'httpRequest',
|
||||||
|
authToken,
|
||||||
|
[url, options],
|
||||||
|
),
|
||||||
|
} satisfies Record<(typeof availableFunctions)[number], unknown>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { WorkflowType } from '@immich/sdk';
|
|
||||||
import { hostFunctions } from 'src/host-functions.js';
|
import { hostFunctions } from 'src/host-functions.js';
|
||||||
import type {
|
import type {
|
||||||
WorkflowEventPayload,
|
WorkflowEventPayload,
|
||||||
@@ -53,52 +52,56 @@ type ConfigValue<
|
|||||||
'required' extends keyof T ? T['required'] : undefined
|
'required' extends keyof T ? T['required'] : undefined
|
||||||
>['properties'];
|
>['properties'];
|
||||||
|
|
||||||
export const getWrapper =
|
export const wrapper = <T extends Record<string, any>>(methods: {
|
||||||
<T extends Record<string, any>>() =>
|
[K in T['methods'][number] as K['name']]: (
|
||||||
<
|
payload: WorkflowEventPayload<
|
||||||
K extends T['methods'][number]['name'],
|
K['types'][number],
|
||||||
L extends WorkflowType = (T['methods'][number] & {
|
ConfigValue<K['schema']>
|
||||||
name: K;
|
> & {
|
||||||
})['types'][number],
|
functions: ReturnType<typeof hostFunctions>;
|
||||||
TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>,
|
},
|
||||||
>(
|
) => WorkflowResponse<K['types'][number]> | undefined;
|
||||||
fn: (
|
}) => {
|
||||||
payload: WorkflowEventPayload<L, TConfig> & {
|
const result: { [K in keyof typeof methods]: () => void } = {} as never;
|
||||||
functions: ReturnType<typeof hostFunctions>;
|
for (const name of Object.keys(methods) as (keyof typeof methods)[]) {
|
||||||
},
|
result[name] = () => {
|
||||||
) => WorkflowResponse<L> | undefined,
|
const input = Host.inputString();
|
||||||
) => {
|
|
||||||
const input = Host.inputString();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(input) as WorkflowEventPayload<K, TConfig>;
|
const payload = JSON.parse(input) as WorkflowEventPayload<
|
||||||
const event = {
|
typeof name,
|
||||||
...payload,
|
(T['methods'][number]['name'] & { name: typeof name })['schema']
|
||||||
functions: hostFunctions(payload.workflow.authToken),
|
>;
|
||||||
};
|
const event = {
|
||||||
|
...payload,
|
||||||
|
functions: hostFunctions(payload.workflow.authToken),
|
||||||
|
};
|
||||||
|
|
||||||
const eventConfigBefore = JSON.stringify(event.config);
|
const eventConfigBefore = JSON.stringify(event.config);
|
||||||
|
|
||||||
console.debug(
|
console.debug(
|
||||||
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
|
`Inputs: trigger=${event.trigger}, event=${String(event.type)}, config=${eventConfigBefore}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = fn(event) ?? {};
|
const response = methods[name](event) ?? {};
|
||||||
|
|
||||||
// if config changed, notify host
|
// if config changed, notify host
|
||||||
const eventConfigAfter = JSON.stringify(event.config);
|
const eventConfigAfter = JSON.stringify(event.config);
|
||||||
if (!response.config && eventConfigBefore !== eventConfigAfter) {
|
if (!response.config && eventConfigBefore !== eventConfigAfter) {
|
||||||
response.config = event.config as WorkflowStepConfig;
|
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;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
console.debug(
|
}
|
||||||
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
|
return result;
|
||||||
);
|
};
|
||||||
|
|
||||||
const output = JSON.stringify(response);
|
|
||||||
Host.outputString(output);
|
|
||||||
} catch (error: Error | any) {
|
|
||||||
console.error(`Unhandled plugin exception: ${error.message || error}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
Generated
+554
-609
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -29,7 +29,7 @@ allowBuilds:
|
|||||||
postman-code-generators: false
|
postman-code-generators: false
|
||||||
overrides:
|
overrides:
|
||||||
canvas: 3.2.3
|
canvas: 3.2.3
|
||||||
sharp: ^0.34.5
|
sharp: ^0.35.2
|
||||||
packageExtensions:
|
packageExtensions:
|
||||||
nestjs-kysely:
|
nestjs-kysely:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
+6
-5
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS builder
|
FROM ghcr.io/immich-app/base-server-dev:202606180900@sha256:3871e19b02c37d0e3d2ee200e4977e0f2afc77730fd8dcc5e4532b6e2b26bdce AS builder
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp \
|
COREPACK_HOME=/tmp \
|
||||||
@@ -20,8 +20,9 @@ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
|
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
|
||||||
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
|
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
|
||||||
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
|
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
|
||||||
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \
|
pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \
|
||||||
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --prod --no-optional deploy /output/server-pruned
|
pnpm --filter immich --prod deploy /output/server-pruned && \
|
||||||
|
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --dir /output/server-pruned/node_modules/sharp exec npm run build
|
||||||
|
|
||||||
FROM builder AS web
|
FROM builder AS web
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
|
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
|
||||||
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
|
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
|
||||||
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
|
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
|
||||||
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \
|
pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \
|
||||||
pnpm --filter @immich/sdk --filter immich-web build
|
pnpm --filter @immich/sdk --filter immich-web build
|
||||||
|
|
||||||
FROM builder AS cli
|
FROM builder AS cli
|
||||||
@@ -80,7 +81,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||||
mise //:plugins
|
mise //:plugins
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:202606161235@sha256:c6d59e3923f548d29a212b4dc51b6281a722cfa1da7972a009c0f3830f5762d6
|
FROM ghcr.io/immich-app/base-server-prod:202606180900@sha256:442159fae88a04b01a4caafbd9813f08aeefb2e1ae3b8a47cc82409208dfbd80
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# dev build
|
# dev build
|
||||||
FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS dev
|
FROM ghcr.io/immich-app/base-server-dev:202606180900@sha256:3871e19b02c37d0e3d2ee200e4977e0f2afc77730fd8dcc5e4532b6e2b26bdce AS dev
|
||||||
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise
|
COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise
|
||||||
|
|||||||
+2
-2
@@ -107,7 +107,7 @@
|
|||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"semver": "^7.8.1",
|
"semver": "^7.8.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.35.2",
|
||||||
"sirv": "^3.0.0",
|
"sirv": "^3.0.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"tailwindcss-preset-email": "^1.4.0",
|
"tailwindcss-preset-email": "^1.4.0",
|
||||||
@@ -168,6 +168,6 @@
|
|||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.35.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export const endpointTags: Record<ApiTag, string> = {
|
|||||||
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
|
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
|
||||||
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
|
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
|
||||||
[ApiTag.Faces]:
|
[ApiTag.Faces]:
|
||||||
'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually.',
|
'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created manually.',
|
||||||
[ApiTag.Integrity]: 'Endpoints for viewing and managing integrity reports.',
|
[ApiTag.Integrity]: 'Endpoints for viewing and managing integrity reports.',
|
||||||
[ApiTag.Jobs]:
|
[ApiTag.Jobs]:
|
||||||
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
|
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
|
||||||
|
|||||||
@@ -368,6 +368,7 @@ export const columns = {
|
|||||||
'plugin_method.types',
|
'plugin_method.types',
|
||||||
'plugin_method.schema',
|
'plugin_method.schema',
|
||||||
'plugin_method.hostFunctions',
|
'plugin_method.hostFunctions',
|
||||||
|
'plugin_method.allowedHosts',
|
||||||
'plugin_method.uiHints',
|
'plugin_method.uiHints',
|
||||||
],
|
],
|
||||||
syncAsset: [
|
syncAsset: [
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ const PluginManifestMethodSchema = z
|
|||||||
description: z.string().min(1).describe('Method description'),
|
description: z.string().min(1).describe('Method description'),
|
||||||
types: z.array(WorkflowTypeSchema).min(1).describe('Workflow type'),
|
types: z.array(WorkflowTypeSchema).min(1).describe('Workflow type'),
|
||||||
hostFunctions: z.boolean().optional().default(false).describe('Method uses host functions'),
|
hostFunctions: z.boolean().optional().default(false).describe('Method uses host functions'),
|
||||||
|
allowedHosts: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.default([])
|
||||||
|
.describe('Hostnames the method can access (use * for wildcards)'),
|
||||||
schema: PluginManifestMethodSchemaSchema.describe('Schema'),
|
schema: PluginManifestMethodSchemaSchema.describe('Schema'),
|
||||||
uiHints: z.array(z.string()).optional().describe('Ui hints, for example "filter"'),
|
uiHints: z.array(z.string()).optional().describe('Ui hints, for example "filter"'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ select
|
|||||||
"plugin_method"."types",
|
"plugin_method"."types",
|
||||||
"plugin_method"."schema",
|
"plugin_method"."schema",
|
||||||
"plugin_method"."hostFunctions",
|
"plugin_method"."hostFunctions",
|
||||||
|
"plugin_method"."allowedHosts",
|
||||||
"plugin_method"."uiHints",
|
"plugin_method"."uiHints",
|
||||||
"plugin"."name" as "pluginName"
|
"plugin"."name" as "pluginName"
|
||||||
from
|
from
|
||||||
@@ -84,6 +85,7 @@ select
|
|||||||
"plugin_method"."types",
|
"plugin_method"."types",
|
||||||
"plugin_method"."schema",
|
"plugin_method"."schema",
|
||||||
"plugin_method"."hostFunctions",
|
"plugin_method"."hostFunctions",
|
||||||
|
"plugin_method"."allowedHosts",
|
||||||
"plugin_method"."uiHints",
|
"plugin_method"."uiHints",
|
||||||
"plugin"."name" as "pluginName"
|
"plugin"."name" as "pluginName"
|
||||||
from
|
from
|
||||||
@@ -120,6 +122,7 @@ select
|
|||||||
"plugin_method"."types",
|
"plugin_method"."types",
|
||||||
"plugin_method"."schema",
|
"plugin_method"."schema",
|
||||||
"plugin_method"."hostFunctions",
|
"plugin_method"."hostFunctions",
|
||||||
|
"plugin_method"."allowedHosts",
|
||||||
"plugin_method"."uiHints",
|
"plugin_method"."uiHints",
|
||||||
"plugin"."name" as "pluginName"
|
"plugin"."name" as "pluginName"
|
||||||
from
|
from
|
||||||
@@ -156,6 +159,7 @@ select
|
|||||||
"plugin_method"."types",
|
"plugin_method"."types",
|
||||||
"plugin_method"."schema",
|
"plugin_method"."schema",
|
||||||
"plugin_method"."hostFunctions",
|
"plugin_method"."hostFunctions",
|
||||||
|
"plugin_method"."allowedHosts",
|
||||||
"plugin_method"."uiHints",
|
"plugin_method"."uiHints",
|
||||||
"plugin"."name" as "pluginName"
|
"plugin"."name" as "pluginName"
|
||||||
from
|
from
|
||||||
@@ -190,6 +194,7 @@ select
|
|||||||
"plugin_method"."types",
|
"plugin_method"."types",
|
||||||
"plugin_method"."schema",
|
"plugin_method"."schema",
|
||||||
"plugin_method"."hostFunctions",
|
"plugin_method"."hostFunctions",
|
||||||
|
"plugin_method"."allowedHosts",
|
||||||
"plugin_method"."uiHints"
|
"plugin_method"."uiHints"
|
||||||
from
|
from
|
||||||
"plugin_method"
|
"plugin_method"
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ select
|
|||||||
"plugin_method"."pluginId" as "pluginId",
|
"plugin_method"."pluginId" as "pluginId",
|
||||||
"plugin_method"."name" as "methodName",
|
"plugin_method"."name" as "methodName",
|
||||||
"plugin_method"."types" as "types",
|
"plugin_method"."types" as "types",
|
||||||
"plugin_method"."hostFunctions"
|
"plugin_method"."hostFunctions",
|
||||||
|
"plugin_method"."allowedHosts"
|
||||||
from
|
from
|
||||||
"workflow_step"
|
"workflow_step"
|
||||||
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
|
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export class PluginRepository {
|
|||||||
description: ref('excluded.description'),
|
description: ref('excluded.description'),
|
||||||
types: ref('excluded.types'),
|
types: ref('excluded.types'),
|
||||||
hostFunctions: ref('excluded.hostFunctions'),
|
hostFunctions: ref('excluded.hostFunctions'),
|
||||||
|
allowedHosts: ref('excluded.allowedHosts'),
|
||||||
uiHints: ref('excluded.uiHints'),
|
uiHints: ref('excluded.uiHints'),
|
||||||
schema: ref('excluded.schema'),
|
schema: ref('excluded.schema'),
|
||||||
})),
|
})),
|
||||||
@@ -240,7 +241,7 @@ export class PluginRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown) {
|
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown, context?: unknown) {
|
||||||
const item = this.pluginMap.get(pluginKey);
|
const item = this.pluginMap.get(pluginKey);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
throw new Error(`No loaded plugin found for ${pluginKey}`);
|
throw new Error(`No loaded plugin found for ${pluginKey}`);
|
||||||
@@ -251,7 +252,7 @@ export class PluginRepository {
|
|||||||
try {
|
try {
|
||||||
const plugin = await pool.acquire();
|
const plugin = await pool.acquire();
|
||||||
try {
|
try {
|
||||||
const result = await plugin.call(methodName, JSON.stringify(input));
|
const result = await plugin.call(methodName, JSON.stringify(input), context);
|
||||||
return (result ? result.json() : result) as T;
|
return (result ? result.json() : result) as T;
|
||||||
} finally {
|
} finally {
|
||||||
await pool.release(plugin);
|
await pool.release(plugin);
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export class WorkflowRepository {
|
|||||||
'plugin_method.name as methodName',
|
'plugin_method.name as methodName',
|
||||||
'plugin_method.types as types',
|
'plugin_method.types as types',
|
||||||
'plugin_method.hostFunctions',
|
'plugin_method.hostFunctions',
|
||||||
|
'plugin_method.allowedHosts',
|
||||||
]),
|
]),
|
||||||
).as('steps'),
|
).as('steps'),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "plugin_method" ADD "allowedHosts" character varying[] NOT NULL DEFAULT '{}';`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "plugin_method" DROP COLUMN "allowedHosts";`.execute(db);
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ export class PluginMethodTable {
|
|||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
hostFunctions!: Generated<boolean>;
|
hostFunctions!: Generated<boolean>;
|
||||||
|
|
||||||
|
@Column({ type: 'character varying', default: [], array: true })
|
||||||
|
allowedHosts!: Generated<string[]>;
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
schema!: JsonSchemaDto | null;
|
schema!: JsonSchemaDto | null;
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ type ExecuteOptions<T extends WorkflowType> = {
|
|||||||
|
|
||||||
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
|
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
|
||||||
|
|
||||||
|
type HostContext = {
|
||||||
|
allowedHosts: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export class WorkflowExecutionService extends BaseService {
|
export class WorkflowExecutionService extends BaseService {
|
||||||
private jwtSecret!: string;
|
private jwtSecret!: string;
|
||||||
|
|
||||||
@@ -66,20 +70,48 @@ export class WorkflowExecutionService extends BaseService {
|
|||||||
|
|
||||||
const albumService = BaseService.create(AlbumService, this);
|
const albumService = BaseService.create(AlbumService, this);
|
||||||
|
|
||||||
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, args) => albumService.getAll(authDto, ...args));
|
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args));
|
||||||
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, args) => albumService.create(authDto, ...args));
|
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args));
|
||||||
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
|
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, ctx, args) =>
|
||||||
albumService.addAssets(authDto, ...args),
|
albumService.addAssets(authDto, ...args),
|
||||||
);
|
);
|
||||||
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
|
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, ctx, args) =>
|
||||||
albumService.addAssetsToAlbums(authDto, ...args),
|
albumService.addAssetsToAlbums(authDto, ...args),
|
||||||
);
|
);
|
||||||
|
const httpRequest = this.wrap<
|
||||||
|
[
|
||||||
|
url: string,
|
||||||
|
options?: {
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
},
|
||||||
|
]
|
||||||
|
>(async (authDto, context, args) => {
|
||||||
|
const hostname = new URL(args[0]).hostname;
|
||||||
|
|
||||||
|
for (const pattern of context.allowedHosts) {
|
||||||
|
const regex = new RegExp(pattern.replaceAll('.', String.raw`\.`).replaceAll('*', '.*'));
|
||||||
|
if (regex.test(hostname)) {
|
||||||
|
const res = await fetch(...args);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: res.ok,
|
||||||
|
status: res.status,
|
||||||
|
body: await res.text(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest');
|
||||||
|
});
|
||||||
|
|
||||||
const functions = {
|
const functions = {
|
||||||
searchAlbums,
|
searchAlbums,
|
||||||
createAlbum,
|
createAlbum,
|
||||||
addAssetsToAlbum,
|
addAssetsToAlbum,
|
||||||
addAssetsToAlbums,
|
addAssetsToAlbums,
|
||||||
|
httpRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
const stubs: typeof functions = {
|
const stubs: typeof functions = {
|
||||||
@@ -87,6 +119,7 @@ export class WorkflowExecutionService extends BaseService {
|
|||||||
createAlbum: dummy,
|
createAlbum: dummy,
|
||||||
addAssetsToAlbum: dummy,
|
addAssetsToAlbum: dummy,
|
||||||
addAssetsToAlbums: dummy,
|
addAssetsToAlbums: dummy,
|
||||||
|
httpRequest: dummy,
|
||||||
};
|
};
|
||||||
|
|
||||||
const plugins = await this.pluginRepository.getForLoad();
|
const plugins = await this.pluginRepository.getForLoad();
|
||||||
@@ -121,7 +154,7 @@ export class WorkflowExecutionService extends BaseService {
|
|||||||
return id + (hostFunctions ? '/worker' : '');
|
return id + (hostFunctions ? '/worker' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
private wrap<T>(fn: (authDto: AuthDto, args: T) => Promise<unknown>) {
|
private wrap<T>(fn: (authDto: AuthDto, context: HostContext, args: T) => Promise<unknown>) {
|
||||||
return async (plugin: CurrentPlugin, offset: bigint) => {
|
return async (plugin: CurrentPlugin, offset: bigint) => {
|
||||||
try {
|
try {
|
||||||
const handle = plugin.read(offset);
|
const handle = plugin.read(offset);
|
||||||
@@ -136,8 +169,9 @@ export class WorkflowExecutionService extends BaseService {
|
|||||||
throw new Error('authToken is required');
|
throw new Error('authToken is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const context = plugin.hostContext<HostContext>();
|
||||||
const authDto = this.validate(authToken);
|
const authDto = this.validate(authToken);
|
||||||
const response = await fn(authDto, args);
|
const response = await fn(authDto, context, args);
|
||||||
|
|
||||||
return plugin.store(JSON.stringify({ success: true, response }));
|
return plugin.store(JSON.stringify({ success: true, response }));
|
||||||
} catch (error: Error | any) {
|
} catch (error: Error | any) {
|
||||||
@@ -381,6 +415,10 @@ export class WorkflowExecutionService extends BaseService {
|
|||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const context: HostContext = {
|
||||||
|
allowedHosts: step.allowedHosts,
|
||||||
|
};
|
||||||
|
|
||||||
if (step.methodName.startsWith('noop')) {
|
if (step.methodName.startsWith('noop')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -391,6 +429,7 @@ export class WorkflowExecutionService extends BaseService {
|
|||||||
methodName: step.methodName,
|
methodName: step.methodName,
|
||||||
},
|
},
|
||||||
payload,
|
payload,
|
||||||
|
context,
|
||||||
);
|
);
|
||||||
if (result?.changes) {
|
if (result?.changes) {
|
||||||
await write(
|
await write(
|
||||||
|
|||||||
@@ -427,4 +427,32 @@ describe('core plugin', () => {
|
|||||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
|
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('webhook', () => {
|
||||||
|
it('should trigger a webhook on asset upload', async () => {
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const workflow = await createWorkflow({
|
||||||
|
ownerId: user.id,
|
||||||
|
trigger: WorkflowTrigger.AssetCreate,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
method: 'immich-plugin-core#webhook',
|
||||||
|
config: { url: 'http://localhost', method: 'POST' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||||
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user