mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 04:05:39 -04:00
feat(server, web): search location (#7139)
* feat: search location * fix: tests * feat: outclick * location search index * update query * fixed query * updated sql * update query * Update search.dto.ts Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * coalesce * fix: tests * feat: add alternate names * fix: generate sql files * single table, add alternate names to query, cleanup * merge main * update sql * pr feedback * pr feedback * chore: fix merge --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
This commit is contained in:
parent
719dbcc4d0
commit
a2934b8830
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
@ -108,6 +108,7 @@ doc/PersonResponseDto.md
|
|||||||
doc/PersonStatisticsResponseDto.md
|
doc/PersonStatisticsResponseDto.md
|
||||||
doc/PersonUpdateDto.md
|
doc/PersonUpdateDto.md
|
||||||
doc/PersonWithFacesResponseDto.md
|
doc/PersonWithFacesResponseDto.md
|
||||||
|
doc/PlacesResponseDto.md
|
||||||
doc/QueueStatusDto.md
|
doc/QueueStatusDto.md
|
||||||
doc/ReactionLevel.md
|
doc/ReactionLevel.md
|
||||||
doc/ReactionType.md
|
doc/ReactionType.md
|
||||||
@ -308,6 +309,7 @@ lib/model/person_response_dto.dart
|
|||||||
lib/model/person_statistics_response_dto.dart
|
lib/model/person_statistics_response_dto.dart
|
||||||
lib/model/person_update_dto.dart
|
lib/model/person_update_dto.dart
|
||||||
lib/model/person_with_faces_response_dto.dart
|
lib/model/person_with_faces_response_dto.dart
|
||||||
|
lib/model/places_response_dto.dart
|
||||||
lib/model/queue_status_dto.dart
|
lib/model/queue_status_dto.dart
|
||||||
lib/model/reaction_level.dart
|
lib/model/reaction_level.dart
|
||||||
lib/model/reaction_type.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_statistics_response_dto_test.dart
|
||||||
test/person_update_dto_test.dart
|
test/person_update_dto_test.dart
|
||||||
test/person_with_faces_response_dto_test.dart
|
test/person_with_faces_response_dto_test.dart
|
||||||
|
test/places_response_dto_test.dart
|
||||||
test/queue_status_dto_test.dart
|
test/queue_status_dto_test.dart
|
||||||
test/reaction_level_test.dart
|
test/reaction_level_test.dart
|
||||||
test/reaction_type_test.dart
|
test/reaction_type_test.dart
|
||||||
|
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@ -166,6 +166,7 @@ Class | Method | HTTP request | Description
|
|||||||
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
|
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
|
||||||
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata |
|
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata |
|
||||||
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
|
*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 |
|
*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart |
|
||||||
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
|
*ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config |
|
||||||
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
|
*ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features |
|
||||||
@ -306,6 +307,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
|
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
|
||||||
- [PersonUpdateDto](doc//PersonUpdateDto.md)
|
- [PersonUpdateDto](doc//PersonUpdateDto.md)
|
||||||
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
|
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
|
||||||
|
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
||||||
- [QueueStatusDto](doc//QueueStatusDto.md)
|
- [QueueStatusDto](doc//QueueStatusDto.md)
|
||||||
- [ReactionLevel](doc//ReactionLevel.md)
|
- [ReactionLevel](doc//ReactionLevel.md)
|
||||||
- [ReactionType](doc//ReactionType.md)
|
- [ReactionType](doc//ReactionType.md)
|
||||||
|
19
mobile/openapi/doc/PlacesResponseDto.md
generated
Normal file
19
mobile/openapi/doc/PlacesResponseDto.md
generated
Normal file
@ -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)
|
||||||
|
|
||||||
|
|
56
mobile/openapi/doc/SearchApi.md
generated
56
mobile/openapi/doc/SearchApi.md
generated
@ -14,6 +14,7 @@ Method | HTTP request | Description
|
|||||||
[**search**](SearchApi.md#search) | **GET** /search |
|
[**search**](SearchApi.md#search) | **GET** /search |
|
||||||
[**searchMetadata**](SearchApi.md#searchmetadata) | **POST** /search/metadata |
|
[**searchMetadata**](SearchApi.md#searchmetadata) | **POST** /search/metadata |
|
||||||
[**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person |
|
[**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person |
|
||||||
|
[**searchPlaces**](SearchApi.md#searchplaces) | **GET** /search/places |
|
||||||
[**searchSmart**](SearchApi.md#searchsmart) | **POST** /search/smart |
|
[**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)
|
[[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<PlacesResponseDto> searchPlaces(name)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure API key authorization: cookie
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure API key authorization: api_key
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('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>**](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**
|
# **searchSmart**
|
||||||
> SearchResponseDto searchSmart(smartSearchDto)
|
> SearchResponseDto searchSmart(smartSearchDto)
|
||||||
|
|
||||||
|
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
@ -142,6 +142,7 @@ part 'model/person_response_dto.dart';
|
|||||||
part 'model/person_statistics_response_dto.dart';
|
part 'model/person_statistics_response_dto.dart';
|
||||||
part 'model/person_update_dto.dart';
|
part 'model/person_update_dto.dart';
|
||||||
part 'model/person_with_faces_response_dto.dart';
|
part 'model/person_with_faces_response_dto.dart';
|
||||||
|
part 'model/places_response_dto.dart';
|
||||||
part 'model/queue_status_dto.dart';
|
part 'model/queue_status_dto.dart';
|
||||||
part 'model/reaction_level.dart';
|
part 'model/reaction_level.dart';
|
||||||
part 'model/reaction_type.dart';
|
part 'model/reaction_type.dart';
|
||||||
|
52
mobile/openapi/lib/api/search_api.dart
generated
52
mobile/openapi/lib/api/search_api.dart
generated
@ -360,6 +360,58 @@ class SearchApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /search/places' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] name (required):
|
||||||
|
Future<Response> searchPlacesWithHttpInfo(String name,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/search/places';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
queryParams.addAll(_queryParams('', 'name', name));
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] name (required):
|
||||||
|
Future<List<PlacesResponseDto>?> 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<PlacesResponseDto>') as List)
|
||||||
|
.cast<PlacesResponseDto>()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'POST /search/smart' operation and returns the [Response].
|
/// Performs an HTTP 'POST /search/smart' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
|
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
@ -366,6 +366,8 @@ class ApiClient {
|
|||||||
return PersonUpdateDto.fromJson(value);
|
return PersonUpdateDto.fromJson(value);
|
||||||
case 'PersonWithFacesResponseDto':
|
case 'PersonWithFacesResponseDto':
|
||||||
return PersonWithFacesResponseDto.fromJson(value);
|
return PersonWithFacesResponseDto.fromJson(value);
|
||||||
|
case 'PlacesResponseDto':
|
||||||
|
return PlacesResponseDto.fromJson(value);
|
||||||
case 'QueueStatusDto':
|
case 'QueueStatusDto':
|
||||||
return QueueStatusDto.fromJson(value);
|
return QueueStatusDto.fromJson(value);
|
||||||
case 'ReactionLevel':
|
case 'ReactionLevel':
|
||||||
|
148
mobile/openapi/lib/model/places_response_dto.dart
generated
Normal file
148
mobile/openapi/lib/model/places_response_dto.dart
generated
Normal file
@ -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<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
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<String, dynamic>();
|
||||||
|
|
||||||
|
return PlacesResponseDto(
|
||||||
|
admin1name: mapValueOfType<String>(json, r'admin1name'),
|
||||||
|
admin2name: mapValueOfType<String>(json, r'admin2name'),
|
||||||
|
latitude: num.parse('${json[r'latitude']}'),
|
||||||
|
longitude: num.parse('${json[r'longitude']}'),
|
||||||
|
name: mapValueOfType<String>(json, r'name')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PlacesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <PlacesResponseDto>[];
|
||||||
|
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<String, PlacesResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, PlacesResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // 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<String, List<PlacesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<PlacesResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
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 = <String>{
|
||||||
|
'latitude',
|
||||||
|
'longitude',
|
||||||
|
'name',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
47
mobile/openapi/test/places_response_dto_test.dart
generated
Normal file
47
mobile/openapi/test/places_response_dto_test.dart
generated
Normal file
@ -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
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
5
mobile/openapi/test/search_api_test.dart
generated
5
mobile/openapi/test/search_api_test.dart
generated
@ -42,6 +42,11 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//Future<List<PlacesResponseDto>> searchPlaces(String name) async
|
||||||
|
test('test searchPlaces', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
//Future<SearchResponseDto> searchSmart(SmartSearchDto smartSearchDto) async
|
//Future<SearchResponseDto> searchSmart(SmartSearchDto smartSearchDto) async
|
||||||
test('test searchSmart', () async {
|
test('test searchSmart', () async {
|
||||||
// TODO
|
// TODO
|
||||||
|
@ -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": {
|
"/search/smart": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "searchSmart",
|
"operationId": "searchSmart",
|
||||||
@ -8756,6 +8800,31 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"QueueStatusDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"isActive": {
|
"isActive": {
|
||||||
|
128
open-api/typescript-sdk/axios-client/api.ts
generated
128
open-api/typescript-sdk/axios-client/api.ts
generated
@ -2994,6 +2994,43 @@ export interface PersonWithFacesResponseDto {
|
|||||||
*/
|
*/
|
||||||
'thumbnailPath': string;
|
'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
|
* @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<RequestArgs> => {
|
||||||
|
// 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);
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
@ -15584,6 +15666,18 @@ export const SearchApiFp = function(configuration?: Configuration) {
|
|||||||
const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url;
|
const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url;
|
||||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
|
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<Array<PlacesResponseDto>>> {
|
||||||
|
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
|
* @param {SmartSearchDto} smartSearchDto
|
||||||
@ -15651,6 +15745,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
|
|||||||
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
|
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
|
||||||
return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath));
|
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<Array<PlacesResponseDto>> {
|
||||||
|
return localVarFp.searchPlaces(requestParameters.name, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
|
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
|
||||||
@ -15817,6 +15920,20 @@ export interface SearchApiSearchPersonRequest {
|
|||||||
readonly withHidden?: boolean
|
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.
|
* Request parameters for searchSmart operation in SearchApi.
|
||||||
* @export
|
* @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));
|
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.
|
* @param {SearchApiSearchSmartRequest} requestParameters Request parameters.
|
||||||
|
19
open-api/typescript-sdk/fetch-client.ts
generated
19
open-api/typescript-sdk/fetch-client.ts
generated
@ -646,6 +646,13 @@ export type MetadataSearchDto = {
|
|||||||
withPeople?: boolean;
|
withPeople?: boolean;
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
};
|
};
|
||||||
|
export type PlacesResponseDto = {
|
||||||
|
admin1name?: string;
|
||||||
|
admin2name?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
export type SmartSearchDto = {
|
export type SmartSearchDto = {
|
||||||
city?: string;
|
city?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
@ -2198,6 +2205,18 @@ export function searchPerson({ name, withHidden }: {
|
|||||||
...opts
|
...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 }: {
|
export function searchSmart({ smartSearchDto }: {
|
||||||
smartSearchDto: SmartSearchDto;
|
smartSearchDto: SmartSearchDto;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
@ -91,7 +91,7 @@ export const citiesFile = 'cities500.txt';
|
|||||||
export const geodataDatePath = join(GEODATA_ROOT_PATH, 'geodata-date.txt');
|
export const geodataDatePath = join(GEODATA_ROOT_PATH, 'geodata-date.txt');
|
||||||
export const geodataAdmin1Path = join(GEODATA_ROOT_PATH, 'admin1CodesASCII.txt');
|
export const geodataAdmin1Path = join(GEODATA_ROOT_PATH, 'admin1CodesASCII.txt');
|
||||||
export const geodataAdmin2Path = join(GEODATA_ROOT_PATH, 'admin2Codes.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<string, string[]> = {
|
const image: Record<string, string[]> = {
|
||||||
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
|
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
|
||||||
|
@ -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';
|
import { Paginated } from '../domain.util';
|
||||||
|
|
||||||
export const ISearchRepository = 'ISearchRepository';
|
export const ISearchRepository = 'ISearchRepository';
|
||||||
@ -186,4 +186,5 @@ export interface ISearchRepository {
|
|||||||
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
|
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
|
||||||
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
||||||
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
|
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
|
||||||
|
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { AssetOrder } from '@app/domain/asset/dto/asset.dto';
|
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 { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
||||||
@ -241,6 +241,12 @@ export class SearchDto {
|
|||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SearchPlacesDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SearchPeopleDto {
|
export class SearchPeopleDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ -251,3 +257,21 @@ export class SearchPeopleDto {
|
|||||||
@Optional()
|
@Optional()
|
||||||
withHidden?: boolean;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -16,7 +16,15 @@ import {
|
|||||||
SearchStrategy,
|
SearchStrategy,
|
||||||
} from '../repositories';
|
} from '../repositories';
|
||||||
import { FeatureFlag, SystemConfigCore } from '../system-config';
|
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 { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto';
|
||||||
import { SearchResponseDto } from './response-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 });
|
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchPlaces(dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
|
||||||
|
const places = await this.searchRepository.searchPlaces(dto.name);
|
||||||
|
return places.map((place) => mapPlaces(place));
|
||||||
|
}
|
||||||
|
|
||||||
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
|
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
|
||||||
await this.configCore.requireFeature(FeatureFlag.SEARCH);
|
await this.configCore.requireFeature(FeatureFlag.SEARCH);
|
||||||
const options = { maxFields: 12, minAssetsPerField: 5 };
|
const options = { maxFields: 12, minAssetsPerField: 5 };
|
||||||
|
@ -2,9 +2,11 @@ import {
|
|||||||
AuthDto,
|
AuthDto,
|
||||||
MetadataSearchDto,
|
MetadataSearchDto,
|
||||||
PersonResponseDto,
|
PersonResponseDto,
|
||||||
|
PlacesResponseDto,
|
||||||
SearchDto,
|
SearchDto,
|
||||||
SearchExploreResponseDto,
|
SearchExploreResponseDto,
|
||||||
SearchPeopleDto,
|
SearchPeopleDto,
|
||||||
|
SearchPlacesDto,
|
||||||
SearchResponseDto,
|
SearchResponseDto,
|
||||||
SearchService,
|
SearchService,
|
||||||
SmartSearchDto,
|
SmartSearchDto,
|
||||||
@ -48,6 +50,11 @@ export class SearchController {
|
|||||||
return this.service.searchPerson(auth, dto);
|
return this.service.searchPerson(auth, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('places')
|
||||||
|
searchPlaces(@Query() dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
|
||||||
|
return this.service.searchPlaces(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('suggestions')
|
@Get('suggestions')
|
||||||
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
|
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
|
||||||
return this.service.getSearchSuggestions(auth, dto);
|
return this.service.getSearchSuggestions(auth, dto);
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -1,6 +1,4 @@
|
|||||||
import { GeodataAdmin1Entity } from '@app/infra/entities/geodata-admin1.entity';
|
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||||
import { GeodataAdmin2Entity } from '@app/infra/entities/geodata-admin2.entity';
|
|
||||||
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
|
|
||||||
|
|
||||||
@Entity('geodata_places', { synchronize: false })
|
@Entity('geodata_places', { synchronize: false })
|
||||||
export class GeodataPlacesEntity {
|
export class GeodataPlacesEntity {
|
||||||
@ -21,7 +19,7 @@ export class GeodataPlacesEntity {
|
|||||||
// asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)',
|
// asExpression: 'll_to_earth((latitude)::double precision, (longitude)::double precision)',
|
||||||
// type: 'earth',
|
// type: 'earth',
|
||||||
// })
|
// })
|
||||||
earthCoord!: unknown;
|
// earthCoord!: unknown;
|
||||||
|
|
||||||
@Column({ type: 'char', length: 2 })
|
@Column({ type: 'char', length: 2 })
|
||||||
countryCode!: string;
|
countryCode!: string;
|
||||||
@ -32,27 +30,14 @@ export class GeodataPlacesEntity {
|
|||||||
@Column({ type: 'varchar', length: 80, nullable: true })
|
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||||
admin2Code!: string;
|
admin2Code!: string;
|
||||||
|
|
||||||
@Column({
|
@Column({ type: 'varchar', nullable: true })
|
||||||
type: 'varchar',
|
admin1Name!: string;
|
||||||
generatedType: 'STORED',
|
|
||||||
asExpression: `"countryCode" || '.' || "admin1Code"`,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
admin1Key!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => GeodataAdmin1Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
admin1!: GeodataAdmin1Entity;
|
admin2Name!: string;
|
||||||
|
|
||||||
@Column({
|
@Column({ type: 'varchar', nullable: true })
|
||||||
type: 'varchar',
|
alternateNames!: string;
|
||||||
generatedType: 'STORED',
|
|
||||||
asExpression: `"countryCode" || '.' || "admin1Code" || '.' || "admin2Code"`,
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
admin2Key!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => GeodataAdmin2Entity, { eager: true, nullable: true, createForeignKeyConstraints: false })
|
|
||||||
admin2!: GeodataAdmin2Entity;
|
|
||||||
|
|
||||||
@Column({ type: 'date' })
|
@Column({ type: 'date' })
|
||||||
modificationDate!: Date;
|
modificationDate!: Date;
|
||||||
|
@ -7,8 +7,6 @@ import { AssetStackEntity } from './asset-stack.entity';
|
|||||||
import { AssetEntity } from './asset.entity';
|
import { AssetEntity } from './asset.entity';
|
||||||
import { AuditEntity } from './audit.entity';
|
import { AuditEntity } from './audit.entity';
|
||||||
import { ExifEntity } from './exif.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 { GeodataPlacesEntity } from './geodata-places.entity';
|
||||||
import { LibraryEntity } from './library.entity';
|
import { LibraryEntity } from './library.entity';
|
||||||
import { MoveEntity } from './move.entity';
|
import { MoveEntity } from './move.entity';
|
||||||
@ -32,8 +30,6 @@ export * from './asset-stack.entity';
|
|||||||
export * from './asset.entity';
|
export * from './asset.entity';
|
||||||
export * from './audit.entity';
|
export * from './audit.entity';
|
||||||
export * from './exif.entity';
|
export * from './exif.entity';
|
||||||
export * from './geodata-admin1.entity';
|
|
||||||
export * from './geodata-admin2.entity';
|
|
||||||
export * from './geodata-places.entity';
|
export * from './geodata-places.entity';
|
||||||
export * from './library.entity';
|
export * from './library.entity';
|
||||||
export * from './move.entity';
|
export * from './move.entity';
|
||||||
@ -59,8 +55,6 @@ export const databaseEntities = [
|
|||||||
AuditEntity,
|
AuditEntity,
|
||||||
ExifEntity,
|
ExifEntity,
|
||||||
GeodataPlacesEntity,
|
GeodataPlacesEntity,
|
||||||
GeodataAdmin1Entity,
|
|
||||||
GeodataAdmin2Entity,
|
|
||||||
MoveEntity,
|
MoveEntity,
|
||||||
PartnerEntity,
|
PartnerEntity,
|
||||||
PersonEntity,
|
PersonEntity,
|
||||||
|
@ -0,0 +1,152 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class GeodataLocationSearch1708059341865 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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"',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class GeonamesEnhancement1708116312820 implements MigrationInterface {
|
||||||
|
name = 'GeonamesEnhancement1708116312820'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE geodata_places DROP COLUMN "alternateNames"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -2,7 +2,7 @@ import {
|
|||||||
citiesFile,
|
citiesFile,
|
||||||
geodataAdmin1Path,
|
geodataAdmin1Path,
|
||||||
geodataAdmin2Path,
|
geodataAdmin2Path,
|
||||||
geodataCitites500Path,
|
geodataCities500Path,
|
||||||
geodataDatePath,
|
geodataDatePath,
|
||||||
GeoPoint,
|
GeoPoint,
|
||||||
IMetadataRepository,
|
IMetadataRepository,
|
||||||
@ -10,13 +10,7 @@ import {
|
|||||||
ISystemMetadataRepository,
|
ISystemMetadataRepository,
|
||||||
ReverseGeocodeResult,
|
ReverseGeocodeResult,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import {
|
import { ExifEntity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
|
||||||
ExifEntity,
|
|
||||||
GeodataAdmin1Entity,
|
|
||||||
GeodataAdmin2Entity,
|
|
||||||
GeodataPlacesEntity,
|
|
||||||
SystemMetadataKey,
|
|
||||||
} from '@app/infra/entities';
|
|
||||||
import { ImmichLogger } from '@app/infra/logger';
|
import { ImmichLogger } from '@app/infra/logger';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
@ -26,19 +20,16 @@ import { getName } from 'i18n-iso-countries';
|
|||||||
import { createReadStream, existsSync } from 'node:fs';
|
import { createReadStream, existsSync } from 'node:fs';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import * as readLine from 'node:readline';
|
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';
|
import { DummyValue, GenerateSql } from '../infra.util';
|
||||||
|
|
||||||
type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
|
|
||||||
type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
|
|
||||||
|
|
||||||
export class MetadataRepository implements IMetadataRepository {
|
export class MetadataRepository implements IMetadataRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
|
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
|
||||||
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
|
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
|
||||||
@InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository<GeodataAdmin1Entity>,
|
@Inject(ISystemMetadataRepository)
|
||||||
@InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository<GeodataAdmin2Entity>,
|
private readonly systemMetadataRepository: ISystemMetadataRepository,
|
||||||
@Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository,
|
|
||||||
@InjectDataSource() private dataSource: DataSource,
|
@InjectDataSource() private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -54,7 +45,6 @@ export class MetadataRepository implements IMetadataRepository {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log('Importing geodata to database from file');
|
|
||||||
await this.importGeodata();
|
await this.importGeodata();
|
||||||
|
|
||||||
await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
|
await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, {
|
||||||
@ -69,12 +59,14 @@ export class MetadataRepository implements IMetadataRepository {
|
|||||||
const queryRunner = this.dataSource.createQueryRunner();
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
|
|
||||||
|
const admin1 = await this.loadAdmin(geodataAdmin1Path);
|
||||||
|
const admin2 = await this.loadAdmin(geodataAdmin2Path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await queryRunner.startTransaction();
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
await this.loadCities500(queryRunner);
|
await queryRunner.manager.clear(GeodataPlacesEntity);
|
||||||
await this.loadAdmin1(queryRunner);
|
await this.loadCities500(queryRunner, admin1, admin2);
|
||||||
await this.loadAdmin2(queryRunner);
|
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -86,76 +78,73 @@ export class MetadataRepository implements IMetadataRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadGeodataToTableFromFile<T extends GeoEntity>(
|
private async loadGeodataToTableFromFile(
|
||||||
queryRunner: QueryRunner,
|
queryRunner: QueryRunner,
|
||||||
lineToEntityMapper: (lineSplit: string[]) => T,
|
lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
entity: GeoEntityClass,
|
|
||||||
) {
|
) {
|
||||||
if (!existsSync(filePath)) {
|
if (!existsSync(filePath)) {
|
||||||
this.logger.error(`Geodata file ${filePath} not found`);
|
this.logger.error(`Geodata file ${filePath} not found`);
|
||||||
throw new Error(`Geodata file ${filePath} not found`);
|
throw new Error(`Geodata file ${filePath} not found`);
|
||||||
}
|
}
|
||||||
await queryRunner.manager.clear(entity);
|
|
||||||
|
|
||||||
const input = createReadStream(filePath);
|
const input = createReadStream(filePath);
|
||||||
let buffer: DeepPartial<T>[] = [];
|
let bufferGeodata: QueryDeepPartialEntity<GeodataPlacesEntity>[] = [];
|
||||||
const lineReader = readLine.createInterface({ input: input });
|
const lineReader = readLine.createInterface({ input });
|
||||||
|
|
||||||
for await (const line of lineReader) {
|
for await (const line of lineReader) {
|
||||||
const lineSplit = line.split('\t');
|
const lineSplit = line.split('\t');
|
||||||
buffer.push(lineToEntityMapper(lineSplit));
|
const geoData = lineToEntityMapper(lineSplit);
|
||||||
if (buffer.length > 1000) {
|
bufferGeodata.push(geoData);
|
||||||
await queryRunner.manager.save(buffer);
|
if (bufferGeodata.length > 1000) {
|
||||||
buffer = [];
|
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) {
|
private async loadCities500(
|
||||||
await this.loadGeodataToTableFromFile<GeodataPlacesEntity>(
|
queryRunner: QueryRunner,
|
||||||
|
admin1Map: Map<string, string>,
|
||||||
|
admin2Map: Map<string, string>,
|
||||||
|
) {
|
||||||
|
await this.loadGeodataToTableFromFile(
|
||||||
queryRunner,
|
queryRunner,
|
||||||
(lineSplit: string[]) =>
|
(lineSplit: string[]) =>
|
||||||
this.geodataPlacesRepository.create({
|
this.geodataPlacesRepository.create({
|
||||||
id: Number.parseInt(lineSplit[0]),
|
id: Number.parseInt(lineSplit[0]),
|
||||||
name: lineSplit[1],
|
name: lineSplit[1],
|
||||||
|
alternateNames: lineSplit[3],
|
||||||
latitude: Number.parseFloat(lineSplit[4]),
|
latitude: Number.parseFloat(lineSplit[4]),
|
||||||
longitude: Number.parseFloat(lineSplit[5]),
|
longitude: Number.parseFloat(lineSplit[5]),
|
||||||
countryCode: lineSplit[8],
|
countryCode: lineSplit[8],
|
||||||
admin1Code: lineSplit[10],
|
admin1Code: lineSplit[10],
|
||||||
admin2Code: lineSplit[11],
|
admin2Code: lineSplit[11],
|
||||||
modificationDate: lineSplit[18],
|
modificationDate: lineSplit[18],
|
||||||
|
admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`),
|
||||||
|
admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`),
|
||||||
}),
|
}),
|
||||||
geodataCitites500Path,
|
geodataCities500Path,
|
||||||
GeodataPlacesEntity,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadAdmin1(queryRunner: QueryRunner) {
|
private async loadAdmin(filePath: string) {
|
||||||
await this.loadGeodataToTableFromFile<GeodataAdmin1Entity>(
|
if (!existsSync(filePath)) {
|
||||||
queryRunner,
|
this.logger.error(`Geodata file ${filePath} not found`);
|
||||||
(lineSplit: string[]) =>
|
throw new Error(`Geodata file ${filePath} not found`);
|
||||||
this.geodataAdmin1Repository.create({
|
}
|
||||||
key: lineSplit[0],
|
|
||||||
name: lineSplit[1],
|
|
||||||
}),
|
|
||||||
geodataAdmin1Path,
|
|
||||||
GeodataAdmin1Entity,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadAdmin2(queryRunner: QueryRunner) {
|
const input = createReadStream(filePath);
|
||||||
await this.loadGeodataToTableFromFile<GeodataAdmin2Entity>(
|
const lineReader = readLine.createInterface({ input: input });
|
||||||
queryRunner,
|
|
||||||
(lineSplit: string[]) =>
|
const adminMap = new Map<string, string>();
|
||||||
this.geodataAdmin2Repository.create({
|
for await (const line of lineReader) {
|
||||||
key: lineSplit[0],
|
const lineSplit = line.split('\t');
|
||||||
name: lineSplit[1],
|
adminMap.set(lineSplit[0], lineSplit[1]);
|
||||||
}),
|
}
|
||||||
geodataAdmin2Path,
|
|
||||||
GeodataAdmin2Entity,
|
return adminMap;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async teardown() {
|
async teardown() {
|
||||||
@ -167,8 +156,6 @@ export class MetadataRepository implements IMetadataRepository {
|
|||||||
|
|
||||||
const response = await this.geodataPlacesRepository
|
const response = await this.geodataPlacesRepository
|
||||||
.createQueryBuilder('geoplaces')
|
.createQueryBuilder('geoplaces')
|
||||||
.leftJoinAndSelect('geoplaces.admin1', 'admin1')
|
|
||||||
.leftJoinAndSelect('geoplaces.admin2', 'admin2')
|
|
||||||
.where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point)
|
.where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point)
|
||||||
.orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")')
|
.orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")')
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@ -183,9 +170,9 @@ export class MetadataRepository implements IMetadataRepository {
|
|||||||
|
|
||||||
this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`);
|
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 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;
|
const state = stateParts.length > 0 ? stateParts.join(', ') : null;
|
||||||
|
|
||||||
return { country, state, city };
|
return { country, state, city };
|
||||||
|
@ -12,7 +12,13 @@ import {
|
|||||||
SmartSearchOptions,
|
SmartSearchOptions,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
|
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 { ImmichLogger } from '@app/infra/logger';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@ -31,6 +37,7 @@ export class SearchRepository implements ISearchRepository {
|
|||||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||||
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
|
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
|
||||||
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
|
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
|
||||||
|
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
|
||||||
) {
|
) {
|
||||||
this.faceColumns = this.assetFaceRepository.manager.connection
|
this.faceColumns = this.assetFaceRepository.manager.connection
|
||||||
.getMetadata(AssetFaceEntity)
|
.getMetadata(AssetFaceEntity)
|
||||||
@ -172,6 +179,27 @@ export class SearchRepository implements ISearchRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.STRING] })
|
||||||
|
async searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
|
||||||
|
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<SmartInfoEntity>, embedding?: Embedding): Promise<void> {
|
async upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void> {
|
||||||
await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
|
await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
|
||||||
if (!smartInfo.assetId || !embedding) {
|
if (!smartInfo.assetId || !embedding) {
|
||||||
|
@ -238,3 +238,37 @@ FROM
|
|||||||
WHERE
|
WHERE
|
||||||
res.distance <= $3
|
res.distance <= $3
|
||||||
COMMIT
|
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
|
||||||
|
@ -7,5 +7,6 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
|
|||||||
searchSmart: jest.fn(),
|
searchSmart: jest.fn(),
|
||||||
searchFaces: jest.fn(),
|
searchFaces: jest.fn(),
|
||||||
upsert: jest.fn(),
|
upsert: jest.fn(),
|
||||||
|
searchPlaces: jest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
|
|
||||||
export let name: string;
|
export let name: string;
|
||||||
|
export let roundedBottom = true;
|
||||||
export let isSearching: boolean;
|
export let isSearching: boolean;
|
||||||
export let placeholder: string;
|
export let placeholder: string;
|
||||||
|
|
||||||
@ -17,7 +18,11 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center text-sm rounded-lg bg-gray-100 p-2 dark:bg-gray-700 gap-2 place-items-center h-full">
|
<div
|
||||||
|
class="flex items-center text-sm {roundedBottom
|
||||||
|
? 'rounded-lg'
|
||||||
|
: 'rounded-t-lg'} bg-gray-100 p-2 dark:bg-gray-700 gap-2 place-items-center h-full"
|
||||||
|
>
|
||||||
<button on:click={() => dispatch('search', { force: true })}>
|
<button on:click={() => dispatch('search', { force: true })}>
|
||||||
<div class="w-fit">
|
<div class="w-fit">
|
||||||
<Icon path={mdiMagnify} size="24" />
|
<Icon path={mdiMagnify} size="24" />
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import ConfirmDialogue from './confirm-dialogue.svelte';
|
import ConfirmDialogue from './confirm-dialogue.svelte';
|
||||||
|
import { maximumLengthSearchPeople, timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
|
||||||
|
import { clickOutside } from '$lib/utils/click-outside';
|
||||||
import LoadingSpinner from './loading-spinner.svelte';
|
import LoadingSpinner from './loading-spinner.svelte';
|
||||||
import { delay } from '$lib/utils/asset-utils';
|
import { delay } from '$lib/utils/asset-utils';
|
||||||
import { timeToLoadTheMap } from '$lib/constants';
|
import { timeToLoadTheMap } from '$lib/constants';
|
||||||
|
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
|
||||||
|
import SearchBar from '../elements/search-bar.svelte';
|
||||||
|
|
||||||
export const title = 'Change Location';
|
export const title = 'Change Location';
|
||||||
export let asset: AssetResponseDto | undefined = undefined;
|
export let asset: AssetResponseDto | undefined = undefined;
|
||||||
@ -14,6 +19,16 @@
|
|||||||
lat: number;
|
lat: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let places: PlacesResponseDto[] = [];
|
||||||
|
let suggestedPlaces: PlacesResponseDto[] = [];
|
||||||
|
let searchWord: string;
|
||||||
|
let isSearching = false;
|
||||||
|
let showSpinner = false;
|
||||||
|
let focusedElements: (HTMLButtonElement | null)[] = Array.from({ length: maximumLengthSearchPeople }, () => null);
|
||||||
|
let indexFocus: number | null = null;
|
||||||
|
let hideSuggestion = false;
|
||||||
|
let addClipMapMarker: (long: number, lat: number) => void;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
cancel: void;
|
cancel: void;
|
||||||
confirm: Point;
|
confirm: Point;
|
||||||
@ -23,6 +38,16 @@
|
|||||||
$: lng = asset?.exifInfo?.longitude || 0;
|
$: lng = asset?.exifInfo?.longitude || 0;
|
||||||
$: zoom = lat && lng ? 15 : 1;
|
$: zoom = lat && lng ? 15 : 1;
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (places) {
|
||||||
|
suggestedPlaces = places.slice(0, 5);
|
||||||
|
indexFocus = null;
|
||||||
|
}
|
||||||
|
if (searchWord === '') {
|
||||||
|
suggestedPlaces = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let point: Point | null = null;
|
let point: Point | null = null;
|
||||||
|
|
||||||
const handleCancel = () => dispatch('cancel');
|
const handleCancel = () => dispatch('cancel');
|
||||||
@ -38,8 +63,82 @@
|
|||||||
dispatch('cancel');
|
dispatch('cancel');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLocation = (name: string, admin1Name?: string, admin2Name?: string): string => {
|
||||||
|
return `${name}${admin1Name ? ', ' + admin1Name : ''}${admin2Name ? ', ' + admin2Name : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchPlaces = async () => {
|
||||||
|
if (searchWord === '' || isSearching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: refactor setTimeout/clearTimeout logic - there are no less than 12 places that duplicate this code
|
||||||
|
isSearching = true;
|
||||||
|
const timeout = setTimeout(() => (showSpinner = true), timeBeforeShowLoadingSpinner);
|
||||||
|
try {
|
||||||
|
places = await searchPlaces({ name: searchWord });
|
||||||
|
} catch (error) {
|
||||||
|
places = [];
|
||||||
|
handleError(error, "Can't search places");
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
isSearching = false;
|
||||||
|
showSpinner = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseSuggested = (latitude: number, longitude: number) => {
|
||||||
|
hideSuggestion = true;
|
||||||
|
point = { lng: longitude, lat: latitude };
|
||||||
|
addClipMapMarker(longitude, latitude);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyboardPress = (event: KeyboardEvent) => {
|
||||||
|
if (suggestedPlaces.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown': {
|
||||||
|
event.preventDefault();
|
||||||
|
if (indexFocus === null) {
|
||||||
|
indexFocus = 0;
|
||||||
|
} else if (indexFocus === suggestedPlaces.length - 1) {
|
||||||
|
indexFocus = 0;
|
||||||
|
} else {
|
||||||
|
indexFocus++;
|
||||||
|
}
|
||||||
|
focusedElements[indexFocus]?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
if (indexFocus === null) {
|
||||||
|
indexFocus = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (indexFocus === 0) {
|
||||||
|
indexFocus = suggestedPlaces.length - 1;
|
||||||
|
} else {
|
||||||
|
indexFocus--;
|
||||||
|
}
|
||||||
|
focusedElements[indexFocus]?.focus();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'Enter': {
|
||||||
|
if (indexFocus !== null) {
|
||||||
|
hideSuggestion = true;
|
||||||
|
handleUseSuggested(suggestedPlaces[indexFocus].latitude, suggestedPlaces[indexFocus].longitude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:document on:keydown={handleKeyboardPress} />
|
||||||
|
|
||||||
<ConfirmDialogue
|
<ConfirmDialogue
|
||||||
confirmColor="primary"
|
confirmColor="primary"
|
||||||
cancelColor="secondary"
|
cancelColor="secondary"
|
||||||
@ -49,6 +148,38 @@
|
|||||||
on:cancel={handleCancel}
|
on:cancel={handleCancel}
|
||||||
>
|
>
|
||||||
<div slot="prompt" class="flex flex-col w-full h-full gap-2">
|
<div slot="prompt" class="flex flex-col w-full h-full gap-2">
|
||||||
|
<div class="relative w-64 sm:w-96" use:clickOutside on:outclick={() => (hideSuggestion = true)}>
|
||||||
|
<button class="w-full" on:click={() => (hideSuggestion = false)}>
|
||||||
|
<SearchBar
|
||||||
|
placeholder="Search places"
|
||||||
|
bind:name={searchWord}
|
||||||
|
isSearching={showSpinner}
|
||||||
|
on:reset={() => {
|
||||||
|
suggestedPlaces = [];
|
||||||
|
}}
|
||||||
|
on:search={handleSearchPlaces}
|
||||||
|
roundedBottom={suggestedPlaces.length === 0 || hideSuggestion}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div class="absolute z-[99] w-full" id="suggestion">
|
||||||
|
{#if !hideSuggestion}
|
||||||
|
{#each suggestedPlaces as place, index}
|
||||||
|
<button
|
||||||
|
bind:this={focusedElements[index]}
|
||||||
|
class=" flex w-full border-t border-gray-400 dark:border-immich-dark-gray h-14 place-items-center bg-gray-200 p-2 dark:bg-gray-700 hover:bg-gray-300 hover:dark:bg-[#232932] focus:bg-gray-300 focus:dark:bg-[#232932] {index ===
|
||||||
|
suggestedPlaces.length - 1
|
||||||
|
? 'rounded-b-lg border-b'
|
||||||
|
: ''}"
|
||||||
|
on:click={() => handleUseSuggested(place.latitude, place.longitude)}
|
||||||
|
>
|
||||||
|
<p class="ml-4 text-sm text-gray-700 dark:text-gray-100 truncate">
|
||||||
|
{getLocation(place.name, place.admin1name, place.admin2name)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label for="datetime">Pick a location</label>
|
<label for="datetime">Pick a location</label>
|
||||||
<div class="h-[500px] min-h-[300px] w-full">
|
<div class="h-[500px] min-h-[300px] w-full">
|
||||||
{#await import('../shared-components/map/map.svelte')}
|
{#await import('../shared-components/map/map.svelte')}
|
||||||
@ -63,6 +194,7 @@
|
|||||||
this={component.default}
|
this={component.default}
|
||||||
mapMarkers={lat && lng && asset ? [{ id: asset.id, lat, lon: lng }] : []}
|
mapMarkers={lat && lng && asset ? [{ id: asset.id, lat, lon: lng }] : []}
|
||||||
{zoom}
|
{zoom}
|
||||||
|
bind:addClipMapMarker
|
||||||
center={lat && lng ? { lat, lng } : undefined}
|
center={lat && lng ? { lat, lng } : undefined}
|
||||||
simplified={true}
|
simplified={true}
|
||||||
clickable={true}
|
clickable={true}
|
||||||
|
@ -32,6 +32,17 @@
|
|||||||
export let simplified = false;
|
export let simplified = false;
|
||||||
export let clickable = false;
|
export let clickable = false;
|
||||||
export let useLocationPin = false;
|
export let useLocationPin = false;
|
||||||
|
export function addClipMapMarker(lng: number, lat: number) {
|
||||||
|
if (map) {
|
||||||
|
if (marker) {
|
||||||
|
marker.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
center = { lng, lat };
|
||||||
|
marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map);
|
||||||
|
map.setZoom(15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let map: maplibregl.Map;
|
let map: maplibregl.Map;
|
||||||
let marker: maplibregl.Marker | null = null;
|
let marker: maplibregl.Marker | null = null;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user