mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 22:05:19 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 216d0ba365 | |||
| 28e42f7e29 | |||
| 733373c0ca | |||
| 5617d6ca7c | |||
| 875dd2dead | |||
| 9043bc8435 | |||
| b3d49045de |
@@ -570,6 +570,7 @@
|
||||
"asset_added_to_album": "Added to album",
|
||||
"asset_adding_to_album": "Adding to album…",
|
||||
"asset_created": "Asset created",
|
||||
"asset_day_count": "{date}: {count, plural, one {# asset} other {# assets}}",
|
||||
"asset_description_updated": "Asset description has been updated",
|
||||
"asset_filename_is_offline": "Asset {filename} is offline",
|
||||
"asset_has_unassigned_faces": "Asset has unassigned faces",
|
||||
@@ -1400,6 +1401,7 @@
|
||||
"leave": "Leave",
|
||||
"leave_album": "Leave album",
|
||||
"lens_model": "Lens model",
|
||||
"less": "Less",
|
||||
"let_others_respond": "Let others respond",
|
||||
"level": "Level",
|
||||
"library": "Library",
|
||||
@@ -2412,6 +2414,7 @@
|
||||
"updated_password": "Updated password",
|
||||
"upload": "Upload",
|
||||
"upload_concurrency": "Upload concurrency",
|
||||
"upload_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}",
|
||||
"upload_details": "Upload Details",
|
||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||
"upload_dialog_title": "Upload Asset",
|
||||
@@ -2427,6 +2430,8 @@
|
||||
"upload_to_immich": "Upload to Immich ({count})",
|
||||
"uploading": "Uploading",
|
||||
"uploading_media": "Uploading media",
|
||||
"uploads": "Uploads",
|
||||
"uploads_count": "{count, plural, one {# upload} other {# uploads}}",
|
||||
"url": "URL",
|
||||
"usage": "Usage",
|
||||
"use_biometric": "Use biometric",
|
||||
|
||||
+3603
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -2,4 +2,5 @@ source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "cocoapods"
|
||||
gem "abbrev" # Required for Ruby 3.4+
|
||||
gem "abbrev" # Required for Ruby 3.4+
|
||||
gem "multi_json"
|
||||
@@ -0,0 +1,128 @@
|
||||
class Ocr {
|
||||
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 Ocr({
|
||||
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,
|
||||
});
|
||||
|
||||
Ocr 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 Ocr(
|
||||
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 Ocr &&
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:immich_mobile/domain/models/ocr.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart';
|
||||
|
||||
class OcrService {
|
||||
final OcrRepository _repository;
|
||||
|
||||
const OcrService(this._repository);
|
||||
|
||||
Future<List<Ocr>?> get(String assetId) {
|
||||
return _repository.get(assetId);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:openapi/api.dart' show Optional;
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -138,7 +138,7 @@ class RemoteAlbumService {
|
||||
Future<RemoteAlbum> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
Option<String?> description = const Option.none(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -317,6 +317,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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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';
|
||||
|
||||
@TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)')
|
||||
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()();
|
||||
|
||||
BoolColumn get isVisible => boolean().withDefault(const Constant(true))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.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';
|
||||
@@ -62,6 +63,7 @@ import 'package:sqlite_async/sqlite_async.dart';
|
||||
TrashedLocalAssetEntity,
|
||||
AssetEditEntity,
|
||||
SettingsEntity,
|
||||
AssetOcrEntity,
|
||||
],
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
@@ -105,7 +107,7 @@ class Drift extends $Drift {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 28;
|
||||
int get schemaVersion => 29;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -289,6 +291,10 @@ class Drift extends $Drift {
|
||||
from27To28: (m, v28) async {
|
||||
await m.createIndex(v28.idxLocalAssetCreatedAt);
|
||||
},
|
||||
from28To29: (m, v29) async {
|
||||
await m.createTable(v29.assetOcrEntity);
|
||||
await m.createIndex(v29.idxAssetOcrAssetId);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+20
-4
@@ -45,9 +45,11 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da
|
||||
as i21;
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'
|
||||
as i22;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'
|
||||
as i23;
|
||||
import 'package:drift/internal/modular.dart' as i24;
|
||||
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
|
||||
as i24;
|
||||
import 'package:drift/internal/modular.dart' as i25;
|
||||
|
||||
abstract class $Drift extends i0.GeneratedDatabase {
|
||||
$Drift(i0.QueryExecutor e) : super(e);
|
||||
@@ -94,9 +96,12 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
late final i22.$SettingsEntityTable settingsEntity = i22.$SettingsEntityTable(
|
||||
this,
|
||||
);
|
||||
i23.MergedAssetDrift get mergedAssetDrift => i24.ReadDatabaseContainer(
|
||||
late final i23.$AssetOcrEntityTable assetOcrEntity = i23.$AssetOcrEntityTable(
|
||||
this,
|
||||
).accessor<i23.MergedAssetDrift>(i23.MergedAssetDrift.new);
|
||||
);
|
||||
i24.MergedAssetDrift get mergedAssetDrift => i25.ReadDatabaseContainer(
|
||||
this,
|
||||
).accessor<i24.MergedAssetDrift>(i24.MergedAssetDrift.new);
|
||||
@override
|
||||
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
|
||||
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
|
||||
@@ -134,6 +139,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
settingsEntity,
|
||||
assetOcrEntity,
|
||||
i10.idxPartnerSharedWithId,
|
||||
i11.idxLatLng,
|
||||
i11.idxRemoteExifCity,
|
||||
@@ -146,6 +152,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
i20.idxTrashedLocalAssetChecksum,
|
||||
i20.idxTrashedLocalAssetAlbum,
|
||||
i21.idxAssetEditAssetId,
|
||||
i23.idxAssetOcrAssetId,
|
||||
];
|
||||
@override
|
||||
i0.StreamQueryUpdateRules
|
||||
@@ -335,6 +342,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
|
||||
),
|
||||
result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)],
|
||||
),
|
||||
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 =>
|
||||
@@ -398,4 +412,6 @@ class $DriftManager {
|
||||
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
|
||||
i22.$$SettingsEntityTableTableManager get settingsEntity =>
|
||||
i22.$$SettingsEntityTableTableManager(_db, _db.settingsEntity);
|
||||
i23.$$AssetOcrEntityTableTableManager get assetOcrEntity =>
|
||||
i23.$$AssetOcrEntityTableTableManager(_db, _db.assetOcrEntity);
|
||||
}
|
||||
|
||||
@@ -14631,6 +14631,706 @@ final class Schema28 extends i0.VersionedSchema {
|
||||
);
|
||||
}
|
||||
|
||||
final class Schema29 extends i0.VersionedSchema {
|
||||
Schema29({required super.database}) : super(version: 29);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxLocalAssetCreatedAt,
|
||||
idxStackPrimaryAssetId,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetOwnerVisibilityDeletedCreated,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
assetEditEntity,
|
||||
settings,
|
||||
assetOcrEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteExifCity,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxAssetFaceVisiblePerson,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
idxAssetEditAssetId,
|
||||
idxAssetOcrAssetId,
|
||||
];
|
||||
late final Shape33 userEntity = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape50 remoteAssetEntity = Shape50(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_212,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 stackEntity = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_130,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape36 localAssetEntity = Shape36(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 remoteAlbumEntity = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_138,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 localAlbumEntity = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_115,
|
||||
_column_142,
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape39 localAlbumAssetEntity = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_146, _column_147, _column_145],
|
||||
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 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 idxLocalAssetCreatedAt = i1.Index(
|
||||
'idx_local_asset_created_at',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_created_at ON local_asset_entity (created_at)',
|
||||
);
|
||||
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 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 idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index(
|
||||
'idx_remote_asset_owner_visibility_deleted_created',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)',
|
||||
);
|
||||
late final Shape40 authUserEntity = Shape40(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_148,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_149,
|
||||
_column_150,
|
||||
_column_151,
|
||||
_column_152,
|
||||
],
|
||||
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_153, _column_154, _column_155],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape41 partnerEntity = Shape41(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_156, _column_157, _column_158],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape42 remoteExifEntity = Shape42(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_160,
|
||||
_column_161,
|
||||
_column_162,
|
||||
_column_163,
|
||||
_column_164,
|
||||
_column_117,
|
||||
_column_116,
|
||||
_column_165,
|
||||
_column_166,
|
||||
_column_167,
|
||||
_column_168,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_169,
|
||||
_column_170,
|
||||
_column_171,
|
||||
_column_172,
|
||||
_column_173,
|
||||
_column_174,
|
||||
_column_175,
|
||||
_column_176,
|
||||
],
|
||||
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_159, _column_177],
|
||||
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_177, _column_153, _column_178],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape43 remoteAssetCloudIdEntity = Shape43(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_159,
|
||||
_column_179,
|
||||
_column_180,
|
||||
_column_134,
|
||||
_column_135,
|
||||
_column_136,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape44 memoryEntity = Shape44(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_124,
|
||||
_column_121,
|
||||
_column_113,
|
||||
_column_181,
|
||||
_column_182,
|
||||
_column_183,
|
||||
_column_184,
|
||||
_column_185,
|
||||
_column_186,
|
||||
],
|
||||
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_159, _column_187],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape45 personEntity = Shape45(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_121,
|
||||
_column_108,
|
||||
_column_188,
|
||||
_column_189,
|
||||
_column_190,
|
||||
_column_191,
|
||||
_column_192,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape46 assetFaceEntity = Shape46(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_193,
|
||||
_column_194,
|
||||
_column_195,
|
||||
_column_196,
|
||||
_column_197,
|
||||
_column_198,
|
||||
_column_199,
|
||||
_column_200,
|
||||
_column_201,
|
||||
_column_124,
|
||||
],
|
||||
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_202, _column_203, _column_204],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape47 trashedLocalAssetEntity = Shape47(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_108,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_107,
|
||||
_column_205,
|
||||
_column_131,
|
||||
_column_120,
|
||||
_column_132,
|
||||
_column_206,
|
||||
_column_137,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape32 assetEditEntity = Shape32(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_edit_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_207,
|
||||
_column_208,
|
||||
_column_209,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 settings = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'settings',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY("key")'],
|
||||
columns: [_column_210, _column_211, _column_115],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape51 assetOcrEntity = Shape51(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_ocr_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_107,
|
||||
_column_159,
|
||||
_column_213,
|
||||
_column_214,
|
||||
_column_215,
|
||||
_column_216,
|
||||
_column_217,
|
||||
_column_218,
|
||||
_column_219,
|
||||
_column_220,
|
||||
_column_221,
|
||||
_column_222,
|
||||
_column_223,
|
||||
_column_201,
|
||||
],
|
||||
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 idxRemoteExifCity = i1.Index(
|
||||
'idx_remote_exif_city',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL',
|
||||
);
|
||||
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 idxAssetFaceVisiblePerson = i1.Index(
|
||||
'idx_asset_face_visible_person',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL',
|
||||
);
|
||||
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)',
|
||||
);
|
||||
final i1.Index idxAssetEditAssetId = i1.Index(
|
||||
'idx_asset_edit_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxAssetOcrAssetId = i1.Index(
|
||||
'idx_asset_ocr_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_ocr_asset_id ON asset_ocr_entity (asset_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape51 extends i0.VersionedTable {
|
||||
Shape51({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['recognized_text']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get isVisible =>
|
||||
columnsByName['is_visible']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<double> _column_213(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x1',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_214(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y1',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_215(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x2',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_216(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y2',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_217(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x3',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_218(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y3',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_219(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'x4',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_220(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'y4',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_221(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'box_score',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<double> _column_222(String aliasedName) =>
|
||||
i1.GeneratedColumn<double>(
|
||||
'text_score',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.double,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_223(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'recognized_text',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NOT NULL',
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -14659,6 +15359,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -14797,6 +15498,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from27To28(migrator, schema);
|
||||
return 28;
|
||||
case 28:
|
||||
final schema = Schema29(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from28To29(migrator, schema);
|
||||
return 29;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -14831,6 +15537,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
|
||||
required Future<void> Function(i1.Migrator m, Schema27 schema) from26To27,
|
||||
required Future<void> Function(i1.Migrator m, Schema28 schema) from27To28,
|
||||
required Future<void> Function(i1.Migrator m, Schema29 schema) from28To29,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -14860,5 +15567,6 @@ i1.OnUpgrade stepByStep({
|
||||
from25To26: from25To26,
|
||||
from26To27: from26To27,
|
||||
from27To28: from27To28,
|
||||
from28To29: from28To29,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
class OcrRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const OcrRepository(this._db) : super(_db);
|
||||
|
||||
Future<List<Ocr>> get(String assetId) async {
|
||||
final query = _db.select(_db.assetOcrEntity)
|
||||
..where((row) => row.assetId.equals(assetId) & row.isVisible.equals(true));
|
||||
|
||||
final result = await query.get();
|
||||
return result.map((e) => e.toDto()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetOcrEntityData {
|
||||
Ocr toDto() {
|
||||
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: recognizedText,
|
||||
isVisible: isVisible,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ class SyncApiRepository {
|
||||
serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)
|
||||
? SyncRequestType.assetFacesV2
|
||||
: SyncRequestType.assetFacesV1,
|
||||
if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1,
|
||||
],
|
||||
).toJson(),
|
||||
);
|
||||
@@ -204,6 +205,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,
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.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/local_album.entity.drift.dart';
|
||||
@@ -69,6 +70,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
await _db.userMetadataEntity.deleteAll();
|
||||
await _db.remoteAssetCloudIdEntity.deleteAll();
|
||||
await _db.assetEditEntity.deleteAll();
|
||||
await _db.assetOcrEntity.deleteAll();
|
||||
});
|
||||
} finally {
|
||||
// re-enable FK even if the transaction throws, otherwise the connection
|
||||
@@ -848,6 +850,52 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateAssetOcrV1(Iterable<SyncAssetOcrV1> data) async {
|
||||
try {
|
||||
await _db.batch((batch) {
|
||||
for (final assetOcr in data) {
|
||||
final companion = AssetOcrEntityCompanion(
|
||||
assetId: Value(assetOcr.assetId),
|
||||
recognizedText: Value(assetOcr.text),
|
||||
x1: Value(assetOcr.x1),
|
||||
y1: Value(assetOcr.y1),
|
||||
x2: Value(assetOcr.x2),
|
||||
y2: Value(assetOcr.y2),
|
||||
x3: Value(assetOcr.x3),
|
||||
y3: Value(assetOcr.y3),
|
||||
x4: Value(assetOcr.x4),
|
||||
y4: Value(assetOcr.y4),
|
||||
boxScore: Value(assetOcr.boxScore),
|
||||
textScore: Value(assetOcr.textScore),
|
||||
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 {
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/shared_link.provider.dart';
|
||||
import 'package:immich_mobile/services/shared_link.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -366,10 +366,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
bool? download;
|
||||
bool? upload;
|
||||
bool? meta;
|
||||
var password = const Optional<String?>.absent();
|
||||
var description = const Optional<String?>.absent();
|
||||
var password = const Option<String?>.none();
|
||||
var description = const Option<String?>.none();
|
||||
String? slug;
|
||||
var expiry = const Optional<DateTime?>.absent();
|
||||
var expiry = const Option<DateTime?>.none();
|
||||
|
||||
if (allowDownload.value != existingLink!.allowDownload) {
|
||||
download = allowDownload.value;
|
||||
@@ -385,14 +385,12 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
|
||||
if (descriptionController.text != (existingLink!.description ?? '')) {
|
||||
description = descriptionController.text.isEmpty
|
||||
? const Optional.present(null)
|
||||
: Optional.present(descriptionController.text);
|
||||
? const Option.some(null)
|
||||
: Option.some(descriptionController.text);
|
||||
}
|
||||
|
||||
if (passwordController.text != (existingLink!.password ?? '')) {
|
||||
password = passwordController.text.isEmpty
|
||||
? const Optional.present(null)
|
||||
: Optional.present(passwordController.text);
|
||||
password = passwordController.text.isEmpty ? const Option.some(null) : Option.some(passwordController.text);
|
||||
}
|
||||
|
||||
if (slugController.text != (existingLink!.slug ?? "")) {
|
||||
@@ -403,7 +401,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
|
||||
final newExpiry = expiryAfter.value;
|
||||
if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) {
|
||||
expiry = newExpiry == null ? const Optional.present(null) : Optional.present(newExpiry.toUtc());
|
||||
expiry = newExpiry == null ? const Option.some(null) : Option.some(newExpiry.toUtc());
|
||||
}
|
||||
|
||||
await ref
|
||||
|
||||
@@ -18,9 +18,9 @@ import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dar
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart';
|
||||
import 'package:openapi/api.dart' show Optional;
|
||||
|
||||
@RoutePage()
|
||||
class RemoteAlbumPage extends ConsumerStatefulWidget {
|
||||
@@ -249,8 +249,8 @@ class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> {
|
||||
final newTitle = titleController.text.trim();
|
||||
final newDescription = descriptionController.text.trim();
|
||||
final description = newDescription.isEmpty
|
||||
? const Optional<String?>.present(null)
|
||||
: Optional<String?>.present(newDescription);
|
||||
? const Option<String?>.some(null)
|
||||
: Option<String?>.some(newDescription);
|
||||
|
||||
await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
|
||||
@@ -14,6 +14,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_stack.widget.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';
|
||||
@@ -394,6 +395,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||
final showingOcr = ref.watch(assetViewerProvider.select((s) => s.showingOcr));
|
||||
|
||||
final asset = _asset;
|
||||
if (asset == null) {
|
||||
@@ -446,6 +448,15 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
localFilePath: viewIntentFilePath,
|
||||
),
|
||||
),
|
||||
if (showingOcr && displayAsset.width != null && displayAsset.height != null)
|
||||
Positioned.fill(
|
||||
child: OcrOverlay(
|
||||
asset: displayAsset,
|
||||
imageSize: Size(displayAsset.width!.toDouble(), displayAsset.height!.toDouble()),
|
||||
viewportSize: Size(viewportWidth, viewportHeight),
|
||||
controller: _viewController,
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !_showingDetails,
|
||||
child: Column(
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
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';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
|
||||
class OcrOverlay extends ConsumerStatefulWidget {
|
||||
final BaseAsset asset;
|
||||
final Size imageSize;
|
||||
final Size viewportSize;
|
||||
final PhotoViewControllerBase? controller;
|
||||
|
||||
const OcrOverlay({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.imageSize,
|
||||
required this.viewportSize,
|
||||
this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<OcrOverlay> createState() => _OcrOverlayState();
|
||||
}
|
||||
|
||||
class _OcrOverlayState extends ConsumerState<OcrOverlay> {
|
||||
int? _selectedBoxIndex;
|
||||
|
||||
// Current transform read from the PhotoView controller.
|
||||
// Null until the controller has emitted at least one real event or until
|
||||
// we can seed a reliable value from controller.value on init.
|
||||
PhotoViewControllerValue? _controllerValue;
|
||||
StreamSubscription<PhotoViewControllerValue>? _controllerSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_attachController(widget.controller);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(OcrOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
_detachController();
|
||||
_attachController(widget.controller);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_detachController();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _attachController(PhotoViewControllerBase? controller) {
|
||||
if (controller == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed with the current value only when scaleBoundaries is already set.
|
||||
// Before the image finishes loading, PhotoView uses childSize = outerSize
|
||||
// (viewport) as a placeholder, which sets scale = 1.0. That placeholder
|
||||
// is wrong for any image that doesn't exactly fill the viewport.
|
||||
// Once scaleBoundaries is set the value is trustworthy (the image has rendered
|
||||
// at least one frame and setScaleInvisibly has been called with the real
|
||||
// initial/zoomed scale).
|
||||
if (controller.scaleBoundaries != null) {
|
||||
_controllerValue = controller.value;
|
||||
}
|
||||
|
||||
_controllerSub = controller.outputStateStream.listen((value) {
|
||||
if (mounted) {
|
||||
setState(() => _controllerValue = value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _detachController() {
|
||||
_controllerSub?.cancel();
|
||||
_controllerSub = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.asset is! RemoteAsset) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final ocrData = ref.watch(ocrAssetProvider((widget.asset as RemoteAsset).id));
|
||||
|
||||
return ocrData.when(
|
||||
data: (data) {
|
||||
if (data == null || data.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _OcrBoxes(
|
||||
ocrData: data,
|
||||
controller: widget.controller,
|
||||
imageSize: widget.imageSize,
|
||||
viewportSize: widget.viewportSize,
|
||||
controllerValue: _controllerValue,
|
||||
selectedBoxIndex: _selectedBoxIndex,
|
||||
onSelectionChanged: (index) => setState(() => _selectedBoxIndex = index),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OcrBoxes extends StatelessWidget {
|
||||
final List<Ocr> ocrData;
|
||||
final PhotoViewControllerBase? controller;
|
||||
final Size imageSize;
|
||||
final Size viewportSize;
|
||||
final PhotoViewControllerValue? controllerValue;
|
||||
final int? selectedBoxIndex;
|
||||
final ValueChanged<int?> onSelectionChanged;
|
||||
|
||||
const _OcrBoxes({
|
||||
required this.ocrData,
|
||||
required this.controller,
|
||||
required this.imageSize,
|
||||
required this.viewportSize,
|
||||
required this.controllerValue,
|
||||
required this.selectedBoxIndex,
|
||||
required this.onSelectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Use the actual decoded image size from PhotoView's scaleBoundaries when
|
||||
// available. The image provider may serve a downscaled preview (e.g. Immich
|
||||
// serves a ~1440px preview for large originals), so the decoded dimensions
|
||||
// can differ significantly from the stored asset dimensions. Using the wrong
|
||||
// size would scale every coordinate by the ratio between the two resolutions.
|
||||
final resolvedImageSize = controller?.scaleBoundaries?.childSize ?? imageSize;
|
||||
|
||||
final scale =
|
||||
controllerValue?.scale ??
|
||||
math.min(viewportSize.width / resolvedImageSize.width, viewportSize.height / resolvedImageSize.height);
|
||||
final position = controllerValue?.position ?? Offset.zero;
|
||||
|
||||
final imageWidth = resolvedImageSize.width;
|
||||
final imageHeight = resolvedImageSize.height;
|
||||
final viewportWidth = viewportSize.width;
|
||||
final viewportHeight = viewportSize.height;
|
||||
|
||||
// Image center in viewport space, accounting for pan
|
||||
final cx = viewportWidth / 2 + position.dx;
|
||||
final cy = viewportHeight / 2 + position.dy;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => onSelectionChanged(null),
|
||||
child: ClipRect(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Fills the viewport so taps outside boxes deselect
|
||||
SizedBox(width: viewportWidth, height: viewportHeight),
|
||||
...ocrData.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final ocr = entry.value;
|
||||
|
||||
// Map normalized image coords (0–1) to viewport space
|
||||
final x1 = cx + (ocr.x1 - 0.5) * imageWidth * scale;
|
||||
final y1 = cy + (ocr.y1 - 0.5) * imageHeight * scale;
|
||||
final x2 = cx + (ocr.x2 - 0.5) * imageWidth * scale;
|
||||
final y2 = cy + (ocr.y2 - 0.5) * imageHeight * scale;
|
||||
final x3 = cx + (ocr.x3 - 0.5) * imageWidth * scale;
|
||||
final y3 = cy + (ocr.y3 - 0.5) * imageHeight * scale;
|
||||
final x4 = cx + (ocr.x4 - 0.5) * imageWidth * scale;
|
||||
final y4 = cy + (ocr.y4 - 0.5) * imageHeight * scale;
|
||||
|
||||
// Bounding rectangle for hit testing and Positioned placement
|
||||
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);
|
||||
|
||||
return _OcrBoxItem(
|
||||
key: ValueKey(index),
|
||||
ocr: ocr,
|
||||
index: index,
|
||||
isSelected: selectedBoxIndex == index,
|
||||
points: [
|
||||
Offset(x1 - minX, y1 - minY),
|
||||
Offset(x2 - minX, y2 - minY),
|
||||
Offset(x3 - minX, y3 - minY),
|
||||
Offset(x4 - minX, y4 - minY),
|
||||
],
|
||||
left: minX,
|
||||
top: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
angle: math.atan2(y2 - y1, x2 - x1),
|
||||
labelDx: (minX + maxX) / 2 - minX,
|
||||
labelDy: (minY + maxY) / 2 - minY,
|
||||
onSelectionChanged: onSelectionChanged,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OcrBoxItem extends StatelessWidget {
|
||||
final Ocr ocr;
|
||||
final int index;
|
||||
final bool isSelected;
|
||||
final List<Offset> points;
|
||||
final double left;
|
||||
final double top;
|
||||
final double width;
|
||||
final double height;
|
||||
final double angle;
|
||||
final double labelDx;
|
||||
final double labelDy;
|
||||
final ValueChanged<int?> onSelectionChanged;
|
||||
|
||||
const _OcrBoxItem({
|
||||
super.key,
|
||||
required this.ocr,
|
||||
required this.index,
|
||||
required this.isSelected,
|
||||
required this.points,
|
||||
required this.left,
|
||||
required this.top,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.angle,
|
||||
required this.labelDx,
|
||||
required this.labelDy,
|
||||
required this.onSelectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
left: left,
|
||||
top: top,
|
||||
child: GestureDetector(
|
||||
onTap: () => onSelectionChanged(isSelected ? null : index),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomPaint(
|
||||
painter: _OcrBoxPainter(
|
||||
points: points,
|
||||
isSelected: isSelected,
|
||||
colorScheme: context.themeData.colorScheme,
|
||||
),
|
||||
size: Size(width, height),
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned(
|
||||
left: labelDx,
|
||||
top: labelDy,
|
||||
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: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: math.max(50, width), maxHeight: math.max(20, height)),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: SelectableText(
|
||||
ocr.text,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: math.max(12, height * 0.6),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OcrBoxPainter extends CustomPainter {
|
||||
final List<Offset> points;
|
||||
final bool isSelected;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
const _OcrBoxPainter({required this.points, required this.isSelected, required this.colorScheme});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = isSelected ? colorScheme.primary : colorScheme.secondary
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.0;
|
||||
|
||||
final fillPaint = Paint()
|
||||
..color = (isSelected ? colorScheme.primary : colorScheme.secondary).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 || !listEquals(oldDelegate.points, points);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/activity.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -35,6 +36,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(ocrAssetProvider(asset.id)).valueOrNull?.isNotEmpty == true;
|
||||
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||
|
||||
@@ -46,8 +48,15 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
|
||||
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(
|
||||
|
||||
@@ -8,6 +8,7 @@ class AssetViewerState {
|
||||
final bool showingDetails;
|
||||
final bool showingControls;
|
||||
final bool isZoomed;
|
||||
final bool showingOcr;
|
||||
final BaseAsset? currentAsset;
|
||||
final int stackIndex;
|
||||
|
||||
@@ -16,6 +17,7 @@ class AssetViewerState {
|
||||
this.showingDetails = false,
|
||||
this.showingControls = true,
|
||||
this.isZoomed = false,
|
||||
this.showingOcr = false,
|
||||
this.currentAsset,
|
||||
this.stackIndex = 0,
|
||||
});
|
||||
@@ -25,6 +27,7 @@ class AssetViewerState {
|
||||
bool? showingDetails,
|
||||
bool? showingControls,
|
||||
bool? isZoomed,
|
||||
bool? showingOcr,
|
||||
BaseAsset? currentAsset,
|
||||
int? stackIndex,
|
||||
}) {
|
||||
@@ -33,6 +36,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,
|
||||
);
|
||||
@@ -40,7 +44,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
|
||||
@@ -56,6 +60,7 @@ class AssetViewerState {
|
||||
other.showingDetails == showingDetails &&
|
||||
other.showingControls == showingControls &&
|
||||
other.isZoomed == isZoomed &&
|
||||
other.showingOcr == showingOcr &&
|
||||
other.currentAsset == currentAsset &&
|
||||
other.stackIndex == stackIndex;
|
||||
}
|
||||
@@ -66,6 +71,7 @@ class AssetViewerState {
|
||||
showingDetails.hashCode ^
|
||||
showingControls.hashCode ^
|
||||
isZoomed.hashCode ^
|
||||
showingOcr.hashCode ^
|
||||
currentAsset.hashCode ^
|
||||
stackIndex.hashCode;
|
||||
}
|
||||
@@ -90,7 +96,7 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
if (asset == state.currentAsset) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||
state = state.copyWith(currentAsset: asset, stackIndex: 0, showingOcr: false);
|
||||
}
|
||||
|
||||
void setOpacity(double opacity) {
|
||||
@@ -137,6 +143,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);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
|
||||
final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
|
||||
final syncStatusNotifier = ref.read(syncStatusProvider.notifier);
|
||||
final db = ref.read(driftProvider);
|
||||
|
||||
final manager = BackgroundSyncManager(
|
||||
onRemoteSyncStart: () {
|
||||
@@ -20,10 +23,14 @@ final backgroundSyncProvider = Provider<BackgroundSyncManager>((ref) {
|
||||
if (backupProvider.mounted) {
|
||||
backupProvider.updateError(isSuccess == true ? BackupError.none : BackupError.syncFailed);
|
||||
}
|
||||
db.notifyUpdates({TableUpdate.onTable(db.remoteAssetEntity, kind: UpdateKind.update)});
|
||||
},
|
||||
onRemoteSyncError: syncStatusNotifier.errorRemoteSync,
|
||||
onLocalSyncStart: syncStatusNotifier.startLocalSync,
|
||||
onLocalSyncComplete: syncStatusNotifier.completeLocalSync,
|
||||
onLocalSyncComplete: () {
|
||||
syncStatusNotifier.completeLocalSync();
|
||||
db.notifyUpdates({TableUpdate.onTable(db.localAssetEntity, kind: UpdateKind.update)});
|
||||
},
|
||||
onLocalSyncError: syncStatusNotifier.errorLocalSync,
|
||||
onHashingStart: syncStatusNotifier.startHashJob,
|
||||
onHashingComplete: syncStatusNotifier.completeHashJob,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
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';
|
||||
|
||||
final ocrRepositoryProvider = Provider<OcrRepository>((ref) => OcrRepository(ref.watch(driftProvider)));
|
||||
|
||||
final ocrServiceProvider = Provider<OcrService>((ref) => OcrService(ref.watch(ocrRepositoryProvider)));
|
||||
|
||||
final ocrAssetProvider = FutureProvider.autoDispose.family<List<Ocr>?, String>((ref, assetId) async {
|
||||
final service = ref.watch(ocrServiceProvider);
|
||||
return service.get(assetId);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/albums/album_search.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:openapi/api.dart' show Optional;
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:immich_mobile/providers/album/pending_album_uploads.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
@@ -154,7 +154,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
Future<RemoteAlbum?> updateAlbum(
|
||||
String albumId, {
|
||||
String? name,
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
Option<String?> description = const Option.none(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
// ignore: import_rule_openapi
|
||||
import 'package:openapi/api.dart' hide AlbumUserRole;
|
||||
|
||||
@@ -71,7 +72,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
String albumId,
|
||||
UserDto owner, {
|
||||
String? name,
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
Option<String?> description = const Option.none(),
|
||||
String? thumbnailAssetId,
|
||||
bool? isActivityEnabled,
|
||||
AlbumAssetOrder? order,
|
||||
@@ -86,7 +87,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
albumId,
|
||||
UpdateAlbumDto(
|
||||
albumName: name == null ? const Optional.absent() : Optional.present(name),
|
||||
description: description,
|
||||
description: description.toOptional(),
|
||||
albumThumbnailAssetId: thumbnailAssetId == null
|
||||
? const Optional.absent()
|
||||
: Optional.present(thumbnailAssetId),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -88,10 +89,10 @@ class SharedLinkService {
|
||||
required bool? showMeta,
|
||||
required bool? allowDownload,
|
||||
required bool? allowUpload,
|
||||
Optional<String?> password = const Optional.absent(),
|
||||
Optional<String?> description = const Optional.absent(),
|
||||
Option<String?> password = const Option.none(),
|
||||
Option<String?> description = const Option.none(),
|
||||
String? slug,
|
||||
Optional<DateTime?> expiresAt = const Optional.absent(),
|
||||
Option<DateTime?> expiresAt = const Option.none(),
|
||||
}) async {
|
||||
try {
|
||||
final responseDto = await _apiService.sharedLinksApi.updateSharedLink(
|
||||
@@ -100,9 +101,9 @@ class SharedLinkService {
|
||||
showMetadata: showMeta == null ? const Optional.absent() : Optional.present(showMeta),
|
||||
allowDownload: allowDownload == null ? const Optional.absent() : Optional.present(allowDownload),
|
||||
allowUpload: allowUpload == null ? const Optional.absent() : Optional.present(allowUpload),
|
||||
password: password,
|
||||
description: description,
|
||||
expiresAt: expiresAt,
|
||||
password: password.toOptional(),
|
||||
description: description.toOptional(),
|
||||
expiresAt: expiresAt.toOptional(),
|
||||
slug: slug == null ? const Optional.absent() : Optional.present(slug),
|
||||
),
|
||||
);
|
||||
|
||||
Generated
+7
@@ -293,6 +293,7 @@ Class | Method | HTTP request | Description
|
||||
*UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | Delete user profile image
|
||||
*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | Delete user product key
|
||||
*UsersApi* | [**deleteUserOnboarding**](doc//UsersApi.md#deleteuseronboarding) | **DELETE** /users/me/onboarding | Delete user onboarding
|
||||
*UsersApi* | [**getMyCalendarHeatmap**](doc//UsersApi.md#getmycalendarheatmap) | **GET** /users/me/calendar-heatmap | Retrieve calendar heatmap activity
|
||||
*UsersApi* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences | Get my preferences
|
||||
*UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me | Get current user
|
||||
*UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image | Retrieve user profile image
|
||||
@@ -307,6 +308,7 @@ Class | Method | HTTP request | Description
|
||||
*UsersAdminApi* | [**createUserAdmin**](doc//UsersAdminApi.md#createuseradmin) | **POST** /admin/users | Create a user
|
||||
*UsersAdminApi* | [**deleteUserAdmin**](doc//UsersAdminApi.md#deleteuseradmin) | **DELETE** /admin/users/{id} | Delete a user
|
||||
*UsersAdminApi* | [**getUserAdmin**](doc//UsersAdminApi.md#getuseradmin) | **GET** /admin/users/{id} | Retrieve a user
|
||||
*UsersAdminApi* | [**getUserCalendarHeatmapAdmin**](doc//UsersAdminApi.md#getusercalendarheatmapadmin) | **GET** /admin/users/{id}/calendar-heatmap | Retrieve calendar heatmap activity
|
||||
*UsersAdminApi* | [**getUserPreferencesAdmin**](doc//UsersAdminApi.md#getuserpreferencesadmin) | **GET** /admin/users/{id}/preferences | Retrieve user preferences
|
||||
*UsersAdminApi* | [**getUserSessionsAdmin**](doc//UsersAdminApi.md#getusersessionsadmin) | **GET** /admin/users/{id}/sessions | Retrieve user sessions
|
||||
*UsersAdminApi* | [**getUserStatisticsAdmin**](doc//UsersAdminApi.md#getuserstatisticsadmin) | **GET** /admin/users/{id}/statistics | Retrieve user statistics
|
||||
@@ -398,6 +400,9 @@ Class | Method | HTTP request | Description
|
||||
- [BulkIdsDto](doc//BulkIdsDto.md)
|
||||
- [CLIPConfig](doc//CLIPConfig.md)
|
||||
- [CQMode](doc//CQMode.md)
|
||||
- [CalendarHeatmapResponseDto](doc//CalendarHeatmapResponseDto.md)
|
||||
- [CalendarHeatmapResponseDtoSeriesInner](doc//CalendarHeatmapResponseDtoSeriesInner.md)
|
||||
- [CalendarHeatmapType](doc//CalendarHeatmapType.md)
|
||||
- [CastResponse](doc//CastResponse.md)
|
||||
- [CastUpdate](doc//CastUpdate.md)
|
||||
- [ChangePasswordDto](doc//ChangePasswordDto.md)
|
||||
@@ -581,6 +586,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)
|
||||
- [SyncAssetV2](doc//SyncAssetV2.md)
|
||||
- [SyncAuthUserV1](doc//SyncAuthUserV1.md)
|
||||
|
||||
Generated
+5
@@ -140,6 +140,9 @@ part 'model/bulk_id_response_dto.dart';
|
||||
part 'model/bulk_ids_dto.dart';
|
||||
part 'model/clip_config.dart';
|
||||
part 'model/cq_mode.dart';
|
||||
part 'model/calendar_heatmap_response_dto.dart';
|
||||
part 'model/calendar_heatmap_response_dto_series_inner.dart';
|
||||
part 'model/calendar_heatmap_type.dart';
|
||||
part 'model/cast_response.dart';
|
||||
part 'model/cast_update.dart';
|
||||
part 'model/change_password_dto.dart';
|
||||
@@ -323,6 +326,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_asset_v2.dart';
|
||||
part 'model/sync_auth_user_v1.dart';
|
||||
|
||||
+84
@@ -193,6 +193,90 @@ class UsersAdminApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Retrieve calendar heatmap activity
|
||||
///
|
||||
/// Retrieve activity counts for a specified period, in a calendar heatmap format.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [DateTime] from:
|
||||
/// Start date in UTC
|
||||
///
|
||||
/// * [DateTime] to:
|
||||
/// End date in UTC
|
||||
///
|
||||
/// * [CalendarHeatmapType] type:
|
||||
Future<Response> getUserCalendarHeatmapAdminWithHttpInfo(String id, { DateTime? from, DateTime? to, CalendarHeatmapType? type, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/admin/users/{id}/calendar-heatmap'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (from != null) {
|
||||
queryParams.addAll(_queryParams('', 'from', from));
|
||||
}
|
||||
if (to != null) {
|
||||
queryParams.addAll(_queryParams('', 'to', to));
|
||||
}
|
||||
if (type != null) {
|
||||
queryParams.addAll(_queryParams('', 'type', type));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve calendar heatmap activity
|
||||
///
|
||||
/// Retrieve activity counts for a specified period, in a calendar heatmap format.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [DateTime] from:
|
||||
/// Start date in UTC
|
||||
///
|
||||
/// * [DateTime] to:
|
||||
/// End date in UTC
|
||||
///
|
||||
/// * [CalendarHeatmapType] type:
|
||||
Future<CalendarHeatmapResponseDto?> getUserCalendarHeatmapAdmin(String id, { DateTime? from, DateTime? to, CalendarHeatmapType? type, Future<void>? abortTrigger, }) async {
|
||||
final response = await getUserCalendarHeatmapAdminWithHttpInfo(id, from: from, to: to, type: type, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'CalendarHeatmapResponseDto',) as CalendarHeatmapResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Retrieve user preferences
|
||||
///
|
||||
/// Retrieve the preferences of a specific user.
|
||||
|
||||
Generated
+79
@@ -208,6 +208,85 @@ class UsersApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve calendar heatmap activity
|
||||
///
|
||||
/// Retrieve activity counts for a specified period, in a calendar heatmap format.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [DateTime] from:
|
||||
/// Start date in UTC
|
||||
///
|
||||
/// * [DateTime] to:
|
||||
/// End date in UTC
|
||||
///
|
||||
/// * [CalendarHeatmapType] type:
|
||||
Future<Response> getMyCalendarHeatmapWithHttpInfo({ DateTime? from, DateTime? to, CalendarHeatmapType? type, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/users/me/calendar-heatmap';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (from != null) {
|
||||
queryParams.addAll(_queryParams('', 'from', from));
|
||||
}
|
||||
if (to != null) {
|
||||
queryParams.addAll(_queryParams('', 'to', to));
|
||||
}
|
||||
if (type != null) {
|
||||
queryParams.addAll(_queryParams('', 'type', type));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve calendar heatmap activity
|
||||
///
|
||||
/// Retrieve activity counts for a specified period, in a calendar heatmap format.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [DateTime] from:
|
||||
/// Start date in UTC
|
||||
///
|
||||
/// * [DateTime] to:
|
||||
/// End date in UTC
|
||||
///
|
||||
/// * [CalendarHeatmapType] type:
|
||||
Future<CalendarHeatmapResponseDto?> getMyCalendarHeatmap({ DateTime? from, DateTime? to, CalendarHeatmapType? type, Future<void>? abortTrigger, }) async {
|
||||
final response = await getMyCalendarHeatmapWithHttpInfo(from: from, to: to, type: type, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'CalendarHeatmapResponseDto',) as CalendarHeatmapResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get my preferences
|
||||
///
|
||||
/// Retrieve the preferences for the current user.
|
||||
|
||||
Generated
+10
@@ -325,6 +325,12 @@ class ApiClient {
|
||||
return CLIPConfig.fromJson(value);
|
||||
case 'CQMode':
|
||||
return CQModeTypeTransformer().decode(value);
|
||||
case 'CalendarHeatmapResponseDto':
|
||||
return CalendarHeatmapResponseDto.fromJson(value);
|
||||
case 'CalendarHeatmapResponseDtoSeriesInner':
|
||||
return CalendarHeatmapResponseDtoSeriesInner.fromJson(value);
|
||||
case 'CalendarHeatmapType':
|
||||
return CalendarHeatmapTypeTypeTransformer().decode(value);
|
||||
case 'CastResponse':
|
||||
return CastResponse.fromJson(value);
|
||||
case 'CastUpdate':
|
||||
@@ -691,6 +697,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 'SyncAssetV2':
|
||||
|
||||
Generated
+3
@@ -100,6 +100,9 @@ String parameterToString(dynamic value) {
|
||||
if (value is CQMode) {
|
||||
return CQModeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is CalendarHeatmapType) {
|
||||
return CalendarHeatmapTypeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is Colorspace) {
|
||||
return ColorspaceTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// 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 CalendarHeatmapResponseDto {
|
||||
/// Returns a new [CalendarHeatmapResponseDto] instance.
|
||||
CalendarHeatmapResponseDto({
|
||||
required this.from,
|
||||
this.series = const [],
|
||||
required this.to,
|
||||
required this.totalCount,
|
||||
});
|
||||
|
||||
/// Start date in UTC
|
||||
String from;
|
||||
|
||||
List<CalendarHeatmapResponseDtoSeriesInner> series;
|
||||
|
||||
/// End date in UTC
|
||||
String to;
|
||||
|
||||
/// Total activity count over the period
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int totalCount;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is CalendarHeatmapResponseDto &&
|
||||
other.from == from &&
|
||||
_deepEquality.equals(other.series, series) &&
|
||||
other.to == to &&
|
||||
other.totalCount == totalCount;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(from.hashCode) +
|
||||
(series.hashCode) +
|
||||
(to.hashCode) +
|
||||
(totalCount.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'CalendarHeatmapResponseDto[from=$from, series=$series, to=$to, totalCount=$totalCount]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'from'] = this.from;
|
||||
json[r'series'] = this.series;
|
||||
json[r'to'] = this.to;
|
||||
json[r'totalCount'] = this.totalCount;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [CalendarHeatmapResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static CalendarHeatmapResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "CalendarHeatmapResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return CalendarHeatmapResponseDto(
|
||||
from: mapValueOfType<String>(json, r'from')!,
|
||||
series: CalendarHeatmapResponseDtoSeriesInner.listFromJson(json[r'series']),
|
||||
to: mapValueOfType<String>(json, r'to')!,
|
||||
totalCount: mapValueOfType<int>(json, r'totalCount')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<CalendarHeatmapResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <CalendarHeatmapResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = CalendarHeatmapResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, CalendarHeatmapResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, CalendarHeatmapResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = CalendarHeatmapResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of CalendarHeatmapResponseDto-objects as value to a dart map
|
||||
static Map<String, List<CalendarHeatmapResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<CalendarHeatmapResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = CalendarHeatmapResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'from',
|
||||
'series',
|
||||
'to',
|
||||
'totalCount',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// 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 CalendarHeatmapResponseDtoSeriesInner {
|
||||
/// Returns a new [CalendarHeatmapResponseDtoSeriesInner] instance.
|
||||
CalendarHeatmapResponseDtoSeriesInner({
|
||||
required this.count,
|
||||
required this.date,
|
||||
});
|
||||
|
||||
/// Activity count
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int count;
|
||||
|
||||
/// Date in UTC
|
||||
String date;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is CalendarHeatmapResponseDtoSeriesInner &&
|
||||
other.count == count &&
|
||||
other.date == date;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(count.hashCode) +
|
||||
(date.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'CalendarHeatmapResponseDtoSeriesInner[count=$count, date=$date]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'count'] = this.count;
|
||||
json[r'date'] = this.date;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [CalendarHeatmapResponseDtoSeriesInner] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static CalendarHeatmapResponseDtoSeriesInner? fromJson(dynamic value) {
|
||||
upgradeDto(value, "CalendarHeatmapResponseDtoSeriesInner");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return CalendarHeatmapResponseDtoSeriesInner(
|
||||
count: mapValueOfType<int>(json, r'count')!,
|
||||
date: mapValueOfType<String>(json, r'date')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<CalendarHeatmapResponseDtoSeriesInner> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <CalendarHeatmapResponseDtoSeriesInner>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = CalendarHeatmapResponseDtoSeriesInner.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, CalendarHeatmapResponseDtoSeriesInner> mapFromJson(dynamic json) {
|
||||
final map = <String, CalendarHeatmapResponseDtoSeriesInner>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = CalendarHeatmapResponseDtoSeriesInner.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of CalendarHeatmapResponseDtoSeriesInner-objects as value to a dart map
|
||||
static Map<String, List<CalendarHeatmapResponseDtoSeriesInner>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<CalendarHeatmapResponseDtoSeriesInner>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = CalendarHeatmapResponseDtoSeriesInner.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'count',
|
||||
'date',
|
||||
};
|
||||
}
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// 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;
|
||||
|
||||
/// Type of calendar heatmap
|
||||
class CalendarHeatmapType {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const CalendarHeatmapType._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const upload = CalendarHeatmapType._(r'Upload');
|
||||
static const taken = CalendarHeatmapType._(r'Taken');
|
||||
|
||||
/// List of all possible values in this [enum][CalendarHeatmapType].
|
||||
static const values = <CalendarHeatmapType>[
|
||||
upload,
|
||||
taken,
|
||||
];
|
||||
|
||||
static CalendarHeatmapType? fromJson(dynamic value) => CalendarHeatmapTypeTypeTransformer().decode(value);
|
||||
|
||||
static List<CalendarHeatmapType> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <CalendarHeatmapType>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = CalendarHeatmapType.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [CalendarHeatmapType] to String,
|
||||
/// and [decode] dynamic data back to [CalendarHeatmapType].
|
||||
class CalendarHeatmapTypeTypeTransformer {
|
||||
factory CalendarHeatmapTypeTypeTransformer() => _instance ??= const CalendarHeatmapTypeTypeTransformer._();
|
||||
|
||||
const CalendarHeatmapTypeTypeTransformer._();
|
||||
|
||||
String encode(CalendarHeatmapType data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a CalendarHeatmapType.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
CalendarHeatmapType? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'Upload': return CalendarHeatmapType.upload;
|
||||
case r'Taken': return CalendarHeatmapType.taken;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [CalendarHeatmapTypeTypeTransformer] instance.
|
||||
static CalendarHeatmapTypeTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// 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'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
|
||||
? this.deletedAt.millisecondsSinceEpoch
|
||||
: 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'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')!,
|
||||
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
@@ -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
|
||||
double 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
|
||||
double textScore;
|
||||
|
||||
/// Top-left X coordinate (normalized 0–1)
|
||||
double x1;
|
||||
|
||||
/// Top-right X coordinate (normalized 0–1)
|
||||
double x2;
|
||||
|
||||
/// Bottom-right X coordinate (normalized 0–1)
|
||||
double x3;
|
||||
|
||||
/// Bottom-left X coordinate (normalized 0–1)
|
||||
double x4;
|
||||
|
||||
/// Top-left Y coordinate (normalized 0–1)
|
||||
double y1;
|
||||
|
||||
/// Top-right Y coordinate (normalized 0–1)
|
||||
double y2;
|
||||
|
||||
/// Bottom-right Y coordinate (normalized 0–1)
|
||||
double y3;
|
||||
|
||||
/// Bottom-left Y coordinate (normalized 0–1)
|
||||
double 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: mapValueOfType<double>(json, r'boxScore')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isVisible: mapValueOfType<bool>(json, r'isVisible')!,
|
||||
text: mapValueOfType<String>(json, r'text')!,
|
||||
textScore: mapValueOfType<double>(json, r'textScore')!,
|
||||
x1: mapValueOfType<double>(json, r'x1')!,
|
||||
x2: mapValueOfType<double>(json, r'x2')!,
|
||||
x3: mapValueOfType<double>(json, r'x3')!,
|
||||
x4: mapValueOfType<double>(json, r'x4')!,
|
||||
y1: mapValueOfType<double>(json, r'y1')!,
|
||||
y2: mapValueOfType<double>(json, r'y2')!,
|
||||
y3: mapValueOfType<double>(json, r'y3')!,
|
||||
y4: mapValueOfType<double>(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
@@ -34,6 +34,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');
|
||||
@@ -94,6 +96,8 @@ class SyncEntityType {
|
||||
assetEditDeleteV1,
|
||||
assetMetadataV1,
|
||||
assetMetadataDeleteV1,
|
||||
assetOcrV1,
|
||||
assetOcrDeleteV1,
|
||||
partnerV1,
|
||||
partnerDeleteV1,
|
||||
partnerAssetV1,
|
||||
@@ -189,6 +193,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
@@ -35,6 +35,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');
|
||||
@@ -64,6 +65,7 @@ class SyncRequestType {
|
||||
assetExifsV1,
|
||||
assetEditsV1,
|
||||
assetMetadataV1,
|
||||
assetOcrV1,
|
||||
authUsersV1,
|
||||
memoriesV1,
|
||||
memoryToAssetsV1,
|
||||
@@ -128,6 +130,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;
|
||||
|
||||
+4
@@ -32,6 +32,7 @@ import 'schema_v25.dart' as v25;
|
||||
import 'schema_v26.dart' as v26;
|
||||
import 'schema_v27.dart' as v27;
|
||||
import 'schema_v28.dart' as v28;
|
||||
import 'schema_v29.dart' as v29;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -93,6 +94,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v27.DatabaseAtV27(db);
|
||||
case 28:
|
||||
return v28.DatabaseAtV28(db);
|
||||
case 29:
|
||||
return v29.DatabaseAtV29(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -127,5 +130,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
26,
|
||||
27,
|
||||
28,
|
||||
29,
|
||||
];
|
||||
}
|
||||
|
||||
+10027
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,12 @@ void main() {
|
||||
if (!deserialized.contains(entry.key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip new DTOs
|
||||
if (!baseRequired.containsKey(entry.key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final have = patched[entry.key] ?? const <String>{};
|
||||
final newlyRequired = entry.value.difference(
|
||||
baseRequired[entry.key] ?? const <String>{},
|
||||
|
||||
@@ -1264,6 +1264,96 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/admin/users/{id}/calendar-heatmap": {
|
||||
"get": {
|
||||
"description": "Retrieve activity counts for a specified period, in a calendar heatmap format.",
|
||||
"operationId": "getUserCalendarHeatmapAdmin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "from",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Start date in UTC",
|
||||
"schema": {
|
||||
"format": "date",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "End date in UTC",
|
||||
"schema": {
|
||||
"format": "date",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"default": "Upload",
|
||||
"$ref": "#/components/schemas/CalendarHeatmapType"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CalendarHeatmapResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Retrieve calendar heatmap activity",
|
||||
"tags": [
|
||||
"Users (admin)"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "user.read",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/admin/users/{id}/preferences": {
|
||||
"get": {
|
||||
"description": "Retrieve the preferences of a specific user.",
|
||||
@@ -14485,6 +14575,86 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/users/me/calendar-heatmap": {
|
||||
"get": {
|
||||
"description": "Retrieve activity counts for a specified period, in a calendar heatmap format.",
|
||||
"operationId": "getMyCalendarHeatmap",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "from",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Start date in UTC",
|
||||
"schema": {
|
||||
"format": "date",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "End date in UTC",
|
||||
"schema": {
|
||||
"format": "date",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"default": "Upload",
|
||||
"$ref": "#/components/schemas/CalendarHeatmapType"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CalendarHeatmapResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Retrieve calendar heatmap activity",
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "user.read",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/users/me/license": {
|
||||
"delete": {
|
||||
"description": "Delete the registered product key for the current user.",
|
||||
@@ -17645,6 +17815,64 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CalendarHeatmapResponseDto": {
|
||||
"properties": {
|
||||
"from": {
|
||||
"description": "Start date in UTC",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
},
|
||||
"series": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"count": {
|
||||
"description": "Activity count",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"date": {
|
||||
"description": "Date in UTC",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"date",
|
||||
"count"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"to": {
|
||||
"description": "End date in UTC",
|
||||
"example": "2024-12-31",
|
||||
"type": "string"
|
||||
},
|
||||
"totalCount": {
|
||||
"description": "Total activity count over the period",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"from",
|
||||
"series",
|
||||
"to",
|
||||
"totalCount"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CalendarHeatmapType": {
|
||||
"description": "Type of calendar heatmap",
|
||||
"enum": [
|
||||
"Upload",
|
||||
"Taken"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CastResponse": {
|
||||
"properties": {
|
||||
"gCastEnabled": {
|
||||
@@ -23577,6 +23805,118 @@
|
||||
],
|
||||
"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",
|
||||
"example": "2024-01-01T00:00:00.000Z",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
|
||||
"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",
|
||||
"format": "double",
|
||||
"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",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"x1": {
|
||||
"description": "Top-left X coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"x2": {
|
||||
"description": "Top-right X coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"x3": {
|
||||
"description": "Bottom-right X coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"x4": {
|
||||
"description": "Bottom-left X coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"y1": {
|
||||
"description": "Top-left Y coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"y2": {
|
||||
"description": "Top-right Y coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"y3": {
|
||||
"description": "Bottom-right Y coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"y4": {
|
||||
"description": "Bottom-left Y coordinate (normalized 0–1)",
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetId",
|
||||
"boxScore",
|
||||
"id",
|
||||
"isVisible",
|
||||
"text",
|
||||
"textScore",
|
||||
"x1",
|
||||
"x2",
|
||||
"x3",
|
||||
"x4",
|
||||
"y1",
|
||||
"y2",
|
||||
"y3",
|
||||
"y4"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SyncAssetV1": {
|
||||
"properties": {
|
||||
"checksum": {
|
||||
@@ -23958,6 +24298,8 @@
|
||||
"AssetEditDeleteV1",
|
||||
"AssetMetadataV1",
|
||||
"AssetMetadataDeleteV1",
|
||||
"AssetOcrV1",
|
||||
"AssetOcrDeleteV1",
|
||||
"PartnerV1",
|
||||
"PartnerDeleteV1",
|
||||
"PartnerAssetV1",
|
||||
@@ -24280,6 +24622,7 @@
|
||||
"AssetExifsV1",
|
||||
"AssetEditsV1",
|
||||
"AssetMetadataV1",
|
||||
"AssetOcrV1",
|
||||
"AuthUsersV1",
|
||||
"MemoriesV1",
|
||||
"MemoryToAssetsV1",
|
||||
|
||||
@@ -262,6 +262,20 @@ export type UserAdminUpdateDto = {
|
||||
/** Storage label */
|
||||
storageLabel?: string | null;
|
||||
};
|
||||
export type CalendarHeatmapResponseDto = {
|
||||
/** Start date in UTC */
|
||||
"from": string;
|
||||
series: {
|
||||
/** Activity count */
|
||||
count: number;
|
||||
/** Date in UTC */
|
||||
date: string;
|
||||
}[];
|
||||
/** End date in UTC */
|
||||
to: string;
|
||||
/** Total activity count over the period */
|
||||
totalCount: number;
|
||||
};
|
||||
export type AlbumsResponse = {
|
||||
defaultAssetOrder: AssetOrder;
|
||||
};
|
||||
@@ -2999,6 +3013,44 @@ export type SyncAssetMetadataV1 = {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
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;
|
||||
@@ -3544,6 +3596,26 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: {
|
||||
body: userAdminUpdateDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Retrieve calendar heatmap activity
|
||||
*/
|
||||
export function getUserCalendarHeatmapAdmin({ $from, id, to, $type }: {
|
||||
$from?: string;
|
||||
id: string;
|
||||
to?: string;
|
||||
$type?: CalendarHeatmapType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: CalendarHeatmapResponseDto;
|
||||
}>(`/admin/users/${encodeURIComponent(id)}/calendar-heatmap${QS.query(QS.explode({
|
||||
"from": $from,
|
||||
to,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Retrieve user preferences
|
||||
*/
|
||||
@@ -6578,6 +6650,25 @@ export function updateMyUser({ userUpdateMeDto }: {
|
||||
body: userUpdateMeDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Retrieve calendar heatmap activity
|
||||
*/
|
||||
export function getMyCalendarHeatmap({ $from, to, $type }: {
|
||||
$from?: string;
|
||||
to?: string;
|
||||
$type?: CalendarHeatmapType;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: CalendarHeatmapResponseDto;
|
||||
}>(`/users/me/calendar-heatmap${QS.query(QS.explode({
|
||||
"from": $from,
|
||||
to,
|
||||
"type": $type
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Delete user product key
|
||||
*/
|
||||
@@ -6905,6 +6996,10 @@ export enum UserStatus {
|
||||
Removing = "removing",
|
||||
Deleted = "deleted"
|
||||
}
|
||||
export enum CalendarHeatmapType {
|
||||
Upload = "Upload",
|
||||
Taken = "Taken"
|
||||
}
|
||||
export enum AssetOrder {
|
||||
Asc = "asc",
|
||||
Desc = "desc"
|
||||
@@ -7279,6 +7374,8 @@ export enum SyncEntityType {
|
||||
AssetEditDeleteV1 = "AssetEditDeleteV1",
|
||||
AssetMetadataV1 = "AssetMetadataV1",
|
||||
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
|
||||
AssetOcrV1 = "AssetOcrV1",
|
||||
AssetOcrDeleteV1 = "AssetOcrDeleteV1",
|
||||
PartnerV1 = "PartnerV1",
|
||||
PartnerDeleteV1 = "PartnerDeleteV1",
|
||||
PartnerAssetV1 = "PartnerAssetV1",
|
||||
@@ -7339,6 +7436,7 @@ export enum SyncRequestType {
|
||||
AssetExifsV1 = "AssetExifsV1",
|
||||
AssetEditsV1 = "AssetEditsV1",
|
||||
AssetMetadataV1 = "AssetMetadataV1",
|
||||
AssetOcrV1 = "AssetOcrV1",
|
||||
AuthUsersV1 = "AuthUsersV1",
|
||||
MemoriesV1 = "MemoriesV1",
|
||||
MemoryToAssetsV1 = "MemoryToAssetsV1",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { CalendarHeatmapDto, CalendarHeatmapResponseDto } from 'src/dtos/calendar-heatmap.dto';
|
||||
import { SessionResponseDto } from 'src/dtos/session.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import {
|
||||
@@ -85,6 +86,21 @@ export class UserAdminController {
|
||||
return this.service.delete(auth, id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/calendar-heatmap')
|
||||
@Authenticated({ permission: Permission.UserRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve calendar heatmap activity',
|
||||
description: 'Retrieve activity counts for a specified period, in a calendar heatmap format.',
|
||||
history: new HistoryBuilder().added('v3').stable('v3'),
|
||||
})
|
||||
getUserCalendarHeatmapAdmin(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: CalendarHeatmapDto,
|
||||
): Promise<CalendarHeatmapResponseDto> {
|
||||
return this.service.getCalendarHeatmap(auth, id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/sessions')
|
||||
@Authenticated({ permission: Permission.AdminSessionRead, admin: true })
|
||||
@Endpoint({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
@@ -17,6 +18,7 @@ import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { CalendarHeatmapDto, CalendarHeatmapResponseDto } from 'src/dtos/calendar-heatmap.dto';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
@@ -60,6 +62,17 @@ export class UserController {
|
||||
return this.service.getMe(auth);
|
||||
}
|
||||
|
||||
@Get('me/calendar-heatmap')
|
||||
@Authenticated({ permission: Permission.UserRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve calendar heatmap activity',
|
||||
description: 'Retrieve activity counts for a specified period, in a calendar heatmap format.',
|
||||
history: new HistoryBuilder().added('v3').stable('v3'),
|
||||
})
|
||||
getMyCalendarHeatmap(@Auth() auth: AuthDto, @Query() dto: CalendarHeatmapDto): Promise<CalendarHeatmapResponseDto> {
|
||||
return this.service.getCalendarHeatmap(auth, dto);
|
||||
}
|
||||
|
||||
@Put('me')
|
||||
@Authenticated({ permission: Permission.UserUpdate })
|
||||
@Endpoint({
|
||||
|
||||
@@ -444,6 +444,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',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { CalendarHeatmapType } from 'src/enum';
|
||||
import { isoDateToDate } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const CalendarHeatmapTypeSchema = z
|
||||
.enum(CalendarHeatmapType)
|
||||
.describe('Type of calendar heatmap')
|
||||
.meta({ id: 'CalendarHeatmapType' });
|
||||
|
||||
const CalendarHeatmapSchema = z
|
||||
.object({
|
||||
from: isoDateToDate.optional().describe('Start date in UTC'),
|
||||
to: isoDateToDate.optional().describe('End date in UTC'),
|
||||
type: CalendarHeatmapTypeSchema.optional().default(CalendarHeatmapType.Upload),
|
||||
})
|
||||
.refine((dto) => !dto.from || !dto.to || dto.from <= dto.to, { message: 'from must be before to', path: ['from'] })
|
||||
.meta({ id: 'CalendarHeatmapDto' });
|
||||
|
||||
export class CalendarHeatmapDto extends createZodDto(CalendarHeatmapSchema) {}
|
||||
|
||||
const CalendarHeatmapResponseSchema = z
|
||||
.object({
|
||||
from: z.string().describe('Start date in UTC').meta({ example: '2024-01-01' }),
|
||||
to: z.string().describe('End date in UTC').meta({ example: '2024-12-31' }),
|
||||
series: z.array(
|
||||
z.object({
|
||||
date: z.string().describe('Date in UTC').meta({ example: '2024-01-01' }),
|
||||
count: z.int().nonnegative().describe('Activity count'),
|
||||
}),
|
||||
),
|
||||
totalCount: z.int().nonnegative().describe('Total activity count over the period'),
|
||||
})
|
||||
.meta({ id: 'CalendarHeatmapResponseDto' });
|
||||
|
||||
export class CalendarHeatmapResponseDto extends createZodDto(CalendarHeatmapResponseSchema) {}
|
||||
@@ -401,6 +401,41 @@ class SyncMemoryDeleteV1 extends createZodDto(SyncMemoryDeleteV1Schema) {}
|
||||
class SyncMemoryAssetV1 extends createZodDto(SyncMemoryAssetV1Schema) {}
|
||||
@ExtraModel()
|
||||
class SyncMemoryAssetDeleteV1 extends createZodDto(SyncMemoryAssetDeleteV1Schema) {}
|
||||
|
||||
const SyncAssetOcrV1Schema = z
|
||||
.object({
|
||||
id: z.string().describe('OCR entry ID'),
|
||||
assetId: z.string().describe('Asset ID'),
|
||||
|
||||
x1: z.number().meta({ format: 'double' }).describe('Top-left X coordinate (normalized 0–1)'),
|
||||
y1: z.number().meta({ format: 'double' }).describe('Top-left Y coordinate (normalized 0–1)'),
|
||||
x2: z.number().meta({ format: 'double' }).describe('Top-right X coordinate (normalized 0–1)'),
|
||||
y2: z.number().meta({ format: 'double' }).describe('Top-right Y coordinate (normalized 0–1)'),
|
||||
x3: z.number().meta({ format: 'double' }).describe('Bottom-right X coordinate (normalized 0–1)'),
|
||||
y3: z.number().meta({ format: 'double' }).describe('Bottom-right Y coordinate (normalized 0–1)'),
|
||||
x4: z.number().meta({ format: 'double' }).describe('Bottom-left X coordinate (normalized 0–1)'),
|
||||
y4: z.number().meta({ format: 'double' }).describe('Bottom-left Y coordinate (normalized 0–1)'),
|
||||
|
||||
boxScore: z.number().meta({ format: 'double' }).describe('Confidence score of the bounding box'),
|
||||
textScore: z.number().meta({ format: 'double' }).describe('Confidence score of the recognized text'),
|
||||
text: z.string().describe('Recognized text content'),
|
||||
isVisible: z.boolean().describe('Whether the OCR entry is visible'),
|
||||
})
|
||||
.meta({ id: 'SyncAssetOcrV1' });
|
||||
|
||||
const SyncAssetOcrDeleteV1Schema = z
|
||||
.object({
|
||||
id: z.string().describe('Audit row ID of the deleted OCR entry'),
|
||||
assetId: z.string().describe('Original asset ID of the deleted OCR entry'),
|
||||
deletedAt: isoDatetimeToDate.describe('Timestamp when the OCR entry was deleted'),
|
||||
})
|
||||
.meta({ id: 'SyncAssetOcrDeleteV1' });
|
||||
|
||||
@ExtraModel()
|
||||
class SyncAssetOcrV1 extends createZodDto(SyncAssetOcrV1Schema) {}
|
||||
|
||||
@ExtraModel()
|
||||
class SyncAssetOcrDeleteV1 extends createZodDto(SyncAssetOcrDeleteV1Schema) {}
|
||||
@ExtraModel()
|
||||
class SyncStackV1 extends createZodDto(SyncStackV1Schema) {}
|
||||
@ExtraModel()
|
||||
@@ -437,6 +472,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.PartnerAssetV2]: SyncAssetV2;
|
||||
|
||||
@@ -952,6 +952,7 @@ export enum SyncRequestType {
|
||||
AssetExifsV1 = 'AssetExifsV1',
|
||||
AssetEditsV1 = 'AssetEditsV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
AssetOcrV1 = 'AssetOcrV1',
|
||||
AuthUsersV1 = 'AuthUsersV1',
|
||||
MemoriesV1 = 'MemoriesV1',
|
||||
MemoryToAssetsV1 = 'MemoryToAssetsV1',
|
||||
@@ -990,6 +991,8 @@ export enum SyncEntityType {
|
||||
AssetEditDeleteV1 = 'AssetEditDeleteV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
|
||||
AssetOcrV1 = 'AssetOcrV1',
|
||||
AssetOcrDeleteV1 = 'AssetOcrDeleteV1',
|
||||
|
||||
PartnerV1 = 'PartnerV1',
|
||||
PartnerDeleteV1 = 'PartnerDeleteV1',
|
||||
@@ -1175,3 +1178,8 @@ export enum WorkflowType {
|
||||
}
|
||||
|
||||
export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' });
|
||||
|
||||
export enum CalendarHeatmapType {
|
||||
Upload = 'Upload',
|
||||
Taken = 'Taken',
|
||||
}
|
||||
|
||||
@@ -339,6 +339,22 @@ where
|
||||
limit
|
||||
$3
|
||||
|
||||
-- AssetRepository.getCalendarHeatmap
|
||||
select
|
||||
date_trunc('DAY', "asset"."createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' as "date",
|
||||
count(*) as "count"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"ownerId" = $1::uuid
|
||||
and "createdAt" >= $2
|
||||
and "createdAt" < $3
|
||||
and "deletedAt" is null
|
||||
group by
|
||||
date_trunc('DAY', "asset"."createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
|
||||
order by
|
||||
"date" asc
|
||||
|
||||
-- AssetRepository.getTimeBuckets
|
||||
with
|
||||
"asset" as (
|
||||
|
||||
@@ -579,6 +579,48 @@ where
|
||||
order by
|
||||
"asset_metadata"."updateId" asc
|
||||
|
||||
-- SyncRepository.assetOcr.getDeletes
|
||||
select
|
||||
"asset_ocr_audit"."id",
|
||||
"asset_ocr_audit"."assetId",
|
||||
"asset_ocr_audit"."deletedAt"
|
||||
from
|
||||
"asset_ocr_audit" as "asset_ocr_audit"
|
||||
left join "asset" on "asset"."id" = "asset_ocr_audit"."assetId"
|
||||
where
|
||||
"asset_ocr_audit"."id" < $1
|
||||
and "asset_ocr_audit"."id" > $2
|
||||
and "asset"."ownerId" = $3
|
||||
order by
|
||||
"asset_ocr_audit"."id" asc
|
||||
|
||||
-- SyncRepository.assetOcr.getUpserts
|
||||
select
|
||||
"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"
|
||||
from
|
||||
"asset_ocr" as "asset_ocr"
|
||||
inner join "asset" on "asset"."id" = "asset_ocr"."assetId"
|
||||
where
|
||||
"asset_ocr"."updateId" < $1
|
||||
and "asset_ocr"."updateId" > $2
|
||||
and "asset"."ownerId" = $3
|
||||
order by
|
||||
"asset_ocr"."updateId" asc
|
||||
|
||||
-- SyncRepository.authUser.getUpserts
|
||||
select
|
||||
"id",
|
||||
|
||||
@@ -17,7 +17,15 @@ import { InjectKysely } from 'nestjs-kysely';
|
||||
import { LockableProperty, Stack } from 'src/database';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetFileType, AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetOrder,
|
||||
AssetOrderBy,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
CalendarHeatmapType,
|
||||
} from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
@@ -706,6 +714,32 @@ export class AssetRepository {
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID, { from: DummyValue.DATE, to: DummyValue.DATE, type: CalendarHeatmapType.Upload }],
|
||||
})
|
||||
getCalendarHeatmap(ownerId: string, dto: { from: Date; to: Date; type: CalendarHeatmapType }) {
|
||||
const dateColumns: Record<CalendarHeatmapType, { order: AssetOrderBy; column: 'createdAt' | 'localDateTime' }> = {
|
||||
[CalendarHeatmapType.Upload]: { order: AssetOrderBy.CreatedAt, column: 'createdAt' },
|
||||
[CalendarHeatmapType.Taken]: { order: AssetOrderBy.TakenAt, column: 'localDateTime' },
|
||||
} as const;
|
||||
|
||||
const { order, column } = dateColumns[dto.type];
|
||||
|
||||
const date = truncatedDate<Date>(order, 'DAY');
|
||||
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(date.as('date'))
|
||||
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.where(column, '>=', dto.from)
|
||||
.where(column, '<', dto.to)
|
||||
.where('deletedAt', 'is', null)
|
||||
.groupBy(date)
|
||||
.orderBy('date', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{}] })
|
||||
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
|
||||
return this.db
|
||||
|
||||
@@ -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);
|
||||
@@ -769,3 +771,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,3 +287,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`,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
asset_delete_audit,
|
||||
asset_face_audit,
|
||||
asset_metadata_audit,
|
||||
asset_ocr_delete_audit,
|
||||
f_concat_ws,
|
||||
f_unaccent,
|
||||
immich_uuid_v7,
|
||||
@@ -43,6 +44,7 @@ import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
||||
import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.table';
|
||||
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
|
||||
import { AssetOcrAuditTable } from 'src/schema/tables/asset-ocr-audit.table';
|
||||
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||
@@ -107,6 +109,7 @@ export class ImmichDatabase {
|
||||
AssetMetadataAuditTable,
|
||||
AssetJobStatusTable,
|
||||
AssetOcrTable,
|
||||
AssetOcrAuditTable,
|
||||
AssetTable,
|
||||
AssetFileTable,
|
||||
AssetExifTable,
|
||||
@@ -168,6 +171,7 @@ export class ImmichDatabase {
|
||||
user_metadata_audit,
|
||||
asset_metadata_audit,
|
||||
asset_face_audit,
|
||||
asset_ocr_delete_audit,
|
||||
];
|
||||
|
||||
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
|
||||
@@ -205,6 +209,7 @@ export interface DB {
|
||||
asset_metadata_audit: AssetMetadataAuditTable;
|
||||
asset_job_status: AssetJobStatusTable;
|
||||
asset_ocr: AssetOcrTable;
|
||||
asset_ocr_audit: AssetOcrAuditTable;
|
||||
asset_audio: AssetAudioTable;
|
||||
asset_video: AssetVideoTable;
|
||||
asset_keyframe: AssetKeyframeTable;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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`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);
|
||||
}
|
||||
@@ -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 { asset_ocr_delete_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
|
||||
@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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { CalendarHeatmapDto } from 'src/dtos/calendar-heatmap.dto';
|
||||
import { CalendarHeatmapType } from 'src/enum';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
|
||||
export const getCalendarHeatmap = async (
|
||||
userId: string,
|
||||
dto: CalendarHeatmapDto,
|
||||
repos: { asset: AssetRepository },
|
||||
) => {
|
||||
const toDate = DateTime.fromJSDate(dto.to ?? new Date(), { zone: 'utc' }).startOf('day');
|
||||
const fromDate = (
|
||||
dto.from ? DateTime.fromJSDate(dto.from, { zone: 'utc' }) : toDate.minus({ weeks: 52 }).plus({ days: 1 })
|
||||
).startOf('day');
|
||||
|
||||
const counts = await repos.asset.getCalendarHeatmap(userId, {
|
||||
from: fromDate.toJSDate(),
|
||||
to: toDate.plus({ days: 1 }).toJSDate(),
|
||||
type: dto.type ?? CalendarHeatmapType.Upload,
|
||||
});
|
||||
const countsMap = new Map(counts.map((item) => [asDateString(item.date)!, item.count]));
|
||||
|
||||
const series: Array<{ date: string; count: number }> = [];
|
||||
for (let date = fromDate; date <= toDate; date = date.plus({ days: 1 })) {
|
||||
const key = date.toISODate()!;
|
||||
series.push({ date: key, count: countsMap.get(key) ?? 0 });
|
||||
}
|
||||
|
||||
return {
|
||||
from: fromDate.toISODate()!,
|
||||
to: toDate.toISODate()!,
|
||||
series,
|
||||
totalCount: series.reduce((totalCount, item) => totalCount + item.count, 0),
|
||||
};
|
||||
};
|
||||
@@ -68,6 +68,7 @@ export const SYNC_TYPES_ORDER = [
|
||||
SyncRequestType.AlbumToAssetsV1,
|
||||
SyncRequestType.AssetExifsV1,
|
||||
SyncRequestType.AlbumAssetExifsV1,
|
||||
SyncRequestType.AssetOcrV1,
|
||||
SyncRequestType.PartnerAssetExifsV1,
|
||||
SyncRequestType.MemoriesV1,
|
||||
SyncRequestType.MemoryToAssetsV1,
|
||||
@@ -188,6 +189,7 @@ export class SyncService extends BaseService {
|
||||
[SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetFacesV2]: () => this.syncAssetFacesV2(options, response, checkpointMap),
|
||||
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetOcrV1]: () => this.syncAssetOcrV1(options, response, checkpointMap, auth),
|
||||
} as const;
|
||||
|
||||
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
|
||||
@@ -218,6 +220,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) {
|
||||
@@ -888,6 +891,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([
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/com
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { CalendarHeatmapDto, CalendarHeatmapResponseDto } from 'src/dtos/calendar-heatmap.dto';
|
||||
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { JobName, UserMetadataKey, UserStatus } from 'src/enum';
|
||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getCalendarHeatmap } from 'src/services/shared/user-methods';
|
||||
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
||||
|
||||
@Injectable()
|
||||
@@ -122,6 +124,11 @@ export class UserAdminService extends BaseService {
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
async getCalendarHeatmap(auth: AuthDto, id: string, dto: CalendarHeatmapDto): Promise<CalendarHeatmapResponseDto> {
|
||||
await this.findOrFail(id, { withDeleted: false });
|
||||
return getCalendarHeatmap(id, dto, { asset: this.assetRepository });
|
||||
}
|
||||
|
||||
async getSessions(auth: AuthDto, id: string): Promise<SessionResponseDto[]> {
|
||||
const sessions = await this.sessionRepository.getByUserId(id);
|
||||
return sessions.map((session) => mapSession(session));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { CalendarHeatmapDto, CalendarHeatmapResponseDto } from 'src/dtos/calendar-heatmap.dto';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||
@@ -15,6 +16,7 @@ import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getCalendarHeatmap } from 'src/services/shared/user-methods';
|
||||
import { JobOf, UserMetadataItem } from 'src/types';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -46,6 +48,10 @@ export class UserService extends BaseService {
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
getCalendarHeatmap(auth: AuthDto, dto: CalendarHeatmapDto): Promise<CalendarHeatmapResponseDto> {
|
||||
return getCalendarHeatmap(auth.user.id, dto, { asset: this.assetRepository });
|
||||
}
|
||||
|
||||
async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
|
||||
if (dto.email) {
|
||||
const duplicate = await this.userRepository.getByEmail(dto.email);
|
||||
|
||||
@@ -301,8 +301,8 @@ export function withTags(eb: ExpressionBuilder<DB, 'asset'>) {
|
||||
).as('tags');
|
||||
}
|
||||
|
||||
export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt) {
|
||||
return sql<O>`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
|
||||
export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt, size?: 'DAY' | 'MONTH') {
|
||||
return sql<O>`date_trunc(${sql.lit(size ?? 'MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
|
||||
}
|
||||
|
||||
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HttpException, StreamableFile } from '@nestjs/common';
|
||||
import { HttpException, NotFoundException, StreamableFile } from '@nestjs/common';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { access, constants } from 'node:fs/promises';
|
||||
import { basename, extname } from 'node:path';
|
||||
@@ -52,6 +52,9 @@ export const sendFile = async (
|
||||
|
||||
try {
|
||||
const file = await handler();
|
||||
|
||||
await access(file.path, constants.R_OK);
|
||||
|
||||
const cacheControlHeader = cacheControlHeaders[file.cacheControl];
|
||||
if (cacheControlHeader) {
|
||||
// set the header to Cache-Control
|
||||
@@ -63,8 +66,6 @@ export const sendFile = async (
|
||||
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
|
||||
}
|
||||
|
||||
await access(file.path, constants.R_OK);
|
||||
|
||||
return await _sendFile(file.path, { dotfiles: 'allow' });
|
||||
} catch (error: Error | any) {
|
||||
// ignore client-closed connection
|
||||
@@ -77,8 +78,7 @@ export const sendFile = async (
|
||||
logger.error(`Unable to send file: ${error}`, error.stack);
|
||||
}
|
||||
|
||||
res.header('Cache-Control', 'none');
|
||||
next(error);
|
||||
next(new NotFoundException());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ describe(OcrService.name, () => {
|
||||
assetId: asset.id,
|
||||
boxScore: 0.99,
|
||||
id: expect.any(String),
|
||||
updateId: expect.any(String),
|
||||
text: 'Test OCR',
|
||||
textScore: 0.95,
|
||||
isVisible: true,
|
||||
@@ -105,6 +106,7 @@ describe(OcrService.name, () => {
|
||||
assetId: asset.id,
|
||||
boxScore: 0.7,
|
||||
id: expect.any(String),
|
||||
updateId: expect.any(String),
|
||||
text: 'One',
|
||||
textScore: 0.9,
|
||||
isVisible: true,
|
||||
@@ -121,6 +123,7 @@ describe(OcrService.name, () => {
|
||||
assetId: asset.id,
|
||||
boxScore: 0.67,
|
||||
id: expect.any(String),
|
||||
updateId: expect.any(String),
|
||||
text: 'Two',
|
||||
textScore: 0.89,
|
||||
isVisible: true,
|
||||
@@ -137,6 +140,7 @@ describe(OcrService.name, () => {
|
||||
assetId: asset.id,
|
||||
boxScore: 0.65,
|
||||
id: expect.any(String),
|
||||
updateId: expect.any(String),
|
||||
text: 'Three',
|
||||
textScore: 0.88,
|
||||
isVisible: true,
|
||||
@@ -153,6 +157,7 @@ describe(OcrService.name, () => {
|
||||
assetId: asset.id,
|
||||
boxScore: 0.62,
|
||||
id: expect.any(String),
|
||||
updateId: expect.any(String),
|
||||
text: 'Four',
|
||||
textScore: 0.87,
|
||||
isVisible: true,
|
||||
@@ -169,6 +174,7 @@ describe(OcrService.name, () => {
|
||||
assetId: asset.id,
|
||||
boxScore: 0.6,
|
||||
id: expect.any(String),
|
||||
updateId: expect.any(String),
|
||||
text: 'Five',
|
||||
textScore: 0.86,
|
||||
isVisible: true,
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
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]);
|
||||
});
|
||||
|
||||
it('should update asset OCR visibility flag', 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 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: false,
|
||||
},
|
||||
],
|
||||
'Hello World',
|
||||
);
|
||||
|
||||
const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]);
|
||||
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.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: false,
|
||||
},
|
||||
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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
remove: vitest.fn(),
|
||||
findLivePhotoMatch: vitest.fn(),
|
||||
getStatistics: vitest.fn(),
|
||||
getCalendarHeatmap: vitest.fn(),
|
||||
getTimeBucket: vitest.fn(),
|
||||
getTimeBuckets: vitest.fn(),
|
||||
getAssetIdByCity: vitest.fn(),
|
||||
|
||||
@@ -201,6 +201,7 @@ const assetSidecarWriteFactory = () => {
|
||||
const assetOcrFactory = (
|
||||
ocr: {
|
||||
id?: string;
|
||||
updateId?: string;
|
||||
assetId?: string;
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
@@ -217,6 +218,7 @@ const assetOcrFactory = (
|
||||
} = {},
|
||||
) => ({
|
||||
id: newUuid(),
|
||||
updateId: newUuidV7(),
|
||||
assetId: newUuid(),
|
||||
x1: 0.1,
|
||||
y1: 0.2,
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
title: string;
|
||||
headerAction?: ActionItem;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { icon, title, headerAction, children }: Props = $props();
|
||||
const { icon, title, headerAction, class: className, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card color="secondary">
|
||||
<Card color="secondary" class={className}>
|
||||
<CardHeader>
|
||||
<div class="flex w-full items-center justify-between px-4 py-2">
|
||||
<div class="flex gap-2 text-primary">
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { CalendarHeatmapResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
data: CalendarHeatmapResponseDto;
|
||||
itemLabel: (item: { date: string; count: number }) => string;
|
||||
totalLabel: (count: number) => string;
|
||||
};
|
||||
|
||||
const { data, itemLabel, totalLabel }: Props = $props();
|
||||
|
||||
const { rows } = $derived.by(() => {
|
||||
const weeks = Array.from({ length: Math.ceil(data.series.length / 7) }, (_, index) =>
|
||||
data.series.slice(index * 7, index * 7 + 7),
|
||||
);
|
||||
|
||||
const rows = Array.from({ length: 7 }, (_, dayIndex) => weeks.map((week) => week[dayIndex]).filter(Boolean));
|
||||
const endDate = DateTime.fromISO(data.to, { zone: 'utc' });
|
||||
|
||||
const months = Array.from({ length: 4 }, (_, index) =>
|
||||
endDate.minus({ months: 11 - index * 4 }).toLocaleString({ month: 'short' }, { locale: $locale }),
|
||||
);
|
||||
|
||||
return { rows, months };
|
||||
});
|
||||
|
||||
const maxCount = $derived(Math.max(...data.series.map((item) => item.count), 0));
|
||||
|
||||
const itemColors = (count: number) => {
|
||||
if (count === 0 || maxCount === 0) {
|
||||
return 'bg-gray-200 dark:bg-gray-700';
|
||||
}
|
||||
|
||||
if (count <= Math.ceil(maxCount * 0.25)) {
|
||||
return 'bg-immich-primary/30';
|
||||
}
|
||||
|
||||
if (count <= Math.ceil(maxCount * 0.5)) {
|
||||
return 'bg-immich-primary/50';
|
||||
}
|
||||
|
||||
if (count <= Math.ceil(maxCount * 0.75)) {
|
||||
return 'bg-immich-primary/70';
|
||||
}
|
||||
|
||||
return 'bg-immich-primary';
|
||||
};
|
||||
|
||||
// const dayLabels = $derived([
|
||||
// '',
|
||||
// dayOfWeek('monday', { locale: $locale, style: 'short' }),
|
||||
// '',
|
||||
// dayOfWeek('wednesday', { locale: $locale, style: 'short' }),
|
||||
// '',
|
||||
// dayOfWeek('friday', { locale: $locale, style: 'short' }),
|
||||
// '',
|
||||
// ]);
|
||||
</script>
|
||||
|
||||
<div class="mt-4 w-full">
|
||||
<div class="relative w-full">
|
||||
<!-- TODO -->
|
||||
<!-- <div class="absolute top-4 left-0 flex flex-col gap-0.5">
|
||||
{#each dayLabels as dayLabel, i (i)}
|
||||
<div class="relative flex h-3 w-6 items-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{dayLabel}
|
||||
</div>
|
||||
{/each}
|
||||
</div> -->
|
||||
|
||||
<!-- <div class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
{#each getUploadActivityMonths() as month (month)}
|
||||
<div>{month}</div>
|
||||
{/each}
|
||||
</div> -->
|
||||
|
||||
<div class="grid grid-rows-7 gap-0.5">
|
||||
{#each rows as row, dayIndex (dayIndex)}
|
||||
<div class="grid grid-cols-52 gap-0.5">
|
||||
{#each row as day (day.date)}
|
||||
<div
|
||||
class="aspect-square w-full min-w-0 rounded-sm {itemColors(day.count)}"
|
||||
title={itemLabel({ date: day.date, count: day.count })}
|
||||
aria-label={itemLabel({ date: day.date, count: day.count })}
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{$t('less')}</span>
|
||||
<span class="size-3 rounded-sm bg-gray-200 dark:bg-gray-700"></span>
|
||||
<span class="size-3 rounded-sm bg-immich-primary/30"></span>
|
||||
<span class="size-3 rounded-sm bg-immich-primary/50"></span>
|
||||
<span class="size-3 rounded-sm bg-immich-primary/70"></span>
|
||||
<span class="size-3 rounded-sm bg-immich-primary"></span>
|
||||
<span>{$t('more')}</span>
|
||||
<span class="ml-4">{totalLabel(data.totalCount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,14 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cleanClass } from '$lib';
|
||||
|
||||
interface Props {
|
||||
height: number;
|
||||
title?: string;
|
||||
invisible?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { height = 0, title, invisible = false }: Props = $props();
|
||||
let { height = 0, title, invisible = false, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={['overflow-clip', { invisible }]} style:height={height + 'px'}>
|
||||
<div class={cleanClass('overflow-clip', invisible && 'invisible', className)} style:height={height + 'px'}>
|
||||
{#if title}
|
||||
<div
|
||||
class="flex h-6 place-items-center pt-7 pb-5 text-xs font-medium text-immich-fg max-md:pt-5 max-md:pb-3 md:text-sm dark:text-immich-dark-fg"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export const cleanClass = (...classNames: unknown[]) => {
|
||||
@@ -16,3 +17,10 @@ export const cleanClass = (...classNames: unknown[]) => {
|
||||
};
|
||||
|
||||
export const isDefined = <T>(value: T): value is NonNullable<T> => value !== null && value !== undefined;
|
||||
|
||||
export const getHeatmapRange = () => {
|
||||
const to = DateTime.utc().startOf('day');
|
||||
const from = to.minus({ weeks: 51 }).plus({ days: 1 });
|
||||
const fromSunday = from.minus({ days: from.weekday % 7 });
|
||||
return { $from: fromSunday.toISODate()!, to: to.toISODate()! };
|
||||
};
|
||||
|
||||
@@ -47,4 +47,39 @@ describe('Route', () => {
|
||||
expect(Route.systemSettings({ isOpen: OpenQueryParam.OAUTH })).toBe('/admin/system-settings?isOpen=oauth');
|
||||
});
|
||||
});
|
||||
|
||||
describe(Route.continue.name, () => {
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - override location for testing
|
||||
globalThis.location = new URL('https://my.immich.server');
|
||||
vi.spyOn(document, 'baseURI', 'get').mockReturnValue('https://my.immich.server/');
|
||||
});
|
||||
|
||||
it('should resolve relative URLs', () => {
|
||||
expect(Route.continue('/some/path', '/fallback')).property('href', 'https://my.immich.server/some/path');
|
||||
});
|
||||
|
||||
it('should resolve absolute URLs on the same origin', () => {
|
||||
expect(Route.continue('https://my.immich.server/some/path', '/fallback')).property(
|
||||
'href',
|
||||
'https://my.immich.server/some/path',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return fallback for absolute URLs on a different origin', () => {
|
||||
expect(Route.continue('https://malicious.site/evil', '/fallback')).toBe('/fallback');
|
||||
});
|
||||
|
||||
it('should return fallback for null URLs', () => {
|
||||
expect(Route.continue(null, '/fallback')).property('href', 'https://my.immich.server/fallback');
|
||||
});
|
||||
|
||||
it('should block javascript: URLs', () => {
|
||||
expect(Route.continue('javascript:alert(1)', '/fallback')).toBe('/fallback');
|
||||
});
|
||||
|
||||
it(String.raw`should block \/ URLs`, () => {
|
||||
expect(Route.continue(String.raw`\/malicious.com`, '/fallback')).toBe('/fallback');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,11 +154,13 @@ export const Route = {
|
||||
viewQueue: ({ name }: { name: QueueName }) => `/admin/queues/${asQueueSlug(name)}`,
|
||||
|
||||
// continue helper for ensuring same-origin URLs
|
||||
continue: (url: string | null, fallback: string) => {
|
||||
if (!url || !url.startsWith('/') || url.startsWith('//')) {
|
||||
continue: (url: string | null, fallback: string): string | URL => {
|
||||
const resolved = new URL(url ?? fallback, document.baseURI);
|
||||
|
||||
if (resolved.origin !== location.origin) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return url;
|
||||
return resolved;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -78,3 +78,12 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string
|
||||
*/
|
||||
export const asLocalTimeISO = (date: DateTime<true>) =>
|
||||
(date.setZone('utc', { keepLocalTime: true }) as DateTime<true>).toISO();
|
||||
|
||||
const days = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
||||
|
||||
type DayOfWeek = (typeof days)[number];
|
||||
export const dayOfWeek = (day: DayOfWeek, options?: { locale?: string; style?: 'long' | 'short' | 'narrow' }) => {
|
||||
const fmt = new Intl.DateTimeFormat(options?.locale, { weekday: options?.style ?? 'long', timeZone: 'UTC' });
|
||||
// 2021-08-01 is a Sunday
|
||||
return fmt.format(new Date(Date.UTC(2021, 7, 1 + days.indexOf(day))));
|
||||
};
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { getHeatmapRange } from '$lib';
|
||||
import CalendarHeatmap from '$lib/components/CalendarHeatmap.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
AssetVisibility,
|
||||
CalendarHeatmapType,
|
||||
getAlbumStatistics,
|
||||
getAssetStatistics,
|
||||
getMyCalendarHeatmap,
|
||||
type AlbumStatisticsResponseDto,
|
||||
type AssetStatsResponseDto,
|
||||
} from '@immich/sdk';
|
||||
@@ -11,6 +16,8 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
// always start on Sunday
|
||||
|
||||
let timelineStats: AssetStatsResponseDto = $state({
|
||||
videos: 0,
|
||||
images: 0,
|
||||
@@ -95,4 +102,28 @@
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div class="hidden lg:block">
|
||||
<Heading size="tiny" class="mt-8">{$t('uploads')}</Heading>
|
||||
{#await getMyCalendarHeatmap({ ...getHeatmapRange(), $type: CalendarHeatmapType.Upload })}
|
||||
<Skeleton height={80} class="mt-2 rounded-lg" />
|
||||
{:then data}
|
||||
<CalendarHeatmap
|
||||
{data}
|
||||
itemLabel={(item) => $t('upload_day_count', { values: item })}
|
||||
totalLabel={(count) => $t('uploads_count', { values: { count } })}
|
||||
/>
|
||||
{/await}
|
||||
|
||||
<Heading size="tiny" class="mt-8">{$t('assets')}</Heading>
|
||||
{#await getMyCalendarHeatmap({ ...getHeatmapRange(), $type: CalendarHeatmapType.Taken })}
|
||||
<Skeleton height={80} class="mt-2 rounded-lg" />
|
||||
{:then data}
|
||||
<CalendarHeatmap
|
||||
{data}
|
||||
itemLabel={(item) => $t('asset_day_count', { values: item })}
|
||||
totalLabel={(count) => $t('assets_count', { values: { count } })}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { CalendarHeatmapType, getUserCalendarHeatmapAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
CardTitle,
|
||||
Code,
|
||||
CommandPaletteDefaultProvider,
|
||||
Container,
|
||||
@@ -33,6 +34,7 @@
|
||||
mdiChartPie,
|
||||
mdiChartPieOutline,
|
||||
mdiCheckCircle,
|
||||
mdiCloudUploadOutline,
|
||||
mdiDevices,
|
||||
mdiFeatureSearchOutline,
|
||||
mdiPlayCircle,
|
||||
@@ -41,6 +43,9 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { LayoutData } from './$types';
|
||||
import { getHeatmapRange } from '$lib';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import CalendarHeatmap from '$lib/components/CalendarHeatmap.svelte';
|
||||
|
||||
type Props = {
|
||||
children?: Snippet;
|
||||
@@ -205,6 +210,23 @@
|
||||
{/each}
|
||||
</Stack>
|
||||
</AdminCard>
|
||||
|
||||
<div class="col-span-2 px-4 py-2">
|
||||
<div class="flex gap-2 text-primary">
|
||||
<Icon icon={mdiCloudUploadOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('uploads')}</CardTitle>
|
||||
</div>
|
||||
{#await getUserCalendarHeatmapAdmin({ ...getHeatmapRange(), id: user.id, $type: CalendarHeatmapType.Upload })}
|
||||
<Skeleton height={80} class="mt-2 rounded-lg" />
|
||||
{:then data}
|
||||
<CalendarHeatmap
|
||||
{data}
|
||||
itemLabel={(item) => $t('upload_day_count', { values: item })}
|
||||
totalLabel={(count) => $t('uploads_count', { values: { count } })}
|
||||
/>
|
||||
{/await}
|
||||
</div>
|
||||
<!-- </AdminCard> -->
|
||||
</div>
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
Reference in New Issue
Block a user