mirror of
https://github.com/immich-app/immich.git
synced 2026-07-06 04:34:45 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b77c90e1c |
@@ -103,7 +103,7 @@ jobs:
|
|||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
|
||||||
|
|
||||||
- uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0
|
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||||
with:
|
with:
|
||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
@@ -237,7 +237,7 @@ jobs:
|
|||||||
run: flutter build ios --config-only --no-codesign
|
run: flutter build ios --config-only --no-codesign
|
||||||
|
|
||||||
- name: Setup Ruby
|
- name: Setup Ruby
|
||||||
uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0
|
uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0
|
||||||
with:
|
with:
|
||||||
ruby-version: '3.3'
|
ruby-version: '3.3'
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for breaking API changes
|
- name: Check for breaking API changes
|
||||||
uses: oasdiff/oasdiff-action/breaking@ccc2442df0d99f8c419ed73e3de88641c91b3bc6 # v0.1.3
|
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
|
||||||
with:
|
with:
|
||||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||||
revision: open-api/immich-openapi-specs.json
|
revision: open-api/immich-openapi-specs.json
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
github-token: ${{ steps.generate-token.outputs.token }}
|
github-token: ${{ steps.generate-token.outputs.token }}
|
||||||
|
|
||||||
- name: Create draft release
|
- name: Create draft release
|
||||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||||
with:
|
with:
|
||||||
draft: true
|
draft: true
|
||||||
prerelease: ${{ needs.bump_version.outputs.rc }}
|
prerelease: ${{ needs.bump_version.outputs.rc }}
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class RemoteAsset extends BaseAsset {
|
|||||||
|
|
||||||
bool get isTrashed => deletedAt != null;
|
bool get isTrashed => deletedAt != null;
|
||||||
|
|
||||||
|
bool get isStacked => stackId != null;
|
||||||
|
|
||||||
|
bool get isArchived => visibility == .archive;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return '''Asset {
|
return '''Asset {
|
||||||
|
|||||||
@@ -3,25 +3,23 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/generated/translations.g.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
import 'package:immich_mobile/presentation/actions/action.dart';
|
import 'package:immich_mobile/presentation/actions/action.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
import 'package:immich_ui/immich_ui.dart';
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
class FavoriteAction extends AssetAction<RemoteAsset> {
|
class FavoriteAction extends AssetAction<RemoteAsset> {
|
||||||
final bool shouldFavorite;
|
final bool favorite;
|
||||||
|
|
||||||
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
|
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Iterable<RemoteAsset> filter(ActionScope scope) => assets
|
Iterable<RemoteAsset> filter(ActionScope scope) =>
|
||||||
.where(
|
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
|
||||||
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
|
|
||||||
)
|
|
||||||
.cast<RemoteAsset>();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
|
||||||
@@ -31,8 +29,8 @@ class FavoriteAction extends AssetAction<RemoteAsset> {
|
|||||||
final ActionScope(:ref) = scope;
|
final ActionScope(:ref) = scope;
|
||||||
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
|
||||||
|
|
||||||
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
|
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
|
||||||
final message = shouldFavorite
|
final message = favorite
|
||||||
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
|
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
|
||||||
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
|
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
|
||||||
snackbar.success(message);
|
snackbar.success(message);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
|
||||||
|
extension type const AssetFilter<T extends BaseAsset>(Iterable<T> assets) implements Iterable<T> {
|
||||||
|
AssetFilter<T> where(bool Function(T asset) test) => AssetFilter(assets.where(test));
|
||||||
|
AssetFilter<T> whereNot(bool Function(T asset) test) => AssetFilter(assets.where((asset) => !test(asset)));
|
||||||
|
|
||||||
|
AssetFilter<T> type(AssetType type) => where((asset) => asset.type == type);
|
||||||
|
AssetFilter<T> favorite({bool isFavorite = true}) => where((asset) => asset.isFavorite == isFavorite);
|
||||||
|
|
||||||
|
AssetFilter<RemoteAsset> remote() => AssetFilter(assets.whereType<RemoteAsset>());
|
||||||
|
AssetFilter<RemoteAsset> owned(String ownerId) => remote().where((asset) => asset.ownerId == ownerId);
|
||||||
|
AssetFilter<RemoteAsset> visibility(AssetVisibility visibility) =>
|
||||||
|
remote().where((asset) => asset.visibility == visibility);
|
||||||
|
AssetFilter<RemoteAsset> notVisibility(AssetVisibility visibility) =>
|
||||||
|
remote().where((asset) => asset.visibility != visibility);
|
||||||
|
AssetFilter<RemoteAsset> archived({bool isArchived = true}) =>
|
||||||
|
remote().where((asset) => asset.isArchived == isArchived);
|
||||||
|
AssetFilter<RemoteAsset> stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked);
|
||||||
|
|
||||||
|
AssetFilter<LocalAsset> local() => AssetFilter(assets.whereType<LocalAsset>());
|
||||||
|
AssetFilter<LocalAsset> backedUp() => local().where((asset) => asset.remoteAssetId != null);
|
||||||
|
}
|
||||||
@@ -5,7 +5,14 @@ import '../../utils.dart';
|
|||||||
class RemoteAssetFactory {
|
class RemoteAssetFactory {
|
||||||
const RemoteAssetFactory();
|
const RemoteAssetFactory();
|
||||||
|
|
||||||
static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) {
|
static RemoteAsset create({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
String? ownerId,
|
||||||
|
bool isFavorite = false,
|
||||||
|
AssetVisibility visibility = AssetVisibility.timeline,
|
||||||
|
String? stackId,
|
||||||
|
}) {
|
||||||
id = TestUtils.uuid(id);
|
id = TestUtils.uuid(id);
|
||||||
|
|
||||||
return RemoteAsset(
|
return RemoteAsset(
|
||||||
@@ -17,6 +24,8 @@ class RemoteAssetFactory {
|
|||||||
createdAt: TestUtils.yesterday(),
|
createdAt: TestUtils.yesterday(),
|
||||||
updatedAt: TestUtils.now(),
|
updatedAt: TestUtils.now(),
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
|
visibility: visibility,
|
||||||
|
stackId: stackId,
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/utils/asset_filter.dart';
|
||||||
|
|
||||||
|
import '../factories/local_asset_factory.dart';
|
||||||
|
import '../factories/remote_asset_factory.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AssetFilter', () {
|
||||||
|
group('type promotion', () {
|
||||||
|
test('a bare filter retains every BaseAsset', () {
|
||||||
|
final remoteAsset = RemoteAssetFactory.create();
|
||||||
|
final localAsset = LocalAssetFactory.create();
|
||||||
|
|
||||||
|
final AssetFilter<BaseAsset> filter = AssetFilter(<BaseAsset>[remoteAsset, localAsset]);
|
||||||
|
|
||||||
|
expect(filter.toList(), [remoteAsset, localAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remote keeps only remote assets', () {
|
||||||
|
final remoteAsset = RemoteAssetFactory.create();
|
||||||
|
final localAsset = LocalAssetFactory.create();
|
||||||
|
|
||||||
|
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).remote();
|
||||||
|
|
||||||
|
expect(remoteOnly.toList(), [remoteAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local keeps only local assets', () {
|
||||||
|
final remoteAsset = RemoteAssetFactory.create();
|
||||||
|
final localAsset = LocalAssetFactory.create();
|
||||||
|
|
||||||
|
final AssetFilter<LocalAsset> localOnly = AssetFilter(<BaseAsset>[remoteAsset, localAsset]).local();
|
||||||
|
|
||||||
|
expect(localOnly.toList(), [localAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('owned promotes to RemoteAsset and drops local assets', () {
|
||||||
|
final remoteAsset = RemoteAssetFactory.create();
|
||||||
|
final localAsset = LocalAssetFactory.create();
|
||||||
|
|
||||||
|
final AssetFilter<RemoteAsset> remoteOnly = AssetFilter(<BaseAsset>[
|
||||||
|
remoteAsset,
|
||||||
|
localAsset,
|
||||||
|
]).owned(remoteAsset.ownerId);
|
||||||
|
|
||||||
|
expect(remoteOnly.toList(), [remoteAsset]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backedUp promotes to LocalAsset and drops remote assets', () {
|
||||||
|
final syncedPhoto = LocalAssetFactory.create().copyWith(remoteId: 'remote');
|
||||||
|
final offlinePhoto = LocalAssetFactory.create();
|
||||||
|
final remotePhoto = RemoteAssetFactory.create();
|
||||||
|
|
||||||
|
final AssetFilter<LocalAsset> syncedPhotos = AssetFilter(<BaseAsset>[
|
||||||
|
syncedPhoto,
|
||||||
|
offlinePhoto,
|
||||||
|
remotePhoto,
|
||||||
|
]).backedUp();
|
||||||
|
|
||||||
|
expect(syncedPhotos.toList(), [syncedPhoto]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('named filters', () {
|
||||||
|
test('owned keeps only assets of the given owner', () {
|
||||||
|
final asset1 = RemoteAssetFactory.create();
|
||||||
|
final asset2 = RemoteAssetFactory.create();
|
||||||
|
|
||||||
|
final alexPhotos = AssetFilter([asset1, asset2]).owned(asset1.ownerId);
|
||||||
|
|
||||||
|
expect(alexPhotos.toList(), [asset1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('favorites keeps only favorite assets', () {
|
||||||
|
final asset1 = RemoteAssetFactory.create(isFavorite: true);
|
||||||
|
final asset2 = RemoteAssetFactory.create(ownerId: asset1.ownerId);
|
||||||
|
|
||||||
|
final favorites = AssetFilter([asset1, asset2]).favorite();
|
||||||
|
|
||||||
|
expect(favorites.toList(), [asset1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type keeps only assets of the given type', () {
|
||||||
|
final image = RemoteAssetFactory.create();
|
||||||
|
final video = RemoteAssetFactory.create(ownerId: image.ownerId).copyWith(type: .video);
|
||||||
|
|
||||||
|
final videos = AssetFilter([image, video]).type(.video);
|
||||||
|
|
||||||
|
expect(videos.toList(), [video]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visibility keeps only assets with the given visibility', () {
|
||||||
|
final locked = RemoteAssetFactory.create(visibility: AssetVisibility.locked);
|
||||||
|
final onTimeline = RemoteAssetFactory.create(ownerId: locked.ownerId);
|
||||||
|
|
||||||
|
final lockedPhotos = AssetFilter([locked, onTimeline]).visibility(.locked);
|
||||||
|
|
||||||
|
expect(lockedPhotos.toList(), [locked]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('archived keeps only archived assets', () {
|
||||||
|
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
|
||||||
|
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId);
|
||||||
|
|
||||||
|
final archivedPhotos = AssetFilter([archived, onTimeline]).archived();
|
||||||
|
|
||||||
|
expect(archivedPhotos.toList(), [archived]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stacked keeps only assets belonging to a stack', () {
|
||||||
|
final stacked = RemoteAssetFactory.create(stackId: 'stack');
|
||||||
|
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
|
||||||
|
|
||||||
|
final stackedPhotos = AssetFilter([stacked, loose]).stacked();
|
||||||
|
|
||||||
|
expect(stackedPhotos.toList(), [stacked]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('inversion', () {
|
||||||
|
test('notArchived keeps every non-archived visibility', () {
|
||||||
|
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
|
||||||
|
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
|
||||||
|
final hidden = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.hidden);
|
||||||
|
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
|
||||||
|
|
||||||
|
final visiblePhotos = AssetFilter([archived, onTimeline, hidden, locked]).archived(isArchived: false);
|
||||||
|
|
||||||
|
expect(visiblePhotos.toSet(), {onTimeline, hidden, locked});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notVisibility keeps every asset not at the target visibility', () {
|
||||||
|
final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive);
|
||||||
|
final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline);
|
||||||
|
final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked);
|
||||||
|
|
||||||
|
final toArchive = AssetFilter([archived, onTimeline, locked]).notVisibility(.archive);
|
||||||
|
|
||||||
|
expect(toArchive.toSet(), {onTimeline, locked});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notStacked keeps only assets without a stack', () {
|
||||||
|
final stacked = RemoteAssetFactory.create(stackId: 'stack');
|
||||||
|
final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId);
|
||||||
|
|
||||||
|
final loosePhotos = AssetFilter([stacked, loose]).stacked(isStacked: false);
|
||||||
|
|
||||||
|
expect(loosePhotos.toList(), [loose]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whereNot inverts an arbitrary predicate', () {
|
||||||
|
final favorite = RemoteAssetFactory.create(isFavorite: true);
|
||||||
|
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
|
||||||
|
|
||||||
|
final nonFavorites = AssetFilter([favorite, regular]).whereNot((asset) => asset.isFavorite);
|
||||||
|
|
||||||
|
expect(nonFavorites.toList(), [regular]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('notFavorites keeps only non-favorite assets', () {
|
||||||
|
final favorite = RemoteAssetFactory.create(isFavorite: true);
|
||||||
|
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
|
||||||
|
|
||||||
|
final nonFavorites = AssetFilter([favorite, regular]).favorite(isFavorite: false);
|
||||||
|
|
||||||
|
expect(nonFavorites.toList(), [regular]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('chaining', () {
|
||||||
|
test('combines predicates across owner, visibility and stack', () {
|
||||||
|
final asset = RemoteAssetFactory.create();
|
||||||
|
final wrongOwner = RemoteAssetFactory.create();
|
||||||
|
final archived = RemoteAssetFactory.create(ownerId: asset.ownerId, visibility: AssetVisibility.archive);
|
||||||
|
final stacked = RemoteAssetFactory.create(ownerId: asset.ownerId, stackId: 'stack-1');
|
||||||
|
final localPhoto = LocalAssetFactory.create();
|
||||||
|
|
||||||
|
final result = AssetFilter(<BaseAsset>[
|
||||||
|
asset,
|
||||||
|
wrongOwner,
|
||||||
|
archived,
|
||||||
|
stacked,
|
||||||
|
localPhoto,
|
||||||
|
]).owned(asset.ownerId).archived(isArchived: false).stacked(isStacked: false);
|
||||||
|
|
||||||
|
expect(result.toList(), [asset]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a base filter after a promotion retains the promoted type', () {
|
||||||
|
final favorite = RemoteAssetFactory.create(isFavorite: true);
|
||||||
|
final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId);
|
||||||
|
|
||||||
|
final AssetFilter<RemoteAsset> result = AssetFilter([favorite, regular]).owned(favorite.ownerId).favorite();
|
||||||
|
|
||||||
|
expect(result.toList(), [favorite]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,17 +7,18 @@ from
|
|||||||
"asset"
|
"asset"
|
||||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||||
where
|
where
|
||||||
"asset"."fileCreatedAt" >= $1
|
"asset"."visibility" = $1
|
||||||
and "asset_exif"."lensModel" = $2
|
and "asset"."fileCreatedAt" >= $2
|
||||||
and "asset"."ownerId" = any ($3::uuid[])
|
and "asset_exif"."lensModel" = $3
|
||||||
and "asset"."isFavorite" = $4
|
and "asset"."ownerId" = any ($4::uuid[])
|
||||||
|
and "asset"."isFavorite" = $5
|
||||||
and "asset"."deletedAt" is null
|
and "asset"."deletedAt" is null
|
||||||
order by
|
order by
|
||||||
"asset"."fileCreatedAt" desc
|
"asset"."fileCreatedAt" desc
|
||||||
limit
|
limit
|
||||||
$5
|
|
||||||
offset
|
|
||||||
$6
|
$6
|
||||||
|
offset
|
||||||
|
$7
|
||||||
|
|
||||||
-- SearchRepository.searchStatistics
|
-- SearchRepository.searchStatistics
|
||||||
select
|
select
|
||||||
@@ -26,10 +27,11 @@ from
|
|||||||
"asset"
|
"asset"
|
||||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||||
where
|
where
|
||||||
"asset"."fileCreatedAt" >= $1
|
"asset"."visibility" = $1
|
||||||
and "asset_exif"."lensModel" = $2
|
and "asset"."fileCreatedAt" >= $2
|
||||||
and "asset"."ownerId" = any ($3::uuid[])
|
and "asset_exif"."lensModel" = $3
|
||||||
and "asset"."isFavorite" = $4
|
and "asset"."ownerId" = any ($4::uuid[])
|
||||||
|
and "asset"."isFavorite" = $5
|
||||||
and "asset"."deletedAt" is null
|
and "asset"."deletedAt" is null
|
||||||
|
|
||||||
-- SearchRepository.searchRandom
|
-- SearchRepository.searchRandom
|
||||||
@@ -39,15 +41,16 @@ from
|
|||||||
"asset"
|
"asset"
|
||||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||||
where
|
where
|
||||||
"asset"."fileCreatedAt" >= $1
|
"asset"."visibility" = $1
|
||||||
and "asset_exif"."lensModel" = $2
|
and "asset"."fileCreatedAt" >= $2
|
||||||
and "asset"."ownerId" = any ($3::uuid[])
|
and "asset_exif"."lensModel" = $3
|
||||||
and "asset"."isFavorite" = $4
|
and "asset"."ownerId" = any ($4::uuid[])
|
||||||
|
and "asset"."isFavorite" = $5
|
||||||
and "asset"."deletedAt" is null
|
and "asset"."deletedAt" is null
|
||||||
order by
|
order by
|
||||||
random()
|
random()
|
||||||
limit
|
limit
|
||||||
$5
|
$6
|
||||||
|
|
||||||
-- SearchRepository.searchLargeAssets
|
-- SearchRepository.searchLargeAssets
|
||||||
select
|
select
|
||||||
@@ -57,16 +60,17 @@ from
|
|||||||
"asset"
|
"asset"
|
||||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||||
where
|
where
|
||||||
"asset"."fileCreatedAt" >= $1
|
"asset"."visibility" = $1
|
||||||
and "asset_exif"."lensModel" = $2
|
and "asset"."fileCreatedAt" >= $2
|
||||||
and "asset"."ownerId" = any ($3::uuid[])
|
and "asset_exif"."lensModel" = $3
|
||||||
and "asset"."isFavorite" = $4
|
and "asset"."ownerId" = any ($4::uuid[])
|
||||||
|
and "asset"."isFavorite" = $5
|
||||||
and "asset"."deletedAt" is null
|
and "asset"."deletedAt" is null
|
||||||
and "asset_exif"."fileSizeInByte" > $5
|
and "asset_exif"."fileSizeInByte" > $6
|
||||||
order by
|
order by
|
||||||
"asset_exif"."fileSizeInByte" desc
|
"asset_exif"."fileSizeInByte" desc
|
||||||
limit
|
limit
|
||||||
$6
|
$7
|
||||||
|
|
||||||
-- SearchRepository.searchSmart
|
-- SearchRepository.searchSmart
|
||||||
begin
|
begin
|
||||||
@@ -79,17 +83,18 @@ from
|
|||||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||||
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
|
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
|
||||||
where
|
where
|
||||||
"asset"."fileCreatedAt" >= $1
|
"asset"."visibility" = $1
|
||||||
and "asset_exif"."lensModel" = $2
|
and "asset"."fileCreatedAt" >= $2
|
||||||
and "asset"."ownerId" = any ($3::uuid[])
|
and "asset_exif"."lensModel" = $3
|
||||||
and "asset"."isFavorite" = $4
|
and "asset"."ownerId" = any ($4::uuid[])
|
||||||
|
and "asset"."isFavorite" = $5
|
||||||
and "asset"."deletedAt" is null
|
and "asset"."deletedAt" is null
|
||||||
order by
|
order by
|
||||||
smart_search.embedding <=> $5
|
smart_search.embedding <=> $6
|
||||||
limit
|
limit
|
||||||
$6
|
|
||||||
offset
|
|
||||||
$7
|
$7
|
||||||
|
offset
|
||||||
|
$8
|
||||||
commit
|
commit
|
||||||
|
|
||||||
-- SearchRepository.getEmbedding
|
-- SearchRepository.getEmbedding
|
||||||
|
|||||||
@@ -117,8 +117,7 @@ type BaseAssetSearchOptions = SearchDateOptions &
|
|||||||
SearchAlbumOptions &
|
SearchAlbumOptions &
|
||||||
SearchOcrOptions;
|
SearchOcrOptions;
|
||||||
|
|
||||||
export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
|
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
|
||||||
SearchRelationOptions & { visibility?: AssetVisibility | 'not-locked' };
|
|
||||||
|
|
||||||
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
||||||
|
|
||||||
@@ -126,11 +125,11 @@ export type SmartSearchOptions = SearchDateOptions &
|
|||||||
SearchEmbeddingOptions &
|
SearchEmbeddingOptions &
|
||||||
SearchExifOptions &
|
SearchExifOptions &
|
||||||
SearchOneToOneRelationOptions &
|
SearchOneToOneRelationOptions &
|
||||||
Omit<SearchStatusOptions, 'visibility'> &
|
SearchStatusOptions &
|
||||||
SearchUserIdOptions &
|
SearchUserIdOptions &
|
||||||
SearchPeopleOptions &
|
SearchPeopleOptions &
|
||||||
SearchTagOptions &
|
SearchTagOptions &
|
||||||
SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' };
|
SearchOcrOptions;
|
||||||
|
|
||||||
export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
|
export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions;
|
||||||
|
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ describe(SearchService.name, () => {
|
|||||||
);
|
);
|
||||||
expect(mocks.search.searchSmart).toHaveBeenCalledWith(
|
expect(mocks.search.searchSmart).toHaveBeenCalledWith(
|
||||||
{ page: 1, size: 100 },
|
{ page: 1, size: 100 },
|
||||||
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id], visibility: 'not-locked' },
|
{ query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id] },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -73,22 +73,14 @@ export class SearchService extends BaseService {
|
|||||||
checksum = Buffer.from(dto.checksum, encoding);
|
checksum = Buffer.from(dto.checksum, encoding);
|
||||||
}
|
}
|
||||||
|
|
||||||
let userIds: string[] | undefined;
|
|
||||||
|
|
||||||
if (dto.albumIds && dto.albumIds.length > 0) {
|
|
||||||
await this.requireAccess({ auth, ids: dto.albumIds, permission: Permission.AlbumRead });
|
|
||||||
} else {
|
|
||||||
userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = dto.page ?? 1;
|
const page = dto.page ?? 1;
|
||||||
const size = dto.size || 250;
|
const size = dto.size || 250;
|
||||||
|
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||||
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
|
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
|
||||||
{ page, size },
|
{ page, size },
|
||||||
{
|
{
|
||||||
...dto,
|
...dto,
|
||||||
checksum,
|
checksum,
|
||||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
|
||||||
userIds,
|
userIds,
|
||||||
orderDirection: dto.order ?? AssetOrder.Desc,
|
orderDirection: dto.order ?? AssetOrder.Desc,
|
||||||
},
|
},
|
||||||
@@ -99,13 +91,9 @@ export class SearchService extends BaseService {
|
|||||||
|
|
||||||
async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
|
async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
|
||||||
const userIds = await this.getUserIdsToSearch(auth);
|
const userIds = await this.getUserIdsToSearch(auth);
|
||||||
if (dto.visibility === AssetVisibility.Locked) {
|
|
||||||
requireElevatedPermission(auth);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.searchRepository.searchStatistics({
|
return await this.searchRepository.searchStatistics({
|
||||||
...dto,
|
...dto,
|
||||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
|
||||||
userIds,
|
userIds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -126,11 +114,7 @@ export class SearchService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||||
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, {
|
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
|
||||||
...dto,
|
|
||||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
|
||||||
userIds,
|
|
||||||
});
|
|
||||||
return items.map((item) => mapAsset(item, { auth }));
|
return items.map((item) => mapAsset(item, { auth }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,12 +155,7 @@ export class SearchService extends BaseService {
|
|||||||
const size = dto.size || 100;
|
const size = dto.size || 100;
|
||||||
const { hasNextPage, items } = await this.searchRepository.searchSmart(
|
const { hasNextPage, items } = await this.searchRepository.searchSmart(
|
||||||
{ page, size },
|
{ page, size },
|
||||||
{
|
{ ...dto, userIds: await userIds, embedding },
|
||||||
...dto,
|
|
||||||
userIds: await userIds,
|
|
||||||
embedding,
|
|
||||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
|
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
|
||||||
|
|||||||
@@ -373,15 +373,12 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
|
|||||||
|
|
||||||
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
|
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
|
||||||
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
|
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
|
||||||
|
const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility;
|
||||||
|
|
||||||
return kysely
|
return kysely
|
||||||
.withPlugin(joinDeduplicationPlugin)
|
.withPlugin(joinDeduplicationPlugin)
|
||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.$if(!!options.visibility, (qb) =>
|
.where('asset.visibility', '=', visibility)
|
||||||
options.visibility === 'not-locked'
|
|
||||||
? qb.where('asset.visibility', '!=', AssetVisibility.Locked)
|
|
||||||
: qb.where('asset.visibility', '=', options.visibility!),
|
|
||||||
)
|
|
||||||
.$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!))
|
.$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!))
|
||||||
.$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!))
|
.$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!))
|
||||||
.$if(options.tagIds === null, (qb) =>
|
.$if(options.tagIds === null, (qb) =>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { SearchSuggestionType } from 'src/dtos/search.dto';
|
import { SearchSuggestionType } from 'src/dtos/search.dto';
|
||||||
import { AlbumUserRole, AssetVisibility } from 'src/enum';
|
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
@@ -109,71 +108,6 @@ describe(SearchService.name, () => {
|
|||||||
expect(response.assets.items.length).toBe(1);
|
expect(response.assets.items.length).toBe(1);
|
||||||
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
|
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('visibility', () => {
|
|
||||||
it('should filter out locked assets in a default session', async () => {
|
|
||||||
const { sut, ctx } = setup();
|
|
||||||
const { user } = await ctx.newUser();
|
|
||||||
|
|
||||||
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
|
|
||||||
|
|
||||||
const auth = factory.auth({ user: { id: user.id } });
|
|
||||||
|
|
||||||
const response = await sut.searchMetadata(auth, { withStacked: false });
|
|
||||||
|
|
||||||
expect(response.assets.items.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return locked assets in an elevated session', async () => {
|
|
||||||
const { sut, ctx } = setup();
|
|
||||||
const { user } = await ctx.newUser();
|
|
||||||
|
|
||||||
await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
|
|
||||||
|
|
||||||
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
|
|
||||||
|
|
||||||
const response = await sut.searchMetadata(auth, { withStacked: false });
|
|
||||||
|
|
||||||
expect(response.assets.items.length).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('albumIds option', () => {
|
|
||||||
it('should return assets from shared album', async () => {
|
|
||||||
const { sut, ctx } = setup();
|
|
||||||
const { user } = await ctx.newUser();
|
|
||||||
const { user: otherUser } = await ctx.newUser();
|
|
||||||
|
|
||||||
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
|
|
||||||
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
|
|
||||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
|
||||||
await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor });
|
|
||||||
|
|
||||||
const auth = factory.auth({ user: { id: user.id } });
|
|
||||||
|
|
||||||
const response = await sut.searchMetadata(auth, { albumIds: [album.id] });
|
|
||||||
|
|
||||||
expect(response.assets.items.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not return assets for album, a user is not in, when partner sharing is enabled', async () => {
|
|
||||||
const { sut, ctx } = setup();
|
|
||||||
const { user } = await ctx.newUser();
|
|
||||||
const { user: otherUser } = await ctx.newUser();
|
|
||||||
|
|
||||||
await ctx.newPartner({ sharedById: otherUser.id, sharedWithId: user.id });
|
|
||||||
|
|
||||||
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
|
|
||||||
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
|
|
||||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
|
||||||
|
|
||||||
const auth = factory.auth({ user: { id: user.id } });
|
|
||||||
|
|
||||||
await expect(sut.searchMetadata(auth, { albumIds: [album.id] })).rejects.toThrow(
|
|
||||||
'Not found or no album.read access',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getSearchSuggestions', () => {
|
describe('getSearchSuggestions', () => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const authFactory = ({
|
|||||||
user,
|
user,
|
||||||
}: {
|
}: {
|
||||||
apiKey?: Partial<AuthApiKey>;
|
apiKey?: Partial<AuthApiKey>;
|
||||||
session?: { id?: string; hasElevatedPermission?: boolean };
|
session?: { id: string };
|
||||||
user?: Omit<
|
user?: Omit<
|
||||||
Partial<UserAdmin>,
|
Partial<UserAdmin>,
|
||||||
'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt'
|
'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt'
|
||||||
@@ -46,8 +46,8 @@ const authFactory = ({
|
|||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
auth.session = {
|
auth.session = {
|
||||||
id: session.id ?? newUuid(),
|
id: session.id,
|
||||||
hasElevatedPermission: session.hasElevatedPermission ?? false,
|
hasElevatedPermission: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user