1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-26 10:50:29 +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 * @memberof AssetBulkUpdateDto
*/ */
'isFavorite'?: boolean; 'isFavorite'?: boolean;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'removeParent'?: boolean;
/**
*
* @type {string}
* @memberof AssetBulkUpdateDto
*/
'stackParentId'?: string;
} }
/** /**
* *
@ -748,6 +760,24 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto * @memberof AssetResponseDto
*/ */
'smartInfo'?: SmartInfoResponseDto; '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>} * @type {Array<TagResponseDto>}
@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto {
*/ */
'name'?: string; 'name'?: string;
} }
/**
*
* @export
* @interface UpdateStackParentDto
*/
export interface UpdateStackParentDto {
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'newParentId': string;
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'oldParentId': string;
}
/** /**
* *
* @export * @export
@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, 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 * @param {File} assetData
@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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 * @param {File} assetData
@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> { updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); 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. * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest {
readonly assetBulkUpdateDto: AssetBulkUpdateDto 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. * Request parameters for uploadFile operation in AssetApi.
* @export * @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)); 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. * @param {AssetApiUploadFileRequest} requestParameters Request parameters.

View File

@ -130,7 +130,9 @@
"control_bottom_app_bar_delete": "Delete", "control_bottom_app_bar_delete": "Delete",
"control_bottom_app_bar_favorite": "Favorite", "control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_share": "Share", "control_bottom_app_bar_share": "Share",
"control_bottom_app_bar_stack": "Stack",
"control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unarchive": "Unarchive",
"control_bottom_app_bar_upload": "Upload",
"create_album_page_untitled": "Untitled", "create_album_page_untitled": "Untitled",
"create_shared_album_page_create": "Create", "create_shared_album_page_create": "Create",
"create_shared_album_page_share": "Share", "create_shared_album_page_share": "Share",
@ -275,6 +277,7 @@
"setting_pages_app_bar_settings": "Settings", "setting_pages_app_bar_settings": "Settings",
"settings_require_restart": "Please restart Immich to apply this setting", "settings_require_restart": "Please restart Immich to apply this setting",
"share_add": "Add", "share_add": "Add",
"share_done": "Done",
"share_add_photos": "Add photos", "share_add_photos": "Add photos",
"share_add_title": "Add a title", "share_add_title": "Add a title",
"share_create_album": "Create album", "share_create_album": "Create album",
@ -337,5 +340,8 @@
"trash_page_select_assets_btn": "Select assets", "trash_page_select_assets_btn": "Select assets",
"trash_page_empty_trash_btn": "Empty trash", "trash_page_empty_trash_btn": "Empty trash",
"trash_page_empty_trash_dialog_ok": "Ok", "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/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.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/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.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?>( await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionRoute( AssetSelectionRoute(
existingAssets: albumInfo.assets, 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:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.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/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:isar/isar.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
class AssetSelectionPage extends HookConsumerWidget { class AssetSelectionPage extends HookConsumerWidget {
const AssetSelectionPage({ const AssetSelectionPage({
Key? key, Key? key,
required this.existingAssets, required this.existingAssets,
this.isNewAlbum = false, this.canDeselect = false,
required this.query,
}) : super(key: key); }) : super(key: key);
final Set<Asset> existingAssets; final Set<Asset> existingAssets;
final bool isNewAlbum; final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
final bool canDeselect;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentUser = ref.watch(currentUserProvider); final renderList = ref.watch(renderListQueryProvider(query));
final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId));
final selected = useState<Set<Asset>>(existingAssets); final selected = useState<Set<Asset>>(existingAssets);
final selectionEnabledHook = useState(true); final selectionEnabledHook = useState(true);
@ -39,8 +40,8 @@ class AssetSelectionPage extends HookConsumerWidget {
selected.value = assets; selected.value = assets;
}, },
selectionActive: true, selectionActive: true,
preselectedAssets: isNewAlbum ? selected.value : existingAssets, preselectedAssets: existingAssets,
canDeselect: isNewAlbum, canDeselect: canDeselect,
showMultiSelectIndicator: false, showMultiSelectIndicator: false,
); );
} }
@ -65,7 +66,7 @@ class AssetSelectionPage extends HookConsumerWidget {
), ),
centerTitle: false, centerTitle: false,
actions: [ actions: [
if (selected.value.isNotEmpty) if (selected.value.isNotEmpty || canDeselect)
TextButton( TextButton(
onPressed: () { onPressed: () {
var payload = var payload =
@ -74,7 +75,7 @@ class AssetSelectionPage extends HookConsumerWidget {
.popForced<AssetSelectionPageResult>(payload); .popForced<AssetSelectionPageResult>(payload);
}, },
child: Text( child: Text(
"share_add", canDeselect ? "share_done" : "share_add",
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor, 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/modules/album/ui/shared_album_thumbnail_image.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class CreateAlbumPage extends HookConsumerWidget { class CreateAlbumPage extends HookConsumerWidget {
@ -31,7 +32,8 @@ class CreateAlbumPage extends HookConsumerWidget {
final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleTextFieldFocus = useState(false);
final isAlbumTitleEmpty = useState(true); final isAlbumTitleEmpty = useState(true);
final selectedAssets = useState<Set<Asset>>( 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; final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
showSelectUserPage() async { showSelectUserPage() async {
@ -59,7 +61,8 @@ class CreateAlbumPage extends HookConsumerWidget {
await AutoRouter.of(context).push<AssetSelectionPageResult?>( await AutoRouter.of(context).push<AssetSelectionPageResult?>(
AssetSelectionRoute( AssetSelectionRoute(
existingAssets: selectedAssets.value, existingAssets: selectedAssets.value,
isNewAlbum: true, canDeselect: true,
query: getRemoteAssetQuery(ref),
), ),
); );
if (selectedAsset == null) { 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/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:isar/isar.dart';
final renderListProvider = final renderListProvider =
FutureProvider.family<RenderList, List<Asset>>((ref, assets) { FutureProvider.family<RenderList, List<Asset>>((ref, assets) {
@ -13,3 +14,19 @@ final renderListProvider =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)], 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:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_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/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/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/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/advanced_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_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'; 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 totalAssets;
final int initialIndex; final int initialIndex;
final int heroOffset; final int heroOffset;
final bool showStack;
GalleryViewerPage({ GalleryViewerPage({
super.key, super.key,
@ -51,6 +54,7 @@ class GalleryViewerPage extends HookConsumerWidget {
required this.loadAsset, required this.loadAsset,
required this.totalAssets, required this.totalAssets,
this.heroOffset = 0, this.heroOffset = 0,
this.showStack = false,
}) : controller = PageController(initialPage: initialIndex); }) : controller = PageController(initialPage: initialIndex);
final PageController controller; final PageController controller;
@ -77,8 +81,17 @@ class GalleryViewerPage extends HookConsumerWidget {
final isFromTrash = isTrashEnabled && final isFromTrash = isTrashEnabled &&
navStack.length > 2 && navStack.length > 2 &&
navStack.elementAt(navStack.length - 2).name == TrashRoute.name; 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( useEffect(
() { () {
@ -165,19 +178,28 @@ class GalleryViewerPage extends HookConsumerWidget {
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom, 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 { void handleDelete(Asset deleteAsset) async {
Future<bool> onDelete(bool force) async { Future<bool> onDelete(bool force) async {
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
{deleteAsset}, {deleteAsset},
force: force, force: force,
); );
if (isDeleted) { if (isDeleted && isParent) {
if (totalAssets == 1) { if (totalAssets == 1) {
// Handle only one asset // Handle only one asset
AutoRouter.of(context).pop(); AutoRouter.of(context).pop();
@ -195,8 +217,9 @@ class GalleryViewerPage extends HookConsumerWidget {
// Asset is trashed // Asset is trashed
if (isTrashEnabled && !isFromTrash) { if (isTrashEnabled && !isFromTrash) {
final isDeleted = await onDelete(false); final isDeleted = await onDelete(false);
if (isDeleted) {
// Can only trash assets stored in server. Local assets are always permanently removed for now // 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( ImmichToast.show(
durationInSecond: 1, durationInSecond: 1,
context: context, context: context,
@ -204,6 +227,8 @@ class GalleryViewerPage extends HookConsumerWidget {
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
); );
} }
removeAssetFromStack();
}
return; return;
} }
@ -211,7 +236,14 @@ class GalleryViewerPage extends HookConsumerWidget {
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext _) { 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 ref
.watch(assetProvider.notifier) .watch(assetProvider.notifier)
.toggleArchive([asset], !asset.isArchived); .toggleArchive([asset], !asset.isArchived);
if (isParent) {
AutoRouter.of(context).pop(); AutoRouter.of(context).pop();
return;
}
removeAssetFromStack();
} }
handleUpload(Asset asset) { 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( return IgnorePointer(
ignoring: !ref.watch(showControlsProvider), ignoring: !ref.watch(showControlsProvider),
child: AnimatedOpacity( child: AnimatedOpacity(
@ -393,6 +608,17 @@ class GalleryViewerPage extends HookConsumerWidget {
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
child: Column( child: Column(
children: [ children: [
if (stack.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 10,
bottom: 30,
),
child: SizedBox(
height: 40,
child: buildStackedChildren(),
),
),
Visibility( Visibility(
visible: !asset().isImage && !isPlayingMotionVideo.value, visible: !asset().isImage && !isPlayingMotionVideo.value,
child: Container( child: Container(
@ -421,44 +647,10 @@ class GalleryViewerPage extends HookConsumerWidget {
selectedLabelStyle: const TextStyle(color: Colors.black), selectedLabelStyle: const TextStyle(color: Colors.black),
showSelectedLabels: false, showSelectedLabels: false,
showUnselectedLabels: false, showUnselectedLabels: false,
items: [ items: 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(),
),
BottomNavigationBarItem(
icon: const Icon(Icons.delete_outline),
label: 'control_bottom_app_bar_delete'.tr(),
tooltip: 'control_bottom_app_bar_delete'.tr(),
),
],
onTap: (index) { onTap: (index) {
switch (index) { if (index < actionslist.length) {
case 0: actionslist[index].call(index);
shareAsset();
break;
case 1:
handleArchive(asset());
break;
case 2:
handleDelete(asset());
break;
} }
}, },
), ),
@ -504,6 +696,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final next = currentIndex.value < value ? value + 1 : value - 1; final next = currentIndex.value < value ? value + 1 : value - 1;
precacheNextImage(next); precacheNextImage(next);
currentIndex.value = value; currentIndex.value = value;
stackIndex.value = -1;
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
loadingBuilder: (context, event, index) { loadingBuilder: (context, event, index) {
@ -544,10 +737,11 @@ class GalleryViewerPage extends HookConsumerWidget {
: webPThumbnail; : webPThumbnail;
}, },
builder: (context, index) { builder: (context, index) {
final asset = loadAsset(index); final a =
final ImageProvider provider = finalImageProvider(asset); index == currentIndex.value ? asset() : loadAsset(index);
final ImageProvider provider = finalImageProvider(a);
if (asset.isImage && !isPlayingMotionVideo.value) { if (a.isImage && !isPlayingMotionVideo.value) {
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) => onDragStart: (_, details, __) =>
localPosition = details.localPosition, localPosition = details.localPosition,
@ -558,13 +752,13 @@ class GalleryViewerPage extends HookConsumerWidget {
}, },
imageProvider: provider, imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes( heroAttributes: PhotoViewHeroAttributes(
tag: asset.id + heroOffset, tag: a.id + heroOffset,
), ),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) => ImmichImage( errorBuilder: (context, error, stackTrace) => ImmichImage(
asset, a,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
); );
@ -575,7 +769,7 @@ class GalleryViewerPage extends HookConsumerWidget {
onDragUpdate: (_, details, __) => onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details), handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes( heroAttributes: PhotoViewHeroAttributes(
tag: asset.id + heroOffset, tag: a.id + heroOffset,
), ),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
maxScale: 1.0, maxScale: 1.0,
@ -584,7 +778,7 @@ class GalleryViewerPage extends HookConsumerWidget {
child: VideoViewerPage( child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true, onPlaying: () => isPlayingVideo.value = true,
onPaused: () => isPlayingVideo.value = false, onPaused: () => isPlayingVideo.value = false,
asset: asset, asset: a,
isMotionVideo: isPlayingMotionVideo.value, isMotionVideo: isPlayingMotionVideo.value,
placeholder: Image( placeholder: Image(
image: provider, 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 Widget? topWidget;
final bool shrinkWrap; final bool shrinkWrap;
final bool showDragScroll; final bool showDragScroll;
final bool showStack;
const ImmichAssetGrid({ const ImmichAssetGrid({
super.key, super.key,
@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.topWidget, this.topWidget,
this.shrinkWrap = false, this.shrinkWrap = false,
this.showDragScroll = true, this.showDragScroll = true,
this.showStack = false,
}); });
@override @override
@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
heroOffset: heroOffset(), heroOffset: heroOffset(),
shrinkWrap: shrinkWrap, shrinkWrap: shrinkWrap,
showDragScroll: showDragScroll, showDragScroll: showDragScroll,
showStack: showStack,
), ),
); );
} }

View File

@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget {
final int heroOffset; final int heroOffset;
final bool shrinkWrap; final bool shrinkWrap;
final bool showDragScroll; final bool showDragScroll;
final bool showStack;
const ImmichAssetGridView({ const ImmichAssetGridView({
super.key, super.key,
@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget {
this.heroOffset = 0, this.heroOffset = 0,
this.shrinkWrap = false, this.shrinkWrap = false,
this.showDragScroll = true, this.showDragScroll = true,
this.showStack = false,
}); });
@override @override
@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
bool _scrolling = false; bool _scrolling = false;
final Set<Asset> _selectedAssets = 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() { Set<Asset> _getSelectedAssets() {
return Set.from(_selectedAssets); return Set.from(_selectedAssets);
@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
void _deselectAssets(List<Asset> assets) { void _deselectAssets(List<Asset> assets) {
setState(() { setState(() {
_selectedAssets.removeAll(assets); _selectedAssets.removeAll(
assets.where(
(a) =>
widget.canDeselect ||
!(widget.preselectedAssets?.contains(a) ?? false),
),
);
_callSelectionListener(_selectedAssets.isNotEmpty); _callSelectionListener(_selectedAssets.isNotEmpty);
}); });
} }
@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
useGrayBoxPlaceholder: true, useGrayBoxPlaceholder: true,
showStorageIndicator: widget.showStorageIndicator, showStorageIndicator: widget.showStorageIndicator,
heroOffset: widget.heroOffset, heroOffset: widget.heroOffset,
showStack: widget.showStack,
); );
} }
@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
setState(() { setState(() {
_selectedAssets.clear(); _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 Asset Function(int index) loadAsset;
final int totalAssets; final int totalAssets;
final bool showStorageIndicator; final bool showStorageIndicator;
final bool showStack;
final bool useGrayBoxPlaceholder; final bool useGrayBoxPlaceholder;
final bool isSelected; final bool isSelected;
final bool multiselectEnabled; final bool multiselectEnabled;
@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget {
required this.loadAsset, required this.loadAsset,
required this.totalAssets, required this.totalAssets,
this.showStorageIndicator = true, this.showStorageIndicator = true,
this.showStack = false,
this.useGrayBoxPlaceholder = false, this.useGrayBoxPlaceholder = false,
this.isSelected = false, this.isSelected = false,
this.multiselectEnabled = 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() { Widget buildImage() {
final image = SizedBox( final image = SizedBox(
width: 300, width: 300,
@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
width: 0, width: 0,
color: assetContainerColor, color: onDeselect == null ? Colors.grey : assetContainerColor,
), ),
color: assetContainerColor, color: onDeselect == null ? Colors.grey : assetContainerColor,
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget {
loadAsset: loadAsset, loadAsset: loadAsset,
totalAssets: totalAssets, totalAssets: totalAssets,
heroOffset: heroOffset, heroOffset: heroOffset,
showStack: showStack,
), ),
); );
} }
@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget {
), ),
), ),
if (!asset.isImage) buildVideoIcon(), 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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/delete_dialog.dart';
import 'package:immich_mobile/modules/home/ui/upload_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/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
@ -19,11 +19,12 @@ class ControlBottomAppBar extends ConsumerWidget {
final Function(Album album) onAddToAlbum; final Function(Album album) onAddToAlbum;
final void Function() onCreateNewAlbum; final void Function() onCreateNewAlbum;
final void Function() onUpload; final void Function() onUpload;
final void Function() onStack;
final List<Album> albums; final List<Album> albums;
final List<Album> sharedAlbums; final List<Album> sharedAlbums;
final bool enabled; final bool enabled;
final AssetState selectionAssetState; final SelectionAssetState selectionAssetState;
const ControlBottomAppBar({ const ControlBottomAppBar({
Key? key, Key? key,
@ -36,19 +37,24 @@ class ControlBottomAppBar extends ConsumerWidget {
required this.onAddToAlbum, required this.onAddToAlbum,
required this.onCreateNewAlbum, required this.onCreateNewAlbum,
required this.onUpload, required this.onUpload,
this.selectionAssetState = AssetState.remote, required this.onStack,
this.selectionAssetState = const SelectionAssetState(),
this.enabled = true, this.enabled = true,
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var isDarkMode = Theme.of(context).brightness == Brightness.dark; 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 = final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
Widget renderActionButtons() { Widget renderActionButtons() {
return Row( return Wrap(
spacing: 10,
runSpacing: 15,
children: [ children: [
ControlBoxButton( ControlBoxButton(
iconData: Platform.isAndroid iconData: Platform.isAndroid
@ -92,7 +98,7 @@ class ControlBottomAppBar extends ConsumerWidget {
if (!hasRemote) if (!hasRemote)
ControlBoxButton( ControlBoxButton(
iconData: Icons.backup_outlined, iconData: Icons.backup_outlined,
label: "Upload", label: "control_bottom_app_bar_upload".tr(),
onPressed: enabled onPressed: enabled
? () => showDialog( ? () => showDialog(
context: context, context: context,
@ -104,6 +110,12 @@ class ControlBottomAppBar extends ConsumerWidget {
) )
: null, : 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( return DraggableScrollableSheet(
initialChildSize: hasRemote ? 0.30 : 0.18, initialChildSize: hasRemote ? 0.30 : 0.18,
minChildSize: 0.18, minChildSize: 0.18,
maxChildSize: hasRemote ? 0.57 : 0.18, maxChildSize: hasRemote ? 0.60 : 0.18,
snap: true, snap: true,
builder: ( builder: (
BuildContext context, BuildContext context,

View File

@ -7,11 +7,15 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_detail.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/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.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/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/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.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'; 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) { Widget build(BuildContext context, WidgetRef ref) {
final multiselectEnabled = ref.watch(multiselectProvider.notifier); final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false); final selectionEnabledHook = useState(false);
final selectionAssetState = useState(AssetState.remote); final selectionAssetState = useState(const SelectionAssetState());
final selection = useState(<Asset>{}); final selection = useState(<Asset>{});
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
@ -83,9 +87,8 @@ class HomePage extends HookConsumerWidget {
) { ) {
selectionEnabledHook.value = multiselect; selectionEnabledHook.value = multiselect;
selection.value = selectedAssets; selection.value = selectedAssets;
selectionAssetState.value = selectedAssets.any((e) => e.isRemote) selectionAssetState.value =
? AssetState.remote SelectionAssetState.fromSelection(selectedAssets);
: AssetState.local;
} }
void onShareAssets() { 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 { Future<void> refreshAssets() async {
final fullRefresh = refreshCount.value > 0; final fullRefresh = refreshCount.value > 0;
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
@ -322,6 +374,7 @@ class HomePage extends HookConsumerWidget {
currentUser.memoryEnabled!) currentUser.memoryEnabled!)
? const MemoryLane() ? const MemoryLane()
: const SizedBox(), : const SizedBox(),
showStack: true,
), ),
error: (error, _) => Center(child: Text(error.toString())), error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator, loading: buildLoadingIndicator,
@ -339,6 +392,7 @@ class HomePage extends HookConsumerWidget {
onUpload: onUpload, onUpload: onUpload,
enabled: !processing.value, enabled: !processing.value,
selectionAssetState: selectionAssetState.value, selectionAssetState: selectionAssetState.value,
onStack: onStack,
), ),
if (processing.value) const Center(child: ImmichLoadingIndicator()), if (processing.value) const Center(child: ImmichLoadingIndicator()),
], ],

View File

@ -252,6 +252,7 @@ class TrashPage extends HookConsumerWidget {
listener: selectionListener, listener: selectionListener,
selectionActive: selectionEnabledHook.value, selectionActive: selectionEnabledHook.value,
showMultiSelectIndicator: false, showMultiSelectIndicator: false,
showStack: true,
topWidget: Padding( topWidget: Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 24, 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/app_log_page.dart';
import 'package:immich_mobile/shared/views/splash_screen.dart'; import 'package:immich_mobile/shared/views/splash_screen.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart'; import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
part 'router.gr.dart'; part 'router.gr.dart';

View File

@ -71,6 +71,7 @@ class _$AppRouter extends RootStackRouter {
loadAsset: args.loadAsset, loadAsset: args.loadAsset,
totalAssets: args.totalAssets, totalAssets: args.totalAssets,
heroOffset: args.heroOffset, heroOffset: args.heroOffset,
showStack: args.showStack,
), ),
); );
}, },
@ -153,7 +154,8 @@ class _$AppRouter extends RootStackRouter {
child: AssetSelectionPage( child: AssetSelectionPage(
key: args.key, key: args.key,
existingAssets: args.existingAssets, existingAssets: args.existingAssets,
isNewAlbum: args.isNewAlbum, canDeselect: args.canDeselect,
query: args.query,
), ),
transitionsBuilder: TransitionsBuilders.slideBottom, transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true, opaque: true,
@ -711,6 +713,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
required Asset Function(int) loadAsset, required Asset Function(int) loadAsset,
required int totalAssets, required int totalAssets,
int heroOffset = 0, int heroOffset = 0,
bool showStack = false,
}) : super( }) : super(
GalleryViewerRoute.name, GalleryViewerRoute.name,
path: '/gallery-viewer-page', path: '/gallery-viewer-page',
@ -720,6 +723,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
loadAsset: loadAsset, loadAsset: loadAsset,
totalAssets: totalAssets, totalAssets: totalAssets,
heroOffset: heroOffset, heroOffset: heroOffset,
showStack: showStack,
), ),
); );
@ -733,6 +737,7 @@ class GalleryViewerRouteArgs {
required this.loadAsset, required this.loadAsset,
required this.totalAssets, required this.totalAssets,
this.heroOffset = 0, this.heroOffset = 0,
this.showStack = false,
}); });
final Key? key; final Key? key;
@ -745,9 +750,11 @@ class GalleryViewerRouteArgs {
final int heroOffset; final int heroOffset;
final bool showStack;
@override @override
String toString() { 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({ AssetSelectionRoute({
Key? key, Key? key,
required Set<Asset> existingAssets, required Set<Asset> existingAssets,
bool isNewAlbum = false, bool canDeselect = false,
required QueryBuilder<Asset, Asset, QAfterSortBy>? query,
}) : super( }) : super(
AssetSelectionRoute.name, AssetSelectionRoute.name,
path: '/asset-selection-page', path: '/asset-selection-page',
args: AssetSelectionRouteArgs( args: AssetSelectionRouteArgs(
key: key, key: key,
existingAssets: existingAssets, existingAssets: existingAssets,
isNewAlbum: isNewAlbum, canDeselect: canDeselect,
query: query,
), ),
); );
@ -979,18 +988,21 @@ class AssetSelectionRouteArgs {
const AssetSelectionRouteArgs({ const AssetSelectionRouteArgs({
this.key, this.key,
required this.existingAssets, required this.existingAssets,
this.isNewAlbum = false, this.canDeselect = false,
required this.query,
}); });
final Key? key; final Key? key;
final Set<Asset> existingAssets; final Set<Asset> existingAssets;
final bool isNewAlbum; final bool canDeselect;
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
@override @override
String toString() { 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, remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite, isFavorite = remote.isFavorite,
isArchived = remote.isArchived, isArchived = remote.isArchived,
isTrashed = remote.isTrashed; isTrashed = remote.isTrashed,
stackParentId = remote.stackParentId,
stackCount = remote.stackCount;
Asset.local(AssetEntity local, List<int> hash) Asset.local(AssetEntity local, List<int> hash)
: localId = local.id, : localId = local.id,
@ -47,6 +49,7 @@ class Asset {
isFavorite = local.isFavorite, isFavorite = local.isFavorite,
isArchived = false, isArchived = false,
isTrashed = false, isTrashed = false,
stackCount = 0,
fileCreatedAt = local.createDateTime { fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) { if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt; fileCreatedAt = fileModifiedAt;
@ -77,6 +80,8 @@ class Asset {
required this.isFavorite, required this.isFavorite,
required this.isArchived, required this.isArchived,
required this.isTrashed, required this.isTrashed,
this.stackParentId,
required this.stackCount,
}); });
@ignore @ignore
@ -146,6 +151,10 @@ class Asset {
@ignore @ignore
ExifInfo? exifInfo; ExifInfo? exifInfo;
String? stackParentId;
int stackCount;
/// `true` if this [Asset] is present on the device /// `true` if this [Asset] is present on the device
@ignore @ignore
bool get isLocal => localId != null; bool get isLocal => localId != null;
@ -200,7 +209,9 @@ class Asset {
isFavorite == other.isFavorite && isFavorite == other.isFavorite &&
isLocal == other.isLocal && isLocal == other.isLocal &&
isArchived == other.isArchived && isArchived == other.isArchived &&
isTrashed == other.isTrashed; isTrashed == other.isTrashed &&
stackCount == other.stackCount &&
stackParentId == other.stackParentId;
} }
@override @override
@ -223,7 +234,9 @@ class Asset {
isFavorite.hashCode ^ isFavorite.hashCode ^
isLocal.hashCode ^ isLocal.hashCode ^
isArchived.hashCode ^ isArchived.hashCode ^
isTrashed.hashCode; isTrashed.hashCode ^
stackCount.hashCode ^
stackParentId.hashCode;
/// Returns `true` if this [Asset] can updated with values from parameter [a] /// Returns `true` if this [Asset] can updated with values from parameter [a]
bool canUpdate(Asset a) { bool canUpdate(Asset a) {
@ -236,9 +249,11 @@ class Asset {
width == null && a.width != null || width == null && a.width != null ||
height == null && a.height != null || height == null && a.height != null ||
livePhotoVideoId == null && a.livePhotoVideoId != null || livePhotoVideoId == null && a.livePhotoVideoId != null ||
stackParentId == null && a.stackParentId != null ||
isFavorite != a.isFavorite || isFavorite != a.isFavorite ||
isArchived != a.isArchived || 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] /// Returns a new [Asset] with values from this and merged & updated with [a]
@ -267,6 +282,8 @@ class Asset {
id: id, id: id,
remoteId: remoteId, remoteId: remoteId,
livePhotoVideoId: livePhotoVideoId, livePhotoVideoId: livePhotoVideoId,
stackParentId: stackParentId,
stackCount: stackCount,
isFavorite: isFavorite, isFavorite: isFavorite,
isArchived: isArchived, isArchived: isArchived,
isTrashed: isTrashed, isTrashed: isTrashed,
@ -281,6 +298,8 @@ class Asset {
width: a.width, width: a.width,
height: a.height, height: a.height,
livePhotoVideoId: a.livePhotoVideoId, livePhotoVideoId: a.livePhotoVideoId,
stackParentId: a.stackParentId,
stackCount: a.stackCount,
// isFavorite + isArchived are not set by device-only assets // isFavorite + isArchived are not set by device-only assets
isFavorite: a.isFavorite, isFavorite: a.isFavorite,
isArchived: a.isArchived, isArchived: a.isArchived,
@ -318,6 +337,8 @@ class Asset {
bool? isArchived, bool? isArchived,
bool? isTrashed, bool? isTrashed,
ExifInfo? exifInfo, ExifInfo? exifInfo,
String? stackParentId,
int? stackCount,
}) => }) =>
Asset( Asset(
id: id ?? this.id, id: id ?? this.id,
@ -338,6 +359,8 @@ class Asset {
isArchived: isArchived ?? this.isArchived, isArchived: isArchived ?? this.isArchived,
isTrashed: isTrashed ?? this.isTrashed, isTrashed: isTrashed ?? this.isTrashed,
exifInfo: exifInfo ?? this.exifInfo, exifInfo: exifInfo ?? this.exifInfo,
stackParentId: stackParentId ?? this.stackParentId,
stackCount: stackCount ?? this.stackCount,
); );
Future<void> put(Isar db) async { Future<void> put(Isar db) async {
@ -379,6 +402,8 @@ class Asset {
"checksum": "$checksum", "checksum": "$checksum",
"ownerId": $ownerId, "ownerId": $ownerId,
"livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}", "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}",
"stackCount": "$stackCount",
"stackParentId": "${stackParentId ?? "N/A"}",
"fileCreatedAt": "$fileCreatedAt", "fileCreatedAt": "$fileCreatedAt",
"fileModifiedAt": "$fileModifiedAt", "fileModifiedAt": "$fileModifiedAt",
"updatedAt": "$updatedAt", "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/store.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.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/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
@ -217,6 +218,7 @@ final assetsProvider =
.filter() .filter()
.isArchivedEqualTo(false) .isArchivedEqualTo(false)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.stackParentIdIsNull()
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider); final settings = ref.watch(appSettingsServiceProvider);
final groupBy = final groupBy =
@ -227,10 +229,12 @@ final assetsProvider =
} }
}); });
final remoteAssetsProvider = QueryBuilder<Asset, Asset, QAfterSortBy>? getRemoteAssetQuery(WidgetRef ref) {
StreamProvider.family<RenderList, int?>((ref, userId) async* { final userId = ref.watch(currentUserProvider)?.isarId;
if (userId == null) return; if (userId == null) {
final query = ref return null;
}
return ref
.watch(dbProvider) .watch(dbProvider)
.assets .assets
.where() .where()
@ -238,12 +242,34 @@ final remoteAssetsProvider =
.filter() .filter()
.ownerIdEqualTo(userId) .ownerIdEqualTo(userId)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.stackParentIdIsNull()
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
final settings = ref.watch(appSettingsServiceProvider); }
final groupBy =
GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; QueryBuilder<Asset, Asset, QAfterSortBy>? getAssetStackSelectionQuery(
yield await RenderList.fromQuery(query, groupBy); WidgetRef ref,
await for (final _ in query.watchLazy()) { Asset parentAsset,
yield await RenderList.fromQuery(query, groupBy); ) {
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();
}

View File

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

View File

@ -149,6 +149,7 @@ doc/TranscodePolicy.md
doc/UpdateAlbumDto.md doc/UpdateAlbumDto.md
doc/UpdateAssetDto.md doc/UpdateAssetDto.md
doc/UpdateLibraryDto.md doc/UpdateLibraryDto.md
doc/UpdateStackParentDto.md
doc/UpdateTagDto.md doc/UpdateTagDto.md
doc/UpdateUserDto.md doc/UpdateUserDto.md
doc/UsageByUserDto.md doc/UsageByUserDto.md
@ -314,6 +315,7 @@ lib/model/transcode_policy.dart
lib/model/update_album_dto.dart lib/model/update_album_dto.dart
lib/model/update_asset_dto.dart lib/model/update_asset_dto.dart
lib/model/update_library_dto.dart lib/model/update_library_dto.dart
lib/model/update_stack_parent_dto.dart
lib/model/update_tag_dto.dart lib/model/update_tag_dto.dart
lib/model/update_user_dto.dart lib/model/update_user_dto.dart
lib/model/usage_by_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_album_dto_test.dart
test/update_asset_dto_test.dart test/update_asset_dto_test.dart
test/update_library_dto_test.dart test/update_library_dto_test.dart
test/update_stack_parent_dto_test.dart
test/update_tag_dto_test.dart test/update_tag_dto_test.dart
test/update_user_dto_test.dart test/update_user_dto_test.dart
test/usage_by_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, isFavorite: false,
isArchived: false, isArchived: false,
isTrashed: false, isTrashed: false,
stackCount: 0,
), ),
); );
} }

View File

@ -35,6 +35,7 @@ void main() {
isFavorite: false, isFavorite: false,
isArchived: false, isArchived: false,
isTrashed: 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": { "/asset/statistics": {
"get": { "get": {
"operationId": "getAssetStats", "operationId": "getAssetStats",
@ -5696,6 +5731,13 @@
}, },
"isFavorite": { "isFavorite": {
"type": "boolean" "type": "boolean"
},
"removeParent": {
"type": "boolean"
},
"stackParentId": {
"format": "uuid",
"type": "string"
} }
}, },
"required": [ "required": [
@ -5941,6 +5983,19 @@
"smartInfo": { "smartInfo": {
"$ref": "#/components/schemas/SmartInfoResponseDto" "$ref": "#/components/schemas/SmartInfoResponseDto"
}, },
"stack": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
},
"stackCount": {
"type": "integer"
},
"stackParentId": {
"nullable": true,
"type": "string"
},
"tags": { "tags": {
"items": { "items": {
"$ref": "#/components/schemas/TagResponseDto" "$ref": "#/components/schemas/TagResponseDto"
@ -5961,6 +6016,7 @@
}, },
"required": [ "required": [
"type", "type",
"stackCount",
"deviceAssetId", "deviceAssetId",
"deviceId", "deviceId",
"ownerId", "ownerId",
@ -8521,6 +8577,23 @@
}, },
"type": "object" "type": "object"
}, },
"UpdateStackParentDto": {
"properties": {
"newParentId": {
"format": "uuid",
"type": "string"
},
"oldParentId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"oldParentId",
"newParentId"
],
"type": "object"
},
"UpdateTagDto": { "UpdateTagDto": {
"properties": { "properties": {
"name": { "name": {

View File

@ -20,6 +20,7 @@ import { Readable } from 'stream';
import { JobName } from '../job'; import { JobName } from '../job';
import { import {
AssetStats, AssetStats,
CommunicationEvent,
IAssetRepository, IAssetRepository,
ICommunicationRepository, ICommunicationRepository,
ICryptoRepository, ICryptoRepository,
@ -636,10 +637,89 @@ describe(AssetService.name, () => {
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
expect(assetMock.updateAll).toHaveBeenCalledWith(['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', () => { 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); accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect( await expect(
sut.deleteAll(authStub.user1, { sut.deleteAll(authStub.user1, {
@ -677,7 +757,7 @@ describe(AssetService.name, () => {
}); });
describe('restoreAll', () => { 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); accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect( await expect(
sut.deleteAll(authStub.user1, { sut.deleteAll(authStub.user1, {
@ -757,6 +837,21 @@ describe(AssetService.name, () => {
expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace); 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 () => { it('should not schedule delete-files job for readonly assets', async () => {
when(assetMock.getById) when(assetMock.getById)
.calledWith(assetStub.readOnly.id) .calledWith(assetStub.readOnly.id)
@ -854,4 +949,70 @@ describe(AssetService.name, () => {
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }); 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, TimeBucketDto,
TrashAction, TrashAction,
UpdateAssetDto, UpdateAssetDto,
UpdateStackParentDto,
mapStats, mapStats,
} from './dto'; } from './dto';
import { import {
@ -208,7 +209,7 @@ export class AssetService {
if (authUser.isShowMetadata) { if (authUser.isShowMetadata) {
return assets.map((asset) => mapAsset(asset)); return assets.map((asset) => mapAsset(asset));
} else { } 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> { 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); 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.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
await this.assetRepository.updateAll(ids, options); await this.assetRepository.updateAll(ids, options);
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
} }
async handleAssetDeletionCheck() { 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.assetRepository.remove(asset);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } });
this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, 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); 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) { async run(authUser: AuthUserDto, dto: AssetJobsDto) {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds); 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 { Type } from 'class-transformer';
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator'; import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
import { Optional } from '../../domain.util'; import { Optional, ValidateUUID } from '../../domain.util';
import { BulkIdsDto } from '../response-dto'; import { BulkIdsDto } from '../response-dto';
export class AssetBulkUpdateDto extends BulkIdsDto { export class AssetBulkUpdateDto extends BulkIdsDto {
@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
@Optional() @Optional()
@IsBoolean() @IsBoolean()
isArchived?: boolean; isArchived?: boolean;
@Optional()
@ValidateUUID()
stackParentId?: string;
@Optional()
@IsBoolean()
removeParent?: boolean;
} }
export class UpdateAssetDto { export class UpdateAssetDto {

View File

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

View File

@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
people?: PersonResponseDto[]; people?: PersonResponseDto[];
/**base64 encoded sha1 hash */ /**base64 encoded sha1 hash */
checksum!: string; 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 = { const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id, id: entity.id,
type: entity.type, type: entity.type,
@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo
tags: entity.tags?.map(mapTag), tags: entity.tags?.map(mapTag),
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden), people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
checksum: entity.checksum.toString('base64'), 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, isExternal: entity.isExternal,
isOffline: entity.isOffline, isOffline: entity.isOffline,
isReadOnly: entity.isReadOnly, isReadOnly: entity.isReadOnly,

View File

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

View File

@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
type: sharedLink.type, type: sharedLink.type,
createdAt: sharedLink.createdAt, createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt, 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, album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload, allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload, allowDownload: sharedLink.allowDownload,

View File

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

View File

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

View File

@ -21,6 +21,7 @@ import {
TimeBucketResponseDto, TimeBucketResponseDto,
TrashAction, TrashAction,
UpdateAssetDto as UpdateDto, UpdateAssetDto as UpdateDto,
UpdateStackParentDto,
} from '@app/domain'; } from '@app/domain';
import { import {
Body, Body,
@ -137,6 +138,12 @@ export class AssetController {
return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL); 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') @Put(':id')
updateAsset( updateAsset(
@AuthUser() authUser: AuthUserDto, @AuthUser() authUser: AuthUserDto,

View File

@ -148,6 +148,16 @@ export class AssetEntity {
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset) @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
faces!: AssetFaceEntity[]; 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 { 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: { faces: {
person: true, person: true,
}, },
stack: true,
}, },
withDeleted: true, withDeleted: true,
}); });
@ -192,6 +193,7 @@ export class AssetRepository implements IAssetRepository {
person: true, person: true,
}, },
library: true, library: true,
stack: true,
}, },
// We are specifically asking for this asset. Return it even if it is soft deleted // We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true, withDeleted: true,
@ -538,6 +540,12 @@ export class AssetRepository implements IAssetRepository {
.andWhere('person.id = :personId', { personId }); .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; return builder;
} }
} }

View File

@ -626,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(body).toEqual([expect.objectContaining({ id: asset2.id })]); 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', libraryId: 'library-id',
library: libraryStub.uploadLibrary1, library: libraryStub.uploadLibrary1,
}), }),
noWebpPath: Object.freeze<AssetEntity>({ noWebpPath: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -80,6 +81,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
deletedAt: null, deletedAt: null,
}), }),
noThumbhash: Object.freeze<AssetEntity>({ noThumbhash: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -116,6 +118,7 @@ export const assetStub = {
sidecarPath: null, sidecarPath: null,
deletedAt: null, deletedAt: null,
}), }),
primaryImage: Object.freeze<AssetEntity>({ primaryImage: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -154,7 +157,9 @@ export const assetStub = {
exifInfo: { exifInfo: {
fileSizeInByte: 5_000, fileSizeInByte: 5_000,
} as ExifEntity, } as ExifEntity,
stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity],
}), }),
image: Object.freeze<AssetEntity>({ image: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -194,6 +199,7 @@ export const assetStub = {
fileSizeInByte: 5_000, fileSizeInByte: 5_000,
} as ExifEntity, } as ExifEntity,
}), }),
external: Object.freeze<AssetEntity>({ external: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -233,6 +239,7 @@ export const assetStub = {
fileSizeInByte: 5_000, fileSizeInByte: 5_000,
} as ExifEntity, } as ExifEntity,
}), }),
offline: Object.freeze<AssetEntity>({ offline: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -272,6 +279,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
deletedAt: null, deletedAt: null,
}), }),
image1: Object.freeze<AssetEntity>({ image1: Object.freeze<AssetEntity>({
id: 'asset-id-1', id: 'asset-id-1',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -311,6 +319,7 @@ export const assetStub = {
fileSizeInByte: 5_000, fileSizeInByte: 5_000,
} as ExifEntity, } as ExifEntity,
}), }),
imageFrom2015: Object.freeze<AssetEntity>({ imageFrom2015: Object.freeze<AssetEntity>({
id: 'asset-id-1', id: 'asset-id-1',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
@ -350,6 +359,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
deletedAt: null, deletedAt: null,
}), }),
video: Object.freeze<AssetEntity>({ video: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
originalFileName: 'asset-id.ext', originalFileName: 'asset-id.ext',
@ -389,6 +399,7 @@ export const assetStub = {
} as ExifEntity, } as ExifEntity,
deletedAt: null, deletedAt: null,
}), }),
livePhotoMotionAsset: Object.freeze({ livePhotoMotionAsset: Object.freeze({
id: 'live-photo-motion-asset', id: 'live-photo-motion-asset',
originalPath: fileStub.livePhotoMotion.originalPath, originalPath: fileStub.livePhotoMotion.originalPath,
@ -497,10 +508,41 @@ export const assetStub = {
sidecarPath: '/original/path.ext.xmp', sidecarPath: '/original/path.ext.xmp',
deletedAt: null, deletedAt: null,
}), }),
readOnly: Object.freeze({
readOnly: Object.freeze<AssetEntity>({
id: 'read-only-asset', 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, isReadOnly: true,
isExternal: false,
isOffline: false,
libraryId: 'library-id', libraryId: 'library-id',
library: libraryStub.uploadLibrary1, 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, isTrashed: false,
libraryId: 'library-id', libraryId: 'library-id',
hasMetadata: true, hasMetadata: true,
stackCount: 0,
}; };
const assetResponseWithoutMetadata = { const assetResponseWithoutMetadata = {

View File

@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto {
* @memberof AssetBulkUpdateDto * @memberof AssetBulkUpdateDto
*/ */
'isFavorite'?: boolean; 'isFavorite'?: boolean;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'removeParent'?: boolean;
/**
*
* @type {string}
* @memberof AssetBulkUpdateDto
*/
'stackParentId'?: string;
} }
/** /**
* *
@ -748,6 +760,24 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto * @memberof AssetResponseDto
*/ */
'smartInfo'?: SmartInfoResponseDto; '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>} * @type {Array<TagResponseDto>}
@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto {
*/ */
'name'?: string; 'name'?: string;
} }
/**
*
* @export
* @interface UpdateStackParentDto
*/
export interface UpdateStackParentDto {
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'newParentId': string;
/**
*
* @type {string}
* @memberof UpdateStackParentDto
*/
'oldParentId': string;
}
/** /**
* *
* @export * @export
@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, 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 * @param {File} assetData
@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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 * @param {File} assetData
@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> { updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); 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. * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest {
readonly assetBulkUpdateDto: AssetBulkUpdateDto 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. * Request parameters for uploadFile operation in AssetApi.
* @export * @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)); 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. * @param {AssetApiUploadFileRequest} requestParameters Request parameters.