diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 354176f12..9835dc13d 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -262,5 +262,11 @@ "version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", - "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89" + "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_tile_subtitle": "Advanced user's settings", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "description_input_submit_error": "Error updating description, check the log for more details", + "description_input_hint_text": "Add description..." } \ No newline at end of file diff --git a/mobile/flutter_01.png b/mobile/flutter_01.png new file mode 100644 index 000000000..8fd3f8814 Binary files /dev/null and b/mobile/flutter_01.png differ diff --git a/mobile/lib/modules/asset_viewer/providers/asset_description.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_description.provider.dart new file mode 100644 index 000000000..9ac68761a --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_description.provider.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_description.service.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +class AssetDescriptionNotifier extends StateNotifier { + final Isar _db; + final AssetDescriptionService _service; + final Asset _asset; + + AssetDescriptionNotifier( + this._db, + this._service, + this._asset, + ) : super('') { + _fetchLocalDescription(); + _fetchRemoteDescription(); + } + + String get description => state; + + /// Fetches the local database value for description + /// and writes it to [state] + void _fetchLocalDescription() async { + final localExifId = _asset.exifInfo?.id; + + // Guard [localExifId] null + if (localExifId == null) { + return; + } + + // Subscribe to local changes + final exifInfo = await _db + .exifInfos + .get(localExifId); + + // Guard + if (exifInfo?.description == null) { + return; + } + + state = exifInfo!.description!; + } + + /// Fetches the remote value and sets the state + void _fetchRemoteDescription() async { + final remoteAssetId = _asset.remoteId; + final localExifId = _asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (remoteAssetId == null || localExifId == null) { + return; + } + + // Reads the latest from the remote and writes it to DB in the service + final latest = await _service.readLatest(remoteAssetId, localExifId); + + state = latest; + } + + /// Sets the description to [description] + /// Uses the service to set the asset value + Future setDescription(String description) async { + state = description; + + final remoteAssetId = _asset.remoteId; + final localExifId = _asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (remoteAssetId == null || localExifId == null) { + return; + } + + return _service + .setDescription(description, remoteAssetId, localExifId); + } +} + +final assetDescriptionProvider = StateNotifierProvider + .autoDispose + .family( + (ref, asset) => AssetDescriptionNotifier( + ref.watch(dbProvider), + ref.watch(assetDescriptionServiceProvider), + asset, + ), +); + + diff --git a/mobile/lib/modules/asset_viewer/services/asset_description.service.dart b/mobile/lib/modules/asset_viewer/services/asset_description.service.dart new file mode 100644 index 000000000..9abf69a93 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/services/asset_description.service.dart @@ -0,0 +1,62 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/exif_info.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:isar/isar.dart'; +import 'package:openapi/api.dart'; + +class AssetDescriptionService { + AssetDescriptionService(this._db, this._api); + + final Isar _db; + final ApiService _api; + + setDescription( + String description, + String remoteAssetId, + int localExifId, + ) async { + final result = await _api.assetApi.updateAsset( + remoteAssetId, + UpdateAssetDto(description: description), + ); + + if (result?.exifInfo?.description != null) { + var exifInfo = await _db.exifInfos.get(localExifId); + + if (exifInfo != null) { + exifInfo.description = result!.exifInfo!.description; + await _db.writeTxn( + () => _db.exifInfos.put(exifInfo), + ); + } + } + } + + Future readLatest(String assetRemoteId, int localExifId) async { + final latestAssetFromServer = + await _api.assetApi.getAssetById(assetRemoteId); + final localExifInfo = await _db.exifInfos.get(localExifId); + + if (latestAssetFromServer != null && localExifInfo != null) { + localExifInfo.description = + latestAssetFromServer.exifInfo?.description ?? ''; + + await _db.writeTxn( + () => _db.exifInfos.put(localExifInfo), + ); + + return localExifInfo.description!; + } + + return ""; + } +} + +final assetDescriptionServiceProvider = Provider( + (ref) => AssetDescriptionService( + ref.watch(dbProvider), + ref.watch(apiServiceProvider), + ), +); diff --git a/mobile/lib/modules/asset_viewer/ui/description_input.dart b/mobile/lib/modules/asset_viewer/ui/description_input.dart new file mode 100644 index 000000000..9b54633f2 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/ui/description_input.dart @@ -0,0 +1,103 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_description.provider.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:logging/logging.dart'; +import 'package:immich_mobile/shared/models/store.dart' as store; + +class DescriptionInput extends HookConsumerWidget { + DescriptionInput({ + super.key, + required this.asset, + }); + + final Asset asset; + final Logger _log = Logger('DescriptionInput'); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDarkTheme = Theme.of(context).brightness == Brightness.dark; + final textColor = isDarkTheme ? Colors.white : Colors.black; + final controller = useTextEditingController(); + final focusNode = useFocusNode(); + final isFocus = useState(false); + final isTextEmpty = useState(controller.text.isEmpty); + final descriptionProvider = ref.watch(assetDescriptionProvider(asset).notifier); + final description = ref.watch(assetDescriptionProvider(asset)); + final owner = store.Store.get(store.StoreKey.currentUser); + final hasError = useState(false); + + controller.text = description; + + submitDescription(String description) async { + hasError.value = false; + try { + await descriptionProvider.setDescription( + description, + ); + } catch (error, stack) { + hasError.value = true; + _log.severe("Error updating description $error", error, stack); + ImmichToast.show( + context: context, + msg: "description_input_submit_error".tr(), + toastType: ToastType.error, + ); + } + } + + Widget? suffixIcon; + if (hasError.value) { + suffixIcon = const Icon(Icons.warning_outlined); + } else if (!isTextEmpty.value && isFocus.value) { + suffixIcon = IconButton( + onPressed: () { + controller.clear(); + isTextEmpty.value = true; + }, + icon: Icon( + Icons.cancel_rounded, + color: Colors.grey[500], + ), + splashRadius: 10, + ); + } + + return TextField( + enabled: owner.isarId == asset.ownerId, + focusNode: focusNode, + onTap: () => isFocus.value = true, + onChanged: (value) { + isTextEmpty.value = false; + }, + onTapOutside: (a) async { + isFocus.value = false; + focusNode.unfocus(); + + if (description != controller.text) { + await submitDescription(controller.text); + } + }, + autofocus: false, + maxLines: null, + keyboardType: TextInputType.multiline, + controller: controller, + style: const TextStyle( + fontSize: 14, + ), + decoration: InputDecoration( + hintText: 'description_input_hint_text'.tr(), + border: InputBorder.none, + hintStyle: TextStyle( + fontWeight: FontWeight.normal, + fontSize: 12, + color: textColor.withOpacity(0.5), + ), + suffixIcon: suffixIcon, + ), + ); + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index cd18b2fa4..428ea6975 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -2,25 +2,25 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:latlong2/latlong.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; class ExifBottomSheet extends HookConsumerWidget { - final Asset assetDetail; + final Asset asset; - const ExifBottomSheet({Key? key, required this.assetDetail}) - : super(key: key); + const ExifBottomSheet({Key? key, required this.asset}) : super(key: key); bool get showMap => - assetDetail.exifInfo?.latitude != null && - assetDetail.exifInfo?.longitude != null; + asset.exifInfo?.latitude != null && asset.exifInfo?.longitude != null; @override Widget build(BuildContext context, WidgetRef ref) { - final ExifInfo? exifInfo = assetDetail.exifInfo; + final exifInfo = asset.exifInfo; + var isDarkTheme = Theme.of(context).brightness == Brightness.dark; + var textColor = isDarkTheme ? Colors.white : Colors.black; buildMap() { return Padding( @@ -76,19 +76,6 @@ class ExifBottomSheet extends HookConsumerWidget { ); } - final textColor = Theme.of(context).primaryColor; - - buildLocationText() { - return Text( - "${exifInfo?.city}, ${exifInfo?.state}", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: textColor, - ), - ); - } - buildSizeText(Asset a) { String resolution = a.width != null && a.height != null ? "${a.height} x ${a.width} " @@ -128,13 +115,39 @@ class ExifBottomSheet extends HookConsumerWidget { children: [ Text( "exif_bottom_sheet_location", - style: TextStyle(fontSize: 11, color: textColor), + style: TextStyle( + fontSize: 11, + color: textColor, + fontWeight: FontWeight.bold, + ), ).tr(), buildMap(), - if (exifInfo != null && - exifInfo.city != null && - exifInfo.state != null) - buildLocationText(), + RichText( + text: TextSpan( + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: textColor, + fontFamily: 'WorkSans', + ), + children: [ + if (exifInfo != null && exifInfo.city != null) + TextSpan( + text: exifInfo.city, + ), + if (exifInfo != null && + exifInfo.city != null && + exifInfo.state != null) + const TextSpan( + text: ", ", + ), + if (exifInfo != null && exifInfo.state != null) + TextSpan( + text: "${exifInfo.state}", + ), + ], + ), + ), Text( "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}", style: const TextStyle(fontSize: 12), @@ -146,7 +159,7 @@ class ExifBottomSheet extends HookConsumerWidget { } buildDate() { - final fileCreatedAt = assetDetail.fileCreatedAt.toLocal(); + final fileCreatedAt = asset.fileCreatedAt.toLocal(); final date = DateFormat.yMMMEd().format(fileCreatedAt); final time = DateFormat.jm().format(fileCreatedAt); @@ -167,27 +180,37 @@ class ExifBottomSheet extends HookConsumerWidget { padding: const EdgeInsets.only(bottom: 8.0), child: Text( "exif_bottom_sheet_details", - style: TextStyle(fontSize: 11, color: textColor), + style: TextStyle( + fontSize: 11, + color: textColor, + fontWeight: FontWeight.bold, + ), ).tr(), ), ListTile( contentPadding: const EdgeInsets.all(0), dense: true, - leading: const Icon(Icons.image), + leading: Icon( + Icons.image, + color: textColor.withAlpha(200), + ), title: Text( - assetDetail.fileName, + asset.fileName, style: TextStyle( fontWeight: FontWeight.bold, color: textColor, ), ), - subtitle: buildSizeText(assetDetail), + subtitle: buildSizeText(asset), ), if (exifInfo?.make != null) ListTile( contentPadding: const EdgeInsets.all(0), dense: true, - leading: const Icon(Icons.camera), + leading: Icon( + Icons.camera, + color: textColor.withAlpha(200), + ), title: Text( "${exifInfo!.make} ${exifInfo.model}", style: TextStyle( @@ -203,80 +226,75 @@ class ExifBottomSheet extends HookConsumerWidget { ); } - return SingleChildScrollView( - child: Card( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(15), - topRight: Radius.circular(15), + return GestureDetector( + onTap: () { + // FocusScope.of(context).unfocus(); + }, + child: SingleChildScrollView( + child: Card( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), + topRight: Radius.circular(15), + ), ), - ), - margin: const EdgeInsets.all(0), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 8.0), - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth > 600) { - // Two column - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildDragHeader(), - buildDate(), - const SizedBox(height: 32.0), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - flex: showMap ? 5 : 0, - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: buildLocation(), + margin: const EdgeInsets.all(0), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16.0), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 600) { + // Two column + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildDragHeader(), + buildDate(), + if (asset.isRemote) DescriptionInput(asset: asset), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + flex: showMap ? 5 : 0, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: buildLocation(), + ), ), - ), - Flexible( - flex: 5, - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: buildDetail(), + Flexible( + flex: 5, + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: buildDetail(), + ), ), - ), - ], - ), - const SizedBox(height: 50), - ], - ), - ); - } - - // One column - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildDragHeader(), - buildDate(), - const SizedBox(height: 16.0), - if (showMap) - Divider( - thickness: 1, - color: Colors.grey[600], + ], + ), + const SizedBox(height: 50), + ], ), - const SizedBox(height: 16.0), - buildLocation(), - const SizedBox(height: 16.0), - Divider( - thickness: 1, - color: Colors.grey[600], - ), - const SizedBox(height: 16.0), - buildDetail(), - const SizedBox(height: 50), - ], - ); - }, + ); + } + + // One column + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildDragHeader(), + buildDate(), + if (asset.isRemote) DescriptionInput(asset: asset), + const SizedBox(height: 8.0), + buildLocation(), + SizedBox(height: showMap ? 16.0 : 0.0), + buildDetail(), + const SizedBox(height: 50), + ], + ); + }, + ), ), ), ), diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 535ad968f..41e704d13 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -195,7 +195,12 @@ class GalleryViewerPage extends HookConsumerWidget { .getSetting(AppSettingsEnum.advancedTroubleshooting)) { return AdvancedBottomSheet(assetDetail: assetDetail!); } - return ExifBottomSheet(assetDetail: assetDetail!); + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: ExifBottomSheet(asset: assetDetail!), + ); }, ); } diff --git a/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart b/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart index 190f98c82..ae9f6c96b 100644 --- a/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart +++ b/mobile/lib/modules/settings/ui/advanced_settings/advanced_settings.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -25,19 +26,25 @@ class AdvancedSettings extends HookConsumerWidget { return ExpansionTile( textColor: Theme.of(context).primaryColor, title: const Text( - "Advanced", + "advanced_settings_tile_title", style: TextStyle( fontWeight: FontWeight.bold, ), - ), + ).tr(), + subtitle: const Text( + "advanced_settings_tile_subtitle", + style: TextStyle( + fontSize: 13, + ), + ).tr(), children: [ SettingsSwitchListTile( enabled: true, appSettingService: appSettingService, valueNotifier: isEnabled, settingsEnum: AppSettingsEnum.advancedTroubleshooting, - title: "Troubleshooting", - subtitle: "Enable additional features for troubleshooting", + title: "advanced_settings_troubleshooting_title".tr(), + subtitle: "advanced_settings_troubleshooting_subtitle".tr(), ), ], ); diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart index 0fd20aaf3..29cc913c2 100644 --- a/mobile/lib/shared/models/exif_info.dart +++ b/mobile/lib/shared/models/exif_info.dart @@ -21,6 +21,7 @@ class ExifInfo { String? city; String? state; String? country; + String? description; @ignore String get exposureTime { @@ -58,7 +59,8 @@ class ExifInfo { long = dto.longitude?.toDouble(), city = dto.city, state = dto.state, - country = dto.country; + country = dto.country, + description = dto.description; ExifInfo({ this.fileSize, @@ -74,6 +76,7 @@ class ExifInfo { this.city, this.state, this.country, + this.description, }); } diff --git a/mobile/lib/shared/models/exif_info.g.dart b/mobile/lib/shared/models/exif_info.g.dart index 228e40b70..825a3e5a0 100644 --- a/mobile/lib/shared/models/exif_info.g.dart +++ b/mobile/lib/shared/models/exif_info.g.dart @@ -27,58 +27,63 @@ const ExifInfoSchema = CollectionSchema( name: r'country', type: IsarType.string, ), - r'exposureSeconds': PropertySchema( + r'description': PropertySchema( id: 2, + name: r'description', + type: IsarType.string, + ), + r'exposureSeconds': PropertySchema( + id: 3, name: r'exposureSeconds', type: IsarType.float, ), r'f': PropertySchema( - id: 3, + id: 4, name: r'f', type: IsarType.float, ), r'fileSize': PropertySchema( - id: 4, + id: 5, name: r'fileSize', type: IsarType.long, ), r'iso': PropertySchema( - id: 5, + id: 6, name: r'iso', type: IsarType.int, ), r'lat': PropertySchema( - id: 6, + id: 7, name: r'lat', type: IsarType.float, ), r'lens': PropertySchema( - id: 7, + id: 8, name: r'lens', type: IsarType.string, ), r'long': PropertySchema( - id: 8, + id: 9, name: r'long', type: IsarType.float, ), r'make': PropertySchema( - id: 9, + id: 10, name: r'make', type: IsarType.string, ), r'mm': PropertySchema( - id: 10, + id: 11, name: r'mm', type: IsarType.float, ), r'model': PropertySchema( - id: 11, + id: 12, name: r'model', type: IsarType.string, ), r'state': PropertySchema( - id: 12, + id: 13, name: r'state', type: IsarType.string, ) @@ -115,6 +120,12 @@ int _exifInfoEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.description; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } { final value = object.lens; if (value != null) { @@ -150,17 +161,18 @@ void _exifInfoSerialize( ) { writer.writeString(offsets[0], object.city); writer.writeString(offsets[1], object.country); - writer.writeFloat(offsets[2], object.exposureSeconds); - writer.writeFloat(offsets[3], object.f); - writer.writeLong(offsets[4], object.fileSize); - writer.writeInt(offsets[5], object.iso); - writer.writeFloat(offsets[6], object.lat); - writer.writeString(offsets[7], object.lens); - writer.writeFloat(offsets[8], object.long); - writer.writeString(offsets[9], object.make); - writer.writeFloat(offsets[10], object.mm); - writer.writeString(offsets[11], object.model); - writer.writeString(offsets[12], object.state); + writer.writeString(offsets[2], object.description); + writer.writeFloat(offsets[3], object.exposureSeconds); + writer.writeFloat(offsets[4], object.f); + writer.writeLong(offsets[5], object.fileSize); + writer.writeInt(offsets[6], object.iso); + writer.writeFloat(offsets[7], object.lat); + writer.writeString(offsets[8], object.lens); + writer.writeFloat(offsets[9], object.long); + writer.writeString(offsets[10], object.make); + writer.writeFloat(offsets[11], object.mm); + writer.writeString(offsets[12], object.model); + writer.writeString(offsets[13], object.state); } ExifInfo _exifInfoDeserialize( @@ -172,17 +184,18 @@ ExifInfo _exifInfoDeserialize( final object = ExifInfo( city: reader.readStringOrNull(offsets[0]), country: reader.readStringOrNull(offsets[1]), - exposureSeconds: reader.readFloatOrNull(offsets[2]), - f: reader.readFloatOrNull(offsets[3]), - fileSize: reader.readLongOrNull(offsets[4]), - iso: reader.readIntOrNull(offsets[5]), - lat: reader.readFloatOrNull(offsets[6]), - lens: reader.readStringOrNull(offsets[7]), - long: reader.readFloatOrNull(offsets[8]), - make: reader.readStringOrNull(offsets[9]), - mm: reader.readFloatOrNull(offsets[10]), - model: reader.readStringOrNull(offsets[11]), - state: reader.readStringOrNull(offsets[12]), + description: reader.readStringOrNull(offsets[2]), + exposureSeconds: reader.readFloatOrNull(offsets[3]), + f: reader.readFloatOrNull(offsets[4]), + fileSize: reader.readLongOrNull(offsets[5]), + iso: reader.readIntOrNull(offsets[6]), + lat: reader.readFloatOrNull(offsets[7]), + lens: reader.readStringOrNull(offsets[8]), + long: reader.readFloatOrNull(offsets[9]), + make: reader.readStringOrNull(offsets[10]), + mm: reader.readFloatOrNull(offsets[11]), + model: reader.readStringOrNull(offsets[12]), + state: reader.readStringOrNull(offsets[13]), ); object.id = id; return object; @@ -200,27 +213,29 @@ P _exifInfoDeserializeProp

( case 1: return (reader.readStringOrNull(offset)) as P; case 2: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 3: return (reader.readFloatOrNull(offset)) as P; case 4: - return (reader.readLongOrNull(offset)) as P; + return (reader.readFloatOrNull(offset)) as P; case 5: - return (reader.readIntOrNull(offset)) as P; + return (reader.readLongOrNull(offset)) as P; case 6: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readIntOrNull(offset)) as P; case 7: - return (reader.readStringOrNull(offset)) as P; + return (reader.readFloatOrNull(offset)) as P; case 8: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 9: - return (reader.readStringOrNull(offset)) as P; - case 10: return (reader.readFloatOrNull(offset)) as P; - case 11: + case 10: return (reader.readStringOrNull(offset)) as P; + case 11: + return (reader.readFloatOrNull(offset)) as P; case 12: return (reader.readStringOrNull(offset)) as P; + case 13: + return (reader.readStringOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -607,6 +622,155 @@ extension ExifInfoQueryFilter }); } + QueryBuilder descriptionIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'description', + )); + }); + } + + QueryBuilder + descriptionIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'description', + )); + }); + } + + QueryBuilder descriptionEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + descriptionGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'description', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'description', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'description', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder descriptionIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'description', + value: '', + )); + }); + } + + QueryBuilder + descriptionIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'description', + value: '', + )); + }); + } + QueryBuilder exposureSecondsIsNull() { return QueryBuilder.apply(this, (query) { @@ -1825,6 +1989,18 @@ extension ExifInfoQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByDescription() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'description', Sort.asc); + }); + } + + QueryBuilder sortByDescriptionDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'description', Sort.desc); + }); + } + QueryBuilder sortByExposureSeconds() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'exposureSeconds', Sort.asc); @@ -1984,6 +2160,18 @@ extension ExifInfoQuerySortThenBy }); } + QueryBuilder thenByDescription() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'description', Sort.asc); + }); + } + + QueryBuilder thenByDescriptionDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'description', Sort.desc); + }); + } + QueryBuilder thenByExposureSeconds() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'exposureSeconds', Sort.asc); @@ -2145,6 +2333,13 @@ extension ExifInfoQueryWhereDistinct }); } + QueryBuilder distinctByDescription( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'description', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByExposureSeconds() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'exposureSeconds'); @@ -2236,6 +2431,12 @@ extension ExifInfoQueryProperty }); } + QueryBuilder descriptionProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'description'); + }); + } + QueryBuilder exposureSecondsProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'exposureSeconds'); diff --git a/mobile/openapi/doc/ExifResponseDto.md b/mobile/openapi/doc/ExifResponseDto.md index 1fb483d63..dd4b3b4f9 100644 --- a/mobile/openapi/doc/ExifResponseDto.md +++ b/mobile/openapi/doc/ExifResponseDto.md @@ -27,6 +27,7 @@ Name | Type | Description | Notes **city** | **String** | | [optional] **state** | **String** | | [optional] **country** | **String** | | [optional] +**description** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/UpdateAssetDto.md b/mobile/openapi/doc/UpdateAssetDto.md index 4b844a54f..bc08c8175 100644 --- a/mobile/openapi/doc/UpdateAssetDto.md +++ b/mobile/openapi/doc/UpdateAssetDto.md @@ -11,6 +11,7 @@ Name | Type | Description | Notes **tagIds** | **List** | | [optional] [default to const []] **isFavorite** | **bool** | | [optional] **isArchived** | **bool** | | [optional] +**description** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 0c3ec0746..fceec66d3 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -32,6 +32,7 @@ class ExifResponseDto { this.city, this.state, this.country, + this.description, }); int? fileSizeInByte; @@ -72,6 +73,8 @@ class ExifResponseDto { String? country; + String? description; + @override bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto && other.fileSizeInByte == fileSizeInByte && @@ -92,7 +95,8 @@ class ExifResponseDto { other.longitude == longitude && other.city == city && other.state == state && - other.country == country; + other.country == country && + other.description == description; @override int get hashCode => @@ -115,10 +119,11 @@ class ExifResponseDto { (longitude == null ? 0 : longitude!.hashCode) + (city == null ? 0 : city!.hashCode) + (state == null ? 0 : state!.hashCode) + - (country == null ? 0 : country!.hashCode); + (country == null ? 0 : country!.hashCode) + + (description == null ? 0 : description!.hashCode); @override - String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country]'; + String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country, description=$description]'; Map toJson() { final json = {}; @@ -217,6 +222,11 @@ class ExifResponseDto { } else { // json[r'country'] = null; } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } return json; } @@ -272,6 +282,7 @@ class ExifResponseDto { city: mapValueOfType(json, r'city'), state: mapValueOfType(json, r'state'), country: mapValueOfType(json, r'country'), + description: mapValueOfType(json, r'description'), ); } return null; diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 2faee2f7c..c24a98dc3 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -16,6 +16,7 @@ class UpdateAssetDto { this.tagIds = const [], this.isFavorite, this.isArchived, + this.description, }); List tagIds; @@ -36,21 +37,31 @@ class UpdateAssetDto { /// bool? isArchived; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + @override bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto && other.tagIds == tagIds && other.isFavorite == isFavorite && - other.isArchived == isArchived; + other.isArchived == isArchived && + other.description == description; @override int get hashCode => // ignore: unnecessary_parenthesis (tagIds.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + - (isArchived == null ? 0 : isArchived!.hashCode); + (isArchived == null ? 0 : isArchived!.hashCode) + + (description == null ? 0 : description!.hashCode); @override - String toString() => 'UpdateAssetDto[tagIds=$tagIds, isFavorite=$isFavorite, isArchived=$isArchived]'; + String toString() => 'UpdateAssetDto[tagIds=$tagIds, isFavorite=$isFavorite, isArchived=$isArchived, description=$description]'; Map toJson() { final json = {}; @@ -65,6 +76,11 @@ class UpdateAssetDto { } else { // json[r'isArchived'] = null; } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } return json; } @@ -92,6 +108,7 @@ class UpdateAssetDto { : const [], isFavorite: mapValueOfType(json, r'isFavorite'), isArchived: mapValueOfType(json, r'isArchived'), + description: mapValueOfType(json, r'description'), ); } return null; diff --git a/mobile/openapi/test/exif_response_dto_test.dart b/mobile/openapi/test/exif_response_dto_test.dart index 138bff961..9918892d3 100644 --- a/mobile/openapi/test/exif_response_dto_test.dart +++ b/mobile/openapi/test/exif_response_dto_test.dart @@ -111,6 +111,11 @@ void main() { // TODO }); + // String description + test('to test the property `description`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/update_asset_dto_test.dart b/mobile/openapi/test/update_asset_dto_test.dart index 7f9d874af..94dcb27fc 100644 --- a/mobile/openapi/test/update_asset_dto_test.dart +++ b/mobile/openapi/test/update_asset_dto_test.dart @@ -31,6 +31,11 @@ void main() { // TODO }); + // String description + test('to test the property `description`', () async { + // TODO + }); + }); diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index 4bd1743da..f01d35119 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -1,6 +1,6 @@ import { SearchPropertiesDto } from './dto/search-properties.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; -import { AssetEntity, AssetType } from '@app/infra/entities'; +import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm/repository/Repository'; @@ -55,6 +55,7 @@ export class AssetRepository implements IAssetRepository { private assetRepository: Repository, @Inject(ITagRepository) private _tagRepository: ITagRepository, + @InjectRepository(ExifEntity) private exifRepository: Repository, ) {} async getAllVideos(): Promise { @@ -268,6 +269,17 @@ export class AssetRepository implements IAssetRepository { asset.tags = tags; } + if (asset.exifInfo != null) { + asset.exifInfo.description = dto.description || ''; + await this.exifRepository.save(asset.exifInfo); + } else { + const exifInfo = new ExifEntity(); + exifInfo.description = dto.description || ''; + exifInfo.asset = asset; + await this.exifRepository.save(exifInfo); + asset.exifInfo = exifInfo; + } + return await this.assetRepository.save(asset); } diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index 48e7d6502..ebadeca75 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/server/apps/immich/src/api-v1/asset/asset.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { AssetService } from './asset.service'; import { AssetController } from './asset.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AssetEntity } from '@app/infra/entities'; +import { AssetEntity, ExifEntity } from '@app/infra/entities'; import { AssetRepository, IAssetRepository } from './asset-repository'; import { DownloadModule } from '../../modules/download/download.module'; import { TagModule } from '../tag/tag.module'; @@ -16,7 +16,7 @@ const ASSET_REPOSITORY_PROVIDER = { @Module({ imports: [ // - TypeOrmModule.forFeature([AssetEntity]), + TypeOrmModule.forFeature([AssetEntity, ExifEntity]), DownloadModule, TagModule, AlbumModule, diff --git a/server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts b/server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts index 5b17b043f..c180c256b 100644 --- a/server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts +++ b/server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts @@ -25,4 +25,8 @@ export class UpdateAssetDto { ], }) tagIds?: string[]; + + @IsOptional() + @IsString() + description?: string; } diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 76f882088..12504e2bc 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -223,7 +223,6 @@ export class MetadataExtractionProcessor { const newExif = new ExifEntity(); newExif.assetId = asset.id; - newExif.description = ''; newExif.fileSizeInByte = data.format.size || null; newExif.dateTimeOriginal = fileCreatedAt ? new Date(fileCreatedAt) : null; newExif.modifyDate = null; diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index fab096fb3..42fd6e3cc 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3675,6 +3675,11 @@ "type": "string", "nullable": true, "default": null + }, + "description": { + "type": "string", + "nullable": true, + "default": null } } }, @@ -5283,6 +5288,9 @@ }, "isArchived": { "type": "boolean" + }, + "description": { + "type": "string" } } }, diff --git a/server/libs/domain/src/asset/response-dto/exif-response.dto.ts b/server/libs/domain/src/asset/response-dto/exif-response.dto.ts index cac1d2468..0f49f09b9 100644 --- a/server/libs/domain/src/asset/response-dto/exif-response.dto.ts +++ b/server/libs/domain/src/asset/response-dto/exif-response.dto.ts @@ -23,6 +23,7 @@ export class ExifResponseDto { city?: string | null = null; state?: string | null = null; country?: string | null = null; + description?: string | null = null; } export function mapExif(entity: ExifEntity): ExifResponseDto { @@ -46,5 +47,6 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { city: entity.city, state: entity.state, country: entity.country, + description: entity.description, }; } diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 708dd3933..cc47da52e 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -343,6 +343,7 @@ const assetInfo: ExifResponseDto = { city: 'city', state: 'state', country: 'country', + description: 'description', }; const assetResponse: AssetResponseDto = { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 7eed6db38..62e679509 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1208,6 +1208,12 @@ export interface ExifResponseDto { * @memberof ExifResponseDto */ 'country'?: string | null; + /** + * + * @type {string} + * @memberof ExifResponseDto + */ + 'description'?: string | null; } /** * @@ -2341,6 +2347,12 @@ export interface UpdateAssetDto { * @memberof UpdateAssetDto */ 'isArchived'?: boolean; + /** + * + * @type {string} + * @memberof UpdateAssetDto + */ + 'description'?: string; } /** * diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 4197d7326..887a773ad 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -251,6 +251,18 @@ } }); }; + + const disableKeyDownEvent = () => { + if (browser) { + document.removeEventListener('keydown', onKeyboardPress); + } + }; + + const enableKeyDownEvent = () => { + if (browser) { + document.addEventListener('keydown', onKeyboardPress); + } + };

- (isShowDetail = false)} /> + (isShowDetail = false)} + on:description-focus-in={disableKeyDownEvent} + on:description-focus-out={enableKeyDownEvent} + /> {/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index d0dd9a866..8a22667b0 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -5,14 +5,26 @@ import CameraIris from 'svelte-material-icons/CameraIris.svelte'; import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte'; import { createEventDispatcher } from 'svelte'; - import { AssetResponseDto, AlbumResponseDto } from '@api'; + import { AssetResponseDto, AlbumResponseDto, api } from '@api'; import { asByteUnitString } from '../../utils/byte-units'; import { locale } from '$lib/stores/preferences.store'; import { DateTime } from 'luxon'; import type { LatLngTuple } from 'leaflet'; + import { page } from '$app/stores'; export let asset: AssetResponseDto; export let albums: AlbumResponseDto[] = []; + let textarea: HTMLTextAreaElement; + let description: string; + + $: { + // Get latest description from server + if (asset.id) { + api.assetApi + .getAssetById(asset.id) + .then((res) => (textarea.value = res.data?.exifInfo?.description || '')); + } + } $: latlng = (() => { const lat = asset.exifInfo?.latitude; @@ -34,6 +46,27 @@ return undefined; }; + + const autoGrowHeight = (e: Event) => { + const target = e.target as HTMLTextAreaElement; + target.style.height = 'auto'; + target.style.height = `${target.scrollHeight}px`; + }; + + const handleFocusIn = () => { + dispatch('description-focus-in'); + }; + + const handleFocusOut = async () => { + dispatch('description-focus-out'); + try { + await api.assetApi.updateAsset(asset.id, { + description: description + }); + } catch (error) { + console.error(error); + } + };
@@ -48,6 +81,23 @@

Info

+
+