1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat: manual stack assets (#4198)

This commit is contained in:
shenlong 2023-10-22 02:38:07 +00:00 committed by GitHub
parent 5ead4af2dc
commit cf08ac7538
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1530 additions and 123 deletions

View File

@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto {
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'removeParent'?: boolean;
/**
*
* @type {string}
* @memberof AssetBulkUpdateDto
*/
'stackParentId'?: string;
}
/**
*
@ -748,6 +760,24 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto
*/
'smartInfo'?: SmartInfoResponseDto;
/**
*
* @type {Array<AssetResponseDto>}
* @memberof AssetResponseDto
*/
'stack'?: Array<AssetResponseDto>;
/**
*
* @type {number}
* @memberof AssetResponseDto
*/
'stackCount': number;
/**
*
* @type {string}
* @memberof AssetResponseDto
*/
'stackParentId'?: string | null;
/**
*
* @type {Array<TagResponseDto>}
@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto {
*/
'name'?: string;
}
/**
*
* @export
* @interface UpdateStackParentDto
*/
export interface UpdateStackParentDto {
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'newParentId': string;
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'oldParentId': string;
}
/**
*
* @export
@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
/**
*
* @param {UpdateStackParentDto} updateStackParentDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'updateStackParentDto' is not null or undefined
assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto)
const localVarPath = `/asset/stack/parent`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {File} assetData
@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {UpdateStackParentDto} updateStackParentDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {File} assetData
@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest {
readonly assetBulkUpdateDto: AssetBulkUpdateDto
}
/**
* Request parameters for updateStackParent operation in AssetApi.
* @export
* @interface AssetApiUpdateStackParentRequest
*/
export interface AssetApiUpdateStackParentRequest {
/**
*
* @type {UpdateStackParentDto}
* @memberof AssetApiUpdateStackParent
*/
readonly updateStackParentDto: UpdateStackParentDto
}
/**
* Request parameters for uploadFile operation in AssetApi.
* @export
@ -8939,6 +9065,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.

View File

@ -130,7 +130,9 @@
"control_bottom_app_bar_delete": "Delete",
"control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_share": "Share",
"control_bottom_app_bar_stack": "Stack",
"control_bottom_app_bar_unarchive": "Unarchive",
"control_bottom_app_bar_upload": "Upload",
"create_album_page_untitled": "Untitled",
"create_shared_album_page_create": "Create",
"create_shared_album_page_share": "Share",
@ -275,6 +277,7 @@
"setting_pages_app_bar_settings": "Settings",
"settings_require_restart": "Please restart Immich to apply this setting",
"share_add": "Add",
"share_done": "Done",
"share_add_photos": "Add photos",
"share_add_title": "Add a title",
"share_create_album": "Create album",
@ -337,5 +340,8 @@
"trash_page_select_assets_btn": "Select assets",
"trash_page_empty_trash_btn": "Empty trash",
"trash_page_empty_trash_dialog_ok": "Ok",
"trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich"
"trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_remove_from_stack": "Remove from Stack",
"viewer_unstack": "Un-Stack"
}

View File

@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@ -69,7 +70,8 @@ class AlbumViewerPage extends HookConsumerWidget {
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionRoute(
existingAssets: albumInfo.assets,
isNewAlbum: false,
canDeselect: false,
query: getRemoteAssetQuery(ref),
),
);

View File

@ -4,26 +4,27 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:isar/isar.dart';
class AssetSelectionPage extends HookConsumerWidget {
const AssetSelectionPage({
Key? key,
required this.existingAssets,
this.isNewAlbum = false,
this.canDeselect = false,
required this.query,
}) : super(key: key);
final Set<Asset> existingAssets;
final bool isNewAlbum;
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
final bool canDeselect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentUser = ref.watch(currentUserProvider);
final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId));
final renderList = ref.watch(renderListQueryProvider(query));
final selected = useState<Set<Asset>>(existingAssets);
final selectionEnabledHook = useState(true);
@ -39,8 +40,8 @@ class AssetSelectionPage extends HookConsumerWidget {
selected.value = assets;
},
selectionActive: true,
preselectedAssets: isNewAlbum ? selected.value : existingAssets,
canDeselect: isNewAlbum,
preselectedAssets: existingAssets,
canDeselect: canDeselect,
showMultiSelectIndicator: false,
);
}
@ -65,7 +66,7 @@ class AssetSelectionPage extends HookConsumerWidget {
),
centerTitle: false,
actions: [
if (selected.value.isNotEmpty)
if (selected.value.isNotEmpty || canDeselect)
TextButton(
onPressed: () {
var payload =
@ -74,7 +75,7 @@ class AssetSelectionPage extends HookConsumerWidget {
.popForced<AssetSelectionPageResult>(payload);
},
child: Text(
"share_add",
canDeselect ? "share_done" : "share_add",
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,

View File

@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart';
import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
// ignore: must_be_immutable
class CreateAlbumPage extends HookConsumerWidget {
@ -31,7 +32,8 @@ class CreateAlbumPage extends HookConsumerWidget {
final isAlbumTitleTextFieldFocus = useState(false);
final isAlbumTitleEmpty = useState(true);
final selectedAssets = useState<Set<Asset>>(
initialAssets != null ? Set.from(initialAssets!) : const {},);
initialAssets != null ? Set.from(initialAssets!) : const {},
);
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
showSelectUserPage() async {
@ -59,7 +61,8 @@ class CreateAlbumPage extends HookConsumerWidget {
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionRoute(
existingAssets: selectedAssets.value,
isNewAlbum: true,
canDeselect: true,
query: getRemoteAssetQuery(ref),
),
);
if (selectedAsset == null) {

View File

@ -0,0 +1,50 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class AssetStackNotifier extends StateNotifier<List<Asset>> {
final Asset _asset;
final Ref _ref;
AssetStackNotifier(
this._asset,
this._ref,
) : super([]) {
fetchStackChildren();
}
void fetchStackChildren() async {
if (mounted) {
state = await _ref.read(assetStackProvider(_asset).future);
}
}
removeChild(int index) {
if (index < state.length) {
state.removeAt(index);
}
}
}
final assetStackStateProvider = StateNotifierProvider.autoDispose
.family<AssetStackNotifier, List<Asset>, Asset>(
(ref, asset) => AssetStackNotifier(asset, ref),
);
final assetStackProvider =
FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
// Guard [local asset]
if (asset.remoteId == null) {
return [];
}
return await ref
.watch(dbProvider)
.assets
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackParentIdEqualTo(asset.remoteId)
.findAll();
});

View File

@ -3,6 +3,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:isar/isar.dart';
final renderListProvider =
FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
@ -13,3 +14,19 @@ final renderListProvider =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)],
);
});
final renderListQueryProvider = StreamProvider.family<RenderList,
QueryBuilder<Asset, Asset, QAfterSortBy>?>(
(ref, query) async* {
if (query == null) {
return;
}
final settings = ref.watch(appSettingsServiceProvider);
final groupBy = GroupAssetsBy
.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
}
},
);

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
class AssetStackService {
AssetStackService(this._api);
final ApiService _api;
updateStack(
Asset parentAsset, {
List<Asset>? childrenToAdd,
List<Asset>? childrenToRemove,
}) async {
// Guard [local asset]
if (parentAsset.remoteId == null) {
return;
}
try {
if (childrenToAdd != null) {
final toAdd = childrenToAdd
.where((e) => e.isRemote)
.map((e) => e.remoteId!)
.toList();
await _api.assetApi.updateAssets(
AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId),
);
}
if (childrenToRemove != null) {
final toRemove = childrenToRemove
.where((e) => e.isRemote)
.map((e) => e.remoteId!)
.toList();
await _api.assetApi.updateAssets(
AssetBulkUpdateDto(ids: toRemove, removeParent: true),
);
}
} catch (error) {
debugPrint("Error while updating stack children: ${error.toString()}");
}
}
updateStackParent(Asset oldParent, Asset newParent) async {
// Guard [local asset]
if (oldParent.remoteId == null || newParent.remoteId == null) {
return;
}
try {
await _api.assetApi.updateStackParent(
UpdateStackParentDto(
oldParentId: oldParent.remoteId!,
newParentId: newParent.remoteId!,
),
);
} catch (error) {
debugPrint("Error while updating stack parent: ${error.toString()}");
}
}
}
final assetStackServiceProvider = Provider(
(ref) => AssetStackService(
ref.watch(apiServiceProvider),
),
);

View File

@ -8,11 +8,13 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
@ -44,6 +46,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final int totalAssets;
final int initialIndex;
final int heroOffset;
final bool showStack;
GalleryViewerPage({
super.key,
@ -51,6 +54,7 @@ class GalleryViewerPage extends HookConsumerWidget {
required this.loadAsset,
required this.totalAssets,
this.heroOffset = 0,
this.showStack = false,
}) : controller = PageController(initialPage: initialIndex);
final PageController controller;
@ -77,8 +81,17 @@ class GalleryViewerPage extends HookConsumerWidget {
final isFromTrash = isTrashEnabled &&
navStack.length > 2 &&
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
final stackIndex = useState(-1);
final stack = showStack && currentAsset.stackCount > 0
? ref.watch(assetStackStateProvider(currentAsset))
: <Asset>[];
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
Asset asset() => currentAsset;
Asset asset() => stackIndex.value == -1
? currentAsset
: stackElements.elementAt(stackIndex.value);
bool isParent = stackIndex.value == -1 || stackIndex.value == 0;
useEffect(
() {
@ -165,19 +178,28 @@ class GalleryViewerPage extends HookConsumerWidget {
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: ExifBottomSheet(asset: currentAsset),
child: ExifBottomSheet(asset: asset()),
);
},
);
}
void removeAssetFromStack() {
if (stackIndex.value > 0 && showStack) {
ref
.read(assetStackStateProvider(currentAsset).notifier)
.removeChild(stackIndex.value - 1);
stackIndex.value = stackIndex.value - 1;
}
}
void handleDelete(Asset deleteAsset) async {
Future<bool> onDelete(bool force) async {
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
{deleteAsset},
force: force,
);
if (isDeleted) {
if (isDeleted && isParent) {
if (totalAssets == 1) {
// Handle only one asset
AutoRouter.of(context).pop();
@ -195,8 +217,9 @@ class GalleryViewerPage extends HookConsumerWidget {
// Asset is trashed
if (isTrashEnabled && !isFromTrash) {
final isDeleted = await onDelete(false);
if (isDeleted) {
// Can only trash assets stored in server. Local assets are always permanently removed for now
if (context.mounted && isDeleted && deleteAsset.isRemote) {
if (context.mounted && deleteAsset.isRemote && isParent) {
ImmichToast.show(
durationInSecond: 1,
context: context,
@ -204,6 +227,8 @@ class GalleryViewerPage extends HookConsumerWidget {
gravity: ToastGravity.BOTTOM,
);
}
removeAssetFromStack();
}
return;
}
@ -211,7 +236,14 @@ class GalleryViewerPage extends HookConsumerWidget {
showDialog(
context: context,
builder: (BuildContext _) {
return DeleteDialog(onDelete: () => onDelete(true));
return DeleteDialog(
onDelete: () async {
final isDeleted = await onDelete(true);
if (isDeleted) {
removeAssetFromStack();
}
},
);
},
);
}
@ -268,7 +300,11 @@ class GalleryViewerPage extends HookConsumerWidget {
ref
.watch(assetProvider.notifier)
.toggleArchive([asset], !asset.isArchived);
if (isParent) {
AutoRouter.of(context).pop();
return;
}
removeAssetFromStack();
}
handleUpload(Asset asset) {
@ -385,7 +421,186 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
buildBottomBar() {
Widget buildStackedChildren() {
return ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: stackElements.length,
itemBuilder: (context, index) {
final assetId = stackElements.elementAt(index).remoteId;
return Padding(
padding: const EdgeInsets.only(right: 10),
child: GestureDetector(
onTap: () => stackIndex.value = index,
child: Container(
width: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
border: index == stackIndex.value
? Border.all(
color: Colors.white,
width: 2,
)
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: CachedNetworkImage(
fit: BoxFit.cover,
imageUrl:
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId',
httpHeaders: {
"Authorization":
"Bearer ${Store.get(StoreKey.accessToken)}",
},
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
),
),
),
),
);
},
);
}
void showStackActionItems() {
showModalBottomSheet<void>(
context: context,
enableDrag: false,
builder: (BuildContext ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isParent)
ListTile(
leading: const Icon(
Icons.bookmark_border_outlined,
size: 24,
),
onTap: () async {
await ref
.read(assetStackServiceProvider)
.updateStackParent(
currentAsset,
stackElements.elementAt(stackIndex.value),
);
Navigator.pop(ctx);
AutoRouter.of(context).pop();
},
title: const Text(
"viewer_stack_use_as_main_asset",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
ListTile(
leading: const Icon(
Icons.copy_all_outlined,
size: 24,
),
onTap: () async {
if (isParent) {
await ref
.read(assetStackServiceProvider)
.updateStackParent(
currentAsset,
stackElements
.elementAt(1), // Next asset as parent
);
// Remove itself from stack
await ref.read(assetStackServiceProvider).updateStack(
stackElements.elementAt(1),
childrenToRemove: [currentAsset],
);
Navigator.pop(ctx);
AutoRouter.of(context).pop();
} else {
await ref.read(assetStackServiceProvider).updateStack(
currentAsset,
childrenToRemove: [
stackElements.elementAt(stackIndex.value),
],
);
removeAssetFromStack();
Navigator.pop(ctx);
}
},
title: const Text(
"viewer_remove_from_stack",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
ListTile(
leading: const Icon(
Icons.filter_none_outlined,
size: 18,
),
onTap: () async {
await ref.read(assetStackServiceProvider).updateStack(
currentAsset,
childrenToRemove: stack,
);
Navigator.pop(ctx);
AutoRouter.of(context).pop();
},
title: const Text(
"viewer_unstack",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
],
),
),
);
},
);
}
Widget buildBottomBar() {
// !!!! itemsList and actionlist should always be in sync
final itemsList = [
BottomNavigationBarItem(
icon: Icon(
Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
),
label: 'control_bottom_app_bar_share'.tr(),
tooltip: 'control_bottom_app_bar_share'.tr(),
),
asset().isArchived
? BottomNavigationBarItem(
icon: const Icon(Icons.unarchive_rounded),
label: 'control_bottom_app_bar_unarchive'.tr(),
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
)
: BottomNavigationBarItem(
icon: const Icon(Icons.archive_outlined),
label: 'control_bottom_app_bar_archive'.tr(),
tooltip: 'control_bottom_app_bar_archive'.tr(),
),
if (stack.isNotEmpty)
BottomNavigationBarItem(
icon: const Icon(Icons.burst_mode_outlined),
label: 'control_bottom_app_bar_stack'.tr(),
tooltip: 'control_bottom_app_bar_stack'.tr(),
),
BottomNavigationBarItem(
icon: const Icon(Icons.delete_outline),
label: 'control_bottom_app_bar_delete'.tr(),
tooltip: 'control_bottom_app_bar_delete'.tr(),
),
];
List<Function(int)> actionslist = [
(_) => shareAsset(),
(_) => handleArchive(asset()),
if (stack.isNotEmpty) (_) => showStackActionItems(),
(_) => handleDelete(asset()),
];
return IgnorePointer(
ignoring: !ref.watch(showControlsProvider),
child: AnimatedOpacity(
@ -393,6 +608,17 @@ class GalleryViewerPage extends HookConsumerWidget {
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
child: Column(
children: [
if (stack.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 10,
bottom: 30,
),
child: SizedBox(
height: 40,
child: buildStackedChildren(),
),
),
Visibility(
visible: !asset().isImage && !isPlayingMotionVideo.value,
child: Container(
@ -421,44 +647,10 @@ class GalleryViewerPage extends HookConsumerWidget {
selectedLabelStyle: const TextStyle(color: Colors.black),
showSelectedLabels: false,
showUnselectedLabels: false,
items: [
BottomNavigationBarItem(
icon: Icon(
Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
),
label: 'control_bottom_app_bar_share'.tr(),
tooltip: 'control_bottom_app_bar_share'.tr(),
),
asset().isArchived
? BottomNavigationBarItem(
icon: const Icon(Icons.unarchive_rounded),
label: 'control_bottom_app_bar_unarchive'.tr(),
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
)
: BottomNavigationBarItem(
icon: const Icon(Icons.archive_outlined),
label: 'control_bottom_app_bar_archive'.tr(),
tooltip: 'control_bottom_app_bar_archive'.tr(),
),
BottomNavigationBarItem(
icon: const Icon(Icons.delete_outline),
label: 'control_bottom_app_bar_delete'.tr(),
tooltip: 'control_bottom_app_bar_delete'.tr(),
),
],
items: itemsList,
onTap: (index) {
switch (index) {
case 0:
shareAsset();
break;
case 1:
handleArchive(asset());
break;
case 2:
handleDelete(asset());
break;
if (index < actionslist.length) {
actionslist[index].call(index);
}
},
),
@ -504,6 +696,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final next = currentIndex.value < value ? value + 1 : value - 1;
precacheNextImage(next);
currentIndex.value = value;
stackIndex.value = -1;
HapticFeedback.selectionClick();
},
loadingBuilder: (context, event, index) {
@ -544,10 +737,11 @@ class GalleryViewerPage extends HookConsumerWidget {
: webPThumbnail;
},
builder: (context, index) {
final asset = loadAsset(index);
final ImageProvider provider = finalImageProvider(asset);
final a =
index == currentIndex.value ? asset() : loadAsset(index);
final ImageProvider provider = finalImageProvider(a);
if (asset.isImage && !isPlayingMotionVideo.value) {
if (a.isImage && !isPlayingMotionVideo.value) {
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
@ -558,13 +752,13 @@ class GalleryViewerPage extends HookConsumerWidget {
},
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(
tag: asset.id + heroOffset,
tag: a.id + heroOffset,
),
filterQuality: FilterQuality.high,
tightMode: true,
minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) => ImmichImage(
asset,
a,
fit: BoxFit.contain,
),
);
@ -575,7 +769,7 @@ class GalleryViewerPage extends HookConsumerWidget {
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(
tag: asset.id + heroOffset,
tag: a.id + heroOffset,
),
filterQuality: FilterQuality.high,
maxScale: 1.0,
@ -584,7 +778,7 @@ class GalleryViewerPage extends HookConsumerWidget {
child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true,
onPaused: () => isPlayingVideo.value = false,
asset: asset,
asset: a,
isMotionVideo: isPlayingMotionVideo.value,
placeholder: Image(
image: provider,

View File

@ -0,0 +1,47 @@
import 'package:immich_mobile/shared/models/asset.dart';
class SelectionAssetState {
final bool hasRemote;
final bool hasLocal;
final bool hasMerged;
const SelectionAssetState({
this.hasRemote = false,
this.hasLocal = false,
this.hasMerged = false,
});
SelectionAssetState copyWith({
bool? hasRemote,
bool? hasLocal,
bool? hasMerged,
}) {
return SelectionAssetState(
hasRemote: hasRemote ?? this.hasRemote,
hasLocal: hasLocal ?? this.hasLocal,
hasMerged: hasMerged ?? this.hasMerged,
);
}
SelectionAssetState.fromSelection(Set<Asset> selection)
: hasLocal = selection.any((e) => e.storage == AssetState.local),
hasMerged = selection.any((e) => e.storage == AssetState.merged),
hasRemote = selection.any((e) => e.storage == AssetState.remote);
@override
String toString() =>
'SelectionAssetState(hasRemote: $hasRemote, hasMerged: $hasMerged, hasMerged: $hasMerged)';
@override
bool operator ==(covariant SelectionAssetState other) {
if (identical(this, other)) return true;
return other.hasRemote == hasRemote &&
other.hasLocal == hasLocal &&
other.hasMerged == hasMerged;
}
@override
int get hashCode =>
hasRemote.hashCode ^ hasLocal.hashCode ^ hasMerged.hashCode;
}

View File

@ -32,6 +32,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
final Widget? topWidget;
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
const ImmichAssetGrid({
super.key,
@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.topWidget,
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
});
@override
@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
heroOffset: heroOffset(),
shrinkWrap: shrinkWrap,
showDragScroll: showDragScroll,
showStack: showStack,
),
);
}

View File

@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget {
final int heroOffset;
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
const ImmichAssetGridView({
super.key,
@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget {
this.heroOffset = 0,
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
});
@override
@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
bool _scrolling = false;
final Set<Asset> _selectedAssets =
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
Set<Asset> _getSelectedAssets() {
return Set.from(_selectedAssets);
@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _deselectAssets(List<Asset> assets) {
setState(() {
_selectedAssets.removeAll(assets);
_selectedAssets.removeAll(
assets.where(
(a) =>
widget.canDeselect ||
!(widget.preselectedAssets?.contains(a) ?? false),
),
);
_callSelectionListener(_selectedAssets.isNotEmpty);
});
}
@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
useGrayBoxPlaceholder: true,
showStorageIndicator: widget.showStorageIndicator,
heroOffset: widget.heroOffset,
showStack: widget.showStack,
);
}
@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
setState(() {
_selectedAssets.clear();
});
} else if (widget.preselectedAssets != null) {
setState(() {
_selectedAssets.addAll(widget.preselectedAssets!);
});
}
}

View File

@ -12,6 +12,7 @@ class ThumbnailImage extends StatelessWidget {
final Asset Function(int index) loadAsset;
final int totalAssets;
final bool showStorageIndicator;
final bool showStack;
final bool useGrayBoxPlaceholder;
final bool isSelected;
final bool multiselectEnabled;
@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget {
required this.loadAsset,
required this.totalAssets,
this.showStorageIndicator = true,
this.showStack = false,
this.useGrayBoxPlaceholder = false,
this.isSelected = false,
this.multiselectEnabled = false,
@ -93,6 +95,35 @@ class ThumbnailImage extends StatelessWidget {
);
}
Widget buildStackIcon() {
return Positioned(
top: 5,
right: 5,
child: Row(
children: [
if (asset.stackCount > 1)
Text(
"${asset.stackCount}",
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
if (asset.stackCount > 1)
const SizedBox(
width: 3,
),
const Icon(
Icons.burst_mode_rounded,
color: Colors.white,
size: 18,
),
],
),
);
}
Widget buildImage() {
final image = SizedBox(
width: 300,
@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget {
decoration: BoxDecoration(
border: Border.all(
width: 0,
color: assetContainerColor,
color: onDeselect == null ? Colors.grey : assetContainerColor,
),
color: assetContainerColor,
color: onDeselect == null ? Colors.grey : assetContainerColor,
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget {
loadAsset: loadAsset,
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
),
);
}
@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget {
),
),
if (!asset.isImage) buildVideoIcon(),
if (asset.isImage && asset.stackCount > 0) buildStackIcon(),
],
),
);

View File

@ -4,9 +4,9 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
import 'package:immich_mobile/modules/home/models/selection_state.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/models/album.dart';
@ -19,11 +19,12 @@ class ControlBottomAppBar extends ConsumerWidget {
final Function(Album album) onAddToAlbum;
final void Function() onCreateNewAlbum;
final void Function() onUpload;
final void Function() onStack;
final List<Album> albums;
final List<Album> sharedAlbums;
final bool enabled;
final AssetState selectionAssetState;
final SelectionAssetState selectionAssetState;
const ControlBottomAppBar({
Key? key,
@ -36,19 +37,24 @@ class ControlBottomAppBar extends ConsumerWidget {
required this.onAddToAlbum,
required this.onCreateNewAlbum,
required this.onUpload,
this.selectionAssetState = AssetState.remote,
required this.onStack,
this.selectionAssetState = const SelectionAssetState(),
this.enabled = true,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
var hasRemote = selectionAssetState == AssetState.remote;
var hasRemote =
selectionAssetState.hasRemote || selectionAssetState.hasMerged;
var hasLocal = selectionAssetState.hasLocal;
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
Widget renderActionButtons() {
return Row(
return Wrap(
spacing: 10,
runSpacing: 15,
children: [
ControlBoxButton(
iconData: Platform.isAndroid
@ -92,7 +98,7 @@ class ControlBottomAppBar extends ConsumerWidget {
if (!hasRemote)
ControlBoxButton(
iconData: Icons.backup_outlined,
label: "Upload",
label: "control_bottom_app_bar_upload".tr(),
onPressed: enabled
? () => showDialog(
context: context,
@ -104,6 +110,12 @@ class ControlBottomAppBar extends ConsumerWidget {
)
: null,
),
if (!hasLocal)
ControlBoxButton(
iconData: Icons.filter_none_rounded,
label: "control_bottom_app_bar_stack".tr(),
onPressed: enabled ? onStack : null,
),
],
);
}
@ -111,7 +123,7 @@ class ControlBottomAppBar extends ConsumerWidget {
return DraggableScrollableSheet(
initialChildSize: hasRemote ? 0.30 : 0.18,
minChildSize: 0.18,
maxChildSize: hasRemote ? 0.57 : 0.18,
maxChildSize: hasRemote ? 0.60 : 0.18,
snap: true,
builder: (
BuildContext context,

View File

@ -7,11 +7,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/models/selection_state.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
@ -36,7 +40,7 @@ class HomePage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false);
final selectionAssetState = useState(AssetState.remote);
final selectionAssetState = useState(const SelectionAssetState());
final selection = useState(<Asset>{});
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
@ -83,9 +87,8 @@ class HomePage extends HookConsumerWidget {
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
selectionAssetState.value = selectedAssets.any((e) => e.isRemote)
? AssetState.remote
: AssetState.local;
selectionAssetState.value =
SelectionAssetState.fromSelection(selectedAssets);
}
void onShareAssets() {
@ -246,6 +249,55 @@ class HomePage extends HookConsumerWidget {
}
}
void onStack() async {
try {
processing.value = true;
if (!selectionEnabledHook.value) {
return;
}
final selectedAsset = selection.value.elementAt(0);
if (selection.value.length == 1) {
final stackChildren =
(await ref.read(assetStackProvider(selectedAsset).future))
.toSet();
AssetSelectionPageResult? returnPayload =
await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionRoute(
existingAssets: stackChildren,
canDeselect: true,
query: getAssetStackSelectionQuery(ref, selectedAsset),
),
);
if (returnPayload != null) {
Set<Asset> selectedAssets = returnPayload.selectedAssets;
// Do not add itself as its stack child
selectedAssets.remove(selectedAsset);
final removedChildren = stackChildren.difference(selectedAssets);
final addedChildren = selectedAssets.difference(stackChildren);
await ref.read(assetStackServiceProvider).updateStack(
selectedAsset,
childrenToAdd: addedChildren.toList(),
childrenToRemove: removedChildren.toList(),
);
}
} else {
// Merge assets
selection.value.remove(selectedAsset);
final selectedAssets = selection.value;
await ref.read(assetStackServiceProvider).updateStack(
selectedAsset,
childrenToAdd: selectedAssets.toList(),
);
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
Future<void> refreshAssets() async {
final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
@ -322,6 +374,7 @@ class HomePage extends HookConsumerWidget {
currentUser.memoryEnabled!)
? const MemoryLane()
: const SizedBox(),
showStack: true,
),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator,
@ -339,6 +392,7 @@ class HomePage extends HookConsumerWidget {
onUpload: onUpload,
enabled: !processing.value,
selectionAssetState: selectionAssetState.value,
onStack: onStack,
),
if (processing.value) const Center(child: ImmichLoadingIndicator()),
],

View File

@ -252,6 +252,7 @@ class TrashPage extends HookConsumerWidget {
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
showMultiSelectIndicator: false,
showStack: true,
topWidget: Padding(
padding: const EdgeInsets.only(
top: 24,

View File

@ -51,6 +51,7 @@ import 'package:immich_mobile/shared/views/app_log_detail_page.dart';
import 'package:immich_mobile/shared/views/app_log_page.dart';
import 'package:immich_mobile/shared/views/splash_screen.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart';
part 'router.gr.dart';

View File

@ -71,6 +71,7 @@ class _$AppRouter extends RootStackRouter {
loadAsset: args.loadAsset,
totalAssets: args.totalAssets,
heroOffset: args.heroOffset,
showStack: args.showStack,
),
);
},
@ -153,7 +154,8 @@ class _$AppRouter extends RootStackRouter {
child: AssetSelectionPage(
key: args.key,
existingAssets: args.existingAssets,
isNewAlbum: args.isNewAlbum,
canDeselect: args.canDeselect,
query: args.query,
),
transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true,
@ -711,6 +713,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
required Asset Function(int) loadAsset,
required int totalAssets,
int heroOffset = 0,
bool showStack = false,
}) : super(
GalleryViewerRoute.name,
path: '/gallery-viewer-page',
@ -720,6 +723,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
loadAsset: loadAsset,
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
),
);
@ -733,6 +737,7 @@ class GalleryViewerRouteArgs {
required this.loadAsset,
required this.totalAssets,
this.heroOffset = 0,
this.showStack = false,
});
final Key? key;
@ -745,9 +750,11 @@ class GalleryViewerRouteArgs {
final int heroOffset;
final bool showStack;
@override
String toString() {
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset}';
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}';
}
}
@ -961,14 +968,16 @@ class AssetSelectionRoute extends PageRouteInfo<AssetSelectionRouteArgs> {
AssetSelectionRoute({
Key? key,
required Set<Asset> existingAssets,
bool isNewAlbum = false,
bool canDeselect = false,
required QueryBuilder<Asset, Asset, QAfterSortBy>? query,
}) : super(
AssetSelectionRoute.name,
path: '/asset-selection-page',
args: AssetSelectionRouteArgs(
key: key,
existingAssets: existingAssets,
isNewAlbum: isNewAlbum,
canDeselect: canDeselect,
query: query,
),
);
@ -979,18 +988,21 @@ class AssetSelectionRouteArgs {
const AssetSelectionRouteArgs({
this.key,
required this.existingAssets,
this.isNewAlbum = false,
this.canDeselect = false,
required this.query,
});
final Key? key;
final Set<Asset> existingAssets;
final bool isNewAlbum;
final bool canDeselect;
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
@override
String toString() {
return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}';
return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect, query: $query}';
}
}

View File

@ -31,7 +31,9 @@ class Asset {
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite,
isArchived = remote.isArchived,
isTrashed = remote.isTrashed;
isTrashed = remote.isTrashed,
stackParentId = remote.stackParentId,
stackCount = remote.stackCount;
Asset.local(AssetEntity local, List<int> hash)
: localId = local.id,
@ -47,6 +49,7 @@ class Asset {
isFavorite = local.isFavorite,
isArchived = false,
isTrashed = false,
stackCount = 0,
fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
@ -77,6 +80,8 @@ class Asset {
required this.isFavorite,
required this.isArchived,
required this.isTrashed,
this.stackParentId,
required this.stackCount,
});
@ignore
@ -146,6 +151,10 @@ class Asset {
@ignore
ExifInfo? exifInfo;
String? stackParentId;
int stackCount;
/// `true` if this [Asset] is present on the device
@ignore
bool get isLocal => localId != null;
@ -200,7 +209,9 @@ class Asset {
isFavorite == other.isFavorite &&
isLocal == other.isLocal &&
isArchived == other.isArchived &&
isTrashed == other.isTrashed;
isTrashed == other.isTrashed &&
stackCount == other.stackCount &&
stackParentId == other.stackParentId;
}
@override
@ -223,7 +234,9 @@ class Asset {
isFavorite.hashCode ^
isLocal.hashCode ^
isArchived.hashCode ^
isTrashed.hashCode;
isTrashed.hashCode ^
stackCount.hashCode ^
stackParentId.hashCode;
/// Returns `true` if this [Asset] can updated with values from parameter [a]
bool canUpdate(Asset a) {
@ -236,9 +249,11 @@ class Asset {
width == null && a.width != null ||
height == null && a.height != null ||
livePhotoVideoId == null && a.livePhotoVideoId != null ||
stackParentId == null && a.stackParentId != null ||
isFavorite != a.isFavorite ||
isArchived != a.isArchived ||
isTrashed != a.isTrashed;
isTrashed != a.isTrashed ||
stackCount != a.stackCount;
}
/// Returns a new [Asset] with values from this and merged & updated with [a]
@ -267,6 +282,8 @@ class Asset {
id: id,
remoteId: remoteId,
livePhotoVideoId: livePhotoVideoId,
stackParentId: stackParentId,
stackCount: stackCount,
isFavorite: isFavorite,
isArchived: isArchived,
isTrashed: isTrashed,
@ -281,6 +298,8 @@ class Asset {
width: a.width,
height: a.height,
livePhotoVideoId: a.livePhotoVideoId,
stackParentId: a.stackParentId,
stackCount: a.stackCount,
// isFavorite + isArchived are not set by device-only assets
isFavorite: a.isFavorite,
isArchived: a.isArchived,
@ -318,6 +337,8 @@ class Asset {
bool? isArchived,
bool? isTrashed,
ExifInfo? exifInfo,
String? stackParentId,
int? stackCount,
}) =>
Asset(
id: id ?? this.id,
@ -338,6 +359,8 @@ class Asset {
isArchived: isArchived ?? this.isArchived,
isTrashed: isTrashed ?? this.isTrashed,
exifInfo: exifInfo ?? this.exifInfo,
stackParentId: stackParentId ?? this.stackParentId,
stackCount: stackCount ?? this.stackCount,
);
Future<void> put(Isar db) async {
@ -379,6 +402,8 @@ class Asset {
"checksum": "$checksum",
"ownerId": $ownerId,
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
"stackCount": "$stackCount",
"stackParentId": "${stackParentId ?? "N/A"}",
"fileCreatedAt": "$fileCreatedAt",
"fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt",

View File

@ -5,6 +5,7 @@ import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
@ -217,6 +218,7 @@ final assetsProvider =
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackParentIdIsNull()
.sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
@ -227,10 +229,12 @@ final assetsProvider =
}
});
final remoteAssetsProvider =
StreamProvider.family<RenderList, int?>((ref, userId) async* {
if (userId == null) return;
final query = ref
QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
final userId = ref.watch(currentUserProvider)?.isarId;
if (userId == null) {
return null;
}
return ref
.watch(dbProvider)
.assets
.where()
@ -238,12 +242,34 @@ final remoteAssetsProvider =
.filter()
.ownerIdEqualTo(userId)
.isTrashedEqualTo(false)
.stackParentIdIsNull()
.sortByFileCreatedAtDesc();
}
QueryBuilder<Asset, Asset, QAfterSortBy>? getAssetStackSelectionQuery(
WidgetRef ref,
Asset parentAsset,
) {
final userId = ref.watch(currentUserProvider)?.isarId;
if (userId == null || !parentAsset.isRemote) {
return null;
}
return ref
.watch(dbProvider)
.assets
.where()
.remoteIdIsNotNull()
.filter()
.isArchivedEqualTo(false)
.ownerIdEqualTo(userId)
.not()
.remoteIdEqualTo(parentAsset.remoteId)
// Show existing stack children in selection page
.group(
(q) => q
.stackParentIdIsNull()
.or()
.stackParentIdEqualTo(parentAsset.remoteId),
)
.sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider);
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)];
yield await RenderList.fromQuery(query, groupBy);
await for (final _ in query.watchLazy()) {
yield await RenderList.fromQuery(query, groupBy);
}
});

View File

@ -133,6 +133,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleServerUpdates);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
} catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
}

View File

@ -149,6 +149,7 @@ doc/TranscodePolicy.md
doc/UpdateAlbumDto.md
doc/UpdateAssetDto.md
doc/UpdateLibraryDto.md
doc/UpdateStackParentDto.md
doc/UpdateTagDto.md
doc/UpdateUserDto.md
doc/UsageByUserDto.md
@ -314,6 +315,7 @@ lib/model/transcode_policy.dart
lib/model/update_album_dto.dart
lib/model/update_asset_dto.dart
lib/model/update_library_dto.dart
lib/model/update_stack_parent_dto.dart
lib/model/update_tag_dto.dart
lib/model/update_user_dto.dart
lib/model/usage_by_user_dto.dart
@ -468,6 +470,7 @@ test/transcode_policy_test.dart
test/update_album_dto_test.dart
test/update_asset_dto_test.dart
test/update_library_dto_test.dart
test/update_stack_parent_dto_test.dart
test/update_tag_dto_test.dart
test/update_user_dto_test.dart
test/usage_by_user_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/UpdateStackParentDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -25,6 +25,7 @@ void main() {
isFavorite: false,
isArchived: false,
isTrashed: false,
stackCount: 0,
),
);
}

View File

@ -35,6 +35,7 @@ void main() {
isFavorite: false,
isArchived: false,
isTrashed: false,
stackCount: 0,
);
}

View File

@ -1673,6 +1673,41 @@
]
}
},
"/asset/stack/parent": {
"put": {
"operationId": "updateStackParent",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateStackParentDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Asset"
]
}
},
"/asset/statistics": {
"get": {
"operationId": "getAssetStats",
@ -5696,6 +5731,13 @@
},
"isFavorite": {
"type": "boolean"
},
"removeParent": {
"type": "boolean"
},
"stackParentId": {
"format": "uuid",
"type": "string"
}
},
"required": [
@ -5941,6 +5983,19 @@
"smartInfo": {
"$ref": "#/components/schemas/SmartInfoResponseDto"
},
"stack": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
},
"stackCount": {
"type": "integer"
},
"stackParentId": {
"nullable": true,
"type": "string"
},
"tags": {
"items": {
"$ref": "#/components/schemas/TagResponseDto"
@ -5961,6 +6016,7 @@
},
"required": [
"type",
"stackCount",
"deviceAssetId",
"deviceId",
"ownerId",
@ -8521,6 +8577,23 @@
},
"type": "object"
},
"UpdateStackParentDto": {
"properties": {
"newParentId": {
"format": "uuid",
"type": "string"
},
"oldParentId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"oldParentId",
"newParentId"
],
"type": "object"
},
"UpdateTagDto": {
"properties": {
"name": {

View File

@ -20,6 +20,7 @@ import { Readable } from 'stream';
import { JobName } from '../job';
import {
AssetStats,
CommunicationEvent,
IAssetRepository,
ICommunicationRepository,
ICryptoRepository,
@ -636,10 +637,89 @@ describe(AssetService.name, () => {
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
});
/// Stack related
it('should require asset update access for parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false);
await expect(
sut.updateAll(authStub.user1, {
ids: ['asset-1'],
stackParentId: 'parent',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should update parent asset when children are added', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
ids: [],
stackParentId: 'parent',
}),
expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null });
});
it('should update parent asset when children are removed', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]);
await sut.updateAll(authStub.user1, {
ids: ['child-1'],
removeParent: true,
}),
expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null });
});
it('update parentId for new children', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
stackParentId: 'parent',
ids: ['child-1', 'child-2'],
});
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
});
it('nullify parentId for remove children', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
removeParent: true,
ids: ['child-1', 'child-2'],
});
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null });
});
it('merge stacks if new child has children', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([
{ id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity,
]);
await sut.updateAll(authStub.user1, {
ids: ['child-1'],
stackParentId: 'parent',
});
expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' });
});
it('should send ws asset update event', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateAll(authStub.user1, {
ids: ['asset-1'],
stackParentId: 'parent',
});
expect(communicationMock.send).toHaveBeenCalledWith(CommunicationEvent.ASSET_UPDATE, authStub.user1.id, [
'asset-1',
]);
});
});
describe('deleteAll', () => {
it('should required asset delete access for all ids', async () => {
it('should require asset delete access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.deleteAll(authStub.user1, {
@ -677,7 +757,7 @@ describe(AssetService.name, () => {
});
describe('restoreAll', () => {
it('should required asset restore access for all ids', async () => {
it('should require asset restore access for all ids', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.deleteAll(authStub.user1, {
@ -757,6 +837,21 @@ describe(AssetService.name, () => {
expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace);
});
it('should update stack parent if asset has stack children', async () => {
when(assetMock.getById)
.calledWith(assetStub.primaryImage.id)
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id });
expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], {
stackParentId: 'stack-child-asset-1',
});
expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], {
stackParentId: null,
});
});
it('should not schedule delete-files job for readonly assets', async () => {
when(assetMock.getById)
.calledWith(assetStub.readOnly.id)
@ -854,4 +949,70 @@ describe(AssetService.name, () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
});
});
describe('updateStackParent', () => {
it('should require asset update access for new parent', async () => {
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false);
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.updateStackParent(authStub.user1, {
oldParentId: 'old',
newParentId: 'new',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should require asset read access for old parent', async () => {
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true);
await expect(
sut.updateStackParent(authStub.user1, {
oldParentId: 'old',
newParentId: 'new',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('make old parent the child of new parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
when(assetMock.getById)
.calledWith(assetStub.image.id)
.mockResolvedValue(assetStub.image as AssetEntity);
await sut.updateStackParent(authStub.user1, {
oldParentId: assetStub.image.id,
newParentId: 'new',
});
expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' });
});
it('remove stackParentId of new parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await sut.updateStackParent(authStub.user1, {
oldParentId: assetStub.primaryImage.id,
newParentId: 'new',
});
expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null });
});
it('update stackParentId of old parents children to new parent', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
when(assetMock.getById)
.calledWith(assetStub.primaryImage.id)
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
await sut.updateStackParent(authStub.user1, {
oldParentId: assetStub.primaryImage.id,
newParentId: 'new',
});
expect(assetMock.updateAll).toBeCalledWith(
[assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'],
{ stackParentId: 'new' },
);
});
});
});

View File

@ -40,6 +40,7 @@ import {
TimeBucketDto,
TrashAction,
UpdateAssetDto,
UpdateStackParentDto,
mapStats,
} from './dto';
import {
@ -208,7 +209,7 @@ export class AssetService {
if (authUser.isShowMetadata) {
return assets.map((asset) => mapAsset(asset));
} else {
return assets.map((asset) => mapAsset(asset, true));
return assets.map((asset) => mapAsset(asset, { stripMetadata: true }));
}
}
@ -338,10 +339,29 @@ export class AssetService {
}
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, ...options } = dto;
const { ids, removeParent, ...options } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
if (removeParent) {
(options as Partial<AssetEntity>).stackParentId = null;
const assets = await this.assetRepository.getByIds(ids);
// This updates the updatedAt column of the parents to indicate that one of its children is removed
// All the unique parent's -> parent is set to null
ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!)));
} else if (options.stackParentId) {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId);
// Merge stacks
const assets = await this.assetRepository.getByIds(ids);
const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0);
ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id)));
// This updates the updatedAt column of the parent to indicate that a new child has been added
await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
}
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
await this.assetRepository.updateAll(ids, options);
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
}
async handleAssetDeletionCheck() {
@ -384,6 +404,14 @@ export class AssetService {
);
}
// Replace the parent of the stack children with a new asset
if (asset.stack && asset.stack.length != 0) {
const stackIds = asset.stack.map((a) => a.id);
const newParentId = stackIds[0];
await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId });
await this.assetRepository.updateAll([newParentId], { stackParentId: null });
}
await this.assetRepository.remove(asset);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id);
@ -454,6 +482,25 @@ export class AssetService {
this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids);
}
async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise<void> {
const { oldParentId, newParentId } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId);
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId);
const childIds: string[] = [];
const oldParent = await this.assetRepository.getById(oldParentId);
if (oldParent != null) {
childIds.push(oldParent.id);
// Get all children of old parent
childIds.push(...(oldParent.stack?.map((a) => a.id) ?? []));
}
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]);
await this.assetRepository.updateAll(childIds, { stackParentId: newParentId });
// Remove ParentId of new parent if this was previously a child of some other asset
return this.assetRepository.updateAll([newParentId], { stackParentId: null });
}
async run(authUser: AuthUserDto, dto: AssetJobsDto) {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds);

View File

@ -0,0 +1,9 @@
import { ValidateUUID } from '../../domain.util';
export class UpdateStackParentDto {
@ValidateUUID()
oldParentId!: string;
@ValidateUUID()
newParentId!: string;
}

View File

@ -1,6 +1,6 @@
import { Type } from 'class-transformer';
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
import { Optional } from '../../domain.util';
import { Optional, ValidateUUID } from '../../domain.util';
import { BulkIdsDto } from '../response-dto';
export class AssetBulkUpdateDto extends BulkIdsDto {
@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
@Optional()
@IsBoolean()
isArchived?: boolean;
@Optional()
@ValidateUUID()
stackParentId?: string;
@Optional()
@IsBoolean()
removeParent?: boolean;
}
export class UpdateAssetDto {

View File

@ -1,4 +1,5 @@
export * from './asset-ids.dto';
export * from './asset-stack.dto';
export * from './asset-statistics.dto';
export * from './asset.dto';
export * from './download.dto';

View File

@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
people?: PersonResponseDto[];
/**base64 encoded sha1 hash */
checksum!: string;
stackParentId?: string | null;
stack?: AssetResponseDto[];
@ApiProperty({ type: 'integer' })
stackCount!: number;
}
export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto {
export type AssetMapOptions = {
stripMetadata?: boolean;
withStack?: boolean;
};
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo
tags: entity.tags?.map(mapTag),
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
checksum: entity.checksum.toString('base64'),
stackParentId: entity.stackParentId,
stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,
stackCount: entity.stack?.length ?? 0,
isExternal: entity.isExternal,
isOffline: entity.isOffline,
isReadOnly: entity.isReadOnly,

View File

@ -4,6 +4,7 @@ export enum CommunicationEvent {
UPLOAD_SUCCESS = 'on_upload_success',
ASSET_DELETE = 'on_asset_delete',
ASSET_TRASH = 'on_asset_trash',
ASSET_UPDATE = 'on_asset_update',
ASSET_RESTORE = 'on_asset_restore',
PERSON_THUMBNAIL = 'on_person_thumbnail',
SERVER_VERSION = 'on_server_version',

View File

@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[],
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[],
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,

View File

@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository {
faces: {
person: true,
},
stack: true,
},
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
@ -131,6 +132,7 @@ export class AssetRepository implements IAssetRepository {
relations: {
exifInfo: true,
tags: true,
stack: true,
},
skip: dto.skip || 0,
order: {

View File

@ -196,7 +196,7 @@ export class AssetService {
const includeMetadata = this.getExifPermission(authUser);
const asset = await this._assetRepository.getById(assetId);
if (includeMetadata) {
const data = mapAsset(asset);
const data = mapAsset(asset, { withStack: true });
if (data.ownerId !== authUser.id) {
data.people = [];
@ -208,7 +208,7 @@ export class AssetService {
return data;
} else {
return mapAsset(asset, true);
return mapAsset(asset, { stripMetadata: true, withStack: true });
}
}

View File

@ -21,6 +21,7 @@ import {
TimeBucketResponseDto,
TrashAction,
UpdateAssetDto as UpdateDto,
UpdateStackParentDto,
} from '@app/domain';
import {
Body,
@ -137,6 +138,12 @@ export class AssetController {
return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL);
}
@Put('stack/parent')
@HttpCode(HttpStatus.OK)
updateStackParent(@AuthUser() authUser: AuthUserDto, @Body() dto: UpdateStackParentDto): Promise<void> {
return this.service.updateStackParent(authUser, dto);
}
@Put(':id')
updateAsset(
@AuthUser() authUser: AuthUserDto,

View File

@ -148,6 +148,16 @@ export class AssetEntity {
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
faces!: AssetFaceEntity[];
@Column({ nullable: true })
stackParentId?: string | null;
@ManyToOne(() => AssetEntity, (asset) => asset.stack, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
@JoinColumn({ name: 'stackParentId' })
stackParent?: AssetEntity | null;
@OneToMany(() => AssetEntity, (asset) => asset.stackParent)
stack?: AssetEntity[];
}
export enum AssetType {

View File

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddStackParentIdToAssets1695354433573 implements MigrationInterface {
name = 'AddStackParentIdToAssets1695354433573'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "stackParentId" uuid`);
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_b463c8edb01364bf2beba08ef19" FOREIGN KEY ("stackParentId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`);
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackParentId"`);
}
}

View File

@ -112,6 +112,7 @@ export class AssetRepository implements IAssetRepository {
faces: {
person: true,
},
stack: true,
},
withDeleted: true,
});
@ -192,6 +193,7 @@ export class AssetRepository implements IAssetRepository {
person: true,
},
library: true,
stack: true,
},
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
@ -538,6 +540,12 @@ export class AssetRepository implements IAssetRepository {
.andWhere('person.id = :personId', { personId });
}
// Hide stack children only in main timeline
// Uncomment after adding support for stacked assets in web client
// if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) {
// builder = builder.andWhere('asset.stackParent IS NULL');
// }
return builder;
}
}

View File

@ -626,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(body).toEqual([expect.objectContaining({ id: asset2.id })]);
});
});
describe('PUT /asset', () => {
beforeEach(async () => {
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
expect(status).toBe(204);
});
it('should require authentication', async () => {
const { status, body } = await request(server).put('/asset');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a valid parent id', async () => {
const { status, body } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: uuidStub.invalid, ids: [asset1.id] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID']));
});
it('should require access to the parent', async () => {
const { status, body } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: asset4.id, ids: [asset1.id] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should add stack children', async () => {
const parent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
const child = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: parent.id, ids: [child.id] });
expect(status).toBe(204);
const asset = await api.assetApi.get(server, user1.accessToken, parent.id);
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })]));
});
it('should remove stack children', async () => {
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ removeParent: true, ids: [asset2.id] });
expect(status).toBe(204);
const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
});
it('should remove all stack children', async () => {
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ removeParent: true, ids: [asset2.id, asset3.id] });
expect(status).toBe(204);
const asset = await api.assetApi.get(server, user1.accessToken, asset1.id);
expect(asset.stack).toHaveLength(0);
});
it('should merge stack children', async () => {
const newParent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01'));
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: newParent.id, ids: [asset1.id] });
expect(status).toBe(204);
const asset = await api.assetApi.get(server, user1.accessToken, newParent.id);
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: asset1.id }),
expect.objectContaining({ id: asset2.id }),
expect.objectContaining({ id: asset3.id }),
]),
);
});
});
describe('PUT /asset/stack/parent', () => {
beforeEach(async () => {
const { status } = await request(server)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] });
expect(status).toBe(204);
});
it('should require authentication', async () => {
const { status, body } = await request(server).put('/asset/stack/parent');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(server)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should require access', async () => {
const { status, body } = await request(server)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: asset4.id, newParentId: asset1.id });
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should make old parent child of new parent', async () => {
const { status } = await request(server)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: asset1.id, newParentId: asset2.id });
expect(status).toBe(200);
const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })]));
});
it('should make all childrens of old parent, a child of new parent', async () => {
const { status } = await request(server)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: asset1.id, newParentId: asset2.id });
expect(status).toBe(200);
const asset = await api.assetApi.get(server, user1.accessToken, asset2.id);
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })]));
});
});
});

View File

@ -41,6 +41,7 @@ export const assetStub = {
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
}),
noWebpPath: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@ -80,6 +81,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
noThumbhash: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@ -116,6 +118,7 @@ export const assetStub = {
sidecarPath: null,
deletedAt: null,
}),
primaryImage: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@ -154,7 +157,9 @@ export const assetStub = {
exifInfo: {
fileSizeInByte: 5_000,
} as ExifEntity,
stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity],
}),
image: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@ -194,6 +199,7 @@ export const assetStub = {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
external: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@ -233,6 +239,7 @@ export const assetStub = {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
offline: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
@ -272,6 +279,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
image1: Object.freeze<AssetEntity>({
id: 'asset-id-1',
deviceAssetId: 'device-asset-id',
@ -311,6 +319,7 @@ export const assetStub = {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
imageFrom2015: Object.freeze<AssetEntity>({
id: 'asset-id-1',
deviceAssetId: 'device-asset-id',
@ -350,6 +359,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
video: Object.freeze<AssetEntity>({
id: 'asset-id',
originalFileName: 'asset-id.ext',
@ -389,6 +399,7 @@ export const assetStub = {
} as ExifEntity,
deletedAt: null,
}),
livePhotoMotionAsset: Object.freeze({
id: 'live-photo-motion-asset',
originalPath: fileStub.livePhotoMotion.originalPath,
@ -497,10 +508,41 @@ export const assetStub = {
sidecarPath: '/original/path.ext.xmp',
deletedAt: null,
}),
readOnly: Object.freeze({
readOnly: Object.freeze<AssetEntity>({
id: 'read-only-asset',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext',
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: null,
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: true,
isExternal: false,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: '/original/path.ext.xmp',
deletedAt: null,
}),
};

View File

@ -72,6 +72,7 @@ const assetResponse: AssetResponseDto = {
isTrashed: false,
libraryId: 'library-id',
hasMetadata: true,
stackCount: 0,
};
const assetResponseWithoutMetadata = {

View File

@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto {
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'removeParent'?: boolean;
/**
*
* @type {string}
* @memberof AssetBulkUpdateDto
*/
'stackParentId'?: string;
}
/**
*
@ -748,6 +760,24 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto
*/
'smartInfo'?: SmartInfoResponseDto;
/**
*
* @type {Array<AssetResponseDto>}
* @memberof AssetResponseDto
*/
'stack'?: Array<AssetResponseDto>;
/**
*
* @type {number}
* @memberof AssetResponseDto
*/
'stackCount': number;
/**
*
* @type {string}
* @memberof AssetResponseDto
*/
'stackParentId'?: string | null;
/**
*
* @type {Array<TagResponseDto>}
@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto {
*/
'name'?: string;
}
/**
*
* @export
* @interface UpdateStackParentDto
*/
export interface UpdateStackParentDto {
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'newParentId': string;
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'oldParentId': string;
}
/**
*
* @export
@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
/**
*
* @param {UpdateStackParentDto} updateStackParentDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'updateStackParentDto' is not null or undefined
assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto)
const localVarPath = `/asset/stack/parent`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {File} assetData
@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {UpdateStackParentDto} updateStackParentDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {File} assetData
@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest {
readonly assetBulkUpdateDto: AssetBulkUpdateDto
}
/**
* Request parameters for updateStackParent operation in AssetApi.
* @export
* @interface AssetApiUpdateStackParentRequest
*/
export interface AssetApiUpdateStackParentRequest {
/**
*
* @type {UpdateStackParentDto}
* @memberof AssetApiUpdateStackParent
*/
readonly updateStackParentDto: UpdateStackParentDto
}
/**
* Request parameters for uploadFile operation in AssetApi.
* @export
@ -8939,6 +9065,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiUploadFileRequest} requestParameters Request parameters.