mirror of
https://github.com/immich-app/immich.git
synced 2026-03-01 11:20:12 +03:00
Compare commits
10 Commits
fix/map-we
...
feat/mobil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef96fa62c1 | ||
|
|
884ebbc965 | ||
|
|
93cd80ad12 | ||
|
|
6052f84022 | ||
|
|
207d8ace07 | ||
|
|
82cfadb599 | ||
|
|
8ab8a9156f | ||
|
|
d1466731d8 | ||
|
|
f706738f93 | ||
|
|
811d3e1c33 |
@@ -1050,7 +1050,6 @@
|
||||
"cant_get_number_of_comments": "Can't get number of comments",
|
||||
"cant_search_people": "Can't search people",
|
||||
"cant_search_places": "Can't search places",
|
||||
"enable_webgl_for_map": "Enable WebGL to load the map.{isAdmin, select, true { To hide this warning, disable the map feature.} other {}}",
|
||||
"error_adding_assets_to_album": "Error adding assets to album",
|
||||
"error_adding_users_to_album": "Error adding users to album",
|
||||
"error_deleting_shared_user": "Error deleting shared user",
|
||||
@@ -1246,7 +1245,6 @@
|
||||
"go_back": "Go back",
|
||||
"go_to_folder": "Go to folder",
|
||||
"go_to_search": "Go to search",
|
||||
"go_to_settings": "Go to settings",
|
||||
"gps": "GPS",
|
||||
"gps_missing": "No GPS",
|
||||
"grant_permission": "Grant permission",
|
||||
@@ -1812,8 +1810,9 @@
|
||||
"rate_asset": "Rate Asset",
|
||||
"rating": "Star rating",
|
||||
"rating_clear": "Clear rating",
|
||||
"rating_count": "{count, plural, =0 {Unrated} one {# star} other {# stars}}",
|
||||
"rating_count": "{count, plural, one {# star} other {# stars}}",
|
||||
"rating_description": "Display the EXIF rating in the info panel",
|
||||
"rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
|
||||
"reaction_options": "Reaction options",
|
||||
"read_changelog": "Read Changelog",
|
||||
"readonly_mode_disabled": "Read-only mode disabled",
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v21.json
generated
Normal file
1
mobile/drift_schemas/main/drift_schema_v21.json
generated
Normal file
File diff suppressed because one or more lines are too long
126
mobile/lib/domain/models/ocr.model.dart
Normal file
126
mobile/lib/domain/models/ocr.model.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
class DriftOcr {
|
||||
final String id;
|
||||
final String assetId;
|
||||
final double x1;
|
||||
final double y1;
|
||||
final double x2;
|
||||
final double y2;
|
||||
final double x3;
|
||||
final double y3;
|
||||
final double x4;
|
||||
final double y4;
|
||||
final double boxScore;
|
||||
final double textScore;
|
||||
final String text;
|
||||
final bool isVisible;
|
||||
|
||||
const DriftOcr({
|
||||
required this.id,
|
||||
required this.assetId,
|
||||
required this.x1,
|
||||
required this.y1,
|
||||
required this.x2,
|
||||
required this.y2,
|
||||
required this.x3,
|
||||
required this.y3,
|
||||
required this.x4,
|
||||
required this.y4,
|
||||
required this.boxScore,
|
||||
required this.textScore,
|
||||
required this.text,
|
||||
required this.isVisible,
|
||||
});
|
||||
|
||||
DriftOcr copyWith({
|
||||
String? id,
|
||||
String? assetId,
|
||||
double? x1,
|
||||
double? y1,
|
||||
double? x2,
|
||||
double? y2,
|
||||
double? x3,
|
||||
double? y3,
|
||||
double? x4,
|
||||
double? y4,
|
||||
double? boxScore,
|
||||
double? textScore,
|
||||
String? text,
|
||||
bool? isVisible,
|
||||
}) {
|
||||
return DriftOcr(
|
||||
id: id ?? this.id,
|
||||
assetId: assetId ?? this.assetId,
|
||||
x1: x1 ?? this.x1,
|
||||
y1: y1 ?? this.y1,
|
||||
x2: x2 ?? this.x2,
|
||||
y2: y2 ?? this.y2,
|
||||
x3: x3 ?? this.x3,
|
||||
y3: y3 ?? this.y3,
|
||||
x4: x4 ?? this.x4,
|
||||
y4: y4 ?? this.y4,
|
||||
boxScore: boxScore ?? this.boxScore,
|
||||
textScore: textScore ?? this.textScore,
|
||||
text: text ?? this.text,
|
||||
isVisible: isVisible ?? this.isVisible,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Ocr {
|
||||
id: $id,
|
||||
assetId: $assetId,
|
||||
x1: $x1,
|
||||
y1: $y1,
|
||||
x2: $x2,
|
||||
y2: $y2,
|
||||
x3: $x3,
|
||||
y3: $y3,
|
||||
x4: $x4,
|
||||
y4: $y4,
|
||||
boxScore: $boxScore,
|
||||
textScore: $textScore,
|
||||
text: $text,
|
||||
isVisible: $isVisible
|
||||
}''';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is DriftOcr &&
|
||||
other.id == id &&
|
||||
other.assetId == assetId &&
|
||||
other.x1 == x1 &&
|
||||
other.y1 == y1 &&
|
||||
other.x2 == x2 &&
|
||||
other.y2 == y2 &&
|
||||
other.x3 == x3 &&
|
||||
other.y3 == y3 &&
|
||||
other.x4 == x4 &&
|
||||
other.y4 == y4 &&
|
||||
other.boxScore == boxScore &&
|
||||
other.textScore == textScore &&
|
||||
other.text == text &&
|
||||
other.isVisible == isVisible;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return id.hashCode ^
|
||||
assetId.hashCode ^
|
||||
x1.hashCode ^
|
||||
y1.hashCode ^
|
||||
x2.hashCode ^
|
||||
y2.hashCode ^
|
||||
x3.hashCode ^
|
||||
y3.hashCode ^
|
||||
x4.hashCode ^
|
||||
y4.hashCode ^
|
||||
boxScore.hashCode ^
|
||||
textScore.hashCode ^
|
||||
text.hashCode ^
|
||||
isVisible.hashCode;
|
||||
}
|
||||
}
|
||||
12
mobile/lib/domain/services/ocr.service.dart
Normal file
12
mobile/lib/domain/services/ocr.service.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:immich_mobile/domain/models/ocr.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
|
||||
|
||||
class DriftOcrService {
|
||||
final DriftOcrRepository _repository;
|
||||
|
||||
const DriftOcrService(this._repository);
|
||||
|
||||
Future<List<DriftOcr>?> get(String assetId) {
|
||||
return _repository.get(assetId);
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,10 @@ class SyncStreamService {
|
||||
return _syncStreamRepository.updateAssetFacesV2(data.cast());
|
||||
case SyncEntityType.assetFaceDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetFacesV1(data.cast());
|
||||
case SyncEntityType.assetOcrV1:
|
||||
return _syncStreamRepository.updateAssetOcrV1(data.cast());
|
||||
case SyncEntityType.assetOcrDeleteV1:
|
||||
return _syncStreamRepository.deleteAssetOcrV1(data.cast());
|
||||
default:
|
||||
_logger.warning("Unknown sync data type: $type");
|
||||
}
|
||||
|
||||
33
mobile/lib/infrastructure/entities/asset_ocr.entity.dart
Normal file
33
mobile/lib/infrastructure/entities/asset_ocr.entity.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
|
||||
|
||||
class AssetOcrEntity extends Table with DriftDefaultsMixin {
|
||||
const AssetOcrEntity();
|
||||
|
||||
TextColumn get id => text()();
|
||||
|
||||
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
RealColumn get x1 => real()();
|
||||
RealColumn get y1 => real()();
|
||||
|
||||
RealColumn get x2 => real()();
|
||||
RealColumn get y2 => real()();
|
||||
|
||||
RealColumn get x3 => real()();
|
||||
RealColumn get y3 => real()();
|
||||
|
||||
RealColumn get x4 => real()();
|
||||
RealColumn get y4 => real()();
|
||||
|
||||
RealColumn get boxScore => real()();
|
||||
RealColumn get textScore => real()();
|
||||
|
||||
TextColumn get recognizedText => text().named('text')();
|
||||
|
||||
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
1284
mobile/lib/infrastructure/entities/asset_ocr.entity.drift.dart
generated
Normal file
1284
mobile/lib/infrastructure/entities/asset_ocr.entity.drift.dart
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart';
|
||||
@@ -66,6 +67,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
|
||||
AssetFaceEntity,
|
||||
StoreEntity,
|
||||
TrashedLocalAssetEntity,
|
||||
AssetOcrEntity,
|
||||
],
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
@@ -97,7 +99,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 20;
|
||||
int get schemaVersion => 21;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -230,6 +232,9 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
|
||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
|
||||
},
|
||||
from20To21: (m, v21) async {
|
||||
await m.create(v21.assetOcrEntity);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -41,9 +41,11 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
|
||||
as i19;
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
|
||||
as i20;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
|
||||
as i21;
|
||||
import 'package:drift/internal/modular.dart' as i22;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
as i22;
|
||||
import 'package:drift/internal/modular.dart' as i23;
|
||||
|
||||
abstract class $Drift extends i0.GeneratedDatabase {
|
||||
$Drift(i0.QueryExecutor e) : super(e);
|
||||
@@ -85,9 +87,12 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
|
||||
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
|
||||
.$TrashedLocalAssetEntityTable(this);
|
||||
i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer(
|
||||
late final i21.$AssetOcrEntityTable assetOcrEntity = i21.$AssetOcrEntityTable(
|
||||
this,
|
||||
).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new);
|
||||
);
|
||||
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
|
||||
this,
|
||||
).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
|
||||
@override
|
||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||
@@ -125,6 +130,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetOcrEntity,
|
||||
i10.idxPartnerSharedWithId,
|
||||
i11.idxLatLng,
|
||||
i12.idxRemoteAlbumAssetAlbumAsset,
|
||||
@@ -325,6 +331,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
),
|
||||
result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)],
|
||||
),
|
||||
i0.WritePropagation(
|
||||
on: i0.TableUpdateQuery.onTableName(
|
||||
'remote_asset_entity',
|
||||
limitUpdateKind: i0.UpdateKind.delete,
|
||||
),
|
||||
result: [i0.TableUpdate('asset_ocr_entity', kind: i0.UpdateKind.delete)],
|
||||
),
|
||||
]);
|
||||
@override
|
||||
i0.DriftDatabaseOptions get options =>
|
||||
@@ -384,4 +397,6 @@ class $DriftManager {
|
||||
_db,
|
||||
_db.trashedLocalAssetEntity,
|
||||
);
|
||||
i21.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
|
||||
i21.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
|
||||
}
|
||||
|
||||
@@ -8904,6 +8904,648 @@ i1.GeneratedColumn<bool> _column_102(String aliasedName) =>
|
||||
),
|
||||
defaultValue: const CustomExpression('1'),
|
||||
);
|
||||
|
||||
final class Schema21 extends i0.VersionedSchema {
|
||||
Schema21({required super.database}) : super(version: 21);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxRemoteAlbumOwnerId,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxStackPrimaryAssetId,
|
||||
idxRemoteAssetOwnerChecksum,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetLocalDateTimeDay,
|
||||
idxRemoteAssetLocalDateTimeMonth,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetOcrEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
late final Shape20 userEntity = Shape20(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_91,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape28 remoteAssetEntity = Shape28(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
_column_16,
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_20,
|
||||
_column_21,
|
||||
_column_86,
|
||||
_column_101,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape3 stackEntity = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape26 localAssetEntity = Shape26(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_98,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape9 remoteAlbumEntity = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_56,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_57,
|
||||
_column_58,
|
||||
_column_59,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape19 localAlbumEntity = Shape19(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_5,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_90,
|
||||
_column_33,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_34, _column_35, _column_33],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
|
||||
'idx_remote_album_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||
'idx_remote_asset_owner_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
|
||||
'idx_remote_asset_local_date_time_day',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
|
||||
'idx_remote_asset_local_date_time_month',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
|
||||
);
|
||||
late final Shape21 authUserEntity = Shape21(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_2,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_92,
|
||||
_column_93,
|
||||
_column_7,
|
||||
_column_94,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_25, _column_26, _column_27],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape5 partnerEntity = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_28, _column_29, _column_30],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape8 remoteExifEntity = Shape8(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_37,
|
||||
_column_38,
|
||||
_column_39,
|
||||
_column_40,
|
||||
_column_41,
|
||||
_column_11,
|
||||
_column_10,
|
||||
_column_42,
|
||||
_column_43,
|
||||
_column_44,
|
||||
_column_45,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_48,
|
||||
_column_49,
|
||||
_column_50,
|
||||
_column_51,
|
||||
_column_52,
|
||||
_column_53,
|
||||
_column_54,
|
||||
_column_55,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_36, _column_60],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_60, _column_25, _column_61],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape27 remoteAssetCloudIdEntity = Shape27(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_99,
|
||||
_column_100,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape11 memoryEntity = Shape11(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_18,
|
||||
_column_15,
|
||||
_column_8,
|
||||
_column_62,
|
||||
_column_63,
|
||||
_column_64,
|
||||
_column_65,
|
||||
_column_66,
|
||||
_column_67,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_36, _column_68],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape14 personEntity = Shape14(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_1,
|
||||
_column_69,
|
||||
_column_71,
|
||||
_column_72,
|
||||
_column_73,
|
||||
_column_74,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape29 assetFaceEntity = Shape29(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_36,
|
||||
_column_76,
|
||||
_column_77,
|
||||
_column_78,
|
||||
_column_79,
|
||||
_column_80,
|
||||
_column_81,
|
||||
_column_82,
|
||||
_column_83,
|
||||
_column_102,
|
||||
_column_18,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_87, _column_88, _column_89],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape25 trashedLocalAssetEntity = Shape25(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_95,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_97,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape30 assetOcrEntity = Shape30(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_ocr_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_36,
|
||||
_column_103,
|
||||
_column_104,
|
||||
_column_105,
|
||||
_column_106,
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
_column_113,
|
||||
_column_102,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape30 extends i0.VersionedTable {
|
||||
Shape30({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get assetId =>
|
||||
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<double> get x1 =>
|
||||
columnsByName['x1']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get y1 =>
|
||||
columnsByName['y1']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get x2 =>
|
||||
columnsByName['x2']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get y2 =>
|
||||
columnsByName['y2']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get x3 =>
|
||||
columnsByName['x3']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get y3 =>
|
||||
columnsByName['y3']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get x4 =>
|
||||
columnsByName['x4']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get y4 =>
|
||||
columnsByName['y4']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get boxScore =>
|
||||
columnsByName['box_score']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get textScore =>
|
||||
columnsByName['text_score']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<String> get recognizedText =>
|
||||
columnsByName['text']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isVisible =>
|
||||
columnsByName['is_visible']! as i1.GeneratedColumn<bool>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<double> _column_103(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x1',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_104(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y1',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_105(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x2',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_106(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y2',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_107(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x3',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_108(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y3',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_109(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x4',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_110(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y4',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_111(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'box_score',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_112(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'text_score',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_113(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'text',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -8924,6 +9566,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -9022,6 +9665,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from19To20(migrator, schema);
|
||||
return 20;
|
||||
case 20:
|
||||
final schema = Schema21(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from20To21(migrator, schema);
|
||||
return 21;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -9048,6 +9696,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -9069,5 +9718,6 @@ i1.OnUpgrade stepByStep({
|
||||
from17To18: from17To18,
|
||||
from18To19: from18To19,
|
||||
from19To20: from19To20,
|
||||
from20To21: from20To21,
|
||||
),
|
||||
);
|
||||
|
||||
36
mobile/lib/infrastructure/repositories/ocr.repository.dart
Normal file
36
mobile/lib/infrastructure/repositories/ocr.repository.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:immich_mobile/domain/models/ocr.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class DriftOcrRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const DriftOcrRepository(this._db) : super(_db);
|
||||
|
||||
Future<List<DriftOcr>?> get(String assetId) async {
|
||||
final query = _db.select(_db.assetOcrEntity)..where((row) => row.assetId.equals(assetId));
|
||||
|
||||
final result = await query.get();
|
||||
return result.map((e) => e.toDto()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetOcrEntityData {
|
||||
DriftOcr toDto() {
|
||||
return DriftOcr(
|
||||
id: id,
|
||||
assetId: assetId,
|
||||
x1: x1,
|
||||
y1: y1,
|
||||
x2: x2,
|
||||
y2: y2,
|
||||
x3: x3,
|
||||
y3: y3,
|
||||
x4: x4,
|
||||
y4: y4,
|
||||
boxScore: boxScore,
|
||||
textScore: textScore,
|
||||
text: recognizedText,
|
||||
isVisible: isVisible,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ class SyncApiRepository {
|
||||
SyncRequestType.peopleV1,
|
||||
if (serverVersion < const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV1,
|
||||
if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV2,
|
||||
SyncRequestType.assetOcrV1,
|
||||
],
|
||||
reset: shouldReset,
|
||||
).toJson(),
|
||||
@@ -195,6 +196,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
|
||||
SyncEntityType.assetFaceV1: SyncAssetFaceV1.fromJson,
|
||||
SyncEntityType.assetFaceV2: SyncAssetFaceV2.fromJson,
|
||||
SyncEntityType.assetFaceDeleteV1: SyncAssetFaceDeleteV1.fromJson,
|
||||
SyncEntityType.assetOcrV1: SyncAssetOcrV1.fromJson,
|
||||
SyncEntityType.assetOcrDeleteV1: SyncAssetOcrDeleteV1.fromJson,
|
||||
SyncEntityType.syncCompleteV1: _SyncEmptyDto.fromJson,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart';
|
||||
@@ -58,6 +59,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
await _db.userEntity.deleteAll();
|
||||
await _db.userMetadataEntity.deleteAll();
|
||||
await _db.remoteAssetCloudIdEntity.deleteAll();
|
||||
await _db.assetOcrEntity.deleteAll();
|
||||
});
|
||||
await _db.customStatement('PRAGMA foreign_keys = ON');
|
||||
});
|
||||
@@ -696,6 +698,53 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetOcrV1(Iterable<SyncAssetOcrV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final assetOcr in data) {
|
||||
final companion = AssetOcrEntityCompanion(
|
||||
id: Value(assetOcr.id),
|
||||
assetId: Value(assetOcr.assetId),
|
||||
recognizedText: Value(assetOcr.text),
|
||||
x1: Value(assetOcr.x1.toDouble()),
|
||||
y1: Value(assetOcr.y1.toDouble()),
|
||||
x2: Value(assetOcr.x2.toDouble()),
|
||||
y2: Value(assetOcr.y2.toDouble()),
|
||||
x3: Value(assetOcr.x3.toDouble()),
|
||||
y3: Value(assetOcr.y3.toDouble()),
|
||||
x4: Value(assetOcr.x4.toDouble()),
|
||||
y4: Value(assetOcr.y4.toDouble()),
|
||||
boxScore: Value(assetOcr.boxScore.toDouble()),
|
||||
textScore: Value(assetOcr.textScore.toDouble()),
|
||||
isVisible: Value(assetOcr.isVisible),
|
||||
);
|
||||
|
||||
batch.insert(
|
||||
_db.assetOcrEntity,
|
||||
companion.copyWith(id: Value(assetOcr.id)),
|
||||
onConflict: DoUpdate((_) => companion),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: updateAssetOcrV1', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAssetOcrV1(Iterable<SyncAssetOcrDeleteV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final assetOcr in data) {
|
||||
batch.deleteWhere(_db.assetOcrEntity, (row) => row.id.equals(assetOcr.id));
|
||||
}
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Error: deleteAssetOcrV1', error, stack);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pruneAssets() async {
|
||||
try {
|
||||
await _db.transaction(() async {
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/ocr_overlay.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
@@ -372,6 +373,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
|
||||
|
||||
final asset = ref.read(timelineServiceProvider).getAssetSafe(widget.index);
|
||||
if (asset == null) {
|
||||
@@ -432,6 +434,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
||||
),
|
||||
),
|
||||
if (showingOcr && !_isZoomed && displayAsset.width != null && displayAsset.height != null)
|
||||
Positioned.fill(
|
||||
child: OcrOverlay(
|
||||
asset: displayAsset,
|
||||
imageSize: Size(displayAsset.width!.toDouble(), displayAsset.height!.toDouble()),
|
||||
viewportSize: Size(viewportWidth, viewportHeight),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !_showingDetails,
|
||||
child: Column(
|
||||
|
||||
@@ -7,6 +7,7 @@ class AssetViewerState {
|
||||
final bool showingDetails;
|
||||
final bool showingControls;
|
||||
final bool isZoomed;
|
||||
final bool showingOcr;
|
||||
final BaseAsset? currentAsset;
|
||||
final int stackIndex;
|
||||
|
||||
@@ -15,6 +16,7 @@ class AssetViewerState {
|
||||
this.showingDetails = false,
|
||||
this.showingControls = true,
|
||||
this.isZoomed = false,
|
||||
this.showingOcr = false,
|
||||
this.currentAsset,
|
||||
this.stackIndex = 0,
|
||||
});
|
||||
@@ -24,6 +26,7 @@ class AssetViewerState {
|
||||
bool? showingDetails,
|
||||
bool? showingControls,
|
||||
bool? isZoomed,
|
||||
bool? showingOcr,
|
||||
BaseAsset? currentAsset,
|
||||
int? stackIndex,
|
||||
}) {
|
||||
@@ -32,6 +35,7 @@ class AssetViewerState {
|
||||
showingDetails: showingDetails ?? this.showingDetails,
|
||||
showingControls: showingControls ?? this.showingControls,
|
||||
isZoomed: isZoomed ?? this.isZoomed,
|
||||
showingOcr: showingOcr ?? this.showingOcr,
|
||||
currentAsset: currentAsset ?? this.currentAsset,
|
||||
stackIndex: stackIndex ?? this.stackIndex,
|
||||
);
|
||||
@@ -39,7 +43,7 @@ class AssetViewerState {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed)';
|
||||
return 'AssetViewerState(opacity: $backgroundOpacity, showingDetails: $showingDetails, controls: $showingControls, isZoomed: $isZoomed, showingOcr: $showingOcr)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -51,6 +55,7 @@ class AssetViewerState {
|
||||
other.showingDetails == showingDetails &&
|
||||
other.showingControls == showingControls &&
|
||||
other.isZoomed == isZoomed &&
|
||||
other.showingOcr == showingOcr &&
|
||||
other.currentAsset == currentAsset &&
|
||||
other.stackIndex == stackIndex;
|
||||
}
|
||||
@@ -61,6 +66,7 @@ class AssetViewerState {
|
||||
showingDetails.hashCode ^
|
||||
showingControls.hashCode ^
|
||||
isZoomed.hashCode ^
|
||||
showingOcr.hashCode ^
|
||||
currentAsset.hashCode ^
|
||||
stackIndex.hashCode;
|
||||
}
|
||||
@@ -123,6 +129,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
}
|
||||
state = state.copyWith(stackIndex: index);
|
||||
}
|
||||
|
||||
void toggleOcr() {
|
||||
state = state.copyWith(showingOcr: !state.showingOcr);
|
||||
}
|
||||
}
|
||||
|
||||
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/ocr.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
|
||||
|
||||
class OcrOverlay extends ConsumerStatefulWidget {
|
||||
final BaseAsset asset;
|
||||
final Size imageSize;
|
||||
final Size viewportSize;
|
||||
|
||||
const OcrOverlay({super.key, required this.asset, required this.imageSize, required this.viewportSize});
|
||||
|
||||
@override
|
||||
ConsumerState<OcrOverlay> createState() => _OcrOverlayState();
|
||||
}
|
||||
|
||||
class _OcrOverlayState extends ConsumerState<OcrOverlay> {
|
||||
int? _selectedBoxIndex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.asset is! RemoteAsset) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final ocrData = ref.watch(driftOcrAssetProvider((widget.asset as RemoteAsset).id));
|
||||
|
||||
return ocrData.when(
|
||||
data: (data) {
|
||||
if (data == null || data.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return _buildOcrBoxes(data);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOcrBoxes(List<DriftOcr> ocrData) {
|
||||
// Calculate the scale factor to fit the image in the viewport
|
||||
final imageWidth = widget.imageSize.width;
|
||||
final imageHeight = widget.imageSize.height;
|
||||
final viewportWidth = widget.viewportSize.width;
|
||||
final viewportHeight = widget.viewportSize.height;
|
||||
|
||||
// Calculate how the image is scaled to fit in the viewport
|
||||
final scaleX = viewportWidth / imageWidth;
|
||||
final scaleY = viewportHeight / imageHeight;
|
||||
final scale = scaleX < scaleY ? scaleX : scaleY;
|
||||
|
||||
// Calculate the actual displayed image size
|
||||
final displayedWidth = imageWidth * scale;
|
||||
final displayedHeight = imageHeight * scale;
|
||||
|
||||
// Calculate the offset to center the image
|
||||
final offsetX = (viewportWidth - displayedWidth) / 2;
|
||||
final offsetY = (viewportHeight - displayedHeight) / 2;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedBoxIndex = null;
|
||||
});
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
// Invisible layer to catch taps outside of boxes
|
||||
SizedBox(width: viewportWidth, height: viewportHeight),
|
||||
...ocrData.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ocr = entry.value;
|
||||
final isSelected = _selectedBoxIndex == index;
|
||||
|
||||
// Normalize coordinates (0-1 range) and scale to displayed image size
|
||||
final x1 = ocr.x1 * displayedWidth + offsetX;
|
||||
final y1 = ocr.y1 * displayedHeight + offsetY;
|
||||
final x2 = ocr.x2 * displayedWidth + offsetX;
|
||||
final y2 = ocr.y2 * displayedHeight + offsetY;
|
||||
final x3 = ocr.x3 * displayedWidth + offsetX;
|
||||
final y3 = ocr.y3 * displayedHeight + offsetY;
|
||||
final x4 = ocr.x4 * displayedWidth + offsetX;
|
||||
final y4 = ocr.y4 * displayedHeight + offsetY;
|
||||
|
||||
// Calculate bounding rectangle for hit testing
|
||||
final minX = [x1, x2, x3, x4].reduce((a, b) => a < b ? a : b);
|
||||
final maxX = [x1, x2, x3, x4].reduce((a, b) => a > b ? a : b);
|
||||
final minY = [y1, y2, y3, y4].reduce((a, b) => a < b ? a : b);
|
||||
final maxY = [y1, y2, y3, y4].reduce((a, b) => a > b ? a : b);
|
||||
|
||||
// Calculate rotation angle from the bottom edge (x1,y1) to (x2,y2)
|
||||
final angle = math.atan2(y2 - y1, x2 - x1);
|
||||
final centerX = (minX + maxX) / 2;
|
||||
final centerY = (minY + maxY) / 2;
|
||||
|
||||
return Positioned(
|
||||
left: minX,
|
||||
top: minY,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedBoxIndex = isSelected ? null : index;
|
||||
});
|
||||
},
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: SizedBox(
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomPaint(
|
||||
painter: _OcrBoxPainter(
|
||||
points: [
|
||||
Offset(x1 - minX, y1 - minY),
|
||||
Offset(x2 - minX, y2 - minY),
|
||||
Offset(x3 - minX, y3 - minY),
|
||||
Offset(x4 - minX, y4 - minY),
|
||||
],
|
||||
isSelected: isSelected,
|
||||
context: context,
|
||||
),
|
||||
size: Size(maxX - minX, maxY - minY),
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned(
|
||||
left: centerX - minX,
|
||||
top: centerY - minY,
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, -0.5),
|
||||
child: Transform.rotate(
|
||||
angle: angle,
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800]?.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: math.max(50, maxX - minX),
|
||||
maxHeight: math.max(20, maxY - minY),
|
||||
),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: SelectableText(
|
||||
ocr.text,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: math.max(12, (maxY - minY) * 0.6),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OcrBoxPainter extends CustomPainter {
|
||||
final List<Offset> points;
|
||||
final bool isSelected;
|
||||
final BuildContext context;
|
||||
|
||||
_OcrBoxPainter({required this.points, required this.isSelected, required this.context});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = isSelected ? context.primaryColor : Colors.green
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
final fillPaint = Paint()
|
||||
..color = (isSelected ? context.primaryColor : Colors.green).withValues(alpha: 0.1)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final path = Path()
|
||||
..moveTo(points[0].dx, points[0].dy)
|
||||
..lineTo(points[1].dx, points[1].dy)
|
||||
..lineTo(points[2].dx, points[2].dy)
|
||||
..lineTo(points[3].dx, points[3].dy)
|
||||
..close();
|
||||
|
||||
canvas.drawPath(path, fillPaint);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_OcrBoxPainter oldDelegate) {
|
||||
return oldDelegate.isSelected != isSelected || oldDelegate.points != points;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||
@@ -33,6 +34,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final hasOcr = asset is RemoteAsset && ref.watch(driftOcrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
|
||||
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
@@ -47,8 +49,15 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
}
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
final showingOcr = ref.watch(assetViewerProvider.select((state) => state.showingOcr));
|
||||
|
||||
final actions = <Widget>[
|
||||
if (hasOcr)
|
||||
IconButton(
|
||||
icon: Icon(showingOcr ? Icons.text_fields : Icons.text_fields_outlined),
|
||||
onPressed: () => ref.read(assetViewerProvider.notifier).toggleOcr(),
|
||||
color: showingOcr ? context.primaryColor : null,
|
||||
),
|
||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||
if (album != null && album.isActivityEnabled && album.isShared)
|
||||
IconButton(
|
||||
|
||||
16
mobile/lib/providers/infrastructure/ocr.provider.dart
Normal file
16
mobile/lib/providers/infrastructure/ocr.provider.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:immich_mobile/domain/models/ocr.model.dart';
|
||||
import 'package:immich_mobile/domain/services/ocr.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
final driftOcrRepositoryProvider = Provider<DriftOcrRepository>((ref) => DriftOcrRepository(ref.watch(driftProvider)));
|
||||
|
||||
final driftOcrServiceProvider = Provider<DriftOcrService>(
|
||||
(ref) => DriftOcrService(ref.watch(driftOcrRepositoryProvider)),
|
||||
);
|
||||
|
||||
final driftOcrAssetProvider = FutureProvider.family<List<DriftOcr>?, String>((ref, assetId) async {
|
||||
final service = ref.watch(driftOcrServiceProvider);
|
||||
return service.get(assetId);
|
||||
});
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -584,6 +584,8 @@ Class | Method | HTTP request | Description
|
||||
- [SyncAssetFaceV2](doc//SyncAssetFaceV2.md)
|
||||
- [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md)
|
||||
- [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md)
|
||||
- [SyncAssetOcrDeleteV1](doc//SyncAssetOcrDeleteV1.md)
|
||||
- [SyncAssetOcrV1](doc//SyncAssetOcrV1.md)
|
||||
- [SyncAssetV1](doc//SyncAssetV1.md)
|
||||
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
|
||||
- [SyncEntityType](doc//SyncEntityType.md)
|
||||
|
||||
2
mobile/openapi/lib/api.dart
generated
2
mobile/openapi/lib/api.dart
generated
@@ -323,6 +323,8 @@ part 'model/sync_asset_face_v1.dart';
|
||||
part 'model/sync_asset_face_v2.dart';
|
||||
part 'model/sync_asset_metadata_delete_v1.dart';
|
||||
part 'model/sync_asset_metadata_v1.dart';
|
||||
part 'model/sync_asset_ocr_delete_v1.dart';
|
||||
part 'model/sync_asset_ocr_v1.dart';
|
||||
part 'model/sync_asset_v1.dart';
|
||||
part 'model/sync_auth_user_v1.dart';
|
||||
part 'model/sync_entity_type.dart';
|
||||
|
||||
4
mobile/openapi/lib/api/search_api.dart
generated
4
mobile/openapi/lib/api/search_api.dart
generated
@@ -410,7 +410,7 @@ class SearchApi {
|
||||
/// Filter by person IDs
|
||||
///
|
||||
/// * [num] rating:
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
/// Filter by rating
|
||||
///
|
||||
/// * [num] size:
|
||||
/// Number of results to return
|
||||
@@ -633,7 +633,7 @@ class SearchApi {
|
||||
/// Filter by person IDs
|
||||
///
|
||||
/// * [num] rating:
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
/// Filter by rating
|
||||
///
|
||||
/// * [num] size:
|
||||
/// Number of results to return
|
||||
|
||||
4
mobile/openapi/lib/api_client.dart
generated
4
mobile/openapi/lib/api_client.dart
generated
@@ -692,6 +692,10 @@ class ApiClient {
|
||||
return SyncAssetMetadataDeleteV1.fromJson(value);
|
||||
case 'SyncAssetMetadataV1':
|
||||
return SyncAssetMetadataV1.fromJson(value);
|
||||
case 'SyncAssetOcrDeleteV1':
|
||||
return SyncAssetOcrDeleteV1.fromJson(value);
|
||||
case 'SyncAssetOcrV1':
|
||||
return SyncAssetOcrV1.fromJson(value);
|
||||
case 'SyncAssetV1':
|
||||
return SyncAssetV1.fromJson(value);
|
||||
case 'SyncAuthUserV1':
|
||||
|
||||
12
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
12
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
@@ -86,10 +86,16 @@ class AssetBulkUpdateDto {
|
||||
///
|
||||
num? longitude;
|
||||
|
||||
/// Rating in range [1-5], or null for unrated
|
||||
/// Rating
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
num? rating;
|
||||
|
||||
/// Time zone (IANA timezone)
|
||||
@@ -217,9 +223,7 @@ class AssetBulkUpdateDto {
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
|
||||
latitude: num.parse('${json[r'latitude']}'),
|
||||
longitude: num.parse('${json[r'longitude']}'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
timeZone: mapValueOfType<String>(json, r'timeZone'),
|
||||
visibility: AssetVisibility.fromJson(json[r'visibility']),
|
||||
);
|
||||
|
||||
12
mobile/openapi/lib/model/metadata_search_dto.dart
generated
12
mobile/openapi/lib/model/metadata_search_dto.dart
generated
@@ -256,10 +256,16 @@ class MetadataSearchDto {
|
||||
///
|
||||
String? previewPath;
|
||||
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
/// Filter by rating
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
num? rating;
|
||||
|
||||
/// Number of results to return
|
||||
@@ -748,9 +754,7 @@ class MetadataSearchDto {
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
previewPath: mapValueOfType<String>(json, r'previewPath'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
|
||||
12
mobile/openapi/lib/model/random_search_dto.dart
generated
12
mobile/openapi/lib/model/random_search_dto.dart
generated
@@ -159,10 +159,16 @@ class RandomSearchDto {
|
||||
/// Filter by person IDs
|
||||
List<String> personIds;
|
||||
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
/// Filter by rating
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
num? rating;
|
||||
|
||||
/// Number of results to return
|
||||
@@ -559,9 +565,7 @@ class RandomSearchDto {
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
|
||||
12
mobile/openapi/lib/model/smart_search_dto.dart
generated
12
mobile/openapi/lib/model/smart_search_dto.dart
generated
@@ -199,10 +199,16 @@ class SmartSearchDto {
|
||||
///
|
||||
String? queryAssetId;
|
||||
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
/// Filter by rating
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
num? rating;
|
||||
|
||||
/// Number of results to return
|
||||
@@ -599,9 +605,7 @@ class SmartSearchDto {
|
||||
: const [],
|
||||
query: mapValueOfType<String>(json, r'query'),
|
||||
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
|
||||
12
mobile/openapi/lib/model/statistics_search_dto.dart
generated
12
mobile/openapi/lib/model/statistics_search_dto.dart
generated
@@ -164,10 +164,16 @@ class StatisticsSearchDto {
|
||||
/// Filter by person IDs
|
||||
List<String> personIds;
|
||||
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
/// Filter by rating
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
num? rating;
|
||||
|
||||
/// Filter by state/province name
|
||||
@@ -489,9 +495,7 @@ class StatisticsSearchDto {
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
||||
118
mobile/openapi/lib/model/sync_asset_ocr_delete_v1.dart
generated
Normal file
118
mobile/openapi/lib/model/sync_asset_ocr_delete_v1.dart
generated
Normal file
@@ -0,0 +1,118 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SyncAssetOcrDeleteV1 {
|
||||
/// Returns a new [SyncAssetOcrDeleteV1] instance.
|
||||
SyncAssetOcrDeleteV1({
|
||||
required this.assetId,
|
||||
required this.deletedAt,
|
||||
required this.id,
|
||||
});
|
||||
|
||||
/// Original asset ID of the deleted OCR entry
|
||||
String assetId;
|
||||
|
||||
/// Timestamp when the OCR entry was deleted
|
||||
DateTime deletedAt;
|
||||
|
||||
/// Audit row ID of the deleted OCR entry
|
||||
String id;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SyncAssetOcrDeleteV1 &&
|
||||
other.assetId == assetId &&
|
||||
other.deletedAt == deletedAt &&
|
||||
other.id == id;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetId.hashCode) +
|
||||
(deletedAt.hashCode) +
|
||||
(id.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SyncAssetOcrDeleteV1[assetId=$assetId, deletedAt=$deletedAt, id=$id]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assetId'] = this.assetId;
|
||||
json[r'deletedAt'] = this.deletedAt.toUtc().toIso8601String();
|
||||
json[r'id'] = this.id;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SyncAssetOcrDeleteV1] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SyncAssetOcrDeleteV1? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SyncAssetOcrDeleteV1");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SyncAssetOcrDeleteV1(
|
||||
assetId: mapValueOfType<String>(json, r'assetId')!,
|
||||
deletedAt: mapDateTime(json, r'deletedAt', r'')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SyncAssetOcrDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SyncAssetOcrDeleteV1>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SyncAssetOcrDeleteV1.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SyncAssetOcrDeleteV1> mapFromJson(dynamic json) {
|
||||
final map = <String, SyncAssetOcrDeleteV1>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SyncAssetOcrDeleteV1.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SyncAssetOcrDeleteV1-objects as value to a dart map
|
||||
static Map<String, List<SyncAssetOcrDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SyncAssetOcrDeleteV1>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SyncAssetOcrDeleteV1.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'assetId',
|
||||
'deletedAt',
|
||||
'id',
|
||||
};
|
||||
}
|
||||
|
||||
217
mobile/openapi/lib/model/sync_asset_ocr_v1.dart
generated
Normal file
217
mobile/openapi/lib/model/sync_asset_ocr_v1.dart
generated
Normal file
@@ -0,0 +1,217 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SyncAssetOcrV1 {
|
||||
/// Returns a new [SyncAssetOcrV1] instance.
|
||||
SyncAssetOcrV1({
|
||||
required this.assetId,
|
||||
required this.boxScore,
|
||||
required this.id,
|
||||
required this.isVisible,
|
||||
required this.text,
|
||||
required this.textScore,
|
||||
required this.x1,
|
||||
required this.x2,
|
||||
required this.x3,
|
||||
required this.x4,
|
||||
required this.y1,
|
||||
required this.y2,
|
||||
required this.y3,
|
||||
required this.y4,
|
||||
});
|
||||
|
||||
/// Asset ID
|
||||
String assetId;
|
||||
|
||||
/// Confidence score of the bounding box
|
||||
num boxScore;
|
||||
|
||||
/// OCR entry ID
|
||||
String id;
|
||||
|
||||
/// Whether the OCR entry is visible
|
||||
bool isVisible;
|
||||
|
||||
/// Recognized text content
|
||||
String text;
|
||||
|
||||
/// Confidence score of the recognized text
|
||||
num textScore;
|
||||
|
||||
/// Top-left X coordinate (normalized 0–1)
|
||||
num x1;
|
||||
|
||||
/// Top-right X coordinate (normalized 0–1)
|
||||
num x2;
|
||||
|
||||
/// Bottom-right X coordinate (normalized 0–1)
|
||||
num x3;
|
||||
|
||||
/// Bottom-left X coordinate (normalized 0–1)
|
||||
num x4;
|
||||
|
||||
/// Top-left Y coordinate (normalized 0–1)
|
||||
num y1;
|
||||
|
||||
/// Top-right Y coordinate (normalized 0–1)
|
||||
num y2;
|
||||
|
||||
/// Bottom-right Y coordinate (normalized 0–1)
|
||||
num y3;
|
||||
|
||||
/// Bottom-left Y coordinate (normalized 0–1)
|
||||
num y4;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SyncAssetOcrV1 &&
|
||||
other.assetId == assetId &&
|
||||
other.boxScore == boxScore &&
|
||||
other.id == id &&
|
||||
other.isVisible == isVisible &&
|
||||
other.text == text &&
|
||||
other.textScore == textScore &&
|
||||
other.x1 == x1 &&
|
||||
other.x2 == x2 &&
|
||||
other.x3 == x3 &&
|
||||
other.x4 == x4 &&
|
||||
other.y1 == y1 &&
|
||||
other.y2 == y2 &&
|
||||
other.y3 == y3 &&
|
||||
other.y4 == y4;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetId.hashCode) +
|
||||
(boxScore.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isVisible.hashCode) +
|
||||
(text.hashCode) +
|
||||
(textScore.hashCode) +
|
||||
(x1.hashCode) +
|
||||
(x2.hashCode) +
|
||||
(x3.hashCode) +
|
||||
(x4.hashCode) +
|
||||
(y1.hashCode) +
|
||||
(y2.hashCode) +
|
||||
(y3.hashCode) +
|
||||
(y4.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SyncAssetOcrV1[assetId=$assetId, boxScore=$boxScore, id=$id, isVisible=$isVisible, text=$text, textScore=$textScore, x1=$x1, x2=$x2, x3=$x3, x4=$x4, y1=$y1, y2=$y2, y3=$y3, y4=$y4]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assetId'] = this.assetId;
|
||||
json[r'boxScore'] = this.boxScore;
|
||||
json[r'id'] = this.id;
|
||||
json[r'isVisible'] = this.isVisible;
|
||||
json[r'text'] = this.text;
|
||||
json[r'textScore'] = this.textScore;
|
||||
json[r'x1'] = this.x1;
|
||||
json[r'x2'] = this.x2;
|
||||
json[r'x3'] = this.x3;
|
||||
json[r'x4'] = this.x4;
|
||||
json[r'y1'] = this.y1;
|
||||
json[r'y2'] = this.y2;
|
||||
json[r'y3'] = this.y3;
|
||||
json[r'y4'] = this.y4;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SyncAssetOcrV1] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SyncAssetOcrV1? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SyncAssetOcrV1");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SyncAssetOcrV1(
|
||||
assetId: mapValueOfType<String>(json, r'assetId')!,
|
||||
boxScore: num.parse('${json[r'boxScore']}'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isVisible: mapValueOfType<bool>(json, r'isVisible')!,
|
||||
text: mapValueOfType<String>(json, r'text')!,
|
||||
textScore: num.parse('${json[r'textScore']}'),
|
||||
x1: num.parse('${json[r'x1']}'),
|
||||
x2: num.parse('${json[r'x2']}'),
|
||||
x3: num.parse('${json[r'x3']}'),
|
||||
x4: num.parse('${json[r'x4']}'),
|
||||
y1: num.parse('${json[r'y1']}'),
|
||||
y2: num.parse('${json[r'y2']}'),
|
||||
y3: num.parse('${json[r'y3']}'),
|
||||
y4: num.parse('${json[r'y4']}'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SyncAssetOcrV1> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SyncAssetOcrV1>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SyncAssetOcrV1.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SyncAssetOcrV1> mapFromJson(dynamic json) {
|
||||
final map = <String, SyncAssetOcrV1>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SyncAssetOcrV1.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SyncAssetOcrV1-objects as value to a dart map
|
||||
static Map<String, List<SyncAssetOcrV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SyncAssetOcrV1>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SyncAssetOcrV1.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'assetId',
|
||||
'boxScore',
|
||||
'id',
|
||||
'isVisible',
|
||||
'text',
|
||||
'textScore',
|
||||
'x1',
|
||||
'x2',
|
||||
'x3',
|
||||
'x4',
|
||||
'y1',
|
||||
'y2',
|
||||
'y3',
|
||||
'y4',
|
||||
};
|
||||
}
|
||||
|
||||
6
mobile/openapi/lib/model/sync_entity_type.dart
generated
6
mobile/openapi/lib/model/sync_entity_type.dart
generated
@@ -33,6 +33,8 @@ class SyncEntityType {
|
||||
static const assetEditDeleteV1 = SyncEntityType._(r'AssetEditDeleteV1');
|
||||
static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1');
|
||||
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
|
||||
static const assetOcrV1 = SyncEntityType._(r'AssetOcrV1');
|
||||
static const assetOcrDeleteV1 = SyncEntityType._(r'AssetOcrDeleteV1');
|
||||
static const partnerV1 = SyncEntityType._(r'PartnerV1');
|
||||
static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1');
|
||||
static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1');
|
||||
@@ -86,6 +88,8 @@ class SyncEntityType {
|
||||
assetEditDeleteV1,
|
||||
assetMetadataV1,
|
||||
assetMetadataDeleteV1,
|
||||
assetOcrV1,
|
||||
assetOcrDeleteV1,
|
||||
partnerV1,
|
||||
partnerDeleteV1,
|
||||
partnerAssetV1,
|
||||
@@ -174,6 +178,8 @@ class SyncEntityTypeTypeTransformer {
|
||||
case r'AssetEditDeleteV1': return SyncEntityType.assetEditDeleteV1;
|
||||
case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1;
|
||||
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
|
||||
case r'AssetOcrV1': return SyncEntityType.assetOcrV1;
|
||||
case r'AssetOcrDeleteV1': return SyncEntityType.assetOcrDeleteV1;
|
||||
case r'PartnerV1': return SyncEntityType.partnerV1;
|
||||
case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1;
|
||||
case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1;
|
||||
|
||||
3
mobile/openapi/lib/model/sync_request_type.dart
generated
3
mobile/openapi/lib/model/sync_request_type.dart
generated
@@ -32,6 +32,7 @@ class SyncRequestType {
|
||||
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
|
||||
static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1');
|
||||
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
|
||||
static const assetOcrV1 = SyncRequestType._(r'AssetOcrV1');
|
||||
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
|
||||
static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
|
||||
static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1');
|
||||
@@ -57,6 +58,7 @@ class SyncRequestType {
|
||||
assetExifsV1,
|
||||
assetEditsV1,
|
||||
assetMetadataV1,
|
||||
assetOcrV1,
|
||||
authUsersV1,
|
||||
memoriesV1,
|
||||
memoryToAssetsV1,
|
||||
@@ -117,6 +119,7 @@ class SyncRequestTypeTypeTransformer {
|
||||
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
|
||||
case r'AssetEditsV1': return SyncRequestType.assetEditsV1;
|
||||
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
|
||||
case r'AssetOcrV1': return SyncRequestType.assetOcrV1;
|
||||
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
|
||||
case r'MemoriesV1': return SyncRequestType.memoriesV1;
|
||||
case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1;
|
||||
|
||||
12
mobile/openapi/lib/model/update_asset_dto.dart
generated
12
mobile/openapi/lib/model/update_asset_dto.dart
generated
@@ -71,10 +71,16 @@ class UpdateAssetDto {
|
||||
///
|
||||
num? longitude;
|
||||
|
||||
/// Rating in range [1-5], or null for unrated
|
||||
/// Rating
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
num? rating;
|
||||
|
||||
/// Asset visibility
|
||||
@@ -172,9 +178,7 @@ class UpdateAssetDto {
|
||||
latitude: num.parse('${json[r'latitude']}'),
|
||||
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
|
||||
longitude: num.parse('${json[r'longitude']}'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
visibility: AssetVisibility.fromJson(json[r'visibility']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1217,10 +1217,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1910,10 +1910,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.6"
|
||||
thumbhash:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
4
mobile/test/drift/main/generated/schema.dart
generated
4
mobile/test/drift/main/generated/schema.dart
generated
@@ -23,6 +23,7 @@ import 'schema_v17.dart' as v17;
|
||||
import 'schema_v18.dart' as v18;
|
||||
import 'schema_v19.dart' as v19;
|
||||
import 'schema_v20.dart' as v20;
|
||||
import 'schema_v21.dart' as v21;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -68,6 +69,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v19.DatabaseAtV19(db);
|
||||
case 20:
|
||||
return v20.DatabaseAtV20(db);
|
||||
case 21:
|
||||
return v21.DatabaseAtV21(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -94,5 +97,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
];
|
||||
}
|
||||
|
||||
9084
mobile/test/drift/main/generated/schema_v21.dart
generated
Normal file
9084
mobile/test/drift/main/generated/schema_v21.dart
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9407,27 +9407,10 @@
|
||||
"name": "rating",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by rating [1-5], or null for unrated",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable",
|
||||
"description": "Filter by rating",
|
||||
"schema": {
|
||||
"minimum": -1,
|
||||
"maximum": 5,
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
@@ -15889,27 +15872,10 @@
|
||||
"type": "number"
|
||||
},
|
||||
"rating": {
|
||||
"description": "Rating in range [1-5], or null for unrated",
|
||||
"description": "Rating",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
"type": "number"
|
||||
},
|
||||
"timeZone": {
|
||||
"description": "Time zone (IANA timezone)",
|
||||
@@ -19022,27 +18988,10 @@
|
||||
"type": "string"
|
||||
},
|
||||
"rating": {
|
||||
"description": "Filter by rating [1-5], or null for unrated",
|
||||
"description": "Filter by rating",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
"type": "number"
|
||||
},
|
||||
"size": {
|
||||
"description": "Number of results to return",
|
||||
@@ -20765,27 +20714,10 @@
|
||||
"type": "array"
|
||||
},
|
||||
"rating": {
|
||||
"description": "Filter by rating [1-5], or null for unrated",
|
||||
"description": "Filter by rating",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
"type": "number"
|
||||
},
|
||||
"size": {
|
||||
"description": "Number of results to return",
|
||||
@@ -22156,27 +22088,10 @@
|
||||
"type": "string"
|
||||
},
|
||||
"rating": {
|
||||
"description": "Filter by rating [1-5], or null for unrated",
|
||||
"description": "Filter by rating",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
"type": "number"
|
||||
},
|
||||
"size": {
|
||||
"description": "Number of results to return",
|
||||
@@ -22407,27 +22322,10 @@
|
||||
"type": "array"
|
||||
},
|
||||
"rating": {
|
||||
"description": "Filter by rating [1-5], or null for unrated",
|
||||
"description": "Filter by rating",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
"type": "number"
|
||||
},
|
||||
"state": {
|
||||
"description": "Filter by state/province name",
|
||||
@@ -23088,6 +22986,106 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAssetOcrDeleteV1": {
|
||||
"properties": {
|
||||
"assetId": {
|
||||
"description": "Original asset ID of the deleted OCR entry",
|
||||
"type": "string"
|
||||
},
|
||||
"deletedAt": {
|
||||
"description": "Timestamp when the OCR entry was deleted",
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Audit row ID of the deleted OCR entry",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetId",
|
||||
"deletedAt",
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAssetOcrV1": {
|
||||
"properties": {
|
||||
"assetId": {
|
||||
"description": "Asset ID",
|
||||
"type": "string"
|
||||
},
|
||||
"boxScore": {
|
||||
"description": "Confidence score of the bounding box",
|
||||
"type": "number"
|
||||
},
|
||||
"id": {
|
||||
"description": "OCR entry ID",
|
||||
"type": "string"
|
||||
},
|
||||
"isVisible": {
|
||||
"description": "Whether the OCR entry is visible",
|
||||
"type": "boolean"
|
||||
},
|
||||
"text": {
|
||||
"description": "Recognized text content",
|
||||
"type": "string"
|
||||
},
|
||||
"textScore": {
|
||||
"description": "Confidence score of the recognized text",
|
||||
"type": "number"
|
||||
},
|
||||
"x1": {
|
||||
"description": "Top-left X coordinate (normalized 0–1)",
|
||||
"type": "number"
|
||||
},
|
||||
"x2": {
|
||||
"description": "Top-right X coordinate (normalized 0–1)",
|
||||
"type": "number"
|
||||
},
|
||||
"x3": {
|
||||
"description": "Bottom-right X coordinate (normalized 0–1)",
|
||||
"type": "number"
|
||||
},
|
||||
"x4": {
|
||||
"description": "Bottom-left X coordinate (normalized 0–1)",
|
||||
"type": "number"
|
||||
},
|
||||
"y1": {
|
||||
"description": "Top-left Y coordinate (normalized 0–1)",
|
||||
"type": "number"
|
||||
},
|
||||
"y2": {
|
||||
"description": "Top-right Y coordinate (normalized 0–1)",
|
||||
"type": "number"
|
||||
},
|
||||
"y3": {
|
||||
"description": "Bottom-right Y coordinate (normalized 0–1)",
|
||||
"type": "number"
|
||||
},
|
||||
"y4": {
|
||||
"description": "Bottom-left Y coordinate (normalized 0–1)",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetId",
|
||||
"boxScore",
|
||||
"id",
|
||||
"isVisible",
|
||||
"text",
|
||||
"textScore",
|
||||
"x1",
|
||||
"x2",
|
||||
"x3",
|
||||
"x4",
|
||||
"y1",
|
||||
"y2",
|
||||
"y3",
|
||||
"y4"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAssetV1": {
|
||||
"properties": {
|
||||
"checksum": {
|
||||
@@ -23311,6 +23309,8 @@
|
||||
"AssetEditDeleteV1",
|
||||
"AssetMetadataV1",
|
||||
"AssetMetadataDeleteV1",
|
||||
"AssetOcrV1",
|
||||
"AssetOcrDeleteV1",
|
||||
"PartnerV1",
|
||||
"PartnerDeleteV1",
|
||||
"PartnerAssetV1",
|
||||
@@ -23608,6 +23608,7 @@
|
||||
"AssetExifsV1",
|
||||
"AssetEditsV1",
|
||||
"AssetMetadataV1",
|
||||
"AssetOcrV1",
|
||||
"AuthUsersV1",
|
||||
"MemoriesV1",
|
||||
"MemoryToAssetsV1",
|
||||
@@ -25311,27 +25312,10 @@
|
||||
"type": "number"
|
||||
},
|
||||
"rating": {
|
||||
"description": "Rating in range [1-5], or null for unrated",
|
||||
"description": "Rating",
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Updated",
|
||||
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Stable"
|
||||
"type": "number"
|
||||
},
|
||||
"visibility": {
|
||||
"allOf": [
|
||||
|
||||
@@ -834,8 +834,8 @@ export type AssetBulkUpdateDto = {
|
||||
latitude?: number;
|
||||
/** Longitude coordinate */
|
||||
longitude?: number;
|
||||
/** Rating in range [1-5], or null for unrated */
|
||||
rating?: number | null;
|
||||
/** Rating */
|
||||
rating?: number;
|
||||
/** Time zone (IANA timezone) */
|
||||
timeZone?: string;
|
||||
/** Asset visibility */
|
||||
@@ -944,8 +944,8 @@ export type UpdateAssetDto = {
|
||||
livePhotoVideoId?: string | null;
|
||||
/** Longitude coordinate */
|
||||
longitude?: number;
|
||||
/** Rating in range [1-5], or null for unrated */
|
||||
rating?: number | null;
|
||||
/** Rating */
|
||||
rating?: number;
|
||||
/** Asset visibility */
|
||||
visibility?: AssetVisibility;
|
||||
};
|
||||
@@ -1711,8 +1711,8 @@ export type MetadataSearchDto = {
|
||||
personIds?: string[];
|
||||
/** Filter by preview file path */
|
||||
previewPath?: string;
|
||||
/** Filter by rating [1-5], or null for unrated */
|
||||
rating?: number | null;
|
||||
/** Filter by rating */
|
||||
rating?: number;
|
||||
/** Number of results to return */
|
||||
size?: number;
|
||||
/** Filter by state/province name */
|
||||
@@ -1827,8 +1827,8 @@ export type RandomSearchDto = {
|
||||
ocr?: string;
|
||||
/** Filter by person IDs */
|
||||
personIds?: string[];
|
||||
/** Filter by rating [1-5], or null for unrated */
|
||||
rating?: number | null;
|
||||
/** Filter by rating */
|
||||
rating?: number;
|
||||
/** Number of results to return */
|
||||
size?: number;
|
||||
/** Filter by state/province name */
|
||||
@@ -1903,8 +1903,8 @@ export type SmartSearchDto = {
|
||||
query?: string;
|
||||
/** Asset ID to use as search reference */
|
||||
queryAssetId?: string;
|
||||
/** Filter by rating [1-5], or null for unrated */
|
||||
rating?: number | null;
|
||||
/** Filter by rating */
|
||||
rating?: number;
|
||||
/** Number of results to return */
|
||||
size?: number;
|
||||
/** Filter by state/province name */
|
||||
@@ -1969,8 +1969,8 @@ export type StatisticsSearchDto = {
|
||||
ocr?: string;
|
||||
/** Filter by person IDs */
|
||||
personIds?: string[];
|
||||
/** Filter by rating [1-5], or null for unrated */
|
||||
rating?: number | null;
|
||||
/** Filter by rating */
|
||||
rating?: number;
|
||||
/** Filter by state/province name */
|
||||
state?: string | null;
|
||||
/** Filter by tag IDs */
|
||||
@@ -3083,6 +3083,44 @@ export type SyncAssetMetadataV1 = {
|
||||
/** Value */
|
||||
value: object;
|
||||
};
|
||||
export type SyncAssetOcrDeleteV1 = {
|
||||
/** Original asset ID of the deleted OCR entry */
|
||||
assetId: string;
|
||||
/** Timestamp when the OCR entry was deleted */
|
||||
deletedAt: string;
|
||||
/** Audit row ID of the deleted OCR entry */
|
||||
id: string;
|
||||
};
|
||||
export type SyncAssetOcrV1 = {
|
||||
/** Asset ID */
|
||||
assetId: string;
|
||||
/** Confidence score of the bounding box */
|
||||
boxScore: number;
|
||||
/** OCR entry ID */
|
||||
id: string;
|
||||
/** Whether the OCR entry is visible */
|
||||
isVisible: boolean;
|
||||
/** Recognized text content */
|
||||
text: string;
|
||||
/** Confidence score of the recognized text */
|
||||
textScore: number;
|
||||
/** Top-left X coordinate (normalized 0–1) */
|
||||
x1: number;
|
||||
/** Top-right X coordinate (normalized 0–1) */
|
||||
x2: number;
|
||||
/** Bottom-right X coordinate (normalized 0–1) */
|
||||
x3: number;
|
||||
/** Bottom-left X coordinate (normalized 0–1) */
|
||||
x4: number;
|
||||
/** Top-left Y coordinate (normalized 0–1) */
|
||||
y1: number;
|
||||
/** Top-right Y coordinate (normalized 0–1) */
|
||||
y2: number;
|
||||
/** Bottom-right Y coordinate (normalized 0–1) */
|
||||
y3: number;
|
||||
/** Bottom-left Y coordinate (normalized 0–1) */
|
||||
y4: number;
|
||||
};
|
||||
export type SyncAssetV1 = {
|
||||
/** Checksum */
|
||||
checksum: string;
|
||||
@@ -5454,7 +5492,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
|
||||
model?: string | null;
|
||||
ocr?: string;
|
||||
personIds?: string[];
|
||||
rating?: number | null;
|
||||
rating?: number;
|
||||
size?: number;
|
||||
state?: string | null;
|
||||
tagIds?: string[] | null;
|
||||
@@ -7244,6 +7282,8 @@ export enum SyncEntityType {
|
||||
AssetEditDeleteV1 = "AssetEditDeleteV1",
|
||||
AssetMetadataV1 = "AssetMetadataV1",
|
||||
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
|
||||
AssetOcrV1 = "AssetOcrV1",
|
||||
AssetOcrDeleteV1 = "AssetOcrDeleteV1",
|
||||
PartnerV1 = "PartnerV1",
|
||||
PartnerDeleteV1 = "PartnerDeleteV1",
|
||||
PartnerAssetV1 = "PartnerAssetV1",
|
||||
@@ -7295,6 +7335,7 @@ export enum SyncRequestType {
|
||||
AssetExifsV1 = "AssetExifsV1",
|
||||
AssetEditsV1 = "AssetEditsV1",
|
||||
AssetMetadataV1 = "AssetMetadataV1",
|
||||
AssetOcrV1 = "AssetOcrV1",
|
||||
AuthUsersV1 = "AuthUsersV1",
|
||||
MemoriesV1 = "MemoriesV1",
|
||||
MemoryToAssetsV1 = "MemoryToAssetsV1",
|
||||
|
||||
@@ -207,28 +207,12 @@ describe(AssetController.name, () => {
|
||||
});
|
||||
|
||||
it('should reject invalid rating', async () => {
|
||||
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) {
|
||||
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
}
|
||||
});
|
||||
|
||||
it('should convert rating 0 to null', async () => {
|
||||
const assetId = factory.uuid();
|
||||
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should leave correct ratings as-is', async () => {
|
||||
const assetId = factory.uuid();
|
||||
for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) {
|
||||
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test);
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, assetId, test);
|
||||
expect(status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/statistics', () => {
|
||||
|
||||
@@ -437,6 +437,23 @@ export const columns = {
|
||||
'asset_exif.rating',
|
||||
'asset_exif.fps',
|
||||
],
|
||||
syncAssetOcr: [
|
||||
'asset_ocr.id',
|
||||
'asset_ocr.assetId',
|
||||
'asset_ocr.x1',
|
||||
'asset_ocr.y1',
|
||||
'asset_ocr.x2',
|
||||
'asset_ocr.y2',
|
||||
'asset_ocr.x3',
|
||||
'asset_ocr.y3',
|
||||
'asset_ocr.x4',
|
||||
'asset_ocr.y4',
|
||||
'asset_ocr.text',
|
||||
'asset_ocr.boxScore',
|
||||
'asset_ocr.textScore',
|
||||
'asset_ocr.updateId',
|
||||
'asset_ocr.isVisible',
|
||||
],
|
||||
syncAssetEdit: [
|
||||
'asset_edit.id',
|
||||
'asset_edit.assetId',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
@@ -57,19 +56,12 @@ export class UpdateAssetBase {
|
||||
@IsNotEmpty()
|
||||
longitude?: number;
|
||||
|
||||
@Property({
|
||||
description: 'Rating in range [1-5], or null for unrated',
|
||||
history: new HistoryBuilder()
|
||||
.added('v1')
|
||||
.stable('v2')
|
||||
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
|
||||
})
|
||||
@Optional({ nullable: true })
|
||||
@ApiProperty({ description: 'Rating' })
|
||||
@Optional()
|
||||
@IsInt()
|
||||
@Max(5)
|
||||
@Min(-1)
|
||||
@Transform(({ value }) => (value === 0 ? null : value))
|
||||
rating?: number | null;
|
||||
rating?: number;
|
||||
|
||||
@ApiProperty({ description: 'Asset description' })
|
||||
@Optional()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
||||
import { Place } from 'src/database';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseDto } from 'src/dtos/album.dto';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
|
||||
@@ -103,21 +103,12 @@ class BaseSearchDto {
|
||||
@ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' })
|
||||
albumIds?: string[];
|
||||
|
||||
@Property({
|
||||
type: 'number',
|
||||
description: 'Filter by rating [1-5], or null for unrated',
|
||||
minimum: -1,
|
||||
maximum: 5,
|
||||
history: new HistoryBuilder()
|
||||
.added('v1')
|
||||
.stable('v2')
|
||||
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
|
||||
})
|
||||
@Optional({ nullable: true })
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Filter by rating', minimum: -1, maximum: 5 })
|
||||
@Optional()
|
||||
@IsInt()
|
||||
@Max(5)
|
||||
@Min(-1)
|
||||
rating?: number | null;
|
||||
rating?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by OCR text content' })
|
||||
@IsString()
|
||||
|
||||
@@ -220,6 +220,62 @@ export class SyncAssetExifV1 {
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetOcrV1 {
|
||||
@ApiProperty({ description: 'OCR entry ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Top-left X coordinate (normalized 0–1)' })
|
||||
x1!: number;
|
||||
|
||||
@ApiProperty({ description: 'Top-left Y coordinate (normalized 0–1)' })
|
||||
y1!: number;
|
||||
|
||||
@ApiProperty({ description: 'Top-right X coordinate (normalized 0–1)' })
|
||||
x2!: number;
|
||||
|
||||
@ApiProperty({ description: 'Top-right Y coordinate (normalized 0–1)' })
|
||||
y2!: number;
|
||||
|
||||
@ApiProperty({ description: 'Bottom-right X coordinate (normalized 0–1)' })
|
||||
x3!: number;
|
||||
|
||||
@ApiProperty({ description: 'Bottom-right Y coordinate (normalized 0–1)' })
|
||||
y3!: number;
|
||||
|
||||
@ApiProperty({ description: 'Bottom-left X coordinate (normalized 0–1)' })
|
||||
x4!: number;
|
||||
|
||||
@ApiProperty({ description: 'Bottom-left Y coordinate (normalized 0–1)' })
|
||||
y4!: number;
|
||||
|
||||
@ApiProperty({ description: 'Confidence score of the bounding box' })
|
||||
boxScore!: number;
|
||||
|
||||
@ApiProperty({ description: 'Confidence score of the recognized text' })
|
||||
textScore!: number;
|
||||
|
||||
@ApiProperty({ description: 'Recognized text content' })
|
||||
text!: string;
|
||||
|
||||
@ApiProperty({ description: 'Whether the OCR entry is visible' })
|
||||
isVisible!: boolean;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetOcrDeleteV1 {
|
||||
@ApiProperty({ description: 'Audit row ID of the deleted OCR entry' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: 'Original asset ID of the deleted OCR entry' })
|
||||
assetId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Timestamp when the OCR entry was deleted' })
|
||||
deletedAt!: Date;
|
||||
}
|
||||
|
||||
export class SyncAssetEditV1 {
|
||||
id!: string;
|
||||
assetId!: string;
|
||||
@@ -499,6 +555,8 @@ export type SyncItem = {
|
||||
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
|
||||
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
|
||||
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
||||
[SyncEntityType.AssetOcrV1]: SyncAssetOcrV1;
|
||||
[SyncEntityType.AssetOcrDeleteV1]: SyncAssetOcrDeleteV1;
|
||||
[SyncEntityType.AssetEditV1]: SyncAssetEditV1;
|
||||
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
|
||||
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
||||
|
||||
@@ -722,6 +722,7 @@ export enum SyncRequestType {
|
||||
AssetExifsV1 = 'AssetExifsV1',
|
||||
AssetEditsV1 = 'AssetEditsV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
AssetOcrV1 = 'AssetOcrV1',
|
||||
AuthUsersV1 = 'AuthUsersV1',
|
||||
MemoriesV1 = 'MemoriesV1',
|
||||
MemoryToAssetsV1 = 'MemoryToAssetsV1',
|
||||
@@ -750,6 +751,8 @@ export enum SyncEntityType {
|
||||
AssetEditDeleteV1 = 'AssetEditDeleteV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
|
||||
AssetOcrV1 = 'AssetOcrV1',
|
||||
AssetOcrDeleteV1 = 'AssetOcrDeleteV1',
|
||||
|
||||
PartnerV1 = 'PartnerV1',
|
||||
PartnerDeleteV1 = 'PartnerDeleteV1',
|
||||
|
||||
@@ -107,7 +107,7 @@ export class MediaRepository {
|
||||
ExposureTime: tags.exposureTime,
|
||||
ProfileDescription: tags.profileDescription,
|
||||
ColorSpace: tags.colorspace,
|
||||
Rating: tags.rating === null ? 0 : tags.rating,
|
||||
Rating: tags.rating,
|
||||
// specially convert Orientation to numeric Orientation# for exiftool
|
||||
'Orientation#': tags.orientation ? Number(tags.orientation) : undefined,
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ export class SyncRepository {
|
||||
assetEdit: AssetEditSync;
|
||||
assetFace: AssetFaceSync;
|
||||
assetMetadata: AssetMetadataSync;
|
||||
assetOcr: AssetOcrSync;
|
||||
authUser: AuthUserSync;
|
||||
memory: MemorySync;
|
||||
memoryToAsset: MemoryToAssetSync;
|
||||
@@ -79,6 +80,7 @@ export class SyncRepository {
|
||||
this.assetEdit = new AssetEditSync(this.db);
|
||||
this.assetFace = new AssetFaceSync(this.db);
|
||||
this.assetMetadata = new AssetMetadataSync(this.db);
|
||||
this.assetOcr = new AssetOcrSync(this.db);
|
||||
this.authUser = new AuthUserSync(this.db);
|
||||
this.memory = new MemorySync(this.db);
|
||||
this.memoryToAsset = new MemoryToAssetSync(this.db);
|
||||
@@ -798,3 +800,27 @@ class AssetMetadataSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
class AssetOcrSync extends BaseSync {
|
||||
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
|
||||
getDeletes(options: SyncQueryOptions, userId: string) {
|
||||
return this.auditQuery('asset_ocr_audit', options)
|
||||
.select(['asset_ocr_audit.id', 'asset_ocr_audit.assetId', 'asset_ocr_audit.deletedAt'])
|
||||
.leftJoin('asset', 'asset.id', 'asset_ocr_audit.assetId')
|
||||
.where('asset.ownerId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('asset_ocr_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
|
||||
getUpserts(options: SyncQueryOptions, userId: string) {
|
||||
return this.upsertQuery('asset_ocr', options)
|
||||
.select(columns.syncAssetOcr)
|
||||
.innerJoin('asset', 'asset.id', 'asset_ocr.assetId')
|
||||
.where('asset.ownerId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ export const asset_edit_delete = registerFunction({
|
||||
UPDATE asset
|
||||
SET "isEdited" = false
|
||||
FROM deleted_edit
|
||||
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
|
||||
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
|
||||
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
|
||||
RETURN NULL;
|
||||
END
|
||||
@@ -299,3 +299,16 @@ export const asset_edit_audit = registerFunction({
|
||||
RETURN NULL;
|
||||
END`,
|
||||
});
|
||||
|
||||
export const asset_ocr_delete_audit = registerFunction({
|
||||
name: 'asset_ocr_delete_audit',
|
||||
returnType: 'TRIGGER',
|
||||
language: 'PLPGSQL',
|
||||
body: `
|
||||
BEGIN
|
||||
INSERT INTO asset_ocr_audit ("assetId")
|
||||
SELECT "assetId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END`,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
asset_delete_audit,
|
||||
asset_face_audit,
|
||||
asset_metadata_audit,
|
||||
asset_ocr_delete_audit,
|
||||
f_concat_ws,
|
||||
f_unaccent,
|
||||
immich_uuid_v7,
|
||||
@@ -74,6 +75,7 @@ import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
|
||||
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
import { AssetOcrAuditTable } from './tables/asset-ocr-audit.table';
|
||||
|
||||
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
|
||||
@Database({ name: 'immich' })
|
||||
@@ -96,6 +98,7 @@ export class ImmichDatabase {
|
||||
AssetMetadataAuditTable,
|
||||
AssetJobStatusTable,
|
||||
AssetOcrTable,
|
||||
AssetOcrAuditTable,
|
||||
AssetTable,
|
||||
AssetFileTable,
|
||||
AuditTable,
|
||||
@@ -158,6 +161,7 @@ export class ImmichDatabase {
|
||||
user_metadata_audit,
|
||||
asset_metadata_audit,
|
||||
asset_face_audit,
|
||||
asset_ocr_delete_audit,
|
||||
];
|
||||
|
||||
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
|
||||
@@ -195,6 +199,7 @@ export interface DB {
|
||||
asset_metadata_audit: AssetMetadataAuditTable;
|
||||
asset_job_status: AssetJobStatusTable;
|
||||
asset_ocr: AssetOcrTable;
|
||||
asset_ocr_audit: AssetOcrAuditTable;
|
||||
ocr_search: OcrSearchTable;
|
||||
|
||||
audit: AuditTable;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = 0;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// not supported
|
||||
}
|
||||
72
server/src/schema/migrations/1772025522559-AssetOcrSync.ts
Normal file
72
server/src/schema/migrations/1772025522559-AssetOcrSync.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE OR REPLACE FUNCTION asset_edit_delete()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE asset
|
||||
SET "isEdited" = false
|
||||
FROM deleted_edit
|
||||
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
|
||||
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE OR REPLACE FUNCTION asset_ocr_delete_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO asset_ocr_audit ("assetId")
|
||||
SELECT "assetId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE TABLE "asset_ocr_audit" (
|
||||
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"assetId" uuid NOT NULL,
|
||||
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT "asset_ocr_audit_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "asset_ocr_audit_assetId_idx" ON "asset_ocr_audit" ("assetId");`.execute(db);
|
||||
await sql`CREATE INDEX "asset_ocr_audit_deletedAt_idx" ON "asset_ocr_audit" ("deletedAt");`.execute(db);
|
||||
await sql`ALTER TABLE "asset_ocr" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
|
||||
await sql`CREATE INDEX "asset_ocr_updateId_idx" ON "asset_ocr" ("updateId");`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "asset_ocr_delete_audit"
|
||||
AFTER DELETE ON "asset_ocr"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH STATEMENT
|
||||
WHEN (pg_trigger_depth() = 0)
|
||||
EXECUTE FUNCTION asset_ocr_delete_audit();`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_ocr_delete_audit', '{"type":"function","name":"asset_ocr_delete_audit","sql":"CREATE OR REPLACE FUNCTION asset_ocr_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_ocr_audit (\\"assetId\\")\\n SELECT \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_ocr_delete_audit', '{"type":"trigger","name":"asset_ocr_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_ocr_delete_audit\\"\\n AFTER DELETE ON \\"asset_ocr\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_ocr_delete_audit();"}'::jsonb);`.execute(db);
|
||||
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\"\\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE OR REPLACE FUNCTION public.asset_edit_delete()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
UPDATE asset
|
||||
SET "isEdited" = false
|
||||
FROM deleted_edit
|
||||
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
|
||||
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
|
||||
RETURN NULL;
|
||||
END
|
||||
$function$
|
||||
`.execute(db);
|
||||
await sql`DROP TRIGGER "asset_ocr_delete_audit" ON "asset_ocr";`.execute(db);
|
||||
await sql`DROP INDEX "asset_ocr_updateId_idx";`.execute(db);
|
||||
await sql`ALTER TABLE "asset_ocr" DROP COLUMN "updateId";`.execute(db);
|
||||
await sql`DROP TABLE "asset_ocr_audit";`.execute(db);
|
||||
await sql`DROP FUNCTION asset_ocr_delete_audit;`.execute(db);
|
||||
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\" \\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_delete","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_ocr_delete_audit';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_ocr_delete_audit';`.execute(db);
|
||||
}
|
||||
14
server/src/schema/tables/asset-ocr-audit.table.ts
Normal file
14
server/src/schema/tables/asset-ocr-audit.table.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Column, CreateDateColumn, Generated, Table } from '@immich/sql-tools';
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
|
||||
@Table('asset_ocr_audit')
|
||||
export class AssetOcrAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ type: 'uuid', index: true })
|
||||
assetId!: string;
|
||||
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
||||
deletedAt!: Date;
|
||||
}
|
||||
@@ -1,7 +1,22 @@
|
||||
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
} from '@immich/sql-tools';
|
||||
import { UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { asset_ocr_delete_audit } from '../functions';
|
||||
|
||||
@Table('asset_ocr')
|
||||
@AfterDeleteTrigger({
|
||||
scope: 'statement',
|
||||
function: asset_ocr_delete_audit,
|
||||
referencingOldTableAs: 'old',
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
export class AssetOcrTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
@@ -45,4 +60,7 @@ export class AssetOcrTable {
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isVisible!: Generated<boolean>;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
}
|
||||
|
||||
@@ -516,7 +516,7 @@ export class AssetService extends BaseService {
|
||||
dateTimeOriginal?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
rating?: number | null;
|
||||
rating?: number;
|
||||
}) {
|
||||
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
|
||||
const writes = _.omitBy(
|
||||
|
||||
@@ -1423,20 +1423,6 @@ describe(MetadataService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle 0 as unrated -> null', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mockReadTags({ Rating: 0 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rating: null,
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle valid negative rating value', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
@@ -1794,28 +1780,6 @@ describe(MetadataService.name, () => {
|
||||
'timeZone',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should write rating', async () => {
|
||||
const asset = factory.jobAssets.sidecarWrite();
|
||||
asset.exifInfo.rating = 4;
|
||||
|
||||
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
|
||||
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 4 });
|
||||
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
|
||||
});
|
||||
|
||||
it('should write null rating as 0', async () => {
|
||||
const asset = factory.jobAssets.sidecarWrite();
|
||||
asset.exifInfo.rating = null;
|
||||
|
||||
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
|
||||
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 0 });
|
||||
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstDateTime', () => {
|
||||
|
||||
@@ -301,7 +301,7 @@ export class MetadataService extends BaseService {
|
||||
// comments
|
||||
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
|
||||
profileDescription: exifTags.ProfileDescription || null,
|
||||
rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, -1, 5),
|
||||
rating: validateRange(exifTags.Rating, -1, 5),
|
||||
|
||||
// grouping
|
||||
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
||||
@@ -451,7 +451,7 @@ export class MetadataService extends BaseService {
|
||||
dateTimeOriginal: asset.exifInfo.dateTimeOriginal as string | null,
|
||||
latitude: asset.exifInfo.latitude,
|
||||
longitude: asset.exifInfo.longitude,
|
||||
rating: asset.exifInfo.rating ?? 0,
|
||||
rating: asset.exifInfo.rating,
|
||||
tags: asset.exifInfo.tags,
|
||||
timeZone: asset.exifInfo.timeZone,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/com
|
||||
import { Insertable } from 'kysely';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { Writable } from 'node:stream';
|
||||
import { ConnectableObservable } from 'rxjs';
|
||||
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
@@ -81,6 +82,7 @@ export const SYNC_TYPES_ORDER = [
|
||||
SyncRequestType.AlbumToAssetsV1,
|
||||
SyncRequestType.AssetExifsV1,
|
||||
SyncRequestType.AlbumAssetExifsV1,
|
||||
SyncRequestType.AssetOcrV1,
|
||||
SyncRequestType.PartnerAssetExifsV1,
|
||||
SyncRequestType.MemoriesV1,
|
||||
SyncRequestType.MemoryToAssetsV1,
|
||||
@@ -195,6 +197,7 @@ export class SyncService extends BaseService {
|
||||
[SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap),
|
||||
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetOcrV1]: () => this.syncAssetOcrV1(options, response, checkpointMap, auth),
|
||||
};
|
||||
|
||||
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
|
||||
@@ -225,6 +228,7 @@ export class SyncService extends BaseService {
|
||||
await this.syncRepository.stack.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.user.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.userMetadata.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.assetOcr.cleanupAuditTable(pruneThreshold);
|
||||
}
|
||||
|
||||
private needsFullSync(checkpointMap: CheckpointMap) {
|
||||
@@ -873,6 +877,33 @@ export class SyncService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncAssetOcrV1(
|
||||
options: SyncQueryOptions,
|
||||
response: Writable,
|
||||
checkpointMap: CheckpointMap,
|
||||
auth: AuthDto,
|
||||
) {
|
||||
const deleteType = SyncEntityType.AssetOcrDeleteV1;
|
||||
const deletes = this.syncRepository.assetOcr.getDeletes(
|
||||
{ ...options, ack: checkpointMap[deleteType] },
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
for await (const row of deletes) {
|
||||
send(response, { type: deleteType, ids: [row.id], data: row });
|
||||
}
|
||||
|
||||
const upsertType = SyncEntityType.AssetOcrV1;
|
||||
const upserts = this.syncRepository.assetOcr.getUpserts(
|
||||
{ ...options, ack: checkpointMap[upsertType] },
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
send(response, { type: upsertType, ids: [updateId], data });
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
|
||||
const { type, sessionId, createId } = item;
|
||||
await this.syncCheckpointRepository.upsertAll([
|
||||
|
||||
265
server/test/medium/specs/sync/sync-asset-ocr.spec.ts
Normal file
265
server/test/medium/specs/sync/sync-asset-ocr.spec.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { SyncEntityType, SyncRequestType } from 'src/enum';
|
||||
import { OcrRepository } from 'src/repositories/ocr.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { SyncTestContext } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = async (db?: Kysely<DB>) => {
|
||||
const ctx = new SyncTestContext(db || defaultDatabase);
|
||||
const { auth, user, session } = await ctx.newSyncAuthUser();
|
||||
return { auth, user, session, ctx };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(SyncEntityType.AssetOcrV1, () => {
|
||||
it('should detect and sync new asset OCR', async () => {
|
||||
const { auth, user, ctx } = await setup();
|
||||
|
||||
const ocrRepo = ctx.get(OcrRepository);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ocrRepo.upsert(
|
||||
asset.id,
|
||||
[
|
||||
{
|
||||
assetId: asset.id,
|
||||
x1: 0.1,
|
||||
y1: 0.2,
|
||||
x2: 0.9,
|
||||
y2: 0.2,
|
||||
x3: 0.9,
|
||||
y3: 0.8,
|
||||
x4: 0.1,
|
||||
y4: 0.8,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.92,
|
||||
text: 'Hello World',
|
||||
isVisible: true,
|
||||
},
|
||||
],
|
||||
'Hello World',
|
||||
);
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
assetId: asset.id,
|
||||
x1: 0.1,
|
||||
y1: 0.2,
|
||||
x2: 0.9,
|
||||
y2: 0.2,
|
||||
x3: 0.9,
|
||||
y3: 0.8,
|
||||
x4: 0.1,
|
||||
y4: 0.8,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.92,
|
||||
text: 'Hello World',
|
||||
isVisible: true,
|
||||
},
|
||||
type: 'AssetOcrV1',
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]);
|
||||
});
|
||||
|
||||
it('should update asset OCR', async () => {
|
||||
const { auth, user, ctx } = await setup();
|
||||
|
||||
const ocrRepo = ctx.get(OcrRepository);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ocrRepo.upsert(
|
||||
asset.id,
|
||||
[
|
||||
{
|
||||
assetId: asset.id,
|
||||
x1: 0.1,
|
||||
y1: 0.2,
|
||||
x2: 0.9,
|
||||
y2: 0.2,
|
||||
x3: 0.9,
|
||||
y3: 0.8,
|
||||
x4: 0.1,
|
||||
y4: 0.8,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.92,
|
||||
text: 'Hello World',
|
||||
isVisible: true,
|
||||
},
|
||||
],
|
||||
'Hello World',
|
||||
);
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
assetId: asset.id,
|
||||
x1: 0.1,
|
||||
y1: 0.2,
|
||||
x2: 0.9,
|
||||
y2: 0.2,
|
||||
x3: 0.9,
|
||||
y3: 0.8,
|
||||
x4: 0.1,
|
||||
y4: 0.8,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.92,
|
||||
text: 'Hello World',
|
||||
isVisible: true,
|
||||
},
|
||||
type: 'AssetOcrV1',
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
|
||||
// Update OCR data (upsert deletes old entries first, then inserts new ones)
|
||||
await ocrRepo.upsert(
|
||||
asset.id,
|
||||
[
|
||||
{
|
||||
assetId: asset.id,
|
||||
x1: 0.15,
|
||||
y1: 0.25,
|
||||
x2: 0.85,
|
||||
y2: 0.25,
|
||||
x3: 0.85,
|
||||
y3: 0.75,
|
||||
x4: 0.15,
|
||||
y4: 0.75,
|
||||
boxScore: 0.98,
|
||||
textScore: 0.96,
|
||||
text: 'Updated Text',
|
||||
isVisible: true,
|
||||
},
|
||||
],
|
||||
'Updated Text',
|
||||
);
|
||||
|
||||
const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
|
||||
// upsert() deletes old entries first, so we expect both delete and upsert events
|
||||
expect(updatedResponse).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
assetId: asset.id,
|
||||
deletedAt: expect.any(String),
|
||||
},
|
||||
type: 'AssetOcrDeleteV1',
|
||||
},
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
assetId: asset.id,
|
||||
x1: 0.15,
|
||||
y1: 0.25,
|
||||
x2: 0.85,
|
||||
y2: 0.25,
|
||||
x3: 0.85,
|
||||
y3: 0.75,
|
||||
x4: 0.15,
|
||||
y4: 0.75,
|
||||
boxScore: 0.98,
|
||||
textScore: 0.96,
|
||||
text: 'Updated Text',
|
||||
isVisible: true,
|
||||
},
|
||||
type: 'AssetOcrV1',
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, updatedResponse);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SyncEntityType.AssetOcrDeleteV1, () => {
|
||||
it('should delete and sync asset OCR', async () => {
|
||||
const { auth, user, ctx } = await setup();
|
||||
|
||||
const ocrRepo = ctx.get(OcrRepository);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ocrRepo.upsert(
|
||||
asset.id,
|
||||
[
|
||||
{
|
||||
assetId: asset.id,
|
||||
x1: 0.1,
|
||||
y1: 0.2,
|
||||
x2: 0.9,
|
||||
y2: 0.2,
|
||||
x3: 0.9,
|
||||
y3: 0.8,
|
||||
x4: 0.1,
|
||||
y4: 0.8,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.92,
|
||||
text: 'Hello World',
|
||||
isVisible: true,
|
||||
},
|
||||
],
|
||||
'Hello World',
|
||||
);
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
assetId: asset.id,
|
||||
x1: 0.1,
|
||||
y1: 0.2,
|
||||
x2: 0.9,
|
||||
y2: 0.2,
|
||||
x3: 0.9,
|
||||
y3: 0.8,
|
||||
x4: 0.1,
|
||||
y4: 0.8,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.92,
|
||||
text: 'Hello World',
|
||||
isVisible: true,
|
||||
},
|
||||
type: 'AssetOcrV1',
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
|
||||
// Delete OCR data
|
||||
await ocrRepo.upsert(asset.id, [], '');
|
||||
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AssetOcrV1])).resolves.toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: {
|
||||
assetId: asset.id,
|
||||
deletedAt: expect.any(String),
|
||||
id: expect.any(String),
|
||||
},
|
||||
type: 'AssetOcrDeleteV1',
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,10 @@
|
||||
|
||||
const rateAsset = async (rating: number | null) => {
|
||||
try {
|
||||
const updateAssetDto = rating === null ? {} : { rating };
|
||||
await updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto: { rating },
|
||||
updateAssetDto,
|
||||
});
|
||||
|
||||
asset = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import StarRating, { type Rating } from '$lib/elements/StarRating.svelte';
|
||||
import StarRating from '$lib/elements/StarRating.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
let { asset, isOwner }: Props = $props();
|
||||
|
||||
let rating = $derived(asset.exifInfo?.rating || null) as Rating;
|
||||
let rating = $derived(asset.exifInfo?.rating || 0);
|
||||
|
||||
const handleChangeRating = async (rating: number | null) => {
|
||||
const handleChangeRating = async (rating: number) => {
|
||||
try {
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { rating } });
|
||||
} catch (error) {
|
||||
@@ -26,7 +26,7 @@
|
||||
</script>
|
||||
|
||||
{#if !authManager.isSharedLink && $preferences?.ratings.enabled}
|
||||
<section class="px-4 pt-4">
|
||||
<section class="px-4 pt-2">
|
||||
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -11,17 +11,15 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { OpenQueryParam, Theme } from '$lib/constants';
|
||||
import { Theme } from '$lib/constants';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { mapSettings } from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
|
||||
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { Icon, Link, modalManager, Text } from '@immich/ui';
|
||||
import { mdiCog, mdiInformationOutline, mdiMap, mdiMapMarker } from '@mdi/js';
|
||||
import { Icon, modalManager } from '@immich/ui';
|
||||
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
|
||||
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
|
||||
import { isEqual, omit } from 'lodash-es';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
@@ -303,133 +301,109 @@
|
||||
|
||||
<OnEvents {onAssetsDelete} />
|
||||
|
||||
<!-- Use svelte:boundary instead of MapLibre onerror until https://github.com/dimfeld/svelte-maplibre/issues/279 is fixed -->
|
||||
<svelte:boundary>
|
||||
<!-- We handle style loading ourselves so we set style blank here -->
|
||||
<MapLibre
|
||||
{hash}
|
||||
style=""
|
||||
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
|
||||
{zoom}
|
||||
{center}
|
||||
bounds={initialBounds}
|
||||
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
|
||||
attributionControl={false}
|
||||
diffStyleUpdates={true}
|
||||
onload={(event: Map) => {
|
||||
event.setMaxZoom(18);
|
||||
event.on('click', handleMapClick);
|
||||
if (!simplified) {
|
||||
event.addControl(new GlobeControl(), 'top-left');
|
||||
}
|
||||
}}
|
||||
bind:map
|
||||
>
|
||||
{#snippet children({ map }: { map: Map })}
|
||||
{#if showSimpleControls}
|
||||
<NavigationControl position="top-left" showCompass={!simplified} />
|
||||
<!-- We handle style loading ourselves so we set style blank here -->
|
||||
<MapLibre
|
||||
{hash}
|
||||
style=""
|
||||
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
|
||||
{zoom}
|
||||
{center}
|
||||
bounds={initialBounds}
|
||||
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
|
||||
attributionControl={false}
|
||||
diffStyleUpdates={true}
|
||||
onload={(event: Map) => {
|
||||
event.setMaxZoom(18);
|
||||
event.on('click', handleMapClick);
|
||||
if (!simplified) {
|
||||
event.addControl(new GlobeControl(), 'top-left');
|
||||
}
|
||||
}}
|
||||
bind:map
|
||||
>
|
||||
{#snippet children({ map }: { map: Map })}
|
||||
{#if showSimpleControls}
|
||||
<NavigationControl position="top-left" showCompass={!simplified} />
|
||||
|
||||
{#if !simplified}
|
||||
<GeolocateControl position="top-left" />
|
||||
<FullscreenControl position="top-left" />
|
||||
<ScaleControl />
|
||||
<AttributionControl compact={false} />
|
||||
{/if}
|
||||
{#if !simplified}
|
||||
<GeolocateControl position="top-left" />
|
||||
<FullscreenControl position="top-left" />
|
||||
<ScaleControl />
|
||||
<AttributionControl compact={false} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showSettings}
|
||||
<Control>
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={handleSettingsClick}>
|
||||
<Icon icon={mdiCog} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
{#if showSettings}
|
||||
<Control>
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={handleSettingsClick}>
|
||||
<Icon icon={mdiCog} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
|
||||
{#if onOpenInMapView && showSimpleControls}
|
||||
<Control position="top-right">
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={() => onOpenInMapView()}>
|
||||
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
{#if onOpenInMapView && showSimpleControls}
|
||||
<Control position="top-right">
|
||||
<ControlGroup>
|
||||
<ControlButton onclick={() => onOpenInMapView()}>
|
||||
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
|
||||
</ControlButton>
|
||||
</ControlGroup>
|
||||
</Control>
|
||||
{/if}
|
||||
|
||||
<GeoJSON
|
||||
data={{
|
||||
type: 'FeatureCollection',
|
||||
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
|
||||
}}
|
||||
id="geojson"
|
||||
cluster={{ radius: 35, maxZoom: 18 }}
|
||||
>
|
||||
<MarkerLayer
|
||||
applyToClusters
|
||||
asButton
|
||||
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
|
||||
>
|
||||
{#snippet children({ feature })}
|
||||
<div
|
||||
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
|
||||
>
|
||||
{feature.properties?.point_count?.toLocaleString()}
|
||||
</div>
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
<MarkerLayer
|
||||
applyToClusters={false}
|
||||
asButton
|
||||
onclick={(event) => {
|
||||
if (!popup) {
|
||||
handleAssetClick(event.feature.properties?.id, map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ feature }: { feature: Feature })}
|
||||
{#if useLocationPin}
|
||||
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: feature.properties?.id })}
|
||||
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
|
||||
alt={feature.properties?.city && feature.properties.country
|
||||
? $t('map_marker_for_images', {
|
||||
values: { city: feature.properties.city, country: feature.properties.country },
|
||||
})
|
||||
: $t('map_marker_with_image')}
|
||||
/>
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
|
||||
{@render popup?.({ marker: asMarker(feature) })}
|
||||
</Popup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
</GeoJSON>
|
||||
{/snippet}
|
||||
</MapLibre>
|
||||
|
||||
{#snippet failed(_error)}
|
||||
<div
|
||||
class={[
|
||||
'flex place-content-center place-items-center text-warning',
|
||||
simplified ? 'gap-4 px-6 text-sm' : 'h-full mx-auto gap-6',
|
||||
]}
|
||||
<GeoJSON
|
||||
data={{
|
||||
type: 'FeatureCollection',
|
||||
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
|
||||
}}
|
||||
id="geojson"
|
||||
cluster={{ radius: 35, maxZoom: 18 }}
|
||||
>
|
||||
<div>
|
||||
<Icon icon={mdiInformationOutline} size={simplified ? '18' : '24'} />
|
||||
</div>
|
||||
<div>
|
||||
<Text>
|
||||
{$t('errors.enable_webgl_for_map', { values: { isAdmin: $user.isAdmin } })}
|
||||
</Text>
|
||||
{#if $user.isAdmin}
|
||||
<Link href={Route.systemSettings({ isOpen: OpenQueryParam.MAP })}>{$t('go_to_settings')}</Link>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<MarkerLayer
|
||||
applyToClusters
|
||||
asButton
|
||||
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
|
||||
>
|
||||
{#snippet children({ feature })}
|
||||
<div
|
||||
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
|
||||
>
|
||||
{feature.properties?.point_count?.toLocaleString()}
|
||||
</div>
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
<MarkerLayer
|
||||
applyToClusters={false}
|
||||
asButton
|
||||
onclick={(event) => {
|
||||
if (!popup) {
|
||||
handleAssetClick(event.feature.properties?.id, map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ feature }: { feature: Feature })}
|
||||
{#if useLocationPin}
|
||||
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
|
||||
{:else}
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: feature.properties?.id })}
|
||||
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
|
||||
alt={feature.properties?.city && feature.properties.country
|
||||
? $t('map_marker_for_images', {
|
||||
values: { city: feature.properties.city, country: feature.properties.country },
|
||||
})
|
||||
: $t('map_marker_with_image')}
|
||||
/>
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
|
||||
{@render popup?.({ marker: asMarker(feature) })}
|
||||
</Popup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</MarkerLayer>
|
||||
</GeoJSON>
|
||||
{/snippet}
|
||||
</svelte:boundary>
|
||||
</MapLibre>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
import Combobox from '../combobox.svelte';
|
||||
|
||||
interface Props {
|
||||
rating?: number | null;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
let { rating = $bindable() }: Props = $props();
|
||||
|
||||
const options = [
|
||||
{ value: 'null', label: $t('rating_count', { values: { count: 0 } }) },
|
||||
{ value: '0', label: $t('rating_count', { values: { count: 0 } }) },
|
||||
{ value: '1', label: $t('rating_count', { values: { count: 1 } }) },
|
||||
{ value: '2', label: $t('rating_count', { values: { count: 2 } }) },
|
||||
{ value: '3', label: $t('rating_count', { values: { count: 3 } }) },
|
||||
@@ -26,7 +26,7 @@
|
||||
placeholder={$t('search_rating')}
|
||||
hideLabel
|
||||
{options}
|
||||
selectedOption={rating === undefined ? undefined : options[rating === null ? 0 : rating]}
|
||||
selectedOption={rating === undefined ? undefined : options[rating]}
|
||||
onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,6 @@ export enum OpenQueryParam {
|
||||
OAUTH = 'oauth',
|
||||
JOB = 'job',
|
||||
STORAGE_TEMPLATE = 'storage-template',
|
||||
MAP = 'location map',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
PURCHASE_SETTINGS = 'user-purchase-settings',
|
||||
}
|
||||
|
||||
@@ -3,28 +3,27 @@
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiStar, mdiStarOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
export type Rating = 1 | 2 | 3 | 4 | 5 | null;
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
rating: Rating;
|
||||
rating: number;
|
||||
readOnly?: boolean;
|
||||
onRating: (rating: Rating) => void | undefined;
|
||||
onRating: (rating: number) => void | undefined;
|
||||
}
|
||||
|
||||
let { count = 5, rating, readOnly = false, onRating }: Props = $props();
|
||||
|
||||
let ratingSelection = $derived(rating);
|
||||
let hoverRating: Rating = $state(null);
|
||||
let focusRating: Rating = $state(null);
|
||||
let hoverRating = $state(0);
|
||||
let focusRating = $state(0);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const starIcon =
|
||||
'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z';
|
||||
const id = generateId();
|
||||
|
||||
const handleSelect = (newRating: Rating) => {
|
||||
const handleSelect = (newRating: number) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
@@ -36,7 +35,7 @@
|
||||
onRating(newRating);
|
||||
};
|
||||
|
||||
const setHoverRating = (value: Rating) => {
|
||||
const setHoverRating = (value: number) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
@@ -44,11 +43,11 @@
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setHoverRating(null);
|
||||
focusRating = null;
|
||||
setHoverRating(0);
|
||||
focusRating = 0;
|
||||
};
|
||||
|
||||
const handleSelectDebounced = (value: Rating) => {
|
||||
const handleSelectDebounced = (value: number) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
handleSelect(value);
|
||||
@@ -59,7 +58,7 @@
|
||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||
<fieldset
|
||||
class="text-primary w-fit cursor-default"
|
||||
onmouseleave={() => setHoverRating(null)}
|
||||
onmouseleave={() => setHoverRating(0)}
|
||||
use:focusOutside={{ onFocusOut: reset }}
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
|
||||
@@ -70,7 +69,7 @@
|
||||
<div class="flex flex-row" data-testid="star-container">
|
||||
{#each { length: count } as _, index (index)}
|
||||
{@const value = index + 1}
|
||||
{@const filled = hoverRating === null ? (ratingSelection || 0) >= value : hoverRating >= value}
|
||||
{@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)}
|
||||
{@const starId = `${id}-${value}`}
|
||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
@@ -78,12 +77,19 @@
|
||||
for={starId}
|
||||
class:cursor-pointer={!readOnly}
|
||||
class:ring-2={focusRating === value}
|
||||
onmouseover={() => setHoverRating(value as Rating)}
|
||||
onmouseover={() => setHoverRating(value)}
|
||||
tabindex={-1}
|
||||
data-testid="star"
|
||||
>
|
||||
<span class="sr-only">{$t('rating_count', { values: { count: value } })}</span>
|
||||
<Icon icon={filled ? mdiStar : mdiStarOutline} size="1.5em" aria-hidden />
|
||||
<Icon
|
||||
icon={starIcon}
|
||||
size="1.5em"
|
||||
strokeWidth={1}
|
||||
color={filled ? 'currentcolor' : 'transparent'}
|
||||
strokeColor={filled ? 'currentcolor' : '#c1cce8'}
|
||||
aria-hidden
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -93,19 +99,19 @@
|
||||
bind:group={ratingSelection}
|
||||
disabled={readOnly}
|
||||
onfocus={() => {
|
||||
focusRating = value as Rating;
|
||||
focusRating = value;
|
||||
}}
|
||||
onchange={() => handleSelectDebounced(value as Rating)}
|
||||
onchange={() => handleSelectDebounced(value)}
|
||||
class="sr-only"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{#if ratingSelection !== null && !readOnly}
|
||||
{#if ratingSelection > 0 && !readOnly}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
ratingSelection = null;
|
||||
ratingSelection = 0;
|
||||
handleSelect(ratingSelection);
|
||||
}}
|
||||
class="cursor-pointer text-xs text-primary"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
date: SearchDateFilter;
|
||||
display: SearchDisplayFilters;
|
||||
mediaType: MediaType;
|
||||
rating?: number | null;
|
||||
rating?: number;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -276,8 +276,6 @@
|
||||
{#await getTagNames(value) then tagNames}
|
||||
{tagNames}
|
||||
{/await}
|
||||
{:else if searchKey === 'rating'}
|
||||
{$t('rating_count', { values: { count: value ?? 0 } })}
|
||||
{:else if value === null || value === ''}
|
||||
{$t('unknown')}
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user