? children,
}) : super(
@@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs {
this.onPaused,
this.placeholder,
this.showControls = true,
- this.hideControlsTimer = const Duration(seconds: 5),
+ this.hideControlsTimer = const Duration(milliseconds: 1500),
this.showDownloadingIndicator = true,
});
diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart
index afd49adc6..dd38b050b 100644
--- a/mobile/lib/shared/models/asset.dart
+++ b/mobile/lib/shared/models/asset.dart
@@ -171,6 +171,11 @@ class Asset {
int? stackCount;
+ /// Aspect ratio of the asset
+ @ignore
+ double? get aspectRatio =>
+ width == null || height == null ? 0 : width! / height!;
+
/// `true` if this [Asset] is present on the device
@ignore
bool get isLocal => localId != null;
diff --git a/mobile/lib/shared/models/logger_message.model.dart b/mobile/lib/shared/models/logger_message.model.dart
index cb1d45a58..f657257ea 100644
--- a/mobile/lib/shared/models/logger_message.model.dart
+++ b/mobile/lib/shared/models/logger_message.model.dart
@@ -9,6 +9,7 @@ part 'logger_message.model.g.dart';
class LoggerMessage {
Id id = Isar.autoIncrement;
String message;
+ String? details;
@Enumerated(EnumType.ordinal)
LogLevel level = LogLevel.INFO;
DateTime createdAt;
@@ -17,6 +18,7 @@ class LoggerMessage {
LoggerMessage({
required this.message,
+ required this.details,
required this.level,
required this.createdAt,
required this.context1,
diff --git a/mobile/lib/shared/models/logger_message.model.g.dart b/mobile/lib/shared/models/logger_message.model.g.dart
index a6b960eec..76c823704 100644
--- a/mobile/lib/shared/models/logger_message.model.g.dart
+++ b/mobile/lib/shared/models/logger_message.model.g.dart
@@ -32,14 +32,19 @@ const LoggerMessageSchema = CollectionSchema(
name: r'createdAt',
type: IsarType.dateTime,
),
- r'level': PropertySchema(
+ r'details': PropertySchema(
id: 3,
+ name: r'details',
+ type: IsarType.string,
+ ),
+ r'level': PropertySchema(
+ id: 4,
name: r'level',
type: IsarType.byte,
enumMap: _LoggerMessagelevelEnumValueMap,
),
r'message': PropertySchema(
- id: 4,
+ id: 5,
name: r'message',
type: IsarType.string,
)
@@ -76,6 +81,12 @@ int _loggerMessageEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
+ {
+ final value = object.details;
+ if (value != null) {
+ bytesCount += 3 + value.length * 3;
+ }
+ }
bytesCount += 3 + object.message.length * 3;
return bytesCount;
}
@@ -89,8 +100,9 @@ void _loggerMessageSerialize(
writer.writeString(offsets[0], object.context1);
writer.writeString(offsets[1], object.context2);
writer.writeDateTime(offsets[2], object.createdAt);
- writer.writeByte(offsets[3], object.level.index);
- writer.writeString(offsets[4], object.message);
+ writer.writeString(offsets[3], object.details);
+ writer.writeByte(offsets[4], object.level.index);
+ writer.writeString(offsets[5], object.message);
}
LoggerMessage _loggerMessageDeserialize(
@@ -103,9 +115,10 @@ LoggerMessage _loggerMessageDeserialize(
context1: reader.readStringOrNull(offsets[0]),
context2: reader.readStringOrNull(offsets[1]),
createdAt: reader.readDateTime(offsets[2]),
- level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[3])] ??
+ details: reader.readStringOrNull(offsets[3]),
+ level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ??
LogLevel.ALL,
- message: reader.readString(offsets[4]),
+ message: reader.readString(offsets[5]),
);
object.id = id;
return object;
@@ -125,9 +138,11 @@ P _loggerMessageDeserializeProp(
case 2:
return (reader.readDateTime(offset)) as P;
case 3:
+ return (reader.readStringOrNull(offset)) as P;
+ case 4:
return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ??
LogLevel.ALL) as P;
- case 4:
+ case 5:
return (reader.readString(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -619,6 +634,160 @@ extension LoggerMessageQueryFilter
});
}
+ QueryBuilder
+ detailsIsNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNull(
+ property: r'details',
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsIsNotNull() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(const FilterCondition.isNotNull(
+ property: r'details',
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsEqualTo(
+ String? value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsGreaterThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ include: include,
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsLessThan(
+ String? value, {
+ bool include = false,
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.lessThan(
+ include: include,
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsBetween(
+ 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'details',
+ lower: lower,
+ includeLower: includeLower,
+ upper: upper,
+ includeUpper: includeUpper,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsStartsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.startsWith(
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsEndsWith(
+ String value, {
+ bool caseSensitive = true,
+ }) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.endsWith(
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsContains(String value, {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.contains(
+ property: r'details',
+ value: value,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsMatches(String pattern, {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.matches(
+ property: r'details',
+ wildcard: pattern,
+ caseSensitive: caseSensitive,
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsIsEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.equalTo(
+ property: r'details',
+ value: '',
+ ));
+ });
+ }
+
+ QueryBuilder
+ detailsIsNotEmpty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addFilterCondition(FilterCondition.greaterThan(
+ property: r'details',
+ value: '',
+ ));
+ });
+ }
+
QueryBuilder idEqualTo(
Id value) {
return QueryBuilder.apply(this, (query) {
@@ -913,6 +1082,18 @@ extension LoggerMessageQuerySortBy
});
}
+ QueryBuilder sortByDetails() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'details', Sort.asc);
+ });
+ }
+
+ QueryBuilder sortByDetailsDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'details', Sort.desc);
+ });
+ }
+
QueryBuilder sortByLevel() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'level', Sort.asc);
@@ -979,6 +1160,18 @@ extension LoggerMessageQuerySortThenBy
});
}
+ QueryBuilder thenByDetails() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'details', Sort.asc);
+ });
+ }
+
+ QueryBuilder thenByDetailsDesc() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addSortBy(r'details', Sort.desc);
+ });
+ }
+
QueryBuilder thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
@@ -1038,6 +1231,13 @@ extension LoggerMessageQueryWhereDistinct
});
}
+ QueryBuilder distinctByDetails(
+ {bool caseSensitive = true}) {
+ return QueryBuilder.apply(this, (query) {
+ return query.addDistinctBy(r'details', caseSensitive: caseSensitive);
+ });
+ }
+
QueryBuilder distinctByLevel() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'level');
@@ -1078,6 +1278,12 @@ extension LoggerMessageQueryProperty
});
}
+ QueryBuilder detailsProperty() {
+ return QueryBuilder.apply(this, (query) {
+ return query.addPropertyName(r'details');
+ });
+ }
+
QueryBuilder levelProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'level');
diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart
index 64a0f28ab..3086ab924 100644
--- a/mobile/lib/shared/services/asset.service.dart
+++ b/mobile/lib/shared/services/asset.service.dart
@@ -90,7 +90,7 @@ class AssetService {
return allAssets;
} catch (error, stack) {
log.severe(
- 'Error while getting remote assets: ${error.toString()}',
+ 'Error while getting remote assets',
error,
stack,
);
@@ -117,7 +117,7 @@ class AssetService {
);
return true;
} catch (error, stack) {
- log.severe("Error deleteAssets ${error.toString()}", error, stack);
+ log.severe("Error while deleting assets", error, stack);
}
return false;
}
diff --git a/mobile/lib/shared/services/immich_logger.service.dart b/mobile/lib/shared/services/immich_logger.service.dart
index b66177e57..967ab2d5f 100644
--- a/mobile/lib/shared/services/immich_logger.service.dart
+++ b/mobile/lib/shared/services/immich_logger.service.dart
@@ -12,7 +12,7 @@ import 'package:share_plus/share_plus.dart';
/// [ImmichLogger] is a custom logger that is built on top of the [logging] package.
/// The logs are written to the database and onto console, using `debugPrint` method.
///
-/// The logs are deleted when exceeding the `maxLogEntries` (default 200) property
+/// The logs are deleted when exceeding the `maxLogEntries` (default 500) property
/// in the class.
///
/// Logs can be shared by calling the `shareLogs` method, which will open a share dialog
@@ -58,6 +58,7 @@ class ImmichLogger {
debugPrint('[${record.level.name}] [${record.time}] ${record.message}');
final lm = LoggerMessage(
message: record.message,
+ details: record.error?.toString(),
level: record.level.toLogLevel(),
createdAt: record.time,
context1: record.loggerName,
diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart
index d7daa51b8..be7c0c168 100644
--- a/mobile/lib/shared/services/share.service.dart
+++ b/mobile/lib/shared/services/share.service.dart
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart';
@@ -41,7 +42,8 @@ class ShareService {
if (res.statusCode != 200) {
_log.severe(
- "Asset download failed with status - ${res.statusCode} and response - ${res.body}",
+ "Asset download for ${asset.fileName} failed",
+ res.toLoggerString(),
);
continue;
}
@@ -68,7 +70,7 @@ class ShareService {
);
return true;
} catch (error) {
- _log.severe("Share failed with error $error");
+ _log.severe("Share failed", error);
}
return false;
}
diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart
index d039b3409..a441091d3 100644
--- a/mobile/lib/shared/services/sync.service.dart
+++ b/mobile/lib/shared/services/sync.service.dart
@@ -140,7 +140,7 @@ class SyncService {
try {
await _db.writeTxn(() => a.put(_db));
} on IsarError catch (e) {
- _log.severe("Failed to put new asset into db: $e");
+ _log.severe("Failed to put new asset into db", e);
return false;
}
return true;
@@ -173,7 +173,7 @@ class SyncService {
}
return false;
} on IsarError catch (e) {
- _log.severe("Failed to sync remote assets to db: $e");
+ _log.severe("Failed to sync remote assets to db", e);
}
return null;
}
@@ -232,7 +232,7 @@ class SyncService {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await upsertAssetsWithExif(toAdd + toUpdate);
} on IsarError catch (e) {
- _log.severe("Failed to sync remote assets to db: $e");
+ _log.severe("Failed to sync remote assets to db", e);
}
await _updateUserAssetsETag(user, now);
return true;
@@ -364,7 +364,7 @@ class SyncService {
});
_log.info("Synced changes of remote album ${album.name} to DB");
} on IsarError catch (e) {
- _log.severe("Failed to sync remote album to database $e");
+ _log.severe("Failed to sync remote album to database", e);
}
if (album.shared || dto.shared) {
@@ -441,7 +441,7 @@ class SyncService {
assert(ok);
_log.info("Removed local album $album from DB");
} catch (e) {
- _log.severe("Failed to remove local album $album from DB");
+ _log.severe("Failed to remove local album $album from DB", e);
}
}
@@ -577,7 +577,7 @@ class SyncService {
});
_log.info("Synced changes of local album ${ape.name} to DB");
} on IsarError catch (e) {
- _log.severe("Failed to update synced album ${ape.name} in DB: $e");
+ _log.severe("Failed to update synced album ${ape.name} in DB", e);
}
return true;
@@ -623,7 +623,7 @@ class SyncService {
});
_log.info("Fast synced local album ${ape.name} to DB");
} on IsarError catch (e) {
- _log.severe("Failed to fast sync local album ${ape.name} to DB: $e");
+ _log.severe("Failed to fast sync local album ${ape.name} to DB", e);
return false;
}
@@ -656,7 +656,7 @@ class SyncService {
await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: ${ape.name}");
} on IsarError catch (e) {
- _log.severe("Failed to add new local album ${ape.name} to DB: $e");
+ _log.severe("Failed to add new local album ${ape.name} to DB", e);
}
}
@@ -706,9 +706,7 @@ class SyncService {
});
_log.info("Upserted ${assets.length} assets into the DB");
} on IsarError catch (e) {
- _log.severe(
- "Failed to upsert ${assets.length} assets into the DB: ${e.toString()}",
- );
+ _log.severe("Failed to upsert ${assets.length} assets into the DB", e);
// give details on the errors
assets.sort(Asset.compareByOwnerChecksum);
final inDb = await _db.assets.getAllByOwnerIdChecksum(
@@ -776,7 +774,7 @@ class SyncService {
});
return true;
} catch (e) {
- _log.severe("Failed to remove all local albums and assets: $e");
+ _log.severe("Failed to remove all local albums and assets", e);
return false;
}
}
diff --git a/mobile/lib/shared/services/user.service.dart b/mobile/lib/shared/services/user.service.dart
index 4d398c3a8..ae65ed31d 100644
--- a/mobile/lib/shared/services/user.service.dart
+++ b/mobile/lib/shared/services/user.service.dart
@@ -42,7 +42,7 @@ class UserService {
final dto = await _apiService.userApi.getAllUsers(isAll);
return dto?.map(User.fromUserDto).toList();
} catch (e) {
- _log.warning("Failed get all users:\n$e");
+ _log.warning("Failed get all users", e);
return null;
}
}
@@ -65,7 +65,7 @@ class UserService {
),
);
} catch (e) {
- _log.warning("Failed to upload profile image:\n$e");
+ _log.warning("Failed to upload profile image", e);
return null;
}
}
diff --git a/mobile/lib/shared/ui/delayed_loading_indicator.dart b/mobile/lib/shared/ui/delayed_loading_indicator.dart
new file mode 100644
index 000000000..b4d9f4c80
--- /dev/null
+++ b/mobile/lib/shared/ui/delayed_loading_indicator.dart
@@ -0,0 +1,40 @@
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+
+class DelayedLoadingIndicator extends StatelessWidget {
+ /// The delay to avoid showing the loading indicator
+ final Duration delay;
+
+ /// Defaults to using the [ImmichLoadingIndicator]
+ final Widget? child;
+
+ /// An optional fade in duration to animate the loading
+ final Duration? fadeInDuration;
+
+ const DelayedLoadingIndicator({
+ super.key,
+ this.delay = const Duration(seconds: 3),
+ this.child,
+ this.fadeInDuration,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedSwitcher(
+ duration: fadeInDuration ?? Duration.zero,
+ child: FutureBuilder(
+ future: Future.delayed(delay),
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.done) {
+ return child ??
+ const ImmichLoadingIndicator(
+ key: ValueKey('loading'),
+ );
+ }
+
+ return Container(key: const ValueKey('hiding'));
+ },
+ ),
+ );
+ }
+}
diff --git a/mobile/lib/shared/views/app_log_detail_page.dart b/mobile/lib/shared/views/app_log_detail_page.dart
index 126f46c8f..6b99d7f0a 100644
--- a/mobile/lib/shared/views/app_log_detail_page.dart
+++ b/mobile/lib/shared/views/app_log_detail_page.dart
@@ -15,7 +15,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
var isDarkTheme = context.isDarkTheme;
- buildStackMessage(String stackTrace) {
+ buildTextWithCopyButton(String header, String text) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
@@ -28,7 +28,7 @@ class AppLogDetailPage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
- "STACK TRACES",
+ header,
style: TextStyle(
fontSize: 12.0,
color: context.primaryColor,
@@ -38,8 +38,7 @@ class AppLogDetailPage extends HookConsumerWidget {
),
IconButton(
onPressed: () {
- Clipboard.setData(ClipboardData(text: stackTrace))
- .then((_) {
+ Clipboard.setData(ClipboardData(text: text)).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -68,73 +67,7 @@ class AppLogDetailPage extends HookConsumerWidget {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SelectableText(
- stackTrace,
- style: const TextStyle(
- fontSize: 12.0,
- fontWeight: FontWeight.bold,
- fontFamily: "Inconsolata",
- ),
- ),
- ),
- ),
- ],
- ),
- );
- }
-
- buildLogMessage(String message) {
- return Padding(
- padding: const EdgeInsets.all(8.0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Padding(
- padding: const EdgeInsets.only(bottom: 8.0),
- child: Text(
- "MESSAGE",
- style: TextStyle(
- fontSize: 12.0,
- color: context.primaryColor,
- fontWeight: FontWeight.bold,
- ),
- ),
- ),
- IconButton(
- onPressed: () {
- Clipboard.setData(ClipboardData(text: message)).then((_) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(
- "Copied to clipboard",
- style: context.textTheme.bodyLarge?.copyWith(
- color: context.primaryColor,
- ),
- ),
- ),
- );
- });
- },
- icon: Icon(
- Icons.copy,
- size: 16.0,
- color: context.primaryColor,
- ),
- ),
- ],
- ),
- Container(
- decoration: BoxDecoration(
- color: isDarkTheme ? Colors.grey[900] : Colors.grey[200],
- borderRadius: BorderRadius.circular(15.0),
- ),
- child: Padding(
- padding: const EdgeInsets.all(8.0),
- child: SelectableText(
- message,
+ text,
style: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
@@ -194,11 +127,16 @@ class AppLogDetailPage extends HookConsumerWidget {
body: SafeArea(
child: ListView(
children: [
- buildLogMessage(logMessage.message),
+ buildTextWithCopyButton("MESSAGE", logMessage.message),
+ if (logMessage.details != null)
+ buildTextWithCopyButton("DETAILS", logMessage.details.toString()),
if (logMessage.context1 != null)
buildLogContext1(logMessage.context1.toString()),
if (logMessage.context2 != null)
- buildStackMessage(logMessage.context2.toString()),
+ buildTextWithCopyButton(
+ "STACK TRACE",
+ logMessage.context2.toString(),
+ ),
],
),
),
diff --git a/mobile/lib/shared/views/app_log_page.dart b/mobile/lib/shared/views/app_log_page.dart
index a0c4553f9..993b25c7c 100644
--- a/mobile/lib/shared/views/app_log_page.dart
+++ b/mobile/lib/shared/views/app_log_page.dart
@@ -69,9 +69,9 @@ class AppLogPage extends HookConsumerWidget {
return Scaffold(
appBar: AppBar(
- title: Text(
- "Logs - ${logMessages.value.length}",
- style: const TextStyle(
+ title: const Text(
+ "Logs",
+ style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
@@ -135,29 +135,15 @@ class AppLogPage extends HookConsumerWidget {
dense: true,
tileColor: getTileColor(logMessage.level),
minLeadingWidth: 10,
- title: Text.rich(
- TextSpan(
- children: [
- TextSpan(
- text: "#$index ",
- style: TextStyle(
- color: isDarkTheme ? Colors.white70 : Colors.grey[600],
- fontSize: 14.0,
- fontWeight: FontWeight.bold,
- ),
- ),
- TextSpan(
- text: truncateLogMessage(logMessage.message, 4),
- style: const TextStyle(
- fontSize: 14.0,
- ),
- ),
- ],
+ title: Text(
+ truncateLogMessage(logMessage.message, 4),
+ style: const TextStyle(
+ fontSize: 14.0,
+ fontFamily: "Inconsolata",
),
- style: const TextStyle(fontSize: 14.0, fontFamily: "Inconsolata"),
),
subtitle: Text(
- "[${logMessage.context1}] Logged on ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)}",
+ "at ${DateFormat("HH:mm:ss.SSS").format(logMessage.createdAt)} in ${logMessage.context1}",
style: TextStyle(
fontSize: 12.0,
color: Colors.grey[600],
diff --git a/mobile/lib/shared/views/immich_loading_overlay.dart b/mobile/lib/shared/views/immich_loading_overlay.dart
index 85f0123ed..c600d2a72 100644
--- a/mobile/lib/shared/views/immich_loading_overlay.dart
+++ b/mobile/lib/shared/views/immich_loading_overlay.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
-import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
final _loadingEntry = OverlayEntry(
builder: (context) => SizedBox.square(
@@ -9,7 +9,12 @@ final _loadingEntry = OverlayEntry(
child: DecoratedBox(
decoration:
BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
- child: const Center(child: ImmichLoadingIndicator()),
+ child: const Center(
+ child: DelayedLoadingIndicator(
+ delay: Duration(seconds: 1),
+ fadeInDuration: Duration(milliseconds: 400),
+ ),
+ ),
),
),
);
@@ -27,19 +32,19 @@ class _LoadingOverlay extends Hook> {
class _LoadingOverlayState
extends HookState, _LoadingOverlay> {
- late final _isProcessing = ValueNotifier(false)..addListener(_listener);
- OverlayEntry? overlayEntry;
+ late final _isLoading = ValueNotifier(false)..addListener(_listener);
+ OverlayEntry? _loadingOverlay;
void _listener() {
setState(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
- if (_isProcessing.value) {
- overlayEntry?.remove();
- overlayEntry = _loadingEntry;
+ if (_isLoading.value) {
+ _loadingOverlay?.remove();
+ _loadingOverlay = _loadingEntry;
Overlay.of(context).insert(_loadingEntry);
} else {
- overlayEntry?.remove();
- overlayEntry = null;
+ _loadingOverlay?.remove();
+ _loadingOverlay = null;
}
});
});
@@ -47,17 +52,17 @@ class _LoadingOverlayState
@override
ValueNotifier build(BuildContext context) {
- return _isProcessing;
+ return _isLoading;
}
@override
void dispose() {
- _isProcessing.dispose();
+ _isLoading.dispose();
super.dispose();
}
@override
- Object? get debugValue => _isProcessing.value;
+ Object? get debugValue => _isLoading.value;
@override
String get debugLabel => 'useProcessingOverlay<>';
diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart
index 8dddb60aa..3c0d65bde 100644
--- a/mobile/lib/shared/views/splash_screen.dart
+++ b/mobile/lib/shared/views/splash_screen.dart
@@ -35,10 +35,10 @@ class SplashScreenPage extends HookConsumerWidget {
deviceIsOffline = true;
log.fine("Device seems to be offline upon launch");
} else {
- log.severe(e);
+ log.severe("Failed to resolve endpoint", e);
}
} catch (e) {
- log.severe(e);
+ log.severe("Failed to resolve endpoint", e);
}
try {
@@ -53,7 +53,7 @@ class SplashScreenPage extends HookConsumerWidget {
ref.read(authenticationProvider.notifier).logout();
log.severe(
- 'Cannot set success login info: $error',
+ 'Cannot set success login info',
error,
stackTrace,
);
diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES
index 0679a1749..ea413b487 100644
--- a/mobile/openapi/.openapi-generator/FILES
+++ b/mobile/openapi/.openapi-generator/FILES
@@ -108,6 +108,7 @@ doc/PersonResponseDto.md
doc/PersonStatisticsResponseDto.md
doc/PersonUpdateDto.md
doc/PersonWithFacesResponseDto.md
+doc/PlacesResponseDto.md
doc/QueueStatusDto.md
doc/ReactionLevel.md
doc/ReactionType.md
@@ -308,6 +309,7 @@ lib/model/person_response_dto.dart
lib/model/person_statistics_response_dto.dart
lib/model/person_update_dto.dart
lib/model/person_with_faces_response_dto.dart
+lib/model/places_response_dto.dart
lib/model/queue_status_dto.dart
lib/model/reaction_level.dart
lib/model/reaction_type.dart
@@ -485,6 +487,7 @@ test/person_response_dto_test.dart
test/person_statistics_response_dto_test.dart
test/person_update_dto_test.dart
test/person_with_faces_response_dto_test.dart
+test/places_response_dto_test.dart
test/queue_status_dto_test.dart
test/reaction_level_test.dart
test/reaction_type_test.dart
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 5dd6d196d..41e65ee8b 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -166,6 +166,7 @@ Class | Method | HTTP request | Description
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata |
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
+*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
@@ -306,6 +307,7 @@ Class | Method | HTTP request | Description
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
+ - [PlacesResponseDto](doc//PlacesResponseDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)
diff --git a/mobile/openapi/doc/PlacesResponseDto.md b/mobile/openapi/doc/PlacesResponseDto.md
new file mode 100644
index 000000000..a4bf36493
--- /dev/null
+++ b/mobile/openapi/doc/PlacesResponseDto.md
@@ -0,0 +1,19 @@
+# openapi.model.PlacesResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**admin1name** | **String** | | [optional]
+**admin2name** | **String** | | [optional]
+**latitude** | **num** | |
+**longitude** | **num** | |
+**name** | **String** | |
+
+[[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/SearchApi.md b/mobile/openapi/doc/SearchApi.md
index f975e9448..f63488222 100644
--- a/mobile/openapi/doc/SearchApi.md
+++ b/mobile/openapi/doc/SearchApi.md
@@ -14,6 +14,7 @@ Method | HTTP request | Description
[**search**](SearchApi.md#search) | **GET** /search |
[**searchMetadata**](SearchApi.md#searchmetadata) | **POST** /search/metadata |
[**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person |
+[**searchPlaces**](SearchApi.md#searchplaces) | **GET** /search/places |
[**searchSmart**](SearchApi.md#searchsmart) | **POST** /search/smart |
@@ -316,6 +317,61 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+# **searchPlaces**
+> List searchPlaces(name)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = SearchApi();
+final name = name_example; // String |
+
+try {
+ final result = api_instance.searchPlaces(name);
+ print(result);
+} catch (e) {
+ print('Exception when calling SearchApi->searchPlaces: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description | Notes
+------------- | ------------- | ------------- | -------------
+ **name** | **String**| |
+
+### Return type
+
+[**List**](PlacesResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
# **searchSmart**
> SearchResponseDto searchSmart(smartSearchDto)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 72a656764..56bd907e0 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -142,6 +142,7 @@ part 'model/person_response_dto.dart';
part 'model/person_statistics_response_dto.dart';
part 'model/person_update_dto.dart';
part 'model/person_with_faces_response_dto.dart';
+part 'model/places_response_dto.dart';
part 'model/queue_status_dto.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';
diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart
index 062ca4a50..3a0bc56bb 100644
--- a/mobile/openapi/lib/api/search_api.dart
+++ b/mobile/openapi/lib/api/search_api.dart
@@ -360,6 +360,58 @@ class SearchApi {
return null;
}
+ /// Performs an HTTP 'GET /search/places' operation and returns the [Response].
+ /// Parameters:
+ ///
+ /// * [String] name (required):
+ Future searchPlacesWithHttpInfo(String name,) async {
+ // ignore: prefer_const_declarations
+ final path = r'/search/places';
+
+ // ignore: prefer_final_locals
+ Object? postBody;
+
+ final queryParams = [];
+ final headerParams = {};
+ final formParams = {};
+
+ queryParams.addAll(_queryParams('', 'name', name));
+
+ const contentTypes = [];
+
+
+ return apiClient.invokeAPI(
+ path,
+ 'GET',
+ queryParams,
+ postBody,
+ headerParams,
+ formParams,
+ contentTypes.isEmpty ? null : contentTypes.first,
+ );
+ }
+
+ /// Parameters:
+ ///
+ /// * [String] name (required):
+ Future?> searchPlaces(String name,) async {
+ final response = await searchPlacesWithHttpInfo(name,);
+ 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) {
+ final responseBody = await _decodeBodyBytes(response);
+ return (await apiClient.deserializeAsync(responseBody, 'List') as List)
+ .cast()
+ .toList(growable: false);
+
+ }
+ return null;
+ }
+
/// Performs an HTTP 'POST /search/smart' operation and returns the [Response].
/// Parameters:
///
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 2df5e6711..24cffb7cf 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -366,6 +366,8 @@ class ApiClient {
return PersonUpdateDto.fromJson(value);
case 'PersonWithFacesResponseDto':
return PersonWithFacesResponseDto.fromJson(value);
+ case 'PlacesResponseDto':
+ return PlacesResponseDto.fromJson(value);
case 'QueueStatusDto':
return QueueStatusDto.fromJson(value);
case 'ReactionLevel':
diff --git a/mobile/openapi/lib/model/places_response_dto.dart b/mobile/openapi/lib/model/places_response_dto.dart
new file mode 100644
index 000000000..a2d837888
--- /dev/null
+++ b/mobile/openapi/lib/model/places_response_dto.dart
@@ -0,0 +1,148 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// 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 PlacesResponseDto {
+ /// Returns a new [PlacesResponseDto] instance.
+ PlacesResponseDto({
+ this.admin1name,
+ this.admin2name,
+ required this.latitude,
+ required this.longitude,
+ required this.name,
+ });
+
+ ///
+ /// 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? admin1name;
+
+ ///
+ /// 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? admin2name;
+
+ num latitude;
+
+ num longitude;
+
+ String name;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is PlacesResponseDto &&
+ other.admin1name == admin1name &&
+ other.admin2name == admin2name &&
+ other.latitude == latitude &&
+ other.longitude == longitude &&
+ other.name == name;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (admin1name == null ? 0 : admin1name!.hashCode) +
+ (admin2name == null ? 0 : admin2name!.hashCode) +
+ (latitude.hashCode) +
+ (longitude.hashCode) +
+ (name.hashCode);
+
+ @override
+ String toString() => 'PlacesResponseDto[admin1name=$admin1name, admin2name=$admin2name, latitude=$latitude, longitude=$longitude, name=$name]';
+
+ Map toJson() {
+ final json = {};
+ if (this.admin1name != null) {
+ json[r'admin1name'] = this.admin1name;
+ } else {
+ // json[r'admin1name'] = null;
+ }
+ if (this.admin2name != null) {
+ json[r'admin2name'] = this.admin2name;
+ } else {
+ // json[r'admin2name'] = null;
+ }
+ json[r'latitude'] = this.latitude;
+ json[r'longitude'] = this.longitude;
+ json[r'name'] = this.name;
+ return json;
+ }
+
+ /// Returns a new [PlacesResponseDto] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static PlacesResponseDto? fromJson(dynamic value) {
+ if (value is Map) {
+ final json = value.cast();
+
+ return PlacesResponseDto(
+ admin1name: mapValueOfType(json, r'admin1name'),
+ admin2name: mapValueOfType(json, r'admin2name'),
+ latitude: num.parse('${json[r'latitude']}'),
+ longitude: num.parse('${json[r'longitude']}'),
+ name: mapValueOfType(json, r'name')!,
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = PlacesResponseDto.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = PlacesResponseDto.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of PlacesResponseDto-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = PlacesResponseDto.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'latitude',
+ 'longitude',
+ 'name',
+ };
+}
+
diff --git a/mobile/openapi/test/places_response_dto_test.dart b/mobile/openapi/test/places_response_dto_test.dart
new file mode 100644
index 000000000..5a320fce6
--- /dev/null
+++ b/mobile/openapi/test/places_response_dto_test.dart
@@ -0,0 +1,47 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// 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
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for PlacesResponseDto
+void main() {
+ // final instance = PlacesResponseDto();
+
+ group('test PlacesResponseDto', () {
+ // String admin1name
+ test('to test the property `admin1name`', () async {
+ // TODO
+ });
+
+ // String admin2name
+ test('to test the property `admin2name`', () async {
+ // TODO
+ });
+
+ // num latitude
+ test('to test the property `latitude`', () async {
+ // TODO
+ });
+
+ // num longitude
+ test('to test the property `longitude`', () async {
+ // TODO
+ });
+
+ // String name
+ test('to test the property `name`', () async {
+ // TODO
+ });
+
+
+ });
+
+}
diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart
index 14169e461..aa4a94847 100644
--- a/mobile/openapi/test/search_api_test.dart
+++ b/mobile/openapi/test/search_api_test.dart
@@ -42,6 +42,11 @@ void main() {
// TODO
});
+ //Future> searchPlaces(String name) async
+ test('test searchPlaces', () async {
+ // TODO
+ });
+
//Future searchSmart(SmartSearchDto smartSearchDto) async
test('test searchSmart', () async {
// TODO
diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock
index ffa57f826..6bc37c922 100644
--- a/mobile/pubspec.lock
+++ b/mobile/pubspec.lock
@@ -413,10 +413,10 @@ packages:
dependency: transitive
description:
name: file
- sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
+ sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
url: "https://pub.dev"
source: hosted
- version: "6.1.4"
+ version: "7.0.0"
file_selector_linux:
dependency: transitive
description:
@@ -569,10 +569,10 @@ packages:
dependency: "direct main"
description:
name: flutter_udid
- sha256: "666412097b86d9a6f9803073d0f0ba70de9b198fe6493d89d352a1f8cd6c5c84"
+ sha256: "63384bd96203aaefccfd7137fab642edda18afede12b0e9e1a2c96fe2589fd07"
url: "https://pub.dev"
source: hosted
- version: "2.1.1"
+ version: "3.0.0"
flutter_web_auth:
dependency: "direct main"
description:
@@ -619,10 +619,10 @@ packages:
dependency: "direct main"
description:
name: geolocator
- sha256: e946395fc608842bb2f6c914807e9183f86f3cb787f6b8f832753e5251036f02
+ sha256: "694ec58afe97787b5b72b8a0ab78c1a9244811c3c10e72c4362ef3c0ceb005cd"
url: "https://pub.dev"
source: hosted
- version: "10.1.0"
+ version: "11.0.0"
geolocator_android:
dependency: transitive
description:
@@ -651,10 +651,10 @@ packages:
dependency: transitive
description:
name: geolocator_web
- sha256: "59083f7e0871b78299918d92bf930a14377f711d2d1156c558cd5ebae6c20d58"
+ sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
url: "https://pub.dev"
source: hosted
- version: "2.2.0"
+ version: "3.0.0"
geolocator_windows:
dependency: transitive
description:
@@ -860,6 +860,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.1"
+ leak_tracker:
+ dependency: transitive
+ description:
+ name: leak_tracker
+ sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa"
+ url: "https://pub.dev"
+ source: hosted
+ version: "10.0.0"
+ leak_tracker_flutter_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_flutter_testing
+ sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
+ leak_tracker_testing:
+ dependency: transitive
+ description:
+ name: leak_tracker_testing
+ sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
lints:
dependency: transitive
description:
@@ -907,18 +931,18 @@ packages:
dependency: transitive
description:
name: matcher
- sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
+ sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
- version: "0.12.16"
+ version: "0.12.16+1"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
- sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
+ sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
url: "https://pub.dev"
source: hosted
- version: "0.5.0"
+ version: "0.8.0"
meta:
dependency: "direct overridden"
description:
@@ -1002,10 +1026,10 @@ packages:
dependency: "direct main"
description:
name: path
- sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
+ sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
- version: "1.8.3"
+ version: "1.9.0"
path_provider:
dependency: "direct main"
description:
@@ -1138,10 +1162,10 @@ packages:
dependency: transitive
description:
name: platform
- sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102
+ sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
url: "https://pub.dev"
source: hosted
- version: "3.1.2"
+ version: "3.1.4"
plugin_platform_interface:
dependency: transitive
description:
@@ -1170,10 +1194,10 @@ packages:
dependency: transitive
description:
name: process
- sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09"
+ sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32"
url: "https://pub.dev"
source: hosted
- version: "4.2.4"
+ version: "5.0.2"
provider:
dependency: transitive
description:
@@ -1298,10 +1322,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_linux
- sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1"
+ sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa"
url: "https://pub.dev"
source: hosted
- version: "2.3.0"
+ version: "2.3.2"
shared_preferences_platform_interface:
dependency: transitive
description:
@@ -1322,10 +1346,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_windows
- sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d
+ sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59"
url: "https://pub.dev"
source: hosted
- version: "2.3.0"
+ version: "2.3.2"
shelf:
dependency: transitive
description:
@@ -1631,10 +1655,10 @@ packages:
dependency: transitive
description:
name: vm_service
- sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583
+ sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
url: "https://pub.dev"
source: hosted
- version: "11.10.0"
+ version: "13.0.0"
wakelock_plus:
dependency: "direct main"
description:
@@ -1679,10 +1703,10 @@ packages:
dependency: transitive
description:
name: webdriver
- sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49"
+ sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e"
url: "https://pub.dev"
source: hosted
- version: "3.0.2"
+ version: "3.0.3"
win32:
dependency: transitive
description:
diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml
index 47a4d3805..0869d3973 100644
--- a/mobile/pubspec.yaml
+++ b/mobile/pubspec.yaml
@@ -32,8 +32,8 @@ dependencies:
git:
url: https://github.com/maplibre/flutter-maplibre-gl.git
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
- geolocator: ^10.1.0 # used to move to current location in map view
- flutter_udid: ^2.1.1
+ geolocator: ^11.0.0 # used to move to current location in map view
+ flutter_udid: ^3.0.0
package_info_plus: ^5.0.1
url_launcher: ^6.2.4
http: 0.13.5
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index cac1d663b..8fec89327 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -4691,6 +4691,50 @@
]
}
},
+ "/search/places": {
+ "get": {
+ "operationId": "searchPlaces",
+ "parameters": [
+ {
+ "name": "name",
+ "required": true,
+ "in": "query",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "$ref": "#/components/schemas/PlacesResponseDto"
+ },
+ "type": "array"
+ }
+ }
+ },
+ "description": ""
+ }
+ },
+ "security": [
+ {
+ "bearer": []
+ },
+ {
+ "cookie": []
+ },
+ {
+ "api_key": []
+ }
+ ],
+ "tags": [
+ "Search"
+ ]
+ }
+ },
"/search/smart": {
"post": {
"operationId": "searchSmart",
@@ -8756,6 +8800,31 @@
],
"type": "object"
},
+ "PlacesResponseDto": {
+ "properties": {
+ "admin1name": {
+ "type": "string"
+ },
+ "admin2name": {
+ "type": "string"
+ },
+ "latitude": {
+ "type": "number"
+ },
+ "longitude": {
+ "type": "number"
+ },
+ "name": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "latitude",
+ "longitude",
+ "name"
+ ],
+ "type": "object"
+ },
"QueueStatusDto": {
"properties": {
"isActive": {
diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts
index c01b200d0..ad36bb493 100644
--- a/open-api/typescript-sdk/axios-client/api.ts
+++ b/open-api/typescript-sdk/axios-client/api.ts
@@ -2994,6 +2994,43 @@ export interface PersonWithFacesResponseDto {
*/
'thumbnailPath': string;
}
+/**
+ *
+ * @export
+ * @interface PlacesResponseDto
+ */
+export interface PlacesResponseDto {
+ /**
+ *
+ * @type {string}
+ * @memberof PlacesResponseDto
+ */
+ 'admin1name'?: string;
+ /**
+ *
+ * @type {string}
+ * @memberof PlacesResponseDto
+ */
+ 'admin2name'?: string;
+ /**
+ *
+ * @type {number}
+ * @memberof PlacesResponseDto
+ */
+ 'latitude': number;
+ /**
+ *
+ * @type {number}
+ * @memberof PlacesResponseDto
+ */
+ 'longitude': number;
+ /**
+ *
+ * @type {string}
+ * @memberof PlacesResponseDto
+ */
+ 'name': string;
+}
/**
*
* @export
@@ -15447,6 +15484,51 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
+ /**
+ *
+ * @param {string} name
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ searchPlaces: async (name: string, options: RawAxiosRequestConfig = {}): Promise => {
+ // verify required parameter 'name' is not null or undefined
+ assertParamExists('searchPlaces', 'name', name)
+ const localVarPath = `/search/places`;
+ // use dummy base URL string because the URL constructor only accepts absolute URLs.
+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+ let baseOptions;
+ if (configuration) {
+ baseOptions = configuration.baseOptions;
+ }
+
+ const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication cookie required
+
+ // authentication api_key required
+ await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+ // authentication bearer required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+ if (name !== undefined) {
+ localVarQueryParameter['name'] = name;
+ }
+
+
+
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -15584,6 +15666,18 @@ export const SearchApiFp = function(configuration?: Configuration) {
const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
},
+ /**
+ *
+ * @param {string} name
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async searchPlaces(name: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.searchPlaces(name, options);
+ const index = configuration?.serverIndex ?? 0;
+ const operationBasePath = operationServerMap['SearchApi.searchPlaces']?.[index]?.url;
+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
+ },
/**
*
* @param {SmartSearchDto} smartSearchDto
@@ -15651,6 +15745,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise> {
return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath));
},
+ /**
+ *
+ * @param {SearchApiSearchPlacesRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ searchPlaces(requestParameters: SearchApiSearchPlacesRequest, options?: RawAxiosRequestConfig): AxiosPromise> {
+ return localVarFp.searchPlaces(requestParameters.name, options).then((request) => request(axios, basePath));
+ },
/**
*
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
@@ -15817,6 +15920,20 @@ export interface SearchApiSearchPersonRequest {
readonly withHidden?: boolean
}
+/**
+ * Request parameters for searchPlaces operation in SearchApi.
+ * @export
+ * @interface SearchApiSearchPlacesRequest
+ */
+export interface SearchApiSearchPlacesRequest {
+ /**
+ *
+ * @type {string}
+ * @memberof SearchApiSearchPlaces
+ */
+ readonly name: string
+}
+
/**
* Request parameters for searchSmart operation in SearchApi.
* @export
@@ -15893,6 +16010,17 @@ export class SearchApi extends BaseAPI {
return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
}
+ /**
+ *
+ * @param {SearchApiSearchPlacesRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof SearchApi
+ */
+ public searchPlaces(requestParameters: SearchApiSearchPlacesRequest, options?: RawAxiosRequestConfig) {
+ return SearchApiFp(this.configuration).searchPlaces(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
+ }
+
/**
*
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts
index 0ee871ca6..d023f8ef0 100644
--- a/open-api/typescript-sdk/fetch-client.ts
+++ b/open-api/typescript-sdk/fetch-client.ts
@@ -646,6 +646,13 @@ export type MetadataSearchDto = {
withPeople?: boolean;
withStacked?: boolean;
};
+export type PlacesResponseDto = {
+ admin1name?: string;
+ admin2name?: string;
+ latitude: number;
+ longitude: number;
+ name: string;
+};
export type SmartSearchDto = {
city?: string;
country?: string;
@@ -2198,6 +2205,18 @@ export function searchPerson({ name, withHidden }: {
...opts
}));
}
+export function searchPlaces({ name }: {
+ name: string;
+}, opts?: Oazapfts.RequestOpts) {
+ return oazapfts.ok(oazapfts.fetchJson<{
+ status: 200;
+ data: PlacesResponseDto[];
+ }>(`/search/places${QS.query(QS.explode({
+ name
+ }))}`, {
+ ...opts
+ }));
+}
export function searchSmart({ smartSearchDto }: {
smartSearchDto: SmartSearchDto;
}, opts?: Oazapfts.RequestOpts) {
diff --git a/open-api/typescript-sdk/fetch-errors.ts b/open-api/typescript-sdk/fetch-errors.ts
new file mode 100644
index 000000000..f21f0ed1c
--- /dev/null
+++ b/open-api/typescript-sdk/fetch-errors.ts
@@ -0,0 +1,15 @@
+import { HttpError } from '@oazapfts/runtime';
+
+export interface ApiExceptionResponse {
+ message: string;
+ error?: string;
+ statusCode: number;
+}
+
+export interface ApiHttpError extends HttpError {
+ data: ApiExceptionResponse;
+}
+
+export function isHttpError(error: unknown): error is ApiHttpError {
+ return error instanceof HttpError;
+}
diff --git a/open-api/typescript-sdk/fetch.ts b/open-api/typescript-sdk/fetch.ts
index 5441cd826..5759e66ad 100644
--- a/open-api/typescript-sdk/fetch.ts
+++ b/open-api/typescript-sdk/fetch.ts
@@ -1 +1,2 @@
export * from './fetch-client';
+export * from './fetch-errors';
diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts
index 4e7c4d552..0dc9c5414 100644
--- a/server/src/domain/domain.constant.ts
+++ b/server/src/domain/domain.constant.ts
@@ -91,7 +91,7 @@ export const citiesFile = 'cities500.txt';
export const geodataDatePath = join(GEODATA_ROOT_PATH, 'geodata-date.txt');
export const geodataAdmin1Path = join(GEODATA_ROOT_PATH, 'admin1CodesASCII.txt');
export const geodataAdmin2Path = join(GEODATA_ROOT_PATH, 'admin2Codes.txt');
-export const geodataCitites500Path = join(GEODATA_ROOT_PATH, citiesFile);
+export const geodataCities500Path = join(GEODATA_ROOT_PATH, citiesFile);
const image: Record = {
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts
index 7183e9e3f..8566fcd8e 100644
--- a/server/src/domain/repositories/search.repository.ts
+++ b/server/src/domain/repositories/search.repository.ts
@@ -1,4 +1,4 @@
-import { AssetEntity, AssetFaceEntity, AssetType, SmartInfoEntity } from '@app/infra/entities';
+import { AssetEntity, AssetFaceEntity, AssetType, GeodataPlacesEntity, SmartInfoEntity } from '@app/infra/entities';
import { Paginated } from '../domain.util';
export const ISearchRepository = 'ISearchRepository';
@@ -186,4 +186,5 @@ export interface ISearchRepository {
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated;
searchFaces(search: FaceEmbeddingSearch): Promise;
upsert(smartInfo: Partial, embedding?: Embedding): Promise;
+ searchPlaces(placeName: string): Promise;
}
diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts
index 4f2aa1819..877a494e4 100644
--- a/server/src/domain/search/dto/search.dto.ts
+++ b/server/src/domain/search/dto/search.dto.ts
@@ -1,5 +1,5 @@
import { AssetOrder } from '@app/domain/asset/dto/asset.dto';
-import { AssetType } from '@app/infra/entities';
+import { AssetType, GeodataPlacesEntity } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
@@ -241,6 +241,12 @@ export class SearchDto {
size?: number;
}
+export class SearchPlacesDto {
+ @IsString()
+ @IsNotEmpty()
+ name!: string;
+}
+
export class SearchPeopleDto {
@IsString()
@IsNotEmpty()
@@ -251,3 +257,21 @@ export class SearchPeopleDto {
@Optional()
withHidden?: boolean;
}
+
+export class PlacesResponseDto {
+ name!: string;
+ latitude!: number;
+ longitude!: number;
+ admin1name?: string;
+ admin2name?: string;
+}
+
+export function mapPlaces(place: GeodataPlacesEntity): PlacesResponseDto {
+ return {
+ name: place.name,
+ latitude: place.latitude,
+ longitude: place.longitude,
+ admin1name: place.admin1Name,
+ admin2name: place.admin2Name,
+ };
+}
diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts
index 452c556f4..5b5639998 100644
--- a/server/src/domain/search/search.service.ts
+++ b/server/src/domain/search/search.service.ts
@@ -16,7 +16,15 @@ import {
SearchStrategy,
} from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config';
-import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto';
+import {
+ MetadataSearchDto,
+ PlacesResponseDto,
+ SearchDto,
+ SearchPeopleDto,
+ SearchPlacesDto,
+ SmartSearchDto,
+ mapPlaces,
+} from './dto';
import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto';
import { SearchResponseDto } from './response-dto';
@@ -41,6 +49,11 @@ export class SearchService {
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
}
+ async searchPlaces(dto: SearchPlacesDto): Promise {
+ const places = await this.searchRepository.searchPlaces(dto.name);
+ return places.map((place) => mapPlaces(place));
+ }
+
async getExploreData(auth: AuthDto): Promise[]> {
await this.configCore.requireFeature(FeatureFlag.SEARCH);
const options = { maxFields: 12, minAssetsPerField: 5 };
@@ -182,26 +195,22 @@ export class SearchService {
}
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise {
- if (dto.type === SearchSuggestionType.COUNTRY) {
- return this.metadataRepository.getCountries(auth.user.id);
+ switch (dto.type) {
+ case SearchSuggestionType.COUNTRY: {
+ return this.metadataRepository.getCountries(auth.user.id);
+ }
+ case SearchSuggestionType.STATE: {
+ return this.metadataRepository.getStates(auth.user.id, dto.country);
+ }
+ case SearchSuggestionType.CITY: {
+ return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
+ }
+ case SearchSuggestionType.CAMERA_MAKE: {
+ return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
+ }
+ case SearchSuggestionType.CAMERA_MODEL: {
+ return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
+ }
}
-
- if (dto.type === SearchSuggestionType.STATE) {
- return this.metadataRepository.getStates(auth.user.id, dto.country);
- }
-
- if (dto.type === SearchSuggestionType.CITY) {
- return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
- }
-
- if (dto.type === SearchSuggestionType.CAMERA_MAKE) {
- return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
- }
-
- if (dto.type === SearchSuggestionType.CAMERA_MODEL) {
- return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
- }
-
- return [];
}
}
diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts
index d69698254..857d1df32 100644
--- a/server/src/domain/storage-template/storage-template.service.ts
+++ b/server/src/domain/storage-template/storage-template.service.ts
@@ -117,7 +117,7 @@ export class StorageTemplateService {
return true;
}
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
- this.assetRepository.getAll(pagination),
+ this.assetRepository.getAll(pagination, { withExif: true }),
);
const users = await this.userRepository.getList();
diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts
index 4e57cfaa6..b807da966 100644
--- a/server/src/immich/controllers/search.controller.ts
+++ b/server/src/immich/controllers/search.controller.ts
@@ -2,9 +2,11 @@ import {
AuthDto,
MetadataSearchDto,
PersonResponseDto,
+ PlacesResponseDto,
SearchDto,
SearchExploreResponseDto,
SearchPeopleDto,
+ SearchPlacesDto,
SearchResponseDto,
SearchService,
SmartSearchDto,
@@ -48,6 +50,11 @@ export class SearchController {
return this.service.searchPerson(auth, dto);
}
+ @Get('places')
+ searchPlaces(@Query() dto: SearchPlacesDto): Promise {
+ return this.service.searchPlaces(dto);
+ }
+
@Get('suggestions')
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise {
return this.service.getSearchSuggestions(auth, dto);
diff --git a/server/src/infra/entities/geodata-admin1.entity.ts b/server/src/infra/entities/geodata-admin1.entity.ts
deleted file mode 100644
index 36cf0a805..000000000
--- a/server/src/infra/entities/geodata-admin1.entity.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Column, Entity, PrimaryColumn } from 'typeorm';
-
-@Entity('geodata_admin1')
-export class GeodataAdmin1Entity {
- @PrimaryColumn({ type: 'varchar' })
- key!: string;
-
- @Column({ type: 'varchar' })
- name!: string;
-}
diff --git a/server/src/infra/entities/geodata-admin2.entity.ts b/server/src/infra/entities/geodata-admin2.entity.ts
deleted file mode 100644
index bd03e8377..000000000
--- a/server/src/infra/entities/geodata-admin2.entity.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Column, Entity, PrimaryColumn } from 'typeorm';
-
-@Entity('geodata_admin2')
-export class GeodataAdmin2Entity {
- @PrimaryColumn({ type: 'varchar' })
- key!: string;
-
- @Column({ type: 'varchar' })
- name!: string;
-}
diff --git a/server/src/infra/entities/geodata-places.entity.ts b/server/src/infra/entities/geodata-places.entity.ts
index 244e4261b..966a50d5c 100644
--- a/server/src/infra/entities/geodata-places.entity.ts
+++ b/server/src/infra/entities/geodata-places.entity.ts
@@ -1,6 +1,4 @@
-import { GeodataAdmin1Entity } from '@app/infra/entities/geodata-admin1.entity';
-import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity';
-import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
+import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('geodata_places', { synchronize: false })
export class GeodataPlacesEntity {
@@ -21,7 +19,7 @@ export class GeodataPlacesEntity {
// asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)',
// type: 'earth',
// })
- earthCoord!: unknown;
+ // earthCoord!: unknown;
@Column({ type: 'char', length: 2 })
countryCode!: string;
@@ -32,27 +30,14 @@ export class GeodataPlacesEntity {
@Column({ type: 'varchar', length: 80, nullable: true })
admin2Code!: string;
- @Column({
- type: 'varchar',
- generatedType: 'STORED',
- asExpression: `"countryCode" || '.' || "admin1Code"`,
- nullable: true,
- })
- admin1Key!: string;
+ @Column({ type: 'varchar', nullable: true })
+ admin1Name!: string;
- @ManyToOne(() => GeodataAdmin1Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
- admin1!: GeodataAdmin1Entity;
+ @Column({ type: 'varchar', nullable: true })
+ admin2Name!: string;
- @Column({
- type: 'varchar',
- generatedType: 'STORED',
- asExpression: `"countryCode" || '.' || "admin1Code" || '.' || "admin2Code"`,
- nullable: true,
- })
- admin2Key!: string;
-
- @ManyToOne(() => GeodataAdmin2Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
- admin2!: GeodataAdmin2Entity;
+ @Column({ type: 'varchar', nullable: true })
+ alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts
index 957e15a88..af620790e 100644
--- a/server/src/infra/entities/index.ts
+++ b/server/src/infra/entities/index.ts
@@ -7,8 +7,6 @@ import { AssetStackEntity } from './asset-stack.entity';
import { AssetEntity } from './asset.entity';
import { AuditEntity } from './audit.entity';
import { ExifEntity } from './exif.entity';
-import { GeodataAdmin1Entity } from './geodata-admin1.entity';
-import { GeodataAdmin2Entity } from './geodata-admin2.entity';
import { GeodataPlacesEntity } from './geodata-places.entity';
import { LibraryEntity } from './library.entity';
import { MoveEntity } from './move.entity';
@@ -32,8 +30,6 @@ export * from './asset-stack.entity';
export * from './asset.entity';
export * from './audit.entity';
export * from './exif.entity';
-export * from './geodata-admin1.entity';
-export * from './geodata-admin2.entity';
export * from './geodata-places.entity';
export * from './library.entity';
export * from './move.entity';
@@ -59,8 +55,6 @@ export const databaseEntities = [
AuditEntity,
ExifEntity,
GeodataPlacesEntity,
- GeodataAdmin1Entity,
- GeodataAdmin2Entity,
MoveEntity,
PartnerEntity,
PersonEntity,
diff --git a/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts b/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts
new file mode 100644
index 000000000..136ca2598
--- /dev/null
+++ b/server/src/infra/migrations/1708059341865-GeodataLocationSearch.ts
@@ -0,0 +1,152 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class GeodataLocationSearch1708059341865 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
+ await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS unaccent`);
+
+ // https://stackoverflow.com/a/11007216
+ await queryRunner.query(`
+ CREATE OR REPLACE FUNCTION f_unaccent(text)
+ RETURNS text
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT
+ RETURN unaccent('unaccent', $1)`);
+
+ await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "admin1Name" varchar`);
+ await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "admin2Name" varchar`);
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin1Name" = admin1.name
+ FROM geodata_admin1 admin1
+ WHERE admin1.key = "admin1Key"`);
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin2Name" = admin2.name
+ FROM geodata_admin2 admin2
+ WHERE admin2.key = "admin2Key"`);
+
+ await queryRunner.query(`DROP TABLE geodata_admin1 CASCADE`);
+ await queryRunner.query(`DROP TABLE geodata_admin2 CASCADE`);
+
+ await queryRunner.query(`
+ ALTER TABLE geodata_places
+ DROP COLUMN "admin1Key",
+ DROP COLUMN "admin2Key"`);
+
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_name
+ ON geodata_places
+ USING gin (f_unaccent(name) gin_trgm_ops)`);
+
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_admin1_name
+ ON geodata_places
+ USING gin (f_unaccent("admin1Name") gin_trgm_ops)`);
+
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_admin2_name
+ ON geodata_places
+ USING gin (f_unaccent("admin2Name") gin_trgm_ops)`);
+
+ await queryRunner.query(
+ `
+ DELETE FROM "typeorm_metadata"
+ WHERE
+ "type" = $1 AND
+ "name" = $2 AND
+ "database" = $3 AND
+ "schema" = $4 AND
+ "table" = $5`,
+ ['GENERATED_COLUMN', 'admin1Key', 'immich', 'public', 'geodata_places'],
+ );
+
+ await queryRunner.query(
+ `
+ DELETE FROM "typeorm_metadata"
+ WHERE
+ "type" = $1 AND
+ "name" = $2 AND
+ "database" = $3 AND
+ "schema" = $4 AND
+ "table" = $5`,
+ ['GENERATED_COLUMN', 'admin2Key', 'immich', 'public', 'geodata_places'],
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ CREATE TABLE "geodata_admin1" (
+ "key" character varying NOT NULL,
+ "name" character varying NOT NULL,
+ CONSTRAINT "PK_3fe3a89c5aac789d365871cb172" PRIMARY KEY ("key")
+ )`);
+
+ await queryRunner.query(`
+ CREATE TABLE "geodata_admin2" (
+ "key" character varying NOT NULL,
+ "name" character varying NOT NULL,
+ CONSTRAINT "PK_1e3886455dbb684d6f6b4756726" PRIMARY KEY ("key")
+ )`);
+
+ await queryRunner.query(`
+ ALTER TABLE geodata_places
+ ADD COLUMN "admin1Key" character varying
+ GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code") STORED,
+ ADD COLUMN "admin2Key" character varying
+ GENERATED ALWAYS AS ("countryCode" || '.' || "admin1Code" || '.' || "admin2Code") STORED`);
+
+ await queryRunner.query(
+ `
+ INSERT INTO "geodata_admin1"
+ SELECT DISTINCT
+ "admin1Key" AS "key",
+ "admin1Name" AS "name"
+ FROM geodata_places
+ WHERE "admin1Name" IS NOT NULL`,
+ );
+
+ await queryRunner.query(
+ `
+ INSERT INTO "geodata_admin2"
+ SELECT DISTINCT
+ "admin2Key" AS "key",
+ "admin2Name" AS "name"
+ FROM geodata_places
+ WHERE "admin2Name" IS NOT NULL`,
+ );
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin1Name" = admin1.name
+ FROM geodata_admin1 admin1
+ WHERE admin1.key = "admin1Key"`);
+
+ await queryRunner.query(`
+ UPDATE geodata_places
+ SET "admin2Name" = admin2.name
+ FROM geodata_admin2 admin2
+ WHERE admin2.key = "admin2Key";`);
+
+ await queryRunner.query(
+ `
+ INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value")
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ ['immich', 'public', 'geodata_places', 'GENERATED_COLUMN', 'admin1Key', '"countryCode" || \'.\' || "admin1Code"'],
+ );
+
+ await queryRunner.query(
+ `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value")
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [
+ 'immich',
+ 'public',
+ 'geodata_places',
+ 'GENERATED_COLUMN',
+ 'admin2Key',
+ '"countryCode" || \'.\' || "admin1Code" || \'.\' || "admin2Code"',
+ ],
+ );
+ }
+}
diff --git a/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts b/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts
new file mode 100644
index 000000000..0cea9a041
--- /dev/null
+++ b/server/src/infra/migrations/1708116312820-GeonamesEnhancement.ts
@@ -0,0 +1,18 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class GeonamesEnhancement1708116312820 implements MigrationInterface {
+ name = 'GeonamesEnhancement1708116312820'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE geodata_places ADD COLUMN "alternateNames" varchar`);
+ await queryRunner.query(`
+ CREATE INDEX idx_geodata_places_admin2_alternate_names
+ ON geodata_places
+ USING gin (f_unaccent("alternateNames") gin_trgm_ops)`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE geodata_places DROP COLUMN "alternateNames"`);
+ }
+
+}
diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts
index 6a90ad108..4abfe0eac 100644
--- a/server/src/infra/repositories/metadata.repository.ts
+++ b/server/src/infra/repositories/metadata.repository.ts
@@ -2,7 +2,7 @@ import {
citiesFile,
geodataAdmin1Path,
geodataAdmin2Path,
- geodataCitites500Path,
+ geodataCities500Path,
geodataDatePath,
GeoPoint,
IMetadataRepository,
@@ -10,13 +10,7 @@ import {
ISystemMetadataRepository,
ReverseGeocodeResult,
} from '@app/domain';
-import {
- ExifEntity,
- GeodataAdmin1Entity,
- GeodataAdmin2Entity,
- GeodataPlacesEntity,
- SystemMetadataKey,
-} from '@app/infra/entities';
+import { ExifEntity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
@@ -26,19 +20,16 @@ import { getName } from 'i18n-iso-countries';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import * as readLine from 'node:readline';
-import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm';
+import { DataSource, QueryRunner, Repository } from 'typeorm';
+import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
import { DummyValue, GenerateSql } from '../infra.util';
-type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
-type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
-
export class MetadataRepository implements IMetadataRepository {
constructor(
@InjectRepository(ExifEntity) private exifRepository: Repository,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository,
- @InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository,
- @InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository,
- @Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository,
+ @Inject(ISystemMetadataRepository)
+ private readonly systemMetadataRepository: ISystemMetadataRepository,
@InjectDataSource() private dataSource: DataSource,
) {}
@@ -54,7 +45,6 @@ export class MetadataRepository implements IMetadataRepository {
return;
}
- this.logger.log('Importing geodata to database from file');
await this.importGeodata();
await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
@@ -69,12 +59,14 @@ export class MetadataRepository implements IMetadataRepository {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
+ const admin1 = await this.loadAdmin(geodataAdmin1Path);
+ const admin2 = await this.loadAdmin(geodataAdmin2Path);
+
try {
await queryRunner.startTransaction();
- await this.loadCities500(queryRunner);
- await this.loadAdmin1(queryRunner);
- await this.loadAdmin2(queryRunner);
+ await queryRunner.manager.clear(GeodataPlacesEntity);
+ await this.loadCities500(queryRunner, admin1, admin2);
await queryRunner.commitTransaction();
} catch (error) {
@@ -86,76 +78,73 @@ export class MetadataRepository implements IMetadataRepository {
}
}
- private async loadGeodataToTableFromFile(
+ private async loadGeodataToTableFromFile(
queryRunner: QueryRunner,
- lineToEntityMapper: (lineSplit: string[]) => T,
+ lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity,
filePath: string,
- entity: GeoEntityClass,
) {
if (!existsSync(filePath)) {
this.logger.error(`Geodata file ${filePath} not found`);
throw new Error(`Geodata file ${filePath} not found`);
}
- await queryRunner.manager.clear(entity);
const input = createReadStream(filePath);
- let buffer: DeepPartial[] = [];
- const lineReader = readLine.createInterface({ input: input });
+ let bufferGeodata: QueryDeepPartialEntity[] = [];
+ const lineReader = readLine.createInterface({ input });
for await (const line of lineReader) {
const lineSplit = line.split('\t');
- buffer.push(lineToEntityMapper(lineSplit));
- if (buffer.length > 1000) {
- await queryRunner.manager.save(buffer);
- buffer = [];
+ const geoData = lineToEntityMapper(lineSplit);
+ bufferGeodata.push(geoData);
+ if (bufferGeodata.length > 1000) {
+ await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
+ bufferGeodata = [];
}
}
- await queryRunner.manager.save(buffer);
+ await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']);
}
- private async loadCities500(queryRunner: QueryRunner) {
- await this.loadGeodataToTableFromFile(
+ private async loadCities500(
+ queryRunner: QueryRunner,
+ admin1Map: Map,
+ admin2Map: Map,
+ ) {
+ await this.loadGeodataToTableFromFile(
queryRunner,
(lineSplit: string[]) =>
this.geodataPlacesRepository.create({
id: Number.parseInt(lineSplit[0]),
name: lineSplit[1],
+ alternateNames: lineSplit[3],
latitude: Number.parseFloat(lineSplit[4]),
longitude: Number.parseFloat(lineSplit[5]),
countryCode: lineSplit[8],
admin1Code: lineSplit[10],
admin2Code: lineSplit[11],
modificationDate: lineSplit[18],
+ admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`),
+ admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`),
}),
- geodataCitites500Path,
- GeodataPlacesEntity,
+ geodataCities500Path,
);
}
- private async loadAdmin1(queryRunner: QueryRunner) {
- await this.loadGeodataToTableFromFile(
- queryRunner,
- (lineSplit: string[]) =>
- this.geodataAdmin1Repository.create({
- key: lineSplit[0],
- name: lineSplit[1],
- }),
- geodataAdmin1Path,
- GeodataAdmin1Entity,
- );
- }
+ private async loadAdmin(filePath: string) {
+ if (!existsSync(filePath)) {
+ this.logger.error(`Geodata file ${filePath} not found`);
+ throw new Error(`Geodata file ${filePath} not found`);
+ }
- private async loadAdmin2(queryRunner: QueryRunner) {
- await this.loadGeodataToTableFromFile(
- queryRunner,
- (lineSplit: string[]) =>
- this.geodataAdmin2Repository.create({
- key: lineSplit[0],
- name: lineSplit[1],
- }),
- geodataAdmin2Path,
- GeodataAdmin2Entity,
- );
+ const input = createReadStream(filePath);
+ const lineReader = readLine.createInterface({ input: input });
+
+ const adminMap = new Map();
+ for await (const line of lineReader) {
+ const lineSplit = line.split('\t');
+ adminMap.set(lineSplit[0], lineSplit[1]);
+ }
+
+ return adminMap;
}
async teardown() {
@@ -167,8 +156,6 @@ export class MetadataRepository implements IMetadataRepository {
const response = await this.geodataPlacesRepository
.createQueryBuilder('geoplaces')
- .leftJoinAndSelect('geoplaces.admin1', 'admin1')
- .leftJoinAndSelect('geoplaces.admin2', 'admin2')
.where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point)
.orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")')
.limit(1)
@@ -183,9 +170,9 @@ export class MetadataRepository implements IMetadataRepository {
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
- const { countryCode, name: city, admin1, admin2 } = response;
+ const { countryCode, name: city, admin1Name, admin2Name } = response;
const country = getName(countryCode, 'en') ?? null;
- const stateParts = [admin2?.name, admin1?.name].filter((name) => !!name);
+ const stateParts = [admin2Name, admin1Name].filter((name) => !!name);
const state = stateParts.length > 0 ? stateParts.join(', ') : null;
return { country, state, city };
diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts
index a30c96b10..089640128 100644
--- a/server/src/infra/repositories/search.repository.ts
+++ b/server/src/infra/repositories/search.repository.ts
@@ -12,7 +12,13 @@ import {
SmartSearchOptions,
} from '@app/domain';
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
-import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities';
+import {
+ AssetEntity,
+ AssetFaceEntity,
+ GeodataPlacesEntity,
+ SmartInfoEntity,
+ SmartSearchEntity,
+} from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -31,6 +37,7 @@ export class SearchRepository implements ISearchRepository {
@InjectRepository(AssetEntity) private assetRepository: Repository,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository,
+ @InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository,
) {
this.faceColumns = this.assetFaceRepository.manager.connection
.getMetadata(AssetFaceEntity)
@@ -172,6 +179,27 @@ export class SearchRepository implements ISearchRepository {
}));
}
+ @GenerateSql({ params: [DummyValue.STRING] })
+ async searchPlaces(placeName: string): Promise {
+ return await this.geodataPlacesRepository
+ .createQueryBuilder('geoplaces')
+ .where(`f_unaccent(name) %>> f_unaccent(:placeName)`)
+ .orWhere(`f_unaccent("admin2Name") %>> f_unaccent(:placeName)`)
+ .orWhere(`f_unaccent("admin1Name") %>> f_unaccent(:placeName)`)
+ .orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
+ .orderBy(
+ `
+ COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0) +
+ COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0) +
+ COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0) +
+ COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0)
+ `,
+ )
+ .setParameters({ placeName })
+ .limit(20)
+ .getMany();
+ }
+
async upsert(smartInfo: Partial, embedding?: Embedding): Promise {
await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
if (!smartInfo.assetId || !embedding) {
diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql
index a21697c26..c45d90a7a 100644
--- a/server/src/infra/sql/search.repository.sql
+++ b/server/src/infra/sql/search.repository.sql
@@ -238,3 +238,37 @@ FROM
WHERE
res.distance <= $3
COMMIT
+
+-- SearchRepository.searchPlaces
+SELECT
+ "geoplaces"."id" AS "geoplaces_id",
+ "geoplaces"."name" AS "geoplaces_name",
+ "geoplaces"."longitude" AS "geoplaces_longitude",
+ "geoplaces"."latitude" AS "geoplaces_latitude",
+ "geoplaces"."countryCode" AS "geoplaces_countryCode",
+ "geoplaces"."admin1Code" AS "geoplaces_admin1Code",
+ "geoplaces"."admin2Code" AS "geoplaces_admin2Code",
+ "geoplaces"."admin1Name" AS "geoplaces_admin1Name",
+ "geoplaces"."admin2Name" AS "geoplaces_admin2Name",
+ "geoplaces"."alternateNames" AS "geoplaces_alternateNames",
+ "geoplaces"."modificationDate" AS "geoplaces_modificationDate"
+FROM
+ "geodata_places" "geoplaces"
+WHERE
+ f_unaccent (name) %>> f_unaccent ($1)
+ OR f_unaccent ("admin2Name") %>> f_unaccent ($1)
+ OR f_unaccent ("admin1Name") %>> f_unaccent ($1)
+ OR f_unaccent ("alternateNames") %>> f_unaccent ($1)
+ORDER BY
+ COALESCE(f_unaccent (name) <->>> f_unaccent ($1), 0) + COALESCE(
+ f_unaccent ("admin2Name") <->>> f_unaccent ($1),
+ 0
+ ) + COALESCE(
+ f_unaccent ("admin1Name") <->>> f_unaccent ($1),
+ 0
+ ) + COALESCE(
+ f_unaccent ("alternateNames") <->>> f_unaccent ($1),
+ 0
+ ) ASC
+LIMIT
+ 20
diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts
index e0bdab269..06a2cb76d 100644
--- a/server/test/repositories/search.repository.mock.ts
+++ b/server/test/repositories/search.repository.mock.ts
@@ -7,5 +7,6 @@ export const newSearchRepositoryMock = (): jest.Mocked => {
searchSmart: jest.fn(),
searchFaces: jest.fn(),
upsert: jest.fn(),
+ searchPlaces: jest.fn(),
};
};
diff --git a/web/package-lock.json b/web/package-lock.json
index 84bd64d3e..78e5caf7c 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -32,7 +32,7 @@
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
- "@sveltejs/kit": "^2.5.0",
+ "@sveltejs/kit": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
@@ -1859,9 +1859,9 @@
}
},
"node_modules/@sveltejs/kit": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz",
- "integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==",
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.1.tgz",
+ "integrity": "sha512-TKj08o3mJCoQNLTdRdGkHPePTCPUGTgkew65RDqjVU3MtPVxljsofXQYfXndHfq0P7KoPRO/0/reF6HesU0Djw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
diff --git a/web/package.json b/web/package.json
index 1542acc2d..2b53d0645 100644
--- a/web/package.json
+++ b/web/package.json
@@ -27,7 +27,7 @@
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
- "@sveltejs/kit": "^2.5.0",
+ "@sveltejs/kit": "^2.5.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
diff --git a/web/src/hooks.client.ts b/web/src/hooks.client.ts
index 1e29371fa..802e9a712 100644
--- a/web/src/hooks.client.ts
+++ b/web/src/hooks.client.ts
@@ -1,34 +1,22 @@
+import { isHttpError } from '@immich/sdk';
import type { HandleClientError } from '@sveltejs/kit';
-import type { AxiosError, AxiosResponse } from 'axios';
const LOG_PREFIX = '[hooks.client.ts]';
const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
const parseError = (error: unknown) => {
- const httpError = error as AxiosError;
- const request = httpError?.request as Request & { path: string };
- const response = httpError?.response as AxiosResponse<{
- message: string;
- statusCode: number;
- error: string;
- }>;
+ const httpError = isHttpError(error) ? error : undefined;
+ const statusCode = httpError?.status || httpError?.data?.statusCode || 500;
+ const message = httpError?.data?.message || (httpError?.data && String(httpError.data)) || httpError?.message;
- let code = response?.data?.statusCode || response?.status || httpError.code || '500';
- if (response) {
- code += ` - ${response.data?.error || response.statusText}`;
- }
-
- if (request && response) {
- console.log({
- status: response.status,
- url: `${request.method} ${request.path}`,
- response: response.data || 'No data',
- });
- }
+ console.log({
+ status: statusCode,
+ response: httpError?.data || 'No data',
+ });
return {
- message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE,
- code,
+ message: message || DEFAULT_MESSAGE,
+ code: statusCode,
stack: httpError?.stack,
};
};
diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte
index 6a542d81d..ba24f3aab 100644
--- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte
@@ -112,8 +112,8 @@
desc="Minimum confidence score for a face to be detected from 0-1. Lower values will detect more faces but may result in false positives."
bind:value={config.machineLearning.facialRecognition.minScore}
step="0.1"
- min="0"
- max="1"
+ min={0}
+ max={1}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.minScore !==
savedConfig.machineLearning.facialRecognition.minScore}
@@ -125,8 +125,8 @@
desc="Maximum distance between two faces to be considered the same person, ranging from 0-2. Lowering this can prevent labeling two people as the same person, while raising it can prevent labeling the same person as two different people. Note that it is easier to merge two people than to split one person in two, so err on the side of a lower threshold when possible."
bind:value={config.machineLearning.facialRecognition.maxDistance}
step="0.1"
- min="0"
- max="2"
+ min={0}
+ max={2}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.maxDistance !==
savedConfig.machineLearning.facialRecognition.maxDistance}
@@ -138,7 +138,7 @@
desc="The minimum number of recognized faces for a person to be created. Increasing this makes Facial Recognition more precise at the cost of increasing the chance that a face is not assigned to a person."
bind:value={config.machineLearning.facialRecognition.minFaces}
step="1"
- min="1"
+ min={1}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.facialRecognition.enabled}
isEdited={config.machineLearning.facialRecognition.minFaces !==
savedConfig.machineLearning.facialRecognition.minFaces}
diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
index 5fc3b3e22..11e07d002 100644
--- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
+++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte
@@ -84,7 +84,26 @@
};
-
+
+
{#await getTemplateOptions() then}
- import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
- import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte';
+ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+ import ProgressBar, { ProgressBarStatus } from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
+ import SlideshowSettings from '$lib/components/slideshow-settings.svelte';
import { slideshowStore } from '$lib/stores/slideshow.store';
+ import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiPause, mdiPlay } from '@mdi/js';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
- import {
- mdiChevronLeft,
- mdiChevronRight,
- mdiClose,
- mdiPause,
- mdiPlay,
- mdiShuffle,
- mdiShuffleDisabled,
- } from '@mdi/js';
- const { slideshowShuffle } = slideshowStore;
- const { restartProgress, stopProgress } = slideshowStore;
+ const { restartProgress, stopProgress, slideshowDelay, showProgressBar } = slideshowStore;
let progressBarStatus: ProgressBarStatus;
let progressBar: ProgressBar;
+ let showSettings = false;
let unsubscribeRestart: () => void;
let unsubscribeStop: () => void;
@@ -54,25 +47,27 @@
- dispatch('close')} title="Exit Slideshow" />
- {#if $slideshowShuffle}
- ($slideshowShuffle = false)} title="Shuffle" />
- {:else}
- ($slideshowShuffle = true)} title="No shuffle" />
- {/if}
+ dispatch('close')} title="Exit Slideshow" />
(progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())}
title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'}
/>
- dispatch('prev')} title="Previous" />
- dispatch('next')} title="Next" />
+ dispatch('prev')} title="Previous" />
+ dispatch('next')} title="Next" />
+ (showSettings = !showSettings)} title="Next" />
+{#if showSettings}
+ (showSettings = false)} />
+{/if}
+
dispatch('next')}
- duration={5000}
/>
diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
index ec511d419..8ee042a1a 100644
--- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte
+++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte
@@ -3,7 +3,7 @@
import Icon from '$lib/components/elements/icon.svelte';
import { ProjectionType } from '$lib/constants';
import { getAssetFileUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils';
- import { timeToSeconds } from '$lib/utils/time-to-seconds';
+ import { timeToSeconds } from '$lib/utils/date-time';
import { AssetTypeEnum, ThumbnailFormat, type AssetResponseDto } from '@immich/sdk';
import {
mdiArchiveArrowDownOutline,
diff --git a/web/src/lib/components/elements/date-input.svelte b/web/src/lib/components/elements/date-input.svelte
new file mode 100644
index 000000000..e4ec4bcab
--- /dev/null
+++ b/web/src/lib/components/elements/date-input.svelte
@@ -0,0 +1,24 @@
+
+
+ {
+ updatedValue = e.currentTarget.value;
+
+ // Only update when value is not empty to prevent resetting the input
+ if (updatedValue !== '') {
+ value = updatedValue;
+ }
+ }}
+ on:blur={() => (value = updatedValue)}
+/>
diff --git a/web/src/lib/components/elements/search-bar.svelte b/web/src/lib/components/elements/search-bar.svelte
index 9c6eded22..898601d0a 100644
--- a/web/src/lib/components/elements/search-bar.svelte
+++ b/web/src/lib/components/elements/search-bar.svelte
@@ -6,6 +6,7 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
export let name: string;
+ export let roundedBottom = true;
export let isSearching: boolean;
export let placeholder: string;
@@ -17,7 +18,11 @@
};
-
+