mirror of
https://gitlab.com/nkming2/nc-photos.git
synced 2025-01-22 08:46:18 +01:00
Merge branch 'dev-nc28-exif'
This commit is contained in:
commit
651a48efa8
57 changed files with 1887 additions and 935 deletions
|
@ -17,6 +17,7 @@ import 'package:nc_photos/entity/tagged_file.dart';
|
||||||
import 'package:nc_photos/object_extension.dart';
|
import 'package:nc_photos/object_extension.dart';
|
||||||
import 'package:np_api/np_api.dart' as api;
|
import 'package:np_api/np_api.dart' as api;
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
import 'package:np_common/object_util.dart';
|
||||||
import 'package:np_string/np_string.dart';
|
import 'package:np_string/np_string.dart';
|
||||||
|
|
||||||
part 'entity_converter.g.dart';
|
part 'entity_converter.g.dart';
|
||||||
|
@ -49,23 +50,36 @@ class ApiFavoriteConverter {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApiFileConverter {
|
class ApiFileConverter {
|
||||||
|
static Metadata? _metadataFromApi(api.File file) {
|
||||||
|
if (file.metadataPhotosSize != null) {
|
||||||
|
return Metadata.fromApi(
|
||||||
|
etag: file.etag,
|
||||||
|
ifd0: file.metadataPhotosIfd0,
|
||||||
|
exif: file.metadataPhotosExif,
|
||||||
|
gps: file.metadataPhotosGps,
|
||||||
|
size: file.metadataPhotosSize!,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return file.customProperties?["com.nkming.nc_photos:metadata"]
|
||||||
|
?.let((obj) => Metadata.fromJson(
|
||||||
|
jsonDecode(obj),
|
||||||
|
upgraderV1: MetadataUpgraderV1(
|
||||||
|
fileContentType: file.contentType,
|
||||||
|
logFilePath: file.href,
|
||||||
|
),
|
||||||
|
upgraderV2: MetadataUpgraderV2(
|
||||||
|
fileContentType: file.contentType,
|
||||||
|
logFilePath: file.href,
|
||||||
|
),
|
||||||
|
upgraderV3: MetadataUpgraderV3(
|
||||||
|
fileContentType: file.contentType,
|
||||||
|
logFilePath: file.href,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static File fromApi(api.File file) {
|
static File fromApi(api.File file) {
|
||||||
final metadata = file.customProperties?["com.nkming.nc_photos:metadata"]
|
|
||||||
?.run((obj) => Metadata.fromJson(
|
|
||||||
jsonDecode(obj),
|
|
||||||
upgraderV1: MetadataUpgraderV1(
|
|
||||||
fileContentType: file.contentType,
|
|
||||||
logFilePath: file.href,
|
|
||||||
),
|
|
||||||
upgraderV2: MetadataUpgraderV2(
|
|
||||||
fileContentType: file.contentType,
|
|
||||||
logFilePath: file.href,
|
|
||||||
),
|
|
||||||
upgraderV3: MetadataUpgraderV3(
|
|
||||||
fileContentType: file.contentType,
|
|
||||||
logFilePath: file.href,
|
|
||||||
),
|
|
||||||
));
|
|
||||||
return File(
|
return File(
|
||||||
path: _hrefToPath(file.href),
|
path: _hrefToPath(file.href),
|
||||||
contentLength: file.contentLength,
|
contentLength: file.contentLength,
|
||||||
|
@ -81,7 +95,7 @@ class ApiFileConverter {
|
||||||
trashbinFilename: file.trashbinFilename,
|
trashbinFilename: file.trashbinFilename,
|
||||||
trashbinOriginalLocation: file.trashbinOriginalLocation,
|
trashbinOriginalLocation: file.trashbinOriginalLocation,
|
||||||
trashbinDeletionTime: file.trashbinDeletionTime,
|
trashbinDeletionTime: file.trashbinDeletionTime,
|
||||||
metadata: metadata,
|
metadata: _metadataFromApi(file),
|
||||||
isArchived: file.customProperties?["com.nkming.nc_photos:is-archived"]
|
isArchived: file.customProperties?["com.nkming.nc_photos:is-archived"]
|
||||||
?.run((obj) => obj == "true"),
|
?.run((obj) => obj == "true"),
|
||||||
overrideDateTime: file
|
overrideDateTime: file
|
||||||
|
|
|
@ -63,6 +63,7 @@ class AccountController {
|
||||||
ServerController get serverController =>
|
ServerController get serverController =>
|
||||||
_serverController ??= ServerController(
|
_serverController ??= ServerController(
|
||||||
account: _account!,
|
account: _account!,
|
||||||
|
accountPrefController: accountPrefController,
|
||||||
);
|
);
|
||||||
|
|
||||||
AccountPrefController get accountPrefController =>
|
AccountPrefController get accountPrefController =>
|
||||||
|
@ -107,6 +108,7 @@ class AccountController {
|
||||||
KiwiContainer().resolve(),
|
KiwiContainer().resolve(),
|
||||||
account: account,
|
account: account,
|
||||||
prefController: prefController,
|
prefController: prefController,
|
||||||
|
serverController: serverController,
|
||||||
);
|
);
|
||||||
|
|
||||||
PrefController prefController;
|
PrefController prefController;
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:nc_photos/account.dart';
|
import 'package:nc_photos/account.dart';
|
||||||
import 'package:nc_photos/entity/person.dart';
|
import 'package:nc_photos/entity/person.dart';
|
||||||
import 'package:nc_photos/entity/pref.dart';
|
import 'package:nc_photos/entity/pref.dart';
|
||||||
|
import 'package:nc_photos/entity/server_status.dart';
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
import 'package:np_common/object_util.dart';
|
||||||
import 'package:rxdart/rxdart.dart';
|
import 'package:rxdart/rxdart.dart';
|
||||||
|
|
||||||
part 'account_pref_controller.g.dart';
|
part 'account_pref_controller.g.dart';
|
||||||
|
@ -52,6 +56,13 @@ class AccountPrefController {
|
||||||
value: value,
|
value: value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<void> setServerStatus(ServerStatus value) => _set(
|
||||||
|
controller: _serverStatusController,
|
||||||
|
setter: (pref, value) =>
|
||||||
|
pref.setServerStatus(jsonEncode(value!.toJson())),
|
||||||
|
value: value,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> _set<T>({
|
Future<void> _set<T>({
|
||||||
required BehaviorSubject<T> controller,
|
required BehaviorSubject<T> controller,
|
||||||
required Future<bool> Function(AccountPref pref, T value) setter,
|
required Future<bool> Function(AccountPref pref, T value) setter,
|
||||||
|
@ -89,4 +100,8 @@ class AccountPrefController {
|
||||||
@npSubjectAccessor
|
@npSubjectAccessor
|
||||||
late final _hasNewSharedAlbumController =
|
late final _hasNewSharedAlbumController =
|
||||||
BehaviorSubject.seeded(_accountPref.hasNewSharedAlbum() ?? false);
|
BehaviorSubject.seeded(_accountPref.hasNewSharedAlbum() ?? false);
|
||||||
|
@npSubjectAccessor
|
||||||
|
late final _serverStatusController = BehaviorSubject.seeded(_accountPref
|
||||||
|
.getServerStatus()
|
||||||
|
?.let((e) => ServerStatus.fromJson(jsonDecode(e))));
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,4 +50,10 @@ extension $AccountPrefControllerNpSubjectAccessor on AccountPrefController {
|
||||||
Stream<bool> get hasNewSharedAlbumChange =>
|
Stream<bool> get hasNewSharedAlbumChange =>
|
||||||
hasNewSharedAlbum.distinct().skip(1);
|
hasNewSharedAlbum.distinct().skip(1);
|
||||||
bool get hasNewSharedAlbumValue => _hasNewSharedAlbumController.value;
|
bool get hasNewSharedAlbumValue => _hasNewSharedAlbumController.value;
|
||||||
|
// _serverStatusController
|
||||||
|
ValueStream<ServerStatus?> get serverStatus => _serverStatusController.stream;
|
||||||
|
Stream<ServerStatus?> get serverStatusNew => serverStatus.skip(1);
|
||||||
|
Stream<ServerStatus?> get serverStatusChange =>
|
||||||
|
serverStatus.distinct().skip(1);
|
||||||
|
ServerStatus? get serverStatusValue => _serverStatusController.value;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,4 +5,13 @@ extension on AccountPref {
|
||||||
provider.getBool(AccountPrefKey.hasNewSharedAlbum);
|
provider.getBool(AccountPrefKey.hasNewSharedAlbum);
|
||||||
// Future<bool> setNewSharedAlbum(bool value) =>
|
// Future<bool> setNewSharedAlbum(bool value) =>
|
||||||
// provider.setBool(AccountPrefKey.hasNewSharedAlbum, value);
|
// provider.setBool(AccountPrefKey.hasNewSharedAlbum, value);
|
||||||
|
|
||||||
|
String? getServerStatus() => provider.getString(AccountPrefKey.serverStatus);
|
||||||
|
Future<bool> setServerStatus(String? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return provider.remove(AccountPrefKey.serverStatus);
|
||||||
|
} else {
|
||||||
|
return provider.setString(AccountPrefKey.serverStatus, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -253,7 +253,7 @@ class FilesController {
|
||||||
final failures = <FileDescriptor>[];
|
final failures = <FileDescriptor>[];
|
||||||
for (final f in files) {
|
for (final f in files) {
|
||||||
try {
|
try {
|
||||||
await UpdateProperty(_c)(
|
await UpdateProperty(fileRepo: _c.fileRepo2)(
|
||||||
account,
|
account,
|
||||||
f,
|
f,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
@ -574,6 +574,7 @@ class FilesController {
|
||||||
.map((e) => File(path: file_util.unstripPath(account, e))
|
.map((e) => File(path: file_util.unstripPath(account, e))
|
||||||
.strippedPathWithEmpty)
|
.strippedPathWithEmpty)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
includeRelativeDirs: [accountPrefController.shareFolderValue],
|
||||||
excludeRelativeRoots: [remote_storage_util.remoteStorageDirRelativePath],
|
excludeRelativeRoots: [remote_storage_util.remoteStorageDirRelativePath],
|
||||||
mimes: file_util.supportedFormatMimes,
|
mimes: file_util.supportedFormatMimes,
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,10 +3,11 @@ import 'dart:async';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:nc_photos/account.dart';
|
import 'package:nc_photos/account.dart';
|
||||||
import 'package:nc_photos/controller/pref_controller.dart';
|
import 'package:nc_photos/controller/pref_controller.dart';
|
||||||
|
import 'package:nc_photos/controller/server_controller.dart';
|
||||||
import 'package:nc_photos/db/entity_converter.dart';
|
import 'package:nc_photos/db/entity_converter.dart';
|
||||||
import 'package:nc_photos/di_container.dart';
|
import 'package:nc_photos/di_container.dart';
|
||||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
import 'package:nc_photos/service.dart' as service;
|
import 'package:nc_photos/service/service.dart' as service;
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
|
||||||
part 'metadata_controller.g.dart';
|
part 'metadata_controller.g.dart';
|
||||||
|
@ -17,9 +18,10 @@ class MetadataController {
|
||||||
this._c, {
|
this._c, {
|
||||||
required this.account,
|
required this.account,
|
||||||
required this.prefController,
|
required this.prefController,
|
||||||
|
required this.serverController,
|
||||||
}) {
|
}) {
|
||||||
_subscriptions
|
_subscriptions.add(
|
||||||
.add(prefController.isEnableExifChange.listen(_onSetEnableExif));
|
prefController.isEnableClientExifChange.listen(_onSetEnableClientExif));
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
@ -44,16 +46,21 @@ class MetadataController {
|
||||||
void kickstart() {
|
void kickstart() {
|
||||||
_log.info("[kickstart] Metadata controller enabled");
|
_log.info("[kickstart] Metadata controller enabled");
|
||||||
_isEnable = true;
|
_isEnable = true;
|
||||||
if (prefController.isEnableExifValue && !_hasStarted) {
|
// on NC28+, the service is needed to get metadata for files that are not
|
||||||
|
// yet available the moment we queried them, and files not supported by the
|
||||||
|
// server (if client side exif enabled).
|
||||||
|
if ((serverController.isSupported(ServerFeature.ncMetadata) ||
|
||||||
|
prefController.isEnableClientExifValue) &&
|
||||||
|
!_hasStarted) {
|
||||||
_startMetadataTask();
|
_startMetadataTask();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSetEnableExif(bool value) {
|
void _onSetEnableClientExif(bool value) {
|
||||||
_log.info("[_onSetEnableExif]");
|
_log.info("[_onSetEnableClientExif]");
|
||||||
if (value) {
|
if (value) {
|
||||||
if (!_isEnable) {
|
if (!_isEnable) {
|
||||||
_log.info("[_onSetEnableExif] Ignored as not enabled");
|
_log.info("[_onSetEnableClientExif] Ignored as not enabled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_startMetadataTask();
|
_startMetadataTask();
|
||||||
|
@ -68,10 +75,11 @@ class MetadataController {
|
||||||
final missingCount = await _c.npDb.countFilesByMissingMetadata(
|
final missingCount = await _c.npDb.countFilesByMissingMetadata(
|
||||||
account: account.toDb(),
|
account: account.toDb(),
|
||||||
mimes: file_util.supportedImageFormatMimes,
|
mimes: file_util.supportedImageFormatMimes,
|
||||||
|
ownerId: account.userId.toCaseInsensitiveString(),
|
||||||
);
|
);
|
||||||
_log.info("[_startMetadataTask] Missing count: $missingCount");
|
_log.info("[_startMetadataTask] Missing count: $missingCount");
|
||||||
if (missingCount > 0) {
|
if (missingCount > 0) {
|
||||||
unawaited(service.startService());
|
unawaited(service.startService(prefController: prefController));
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
_log.shout(
|
_log.shout(
|
||||||
|
@ -86,6 +94,7 @@ class MetadataController {
|
||||||
final DiContainer _c;
|
final DiContainer _c;
|
||||||
final Account account;
|
final Account account;
|
||||||
final PrefController prefController;
|
final PrefController prefController;
|
||||||
|
final ServerController serverController;
|
||||||
|
|
||||||
final _subscriptions = <StreamSubscription>[];
|
final _subscriptions = <StreamSubscription>[];
|
||||||
var _isEnable = false;
|
var _isEnable = false;
|
||||||
|
|
|
@ -69,9 +69,9 @@ class PrefController {
|
||||||
value: value,
|
value: value,
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<bool> setEnableExif(bool value) => _set<bool>(
|
Future<bool> setEnableClientExif(bool value) => _set<bool>(
|
||||||
controller: _isEnableExifController,
|
controller: _isEnableClientExifController,
|
||||||
setter: (pref, value) => pref.setEnableExif(value),
|
setter: (pref, value) => pref.setEnableClientExif(value),
|
||||||
value: value,
|
value: value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -331,8 +331,8 @@ class PrefController {
|
||||||
late final _homeAlbumsSortController = BehaviorSubject.seeded(
|
late final _homeAlbumsSortController = BehaviorSubject.seeded(
|
||||||
CollectionSort.values[pref.getHomeAlbumsSortOr(0)]);
|
CollectionSort.values[pref.getHomeAlbumsSortOr(0)]);
|
||||||
@npSubjectAccessor
|
@npSubjectAccessor
|
||||||
late final _isEnableExifController =
|
late final _isEnableClientExifController =
|
||||||
BehaviorSubject.seeded(pref.isEnableExifOr(true));
|
BehaviorSubject.seeded(pref.isEnableClientExif() ?? true);
|
||||||
@npSubjectAccessor
|
@npSubjectAccessor
|
||||||
late final _shouldProcessExifWifiOnlyController =
|
late final _shouldProcessExifWifiOnlyController =
|
||||||
BehaviorSubject.seeded(pref.shouldProcessExifWifiOnlyOr(true));
|
BehaviorSubject.seeded(pref.shouldProcessExifWifiOnlyOr(true));
|
||||||
|
|
|
@ -56,11 +56,13 @@ extension $PrefControllerNpSubjectAccessor on PrefController {
|
||||||
Stream<CollectionSort> get homeAlbumsSortChange =>
|
Stream<CollectionSort> get homeAlbumsSortChange =>
|
||||||
homeAlbumsSort.distinct().skip(1);
|
homeAlbumsSort.distinct().skip(1);
|
||||||
CollectionSort get homeAlbumsSortValue => _homeAlbumsSortController.value;
|
CollectionSort get homeAlbumsSortValue => _homeAlbumsSortController.value;
|
||||||
// _isEnableExifController
|
// _isEnableClientExifController
|
||||||
ValueStream<bool> get isEnableExif => _isEnableExifController.stream;
|
ValueStream<bool> get isEnableClientExif =>
|
||||||
Stream<bool> get isEnableExifNew => isEnableExif.skip(1);
|
_isEnableClientExifController.stream;
|
||||||
Stream<bool> get isEnableExifChange => isEnableExif.distinct().skip(1);
|
Stream<bool> get isEnableClientExifNew => isEnableClientExif.skip(1);
|
||||||
bool get isEnableExifValue => _isEnableExifController.value;
|
Stream<bool> get isEnableClientExifChange =>
|
||||||
|
isEnableClientExif.distinct().skip(1);
|
||||||
|
bool get isEnableClientExifValue => _isEnableClientExifController.value;
|
||||||
// _shouldProcessExifWifiOnlyController
|
// _shouldProcessExifWifiOnlyController
|
||||||
ValueStream<bool> get shouldProcessExifWifiOnly =>
|
ValueStream<bool> get shouldProcessExifWifiOnly =>
|
||||||
_shouldProcessExifWifiOnlyController.stream;
|
_shouldProcessExifWifiOnlyController.stream;
|
||||||
|
|
|
@ -15,6 +15,9 @@ extension on Pref {
|
||||||
Future<bool> setHomeAlbumsSort(int value) =>
|
Future<bool> setHomeAlbumsSort(int value) =>
|
||||||
provider.setInt(PrefKey.homeAlbumsSort, value);
|
provider.setInt(PrefKey.homeAlbumsSort, value);
|
||||||
|
|
||||||
|
bool? isEnableClientExif() => isEnableExif();
|
||||||
|
Future<bool> setEnableClientExif(bool value) => setEnableExif(value);
|
||||||
|
|
||||||
bool? isDarkTheme() => provider.getBool(PrefKey.darkTheme);
|
bool? isDarkTheme() => provider.getBool(PrefKey.darkTheme);
|
||||||
bool isDarkThemeOr(bool def) => isDarkTheme() ?? def;
|
bool isDarkThemeOr(bool def) => isDarkTheme() ?? def;
|
||||||
Future<bool> setDarkTheme(bool value) =>
|
Future<bool> setDarkTheme(bool value) =>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:nc_photos/account.dart';
|
import 'package:nc_photos/account.dart';
|
||||||
import 'package:nc_photos/api/entity_converter.dart';
|
import 'package:nc_photos/api/entity_converter.dart';
|
||||||
|
import 'package:nc_photos/controller/account_pref_controller.dart';
|
||||||
import 'package:nc_photos/entity/server_status.dart';
|
import 'package:nc_photos/entity/server_status.dart';
|
||||||
import 'package:nc_photos/np_api_util.dart';
|
import 'package:nc_photos/np_api_util.dart';
|
||||||
import 'package:np_api/np_api.dart' as api;
|
import 'package:np_api/np_api.dart' as api;
|
||||||
|
@ -13,12 +14,14 @@ part 'server_controller.g.dart';
|
||||||
|
|
||||||
enum ServerFeature {
|
enum ServerFeature {
|
||||||
ncAlbum,
|
ncAlbum,
|
||||||
|
ncMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
@npLog
|
@npLog
|
||||||
class ServerController {
|
class ServerController {
|
||||||
ServerController({
|
ServerController({
|
||||||
required this.account,
|
required this.account,
|
||||||
|
required this.accountPrefController,
|
||||||
});
|
});
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
@ -32,14 +35,6 @@ class ServerController {
|
||||||
return _statusStreamContorller.stream;
|
return _statusStreamContorller.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isSupported(ServerFeature feature) {
|
|
||||||
switch (feature) {
|
|
||||||
case ServerFeature.ncAlbum:
|
|
||||||
return !_statusStreamContorller.hasValue ||
|
|
||||||
_statusStreamContorller.value.majorVersion >= 25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _load() => _getStatus();
|
Future<void> _load() => _getStatus();
|
||||||
|
|
||||||
Future<void> _getStatus() async {
|
Future<void> _getStatus() async {
|
||||||
|
@ -47,19 +42,49 @@ class ServerController {
|
||||||
final response = await ApiUtil.fromAccount(account).status().get();
|
final response = await ApiUtil.fromAccount(account).status().get();
|
||||||
if (!response.isGood) {
|
if (!response.isGood) {
|
||||||
_log.severe("[_getStatus] Failed requesting server: $response");
|
_log.severe("[_getStatus] Failed requesting server: $response");
|
||||||
|
_loadStatus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final apiStatus = await api.StatusParser().parse(response.body);
|
final apiStatus = await api.StatusParser().parse(response.body);
|
||||||
final status = ApiStatusConverter.fromApi(apiStatus);
|
final status = ApiStatusConverter.fromApi(apiStatus);
|
||||||
_log.info("[_getStatus] Server status: $status");
|
_log.info("[_getStatus] Server status: $status");
|
||||||
_statusStreamContorller.add(status);
|
_statusStreamContorller.add(status);
|
||||||
|
_saveStatus(status);
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
_log.severe("[_getStatus] Failed while get", e, stackTrace);
|
_log.severe("[_getStatus] Failed while get", e, stackTrace);
|
||||||
|
_loadStatus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _loadStatus() {
|
||||||
|
final cache = accountPrefController.serverStatusValue;
|
||||||
|
if (cache != null) {
|
||||||
|
_statusStreamContorller.add(cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveStatus(ServerStatus status) {
|
||||||
|
final cache = accountPrefController.serverStatusValue;
|
||||||
|
if (cache != status) {
|
||||||
|
accountPrefController.setServerStatus(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final Account account;
|
final Account account;
|
||||||
|
final AccountPrefController accountPrefController;
|
||||||
|
|
||||||
final _statusStreamContorller = BehaviorSubject<ServerStatus>();
|
final _statusStreamContorller = BehaviorSubject<ServerStatus>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ServerControllerExtension on ServerController {
|
||||||
|
bool isSupported(ServerFeature feature) {
|
||||||
|
final status = _statusStreamContorller.valueOrNull;
|
||||||
|
switch (feature) {
|
||||||
|
case ServerFeature.ncAlbum:
|
||||||
|
return status == null || status.majorVersion >= 25;
|
||||||
|
case ServerFeature.ncMetadata:
|
||||||
|
return status != null && status.majorVersion >= 28;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -87,7 +87,7 @@ class Exif with EquatableMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
toString() {
|
String toString() {
|
||||||
final dataStr = data.entries.map((e) {
|
final dataStr = data.entries.map((e) {
|
||||||
return "${e.key}: '${e.value}'";
|
return "${e.key}: '${e.value}'";
|
||||||
}).join(", ");
|
}).join(", ");
|
||||||
|
@ -117,16 +117,16 @@ class Exif with EquatableMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 0x829a ExposureTime
|
/// 0x829a ExposureTime
|
||||||
Rational? get exposureTime => data["ExposureTime"];
|
Rational? get exposureTime => _readRationalValue("ExposureTime");
|
||||||
|
|
||||||
/// 0x829d FNumber
|
/// 0x829d FNumber
|
||||||
Rational? get fNumber => data["FNumber"];
|
Rational? get fNumber => _readRationalValue("FNumber");
|
||||||
|
|
||||||
/// 0x8827 ISO/ISOSpeedRatings/PhotographicSensitivity
|
/// 0x8827 ISO/ISOSpeedRatings/PhotographicSensitivity
|
||||||
int? get isoSpeedRatings => data["ISOSpeedRatings"];
|
int? get isoSpeedRatings => _readIntValue("ISOSpeedRatings");
|
||||||
|
|
||||||
/// 0x920a FocalLength
|
/// 0x920a FocalLength
|
||||||
Rational? get focalLength => data["FocalLength"];
|
Rational? get focalLength => _readRationalValue("FocalLength");
|
||||||
|
|
||||||
/// 0x8825 GPS tags
|
/// 0x8825 GPS tags
|
||||||
String? get gpsLatitudeRef => data["GPSLatitudeRef"];
|
String? get gpsLatitudeRef => data["GPSLatitudeRef"];
|
||||||
|
@ -135,10 +135,54 @@ class Exif with EquatableMixin {
|
||||||
List<Rational>? get gpsLongitude => data["GPSLongitude"]?.cast<Rational>();
|
List<Rational>? get gpsLongitude => data["GPSLongitude"]?.cast<Rational>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
get props => [
|
List<Object?> get props => [
|
||||||
data,
|
data,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
Rational? _readRationalValue(String key) {
|
||||||
|
// values may be saved as typed (extracted by app) or untyped string
|
||||||
|
// (extracted by server)
|
||||||
|
return data[key] is String ? _tryParseRationalString(data[key]) : data[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _readIntValue(String key) {
|
||||||
|
return data[key] is String ? _tryParseIntString(data[key]) : data[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
static Rational? _tryParseRationalString(String str) {
|
||||||
|
if (str.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final pos = str.indexOf("/");
|
||||||
|
return Rational(
|
||||||
|
int.parse(str.substring(0, pos)),
|
||||||
|
int.parse(str.substring(pos + 1)),
|
||||||
|
);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_$ExifNpLog.log.shout(
|
||||||
|
"[_tryParseRationalString] Failed to parse rational string: $str",
|
||||||
|
e,
|
||||||
|
stackTrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _tryParseIntString(String str) {
|
||||||
|
if (str.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return int.parse(str);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_$ExifNpLog.log.shout(
|
||||||
|
"[_tryParseIntString] Failed to parse int string: $str",
|
||||||
|
e,
|
||||||
|
stackTrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final Map<String, dynamic> data;
|
final Map<String, dynamic> data;
|
||||||
|
|
||||||
static final dateTimeFormat = DateFormat("yyyy:MM:dd HH:mm:ss");
|
static final dateTimeFormat = DateFormat("yyyy:MM:dd HH:mm:ss");
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:exifdart/exifdart.dart';
|
import 'package:exifdart/exifdart.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:nc_photos/entity/exif.dart';
|
import 'package:nc_photos/entity/exif.dart';
|
||||||
|
|
||||||
extension ExifExtension on Exif {
|
extension ExifExtension on Exif {
|
||||||
|
@ -12,7 +13,7 @@ extension ExifExtension on Exif {
|
||||||
// invalid value
|
// invalid value
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
return _gpsDmsToDouble(gpsLatitude!) * (gpsLatitudeRef == "S" ? -1 : 1);
|
return gpsDmsToDouble(gpsLatitude!) * (gpsLatitudeRef == "S" ? -1 : 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,12 +27,23 @@ extension ExifExtension on Exif {
|
||||||
// invalid value
|
// invalid value
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} else {
|
||||||
return _gpsDmsToDouble(gpsLongitude!) * (gpsLongitudeRef == "W" ? -1 : 1);
|
return gpsDmsToDouble(gpsLongitude!) * (gpsLongitudeRef == "W" ? -1 : 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
double _gpsDmsToDouble(List<Rational> dms) {
|
List<Rational> gpsDoubleToDms(double src) {
|
||||||
|
var tmp = src.abs();
|
||||||
|
final d = tmp.floor();
|
||||||
|
tmp -= d;
|
||||||
|
final ss = (tmp * 3600 * 100).floor();
|
||||||
|
final s = ss % (60 * 100);
|
||||||
|
final m = (ss / (60 * 100)).floor();
|
||||||
|
return [Rational(d, 1), Rational(m, 1), Rational(s, 100)];
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
double gpsDmsToDouble(List<Rational> dms) {
|
||||||
double product = dms[0].toDouble();
|
double product = dms[0].toDouble();
|
||||||
if (dms.length > 1) {
|
if (dms.length > 1) {
|
||||||
product += dms[1].toDouble() / 60;
|
product += dms[1].toDouble() / 60;
|
||||||
|
@ -41,3 +53,14 @@ double _gpsDmsToDouble(List<Rational> dms) {
|
||||||
}
|
}
|
||||||
return product;
|
return product;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rational doubleToRational(double src) {
|
||||||
|
final s = src.abs();
|
||||||
|
if (s < 1000) {
|
||||||
|
return Rational((s * 100000).truncate(), 100000);
|
||||||
|
} else if (s < 100000) {
|
||||||
|
return Rational((s * 1000).truncate(), 1000);
|
||||||
|
} else {
|
||||||
|
return Rational(s.truncate(), 1);
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,10 +5,12 @@ import 'package:equatable/equatable.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:nc_photos/account.dart';
|
import 'package:nc_photos/account.dart';
|
||||||
import 'package:nc_photos/entity/exif.dart';
|
import 'package:nc_photos/entity/exif.dart';
|
||||||
|
import 'package:nc_photos/entity/exif_util.dart';
|
||||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
import 'package:nc_photos/json_util.dart' as json_util;
|
import 'package:nc_photos/json_util.dart' as json_util;
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
import 'package:np_common/object_util.dart';
|
||||||
import 'package:np_common/or_null.dart';
|
import 'package:np_common/or_null.dart';
|
||||||
import 'package:np_common/type.dart';
|
import 'package:np_common/type.dart';
|
||||||
import 'package:np_string/np_string.dart';
|
import 'package:np_string/np_string.dart';
|
||||||
|
@ -185,6 +187,43 @@ class Metadata with EquatableMixin {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Metadata? fromApi({
|
||||||
|
required String? etag,
|
||||||
|
Map<String, String>? ifd0,
|
||||||
|
Map<String, String>? exif,
|
||||||
|
Map<String, String>? gps,
|
||||||
|
required Map<String, String> size,
|
||||||
|
}) {
|
||||||
|
final lat = gps?["latitude"]?.let(double.tryParse);
|
||||||
|
final lng = gps?["longitude"]?.let(double.tryParse);
|
||||||
|
final alt = gps?["altitude"]?.let(double.tryParse);
|
||||||
|
return Metadata(
|
||||||
|
lastUpdated: clock.now().toUtc(),
|
||||||
|
fileEtag: etag,
|
||||||
|
imageWidth: int.parse(size["width"]!),
|
||||||
|
imageHeight: int.parse(size["height"]!),
|
||||||
|
exif: ifd0 != null || exif != null || gps != null
|
||||||
|
? Exif({
|
||||||
|
if (ifd0 != null) ...ifd0,
|
||||||
|
if (exif != null) ...exif,
|
||||||
|
if (lat != null && lng != null) ...{
|
||||||
|
"GPSLatitude": gpsDoubleToDms(lat),
|
||||||
|
"GPSLatitudeRef": lat.isNegative ? "S" : "N",
|
||||||
|
"GPSLongitude": gpsDoubleToDms(lng),
|
||||||
|
"GPSLongitudeRef": lng.isNegative ? "W" : "E",
|
||||||
|
if (alt != null) ...{
|
||||||
|
"GPSAltitude": doubleToRational(alt),
|
||||||
|
"GPSAltitudeRef": alt.isNegative ? 1 : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}..removeWhere((key, value) =>
|
||||||
|
key == "MakerNote" ||
|
||||||
|
key == "UserComment" ||
|
||||||
|
key == "ImageDescription"))
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => _$toString();
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
|
|
@ -55,6 +55,10 @@ class FileWebdavDataSource implements FileDataSource {
|
||||||
trashbinFilename: 1,
|
trashbinFilename: 1,
|
||||||
trashbinOriginalLocation: 1,
|
trashbinOriginalLocation: 1,
|
||||||
trashbinDeletionTime: 1,
|
trashbinDeletionTime: 1,
|
||||||
|
metadataPhotosIfd0: 1,
|
||||||
|
metadataPhotosExif: 1,
|
||||||
|
metadataPhotosGps: 1,
|
||||||
|
metadataPhotosSize: 1,
|
||||||
customNamespaces: {
|
customNamespaces: {
|
||||||
"com.nkming.nc_photos": "app",
|
"com.nkming.nc_photos": "app",
|
||||||
},
|
},
|
||||||
|
@ -275,6 +279,10 @@ class FileWebdavDataSource implements FileDataSource {
|
||||||
trashbinFilename,
|
trashbinFilename,
|
||||||
trashbinOriginalLocation,
|
trashbinOriginalLocation,
|
||||||
trashbinDeletionTime,
|
trashbinDeletionTime,
|
||||||
|
metadataPhotosIfd0,
|
||||||
|
metadataPhotosExif,
|
||||||
|
metadataPhotosGps,
|
||||||
|
metadataPhotosSize,
|
||||||
Map<String, String>? customNamespaces,
|
Map<String, String>? customNamespaces,
|
||||||
List<String>? customProperties,
|
List<String>? customProperties,
|
||||||
}) async {
|
}) async {
|
||||||
|
@ -302,6 +310,10 @@ class FileWebdavDataSource implements FileDataSource {
|
||||||
trashbinFilename: trashbinFilename,
|
trashbinFilename: trashbinFilename,
|
||||||
trashbinOriginalLocation: trashbinOriginalLocation,
|
trashbinOriginalLocation: trashbinOriginalLocation,
|
||||||
trashbinDeletionTime: trashbinDeletionTime,
|
trashbinDeletionTime: trashbinDeletionTime,
|
||||||
|
metadataPhotosIfd0: metadataPhotosIfd0,
|
||||||
|
metadataPhotosExif: metadataPhotosExif,
|
||||||
|
metadataPhotosGps: metadataPhotosGps,
|
||||||
|
metadataPhotosSize: metadataPhotosSize,
|
||||||
customNamespaces: customNamespaces,
|
customNamespaces: customNamespaces,
|
||||||
customProperties: customProperties,
|
customProperties: customProperties,
|
||||||
);
|
);
|
||||||
|
@ -623,7 +635,7 @@ class FileCachedDataSource implements FileDataSource {
|
||||||
return state.files;
|
return state.files;
|
||||||
}
|
}
|
||||||
|
|
||||||
await FileSqliteCacheUpdater(_c)(state.account, state.dir,
|
await FileSqliteCacheUpdater(_c.npDb)(state.account, state.dir,
|
||||||
remote: state.files);
|
remote: state.files);
|
||||||
if (shouldCheckCache) {
|
if (shouldCheckCache) {
|
||||||
// update our local touch token to match the remote one
|
// update our local touch token to match the remote one
|
||||||
|
@ -645,7 +657,7 @@ class FileCachedDataSource implements FileDataSource {
|
||||||
if (remote.isCollection != true) {
|
if (remote.isCollection != true) {
|
||||||
// only update regular files
|
// only update regular files
|
||||||
_log.info("[listSingle] Cache single file: ${logFilename(f.path)}");
|
_log.info("[listSingle] Cache single file: ${logFilename(f.path)}");
|
||||||
await FileSqliteCacheUpdater(_c).updateSingle(account, remote);
|
await FileSqliteCacheUpdater(_c.npDb).updateSingle(account, remote);
|
||||||
}
|
}
|
||||||
return remote;
|
return remote;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import 'package:nc_photos/entity/file/data_source.dart';
|
||||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||||
import 'package:nc_photos/exception.dart';
|
import 'package:nc_photos/exception.dart';
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
import 'package:np_db/np_db.dart';
|
||||||
|
|
||||||
part 'file_cache_manager.g.dart';
|
part 'file_cache_manager.g.dart';
|
||||||
|
|
||||||
|
@ -81,7 +82,7 @@ class FileCacheLoader {
|
||||||
|
|
||||||
@npLog
|
@npLog
|
||||||
class FileSqliteCacheUpdater {
|
class FileSqliteCacheUpdater {
|
||||||
const FileSqliteCacheUpdater(this._c);
|
const FileSqliteCacheUpdater(this.db);
|
||||||
|
|
||||||
Future<void> call(
|
Future<void> call(
|
||||||
Account account,
|
Account account,
|
||||||
|
@ -90,7 +91,7 @@ class FileSqliteCacheUpdater {
|
||||||
}) async {
|
}) async {
|
||||||
final s = Stopwatch()..start();
|
final s = Stopwatch()..start();
|
||||||
try {
|
try {
|
||||||
await _c.npDb.syncDirFiles(
|
await db.syncDirFiles(
|
||||||
account: account.toDb(),
|
account: account.toDb(),
|
||||||
dirFile: dir.toDbKey(),
|
dirFile: dir.toDbKey(),
|
||||||
files: remote.map((e) => e.toDb()).toList(),
|
files: remote.map((e) => e.toDb()).toList(),
|
||||||
|
@ -101,13 +102,13 @@ class FileSqliteCacheUpdater {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateSingle(Account account, File remoteFile) async {
|
Future<void> updateSingle(Account account, File remoteFile) async {
|
||||||
await _c.npDb.syncFile(
|
await db.syncFile(
|
||||||
account: account.toDb(),
|
account: account.toDb(),
|
||||||
file: remoteFile.toDb(),
|
file: remoteFile.toDb(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final DiContainer _c;
|
final NpDb db;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileSqliteCacheEmptier {
|
class FileSqliteCacheEmptier {
|
||||||
|
|
|
@ -230,6 +230,7 @@ enum AccountPrefKey implements PrefKeyInterface {
|
||||||
accountLabel,
|
accountLabel,
|
||||||
lastNewCollectionType,
|
lastNewCollectionType,
|
||||||
personProvider,
|
personProvider,
|
||||||
|
serverStatus,
|
||||||
;
|
;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -249,6 +250,8 @@ enum AccountPrefKey implements PrefKeyInterface {
|
||||||
return "lastNewCollectionType";
|
return "lastNewCollectionType";
|
||||||
case AccountPrefKey.personProvider:
|
case AccountPrefKey.personProvider:
|
||||||
return "personProvider";
|
return "personProvider";
|
||||||
|
case AccountPrefKey.serverStatus:
|
||||||
|
return "serverStatus";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:np_common/type.dart';
|
||||||
import 'package:to_string/to_string.dart';
|
import 'package:to_string/to_string.dart';
|
||||||
|
|
||||||
part 'server_status.g.dart';
|
part 'server_status.g.dart';
|
||||||
|
|
||||||
@toString
|
@toString
|
||||||
class ServerStatus {
|
class ServerStatus with EquatableMixin {
|
||||||
const ServerStatus({
|
const ServerStatus({
|
||||||
required this.versionRaw,
|
required this.versionRaw,
|
||||||
required this.versionName,
|
required this.versionName,
|
||||||
|
@ -13,6 +15,25 @@ class ServerStatus {
|
||||||
@override
|
@override
|
||||||
String toString() => _$toString();
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
factory ServerStatus.fromJson(JsonObj json) {
|
||||||
|
return ServerStatus(
|
||||||
|
versionRaw: json["versionRaw"],
|
||||||
|
versionName: json["versionName"],
|
||||||
|
productName: json["productName"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObj toJson() {
|
||||||
|
return {
|
||||||
|
"versionRaw": versionRaw,
|
||||||
|
"versionName": versionName,
|
||||||
|
"productName": productName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [versionRaw, versionName, productName];
|
||||||
|
|
||||||
final String versionRaw;
|
final String versionRaw;
|
||||||
final String versionName;
|
final String versionName;
|
||||||
final String productName;
|
final String productName;
|
||||||
|
|
|
@ -46,6 +46,7 @@ class AccountPrefUpdatedEvent {
|
||||||
final dynamic value;
|
final dynamic value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("not fired anymore, to be removed")
|
||||||
class FilePropertyUpdatedEvent {
|
class FilePropertyUpdatedEvent {
|
||||||
FilePropertyUpdatedEvent(this.account, this.file, this.properties);
|
FilePropertyUpdatedEvent(this.account, this.file, this.properties);
|
||||||
|
|
||||||
|
@ -97,26 +98,6 @@ class FavoriteResyncedEvent {
|
||||||
final Account account;
|
final Account account;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MetadataTaskState {
|
|
||||||
/// No work is being done
|
|
||||||
idle,
|
|
||||||
|
|
||||||
/// Processing images
|
|
||||||
prcoessing,
|
|
||||||
|
|
||||||
/// Paused on data network
|
|
||||||
waitingForWifi,
|
|
||||||
|
|
||||||
/// Paused on low battery
|
|
||||||
lowBattery,
|
|
||||||
}
|
|
||||||
|
|
||||||
class MetadataTaskStateChangedEvent {
|
|
||||||
const MetadataTaskStateChangedEvent(this.state);
|
|
||||||
|
|
||||||
final MetadataTaskState state;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("not fired anymore, to be removed")
|
@Deprecated("not fired anymore, to be removed")
|
||||||
class PrefUpdatedEvent {
|
class PrefUpdatedEvent {
|
||||||
PrefUpdatedEvent(this.key, this.value);
|
PrefUpdatedEvent(this.key, this.value);
|
||||||
|
|
|
@ -274,8 +274,8 @@
|
||||||
"@settingsMetadataTitle": {
|
"@settingsMetadataTitle": {
|
||||||
"description": "Metadata (e.g., date, resolution, GPS, etc)"
|
"description": "Metadata (e.g., date, resolution, GPS, etc)"
|
||||||
},
|
},
|
||||||
"settingsExifSupportTitle": "EXIF support",
|
"settingsExifSupportTitle2": "Client side EXIF support",
|
||||||
"@settingsExifSupportTitle": {
|
"@settingsExifSupportTitle2": {
|
||||||
"description": "Title of the EXIF support setting"
|
"description": "Title of the EXIF support setting"
|
||||||
},
|
},
|
||||||
"settingsExifSupportTrueSubtitle": "Require extra network usage",
|
"settingsExifSupportTrueSubtitle": "Require extra network usage",
|
||||||
|
@ -501,8 +501,12 @@
|
||||||
"@exifSupportDetails": {
|
"@exifSupportDetails": {
|
||||||
"description": "Detailed description of the exif support feature"
|
"description": "Detailed description of the exif support feature"
|
||||||
},
|
},
|
||||||
"exifSupportConfirmationDialogTitle": "Enable EXIF support?",
|
"exifSupportNextcloud28Notes": "Client side support complements your server. The app will process files and attributes not supported by Nextcloud",
|
||||||
"@exifSupportConfirmationDialogTitle": {
|
"@exifSupportNextcloud28Notes": {
|
||||||
|
"description": "Extra notes for Nextcloud 28+"
|
||||||
|
},
|
||||||
|
"exifSupportConfirmationDialogTitle2": "Enable client side EXIF support?",
|
||||||
|
"@exifSupportConfirmationDialogTitle2": {
|
||||||
"description": "Title of the dialog to confirm enabling exif support"
|
"description": "Title of the dialog to confirm enabling exif support"
|
||||||
},
|
},
|
||||||
"captureLogDetails": "To take logs for a bug report:\n\n1. Enable this setting\n2. Reproduce the issue\n3. Disable this setting\n4. Look for nc-photos.log in the download folder\n\n*If the issue causes the app to crash, no logs could be captured. In such case, please contact the developer for further instructions",
|
"captureLogDetails": "To take logs for a bug report:\n\n1. Enable this setting\n2. Reproduce the issue\n3. Disable this setting\n4. Look for nc-photos.log in the download folder\n\n*If the issue causes the app to crash, no logs could be captured. In such case, please contact the developer for further instructions",
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"ca": [
|
"ca": [
|
||||||
"settingsMetadataTitle",
|
"settingsMetadataTitle",
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsShareFolderPickerDescription",
|
"settingsShareFolderPickerDescription",
|
||||||
"settingsPersonProviderTitle",
|
"settingsPersonProviderTitle",
|
||||||
"settingsServerAppSectionTitle",
|
"settingsServerAppSectionTitle",
|
||||||
|
@ -55,7 +56,8 @@
|
||||||
"writePreferenceFailureNotification",
|
"writePreferenceFailureNotification",
|
||||||
"enableButtonLabel",
|
"enableButtonLabel",
|
||||||
"exifSupportDetails",
|
"exifSupportDetails",
|
||||||
"exifSupportConfirmationDialogTitle",
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"captureLogDetails",
|
"captureLogDetails",
|
||||||
"captureLogSuccessNotification",
|
"captureLogSuccessNotification",
|
||||||
"doneButtonLabel",
|
"doneButtonLabel",
|
||||||
|
@ -281,9 +283,12 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"cs": [
|
"cs": [
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
"settingsViewerCustomizeBottomAppBarTitle",
|
"settingsViewerCustomizeBottomAppBarTitle",
|
||||||
"settingsCollectionsCustomizeNavigationBarTitle",
|
"settingsCollectionsCustomizeNavigationBarTitle",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"alternativeSignIn",
|
"alternativeSignIn",
|
||||||
"livePhotoTooltip",
|
"livePhotoTooltip",
|
||||||
"dragAndDropRearrangeButtons",
|
"dragAndDropRearrangeButtons",
|
||||||
|
@ -294,9 +299,12 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"de": [
|
"de": [
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
"settingsViewerCustomizeBottomAppBarTitle",
|
"settingsViewerCustomizeBottomAppBarTitle",
|
||||||
"settingsCollectionsCustomizeNavigationBarTitle",
|
"settingsCollectionsCustomizeNavigationBarTitle",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"alternativeSignIn",
|
"alternativeSignIn",
|
||||||
"livePhotoTooltip",
|
"livePhotoTooltip",
|
||||||
"dragAndDropRearrangeButtons",
|
"dragAndDropRearrangeButtons",
|
||||||
|
@ -313,6 +321,7 @@
|
||||||
"signInHeaderText2",
|
"signInHeaderText2",
|
||||||
"settingsLanguageOptionSystemDefaultLabel",
|
"settingsLanguageOptionSystemDefaultLabel",
|
||||||
"settingsMetadataTitle",
|
"settingsMetadataTitle",
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsExifWifiOnlyTitle",
|
"settingsExifWifiOnlyTitle",
|
||||||
"settingsExifWifiOnlyFalseSubtitle",
|
"settingsExifWifiOnlyFalseSubtitle",
|
||||||
"settingsAccountLabelTitle",
|
"settingsAccountLabelTitle",
|
||||||
|
@ -359,6 +368,8 @@
|
||||||
"settingsUseNewHttpEngineDescription",
|
"settingsUseNewHttpEngineDescription",
|
||||||
"settingsServerVersionTitle",
|
"settingsServerVersionTitle",
|
||||||
"settingsRestartNeededDialog",
|
"settingsRestartNeededDialog",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"slideshowSetupDialogReverseTitle",
|
"slideshowSetupDialogReverseTitle",
|
||||||
"shareMethodPreviewTitle",
|
"shareMethodPreviewTitle",
|
||||||
"shareMethodPreviewDescription",
|
"shareMethodPreviewDescription",
|
||||||
|
@ -465,9 +476,12 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"es": [
|
"es": [
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
"settingsViewerCustomizeBottomAppBarTitle",
|
"settingsViewerCustomizeBottomAppBarTitle",
|
||||||
"settingsCollectionsCustomizeNavigationBarTitle",
|
"settingsCollectionsCustomizeNavigationBarTitle",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"alternativeSignIn",
|
"alternativeSignIn",
|
||||||
"livePhotoTooltip",
|
"livePhotoTooltip",
|
||||||
"dragAndDropRearrangeButtons",
|
"dragAndDropRearrangeButtons",
|
||||||
|
@ -478,6 +492,7 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"fi": [
|
"fi": [
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
"settingsViewerCustomizeBottomAppBarTitle",
|
"settingsViewerCustomizeBottomAppBarTitle",
|
||||||
"settingsCollectionsCustomizeNavigationBarTitle",
|
"settingsCollectionsCustomizeNavigationBarTitle",
|
||||||
|
@ -498,6 +513,8 @@
|
||||||
"settingsUseNewHttpEngine",
|
"settingsUseNewHttpEngine",
|
||||||
"settingsUseNewHttpEngineDescription",
|
"settingsUseNewHttpEngineDescription",
|
||||||
"settingsRestartNeededDialog",
|
"settingsRestartNeededDialog",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"appLockUnlockHint",
|
"appLockUnlockHint",
|
||||||
"appLockUnlockWrongPassword",
|
"appLockUnlockWrongPassword",
|
||||||
"enabledText",
|
"enabledText",
|
||||||
|
@ -527,6 +544,7 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"fr": [
|
"fr": [
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
"settingsViewerCustomizeBottomAppBarTitle",
|
"settingsViewerCustomizeBottomAppBarTitle",
|
||||||
"settingsCollectionsCustomizeNavigationBarTitle",
|
"settingsCollectionsCustomizeNavigationBarTitle",
|
||||||
|
@ -547,6 +565,8 @@
|
||||||
"settingsUseNewHttpEngine",
|
"settingsUseNewHttpEngine",
|
||||||
"settingsUseNewHttpEngineDescription",
|
"settingsUseNewHttpEngineDescription",
|
||||||
"settingsRestartNeededDialog",
|
"settingsRestartNeededDialog",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"appLockUnlockHint",
|
"appLockUnlockHint",
|
||||||
"appLockUnlockWrongPassword",
|
"appLockUnlockWrongPassword",
|
||||||
"enabledText",
|
"enabledText",
|
||||||
|
@ -576,6 +596,7 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"it": [
|
"it": [
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsPersonProviderTitle",
|
"settingsPersonProviderTitle",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
"settingsViewerCustomizeBottomAppBarTitle",
|
"settingsViewerCustomizeBottomAppBarTitle",
|
||||||
|
@ -598,6 +619,8 @@
|
||||||
"settingsUseNewHttpEngine",
|
"settingsUseNewHttpEngine",
|
||||||
"settingsUseNewHttpEngineDescription",
|
"settingsUseNewHttpEngineDescription",
|
||||||
"settingsRestartNeededDialog",
|
"settingsRestartNeededDialog",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"unmuteTooltip",
|
"unmuteTooltip",
|
||||||
"slideshowTooltip",
|
"slideshowTooltip",
|
||||||
"enhanceColorPopTitle",
|
"enhanceColorPopTitle",
|
||||||
|
@ -657,7 +680,7 @@
|
||||||
"settingsLanguageTitle",
|
"settingsLanguageTitle",
|
||||||
"settingsLanguageOptionSystemDefaultLabel",
|
"settingsLanguageOptionSystemDefaultLabel",
|
||||||
"settingsMetadataTitle",
|
"settingsMetadataTitle",
|
||||||
"settingsExifSupportTitle",
|
"settingsExifSupportTitle2",
|
||||||
"settingsExifSupportTrueSubtitle",
|
"settingsExifSupportTrueSubtitle",
|
||||||
"settingsExifWifiOnlyTitle",
|
"settingsExifWifiOnlyTitle",
|
||||||
"settingsExifWifiOnlyFalseSubtitle",
|
"settingsExifWifiOnlyFalseSubtitle",
|
||||||
|
@ -744,7 +767,8 @@
|
||||||
"writePreferenceFailureNotification",
|
"writePreferenceFailureNotification",
|
||||||
"enableButtonLabel",
|
"enableButtonLabel",
|
||||||
"exifSupportDetails",
|
"exifSupportDetails",
|
||||||
"exifSupportConfirmationDialogTitle",
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"captureLogDetails",
|
"captureLogDetails",
|
||||||
"captureLogSuccessNotification",
|
"captureLogSuccessNotification",
|
||||||
"doneButtonLabel",
|
"doneButtonLabel",
|
||||||
|
@ -1028,6 +1052,7 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"pl": [
|
"pl": [
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsMemoriesRangeValueText",
|
"settingsMemoriesRangeValueText",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
"settingsViewerCustomizeBottomAppBarTitle",
|
"settingsViewerCustomizeBottomAppBarTitle",
|
||||||
|
@ -1049,6 +1074,8 @@
|
||||||
"settingsUseNewHttpEngine",
|
"settingsUseNewHttpEngine",
|
||||||
"settingsUseNewHttpEngineDescription",
|
"settingsUseNewHttpEngineDescription",
|
||||||
"settingsRestartNeededDialog",
|
"settingsRestartNeededDialog",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"enhanceColorPopTitle",
|
"enhanceColorPopTitle",
|
||||||
"imageEditTransformOrientationClockwise",
|
"imageEditTransformOrientationClockwise",
|
||||||
"imageEditTransformOrientationCounterclockwise",
|
"imageEditTransformOrientationCounterclockwise",
|
||||||
|
@ -1083,6 +1110,7 @@
|
||||||
"pt": [
|
"pt": [
|
||||||
"nameInputInvalidEmpty",
|
"nameInputInvalidEmpty",
|
||||||
"settingsMetadataTitle",
|
"settingsMetadataTitle",
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsPersonProviderTitle",
|
"settingsPersonProviderTitle",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
"settingsViewerCustomizeBottomAppBarTitle",
|
"settingsViewerCustomizeBottomAppBarTitle",
|
||||||
|
@ -1107,6 +1135,8 @@
|
||||||
"settingsUseNewHttpEngineDescription",
|
"settingsUseNewHttpEngineDescription",
|
||||||
"settingsServerVersionTitle",
|
"settingsServerVersionTitle",
|
||||||
"settingsRestartNeededDialog",
|
"settingsRestartNeededDialog",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"searchLandingPeopleListEmptyText2",
|
"searchLandingPeopleListEmptyText2",
|
||||||
"createCollectionFailureNotification",
|
"createCollectionFailureNotification",
|
||||||
"addItemToCollectionTooltip",
|
"addItemToCollectionTooltip",
|
||||||
|
@ -1150,6 +1180,7 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"ru": [
|
"ru": [
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
"settingsViewerCustomizeBottomAppBarTitle",
|
"settingsViewerCustomizeBottomAppBarTitle",
|
||||||
"settingsCollectionsCustomizeNavigationBarTitle",
|
"settingsCollectionsCustomizeNavigationBarTitle",
|
||||||
|
@ -1170,6 +1201,8 @@
|
||||||
"settingsUseNewHttpEngine",
|
"settingsUseNewHttpEngine",
|
||||||
"settingsUseNewHttpEngineDescription",
|
"settingsUseNewHttpEngineDescription",
|
||||||
"settingsRestartNeededDialog",
|
"settingsRestartNeededDialog",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"appLockUnlockHint",
|
"appLockUnlockHint",
|
||||||
"appLockUnlockWrongPassword",
|
"appLockUnlockWrongPassword",
|
||||||
"enabledText",
|
"enabledText",
|
||||||
|
@ -1199,9 +1232,12 @@
|
||||||
],
|
],
|
||||||
|
|
||||||
"tr": [
|
"tr": [
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
"settingsViewerCustomizeBottomAppBarTitle",
|
"settingsViewerCustomizeBottomAppBarTitle",
|
||||||
"settingsCollectionsCustomizeNavigationBarTitle",
|
"settingsCollectionsCustomizeNavigationBarTitle",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"alternativeSignIn",
|
"alternativeSignIn",
|
||||||
"livePhotoTooltip",
|
"livePhotoTooltip",
|
||||||
"dragAndDropRearrangeButtons",
|
"dragAndDropRearrangeButtons",
|
||||||
|
@ -1213,6 +1249,7 @@
|
||||||
|
|
||||||
"zh": [
|
"zh": [
|
||||||
"settingsMetadataTitle",
|
"settingsMetadataTitle",
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsPersonProviderTitle",
|
"settingsPersonProviderTitle",
|
||||||
"settingsMemoriesRangeValueText",
|
"settingsMemoriesRangeValueText",
|
||||||
"settingsViewerCustomizeAppBarTitle",
|
"settingsViewerCustomizeAppBarTitle",
|
||||||
|
@ -1239,6 +1276,8 @@
|
||||||
"settingsUseNewHttpEngine",
|
"settingsUseNewHttpEngine",
|
||||||
"settingsUseNewHttpEngineDescription",
|
"settingsUseNewHttpEngineDescription",
|
||||||
"settingsRestartNeededDialog",
|
"settingsRestartNeededDialog",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"slideshowSetupDialogReverseTitle",
|
"slideshowSetupDialogReverseTitle",
|
||||||
"enhanceColorPopTitle",
|
"enhanceColorPopTitle",
|
||||||
"enhanceRetouchTitle",
|
"enhanceRetouchTitle",
|
||||||
|
@ -1298,6 +1337,7 @@
|
||||||
"signInHeaderText2",
|
"signInHeaderText2",
|
||||||
"settingsLanguageOptionSystemDefaultLabel",
|
"settingsLanguageOptionSystemDefaultLabel",
|
||||||
"settingsMetadataTitle",
|
"settingsMetadataTitle",
|
||||||
|
"settingsExifSupportTitle2",
|
||||||
"settingsExifWifiOnlyTitle",
|
"settingsExifWifiOnlyTitle",
|
||||||
"settingsExifWifiOnlyFalseSubtitle",
|
"settingsExifWifiOnlyFalseSubtitle",
|
||||||
"settingsAccountLabelTitle",
|
"settingsAccountLabelTitle",
|
||||||
|
@ -1347,6 +1387,8 @@
|
||||||
"settingsUseNewHttpEngineDescription",
|
"settingsUseNewHttpEngineDescription",
|
||||||
"settingsServerVersionTitle",
|
"settingsServerVersionTitle",
|
||||||
"settingsRestartNeededDialog",
|
"settingsRestartNeededDialog",
|
||||||
|
"exifSupportNextcloud28Notes",
|
||||||
|
"exifSupportConfirmationDialogTitle2",
|
||||||
"sortOptionFilenameAscendingLabel",
|
"sortOptionFilenameAscendingLabel",
|
||||||
"sortOptionFilenameDescendingLabel",
|
"sortOptionFilenameDescendingLabel",
|
||||||
"slideshowSetupDialogReverseTitle",
|
"slideshowSetupDialogReverseTitle",
|
||||||
|
|
|
@ -1,134 +0,0 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:event_bus/event_bus.dart';
|
|
||||||
import 'package:kiwi/kiwi.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:nc_photos/account.dart';
|
|
||||||
import 'package:nc_photos/di_container.dart';
|
|
||||||
import 'package:nc_photos/entity/file.dart';
|
|
||||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
|
||||||
import 'package:nc_photos/entity/pref.dart';
|
|
||||||
import 'package:nc_photos/event/event.dart';
|
|
||||||
import 'package:nc_photos/use_case/update_missing_metadata.dart';
|
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
|
||||||
import 'package:np_geocoder/np_geocoder.dart';
|
|
||||||
import 'package:to_string/to_string.dart';
|
|
||||||
|
|
||||||
part 'metadata_task_manager.g.dart';
|
|
||||||
|
|
||||||
/// Task to update metadata for missing files
|
|
||||||
@npLog
|
|
||||||
@ToString(ignorePrivate: true)
|
|
||||||
class MetadataTask {
|
|
||||||
MetadataTask(this._c, this.account, this.pref) : assert(require(_c));
|
|
||||||
|
|
||||||
static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => _$toString();
|
|
||||||
|
|
||||||
Future<void> call() async {
|
|
||||||
try {
|
|
||||||
final shareFolder =
|
|
||||||
File(path: file_util.unstripPath(account, pref.getShareFolderOr()));
|
|
||||||
bool hasScanShareFolder = false;
|
|
||||||
final geocoder = ReverseGeocoder();
|
|
||||||
await geocoder.init();
|
|
||||||
for (final r in account.roots) {
|
|
||||||
final dir = File(path: file_util.unstripPath(account, r));
|
|
||||||
hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir);
|
|
||||||
final op = UpdateMissingMetadata(
|
|
||||||
_c, const _UpdateMissingMetadataConfigProvider(), geocoder);
|
|
||||||
await for (final _ in op(account, dir)) {
|
|
||||||
if (!Pref().isEnableExifOr()) {
|
|
||||||
_log.info("[call] EXIF disabled, task ending immaturely");
|
|
||||||
op.stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hasScanShareFolder) {
|
|
||||||
final op = UpdateMissingMetadata(
|
|
||||||
_c, const _UpdateMissingMetadataConfigProvider(), geocoder);
|
|
||||||
await for (final _ in op(
|
|
||||||
account,
|
|
||||||
shareFolder,
|
|
||||||
isRecursive: false,
|
|
||||||
filter: (f) => f.ownerId != account.userId,
|
|
||||||
)) {
|
|
||||||
if (!Pref().isEnableExifOr()) {
|
|
||||||
_log.info("[call] EXIF disabled, task ending immaturely");
|
|
||||||
op.stop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
KiwiContainer()
|
|
||||||
.resolve<EventBus>()
|
|
||||||
.fire(const MetadataTaskStateChangedEvent(MetadataTaskState.idle));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final DiContainer _c;
|
|
||||||
|
|
||||||
final Account account;
|
|
||||||
@ignore
|
|
||||||
final AccountPref pref;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manage metadata tasks to run concurrently
|
|
||||||
@npLog
|
|
||||||
class MetadataTaskManager {
|
|
||||||
factory MetadataTaskManager() {
|
|
||||||
_inst ??= MetadataTaskManager._();
|
|
||||||
return _inst!;
|
|
||||||
}
|
|
||||||
|
|
||||||
MetadataTaskManager._() {
|
|
||||||
_stateChangedListener.begin();
|
|
||||||
_handleStream();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a task to the queue
|
|
||||||
void addTask(MetadataTask task) {
|
|
||||||
_log.info("[addTask] New task added: $task");
|
|
||||||
_streamController.add(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
MetadataTaskState get state => _currentState;
|
|
||||||
|
|
||||||
void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) {
|
|
||||||
if (ev.state != _currentState) {
|
|
||||||
_currentState = ev.state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleStream() async {
|
|
||||||
await for (final task in _streamController.stream) {
|
|
||||||
if (Pref().isEnableExifOr()) {
|
|
||||||
_log.info("[_doTask] Executing task: $task");
|
|
||||||
await task();
|
|
||||||
} else {
|
|
||||||
_log.info("[_doTask] Ignoring task: $task");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final _streamController = StreamController<MetadataTask>.broadcast();
|
|
||||||
|
|
||||||
var _currentState = MetadataTaskState.idle;
|
|
||||||
late final _stateChangedListener =
|
|
||||||
AppEventListener<MetadataTaskStateChangedEvent>(
|
|
||||||
_onMetadataTaskStateChanged);
|
|
||||||
|
|
||||||
static MetadataTaskManager? _inst;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UpdateMissingMetadataConfigProvider
|
|
||||||
implements UpdateMissingMetadataConfigProvider {
|
|
||||||
const _UpdateMissingMetadataConfigProvider();
|
|
||||||
|
|
||||||
@override
|
|
||||||
isWifiOnly() async => Pref().shouldProcessExifWifiOnlyOr();
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'metadata_task_manager.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// NpLogGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
extension _$MetadataTaskNpLog on MetadataTask {
|
|
||||||
// ignore: unused_element
|
|
||||||
Logger get _log => log;
|
|
||||||
|
|
||||||
static final log = Logger("metadata_task_manager.MetadataTask");
|
|
||||||
}
|
|
||||||
|
|
||||||
extension _$MetadataTaskManagerNpLog on MetadataTaskManager {
|
|
||||||
// ignore: unused_element
|
|
||||||
Logger get _log => log;
|
|
||||||
|
|
||||||
static final log = Logger("metadata_task_manager.MetadataTaskManager");
|
|
||||||
}
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// ToStringGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
extension _$MetadataTaskToString on MetadataTask {
|
|
||||||
String _$toString() {
|
|
||||||
// ignore: unnecessary_string_interpolations
|
|
||||||
return "MetadataTask {account: $account}";
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,358 +0,0 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:devicelocale/devicelocale.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_background_service/flutter_background_service.dart';
|
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
|
||||||
import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';
|
|
||||||
import 'package:kiwi/kiwi.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:nc_photos/account.dart';
|
|
||||||
import 'package:nc_photos/app_init.dart' as app_init;
|
|
||||||
import 'package:nc_photos/app_localizations.dart';
|
|
||||||
import 'package:nc_photos/controller/pref_controller.dart';
|
|
||||||
import 'package:nc_photos/di_container.dart';
|
|
||||||
import 'package:nc_photos/entity/file.dart';
|
|
||||||
import 'package:nc_photos/entity/file/data_source.dart';
|
|
||||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
|
||||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
|
||||||
import 'package:nc_photos/entity/pref.dart';
|
|
||||||
import 'package:nc_photos/event/event.dart';
|
|
||||||
import 'package:nc_photos/event/native_event.dart';
|
|
||||||
import 'package:nc_photos/language_util.dart' as language_util;
|
|
||||||
import 'package:nc_photos/use_case/update_missing_metadata.dart';
|
|
||||||
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
|
||||||
import 'package:np_async/np_async.dart';
|
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
|
||||||
import 'package:np_geocoder/np_geocoder.dart';
|
|
||||||
import 'package:np_platform_message_relay/np_platform_message_relay.dart';
|
|
||||||
|
|
||||||
part 'service.g.dart';
|
|
||||||
|
|
||||||
/// Start the background service
|
|
||||||
Future<void> startService() async {
|
|
||||||
_C.log.info("[startService] Starting service");
|
|
||||||
final service = FlutterBackgroundService();
|
|
||||||
await service.configure(
|
|
||||||
androidConfiguration: AndroidConfiguration(
|
|
||||||
onStart: serviceMain,
|
|
||||||
autoStart: false,
|
|
||||||
isForegroundMode: true,
|
|
||||||
foregroundServiceNotificationTitle:
|
|
||||||
L10n.global().metadataTaskProcessingNotification,
|
|
||||||
),
|
|
||||||
iosConfiguration: IosConfiguration(
|
|
||||||
onForeground: () => throw UnimplementedError(),
|
|
||||||
onBackground: () => throw UnimplementedError(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// sync settings
|
|
||||||
await ServiceConfig.setProcessExifWifiOnly(
|
|
||||||
Pref().shouldProcessExifWifiOnlyOr());
|
|
||||||
await service.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ask the background service to stop ASAP
|
|
||||||
void stopService() {
|
|
||||||
_C.log.info("[stopService] Stopping service");
|
|
||||||
FlutterBackgroundService().sendData({
|
|
||||||
_dataKeyEvent: _eventStop,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
@pragma("vm:entry-point")
|
|
||||||
Future<void> serviceMain() async {
|
|
||||||
_Service._shouldRun.value = true;
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
await _Service()();
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceConfig {
|
|
||||||
static Future<void> setProcessExifWifiOnly(bool flag) async {
|
|
||||||
await Preference.setBool(_servicePref, _servicePrefProcessWifiOnly, flag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@npLog
|
|
||||||
class _Service {
|
|
||||||
Future<void> call() async {
|
|
||||||
final service = FlutterBackgroundService();
|
|
||||||
service.setForegroundMode(true);
|
|
||||||
|
|
||||||
await app_init.init(app_init.InitIsolateType.flutterIsolate);
|
|
||||||
await _L10n().init();
|
|
||||||
|
|
||||||
_log.info("[call] Service started");
|
|
||||||
final onCancelSubscription = service.onCancel.listen((_) {
|
|
||||||
_log.info("[call] User canceled");
|
|
||||||
_stopSelf();
|
|
||||||
});
|
|
||||||
final onDataSubscription =
|
|
||||||
service.onDataReceived.listen((event) => _onReceiveData(event ?? {}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _doWork();
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
_log.shout("[call] Uncaught exception", e, stackTrace);
|
|
||||||
}
|
|
||||||
await onCancelSubscription.cancel();
|
|
||||||
await onDataSubscription.cancel();
|
|
||||||
await KiwiContainer().resolve<DiContainer>().npDb.dispose();
|
|
||||||
service.stopBackgroundService();
|
|
||||||
_log.info("[call] Service stopped");
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _doWork() async {
|
|
||||||
final prefController = PrefController(Pref());
|
|
||||||
final account = prefController.currentAccountValue;
|
|
||||||
if (account == null) {
|
|
||||||
_log.shout("[_doWork] account == null");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final accountPref = AccountPref.of(account);
|
|
||||||
|
|
||||||
final service = FlutterBackgroundService();
|
|
||||||
final metadataTask = _MetadataTask(service, account, accountPref);
|
|
||||||
_metadataTaskStateChangedListener.begin();
|
|
||||||
try {
|
|
||||||
await metadataTask();
|
|
||||||
} finally {
|
|
||||||
_metadataTaskStateChangedListener.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onReceiveData(Map<String, dynamic> data) {
|
|
||||||
try {
|
|
||||||
final event = data[_dataKeyEvent];
|
|
||||||
switch (event) {
|
|
||||||
case _eventStop:
|
|
||||||
_stopSelf();
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
_log.severe("[_onReceiveData] Unknown event: $event");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
_log.shout("[_onReceiveData] Uncaught exception", e, stackTrace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) {
|
|
||||||
if (ev.state == _metadataTaskState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_metadataTaskState = ev.state;
|
|
||||||
if (_isPaused != true) {
|
|
||||||
if (ev.state == MetadataTaskState.waitingForWifi) {
|
|
||||||
FlutterBackgroundService()
|
|
||||||
..setNotificationInfo(
|
|
||||||
title: _L10n.global().metadataTaskPauseNoWiFiNotification,
|
|
||||||
)
|
|
||||||
..pauseWakeLock();
|
|
||||||
_isPaused = true;
|
|
||||||
} else if (ev.state == MetadataTaskState.lowBattery) {
|
|
||||||
FlutterBackgroundService()
|
|
||||||
..setNotificationInfo(
|
|
||||||
title: _L10n.global().metadataTaskPauseLowBatteryNotification,
|
|
||||||
)
|
|
||||||
..pauseWakeLock();
|
|
||||||
_isPaused = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (ev.state == MetadataTaskState.prcoessing) {
|
|
||||||
FlutterBackgroundService().resumeWakeLock();
|
|
||||||
_isPaused = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _stopSelf() {
|
|
||||||
_log.info("[_stopSelf] Stopping service");
|
|
||||||
FlutterBackgroundService().setNotificationInfo(
|
|
||||||
title: _L10n.global().backgroundServiceStopping,
|
|
||||||
);
|
|
||||||
_shouldRun.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var _metadataTaskState = MetadataTaskState.idle;
|
|
||||||
late final _metadataTaskStateChangedListener =
|
|
||||||
AppEventListener<MetadataTaskStateChangedEvent>(
|
|
||||||
_onMetadataTaskStateChanged);
|
|
||||||
|
|
||||||
bool? _isPaused;
|
|
||||||
|
|
||||||
static final _shouldRun = ValueNotifier<bool>(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Access localized string out of the main isolate
|
|
||||||
@npLog
|
|
||||||
class _L10n {
|
|
||||||
factory _L10n() => _inst;
|
|
||||||
|
|
||||||
_L10n._();
|
|
||||||
|
|
||||||
Future<void> init() async {
|
|
||||||
try {
|
|
||||||
final locale = language_util.getSelectedLocale();
|
|
||||||
if (locale == null) {
|
|
||||||
_l10n = await _queryL10n();
|
|
||||||
} else {
|
|
||||||
_l10n = lookupAppLocalizations(locale);
|
|
||||||
}
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
_log.shout("[init] Uncaught exception", e, stackTrace);
|
|
||||||
_l10n = AppLocalizationsEn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static AppLocalizations global() => _L10n()._l10n;
|
|
||||||
|
|
||||||
Future<AppLocalizations> _queryL10n() async {
|
|
||||||
try {
|
|
||||||
final locale = await Devicelocale.currentAsLocale;
|
|
||||||
return lookupAppLocalizations(locale!);
|
|
||||||
} on FlutterError catch (_) {
|
|
||||||
// unsupported locale, use default (en)
|
|
||||||
return AppLocalizationsEn();
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
_log.shout(
|
|
||||||
"[_queryL10n] Failed while lookupAppLocalizations", e, stackTrace);
|
|
||||||
return AppLocalizationsEn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static final _inst = _L10n._();
|
|
||||||
late AppLocalizations _l10n;
|
|
||||||
}
|
|
||||||
|
|
||||||
@npLog
|
|
||||||
class _MetadataTask {
|
|
||||||
_MetadataTask(this.service, this.account, this.accountPref);
|
|
||||||
|
|
||||||
Future<void> call() async {
|
|
||||||
try {
|
|
||||||
await _updateMetadata();
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
_log.shout("[call] Uncaught exception", e, stackTrace);
|
|
||||||
}
|
|
||||||
if (_processedIds.isNotEmpty) {
|
|
||||||
unawaited(
|
|
||||||
MessageRelay.broadcast(FileExifUpdatedEvent(_processedIds).toEvent()),
|
|
||||||
);
|
|
||||||
_processedIds = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
final c = KiwiContainer().resolve<DiContainer>();
|
|
||||||
if (c.fileRepo.dataSrc is FileCachedDataSource) {
|
|
||||||
await (c.fileRepo.dataSrc as FileCachedDataSource).flushRemoteTouch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _updateMetadata() async {
|
|
||||||
final shareFolder = File(
|
|
||||||
path: file_util.unstripPath(account, accountPref.getShareFolderOr()));
|
|
||||||
bool hasScanShareFolder = false;
|
|
||||||
final c = KiwiContainer().resolve<DiContainer>();
|
|
||||||
final geocoder = ReverseGeocoder();
|
|
||||||
await geocoder.init();
|
|
||||||
for (final r in account.roots) {
|
|
||||||
final dir = File(path: file_util.unstripPath(account, r));
|
|
||||||
hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir);
|
|
||||||
final updater = UpdateMissingMetadata(
|
|
||||||
c, const _UpdateMissingMetadataConfigProvider(), geocoder);
|
|
||||||
void onServiceStop() {
|
|
||||||
_log.info("[_updateMetadata] Stopping task: user canceled");
|
|
||||||
updater.stop();
|
|
||||||
_shouldRun = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_Service._shouldRun.addListener(onServiceStop);
|
|
||||||
try {
|
|
||||||
await for (final ev in updater(account, dir)) {
|
|
||||||
if (ev is File) {
|
|
||||||
_onFileProcessed(ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
_Service._shouldRun.removeListener(onServiceStop);
|
|
||||||
}
|
|
||||||
if (!_shouldRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hasScanShareFolder) {
|
|
||||||
final shareUpdater = UpdateMissingMetadata(
|
|
||||||
c, const _UpdateMissingMetadataConfigProvider(), geocoder);
|
|
||||||
void onServiceStop() {
|
|
||||||
_log.info("[_updateMetadata] Stopping task: user canceled");
|
|
||||||
shareUpdater.stop();
|
|
||||||
_shouldRun = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_Service._shouldRun.addListener(onServiceStop);
|
|
||||||
try {
|
|
||||||
await for (final ev in shareUpdater(
|
|
||||||
account,
|
|
||||||
shareFolder,
|
|
||||||
isRecursive: false,
|
|
||||||
filter: (f) => f.ownerId != account.userId,
|
|
||||||
)) {
|
|
||||||
if (ev is File) {
|
|
||||||
_onFileProcessed(ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
_Service._shouldRun.removeListener(onServiceStop);
|
|
||||||
}
|
|
||||||
if (!_shouldRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onFileProcessed(File file) {
|
|
||||||
++_count;
|
|
||||||
service.setNotificationInfo(
|
|
||||||
title: _L10n.global().metadataTaskProcessingNotification,
|
|
||||||
content: file.strippedPath,
|
|
||||||
);
|
|
||||||
|
|
||||||
_processedIds.add(file.fileId!);
|
|
||||||
if (_processedIds.length >= 10) {
|
|
||||||
MessageRelay.broadcast(FileExifUpdatedEvent(_processedIds).toEvent());
|
|
||||||
_processedIds = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final FlutterBackgroundService service;
|
|
||||||
final Account account;
|
|
||||||
final AccountPref accountPref;
|
|
||||||
|
|
||||||
var _shouldRun = true;
|
|
||||||
var _count = 0;
|
|
||||||
var _processedIds = <int>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UpdateMissingMetadataConfigProvider
|
|
||||||
implements UpdateMissingMetadataConfigProvider {
|
|
||||||
const _UpdateMissingMetadataConfigProvider();
|
|
||||||
|
|
||||||
@override
|
|
||||||
isWifiOnly() =>
|
|
||||||
Preference.getBool(_servicePref, _servicePrefProcessWifiOnly, true)
|
|
||||||
.notNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _C {
|
|
||||||
// needed to work with generator logger
|
|
||||||
static final log = Logger("service");
|
|
||||||
}
|
|
||||||
|
|
||||||
const _dataKeyEvent = "event";
|
|
||||||
const _eventStop = "stop";
|
|
||||||
|
|
||||||
const _servicePref = "service";
|
|
||||||
const _servicePrefProcessWifiOnly = "shouldProcessWifiOnly";
|
|
23
app/lib/service/config.dart
Normal file
23
app/lib/service/config.dart
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
part of 'service.dart';
|
||||||
|
|
||||||
|
class ServiceConfig {
|
||||||
|
static Future<bool> isProcessExifWifiOnly() async {
|
||||||
|
return Preference.getBool(_pref, _prefProcessWifiOnly, true).notNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setProcessExifWifiOnly(bool flag) async {
|
||||||
|
await Preference.setBool(_pref, _prefProcessWifiOnly, flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> isEnableClientExif() async {
|
||||||
|
return Preference.getBool(_pref, _prefIsEnableClientExif, false).notNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setEnableClientExif(bool flag) async {
|
||||||
|
await Preference.setBool(_pref, _prefIsEnableClientExif, flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _pref = "service";
|
||||||
|
static const _prefProcessWifiOnly = "shouldProcessWifiOnly";
|
||||||
|
static const _prefIsEnableClientExif = "isEnableClientExif";
|
||||||
|
}
|
43
app/lib/service/l10n.dart
Normal file
43
app/lib/service/l10n.dart
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
part of 'service.dart';
|
||||||
|
|
||||||
|
/// Access localized string out of the main isolate
|
||||||
|
@npLog
|
||||||
|
class _L10n {
|
||||||
|
_L10n._();
|
||||||
|
|
||||||
|
factory _L10n() => _inst;
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
try {
|
||||||
|
final locale = language_util.getSelectedLocale();
|
||||||
|
if (locale == null) {
|
||||||
|
_l10n = await _queryL10n();
|
||||||
|
} else {
|
||||||
|
_l10n = lookupAppLocalizations(locale);
|
||||||
|
}
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.shout("[init] Uncaught exception", e, stackTrace);
|
||||||
|
_l10n = AppLocalizationsEn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppLocalizations global() => _L10n()._l10n;
|
||||||
|
|
||||||
|
Future<AppLocalizations> _queryL10n() async {
|
||||||
|
try {
|
||||||
|
final locale = await Devicelocale.currentAsLocale;
|
||||||
|
return lookupAppLocalizations(locale!);
|
||||||
|
} on FlutterError catch (_) {
|
||||||
|
// unsupported locale, use default (en)
|
||||||
|
return AppLocalizationsEn();
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.shout(
|
||||||
|
"[_queryL10n] Failed while lookupAppLocalizations", e, stackTrace);
|
||||||
|
return AppLocalizationsEn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
late AppLocalizations _l10n;
|
||||||
|
|
||||||
|
static final _inst = _L10n._();
|
||||||
|
}
|
197
app/lib/service/service.dart
Normal file
197
app/lib/service/service.dart
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:devicelocale/devicelocale.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_background_service/flutter_background_service.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';
|
||||||
|
import 'package:kiwi/kiwi.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/app_init.dart' as app_init;
|
||||||
|
import 'package:nc_photos/app_localizations.dart';
|
||||||
|
import 'package:nc_photos/controller/account_pref_controller.dart';
|
||||||
|
import 'package:nc_photos/controller/pref_controller.dart';
|
||||||
|
import 'package:nc_photos/di_container.dart';
|
||||||
|
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||||
|
import 'package:nc_photos/event/native_event.dart';
|
||||||
|
import 'package:nc_photos/language_util.dart' as language_util;
|
||||||
|
import 'package:nc_photos/use_case/battery_ensurer.dart';
|
||||||
|
import 'package:nc_photos/use_case/sync_metadata/sync_metadata.dart';
|
||||||
|
import 'package:nc_photos/use_case/wifi_ensurer.dart';
|
||||||
|
import 'package:nc_photos_plugin/nc_photos_plugin.dart';
|
||||||
|
import 'package:np_async/np_async.dart';
|
||||||
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
import 'package:np_platform_message_relay/np_platform_message_relay.dart';
|
||||||
|
|
||||||
|
part 'config.dart';
|
||||||
|
part 'l10n.dart';
|
||||||
|
part 'service.g.dart';
|
||||||
|
|
||||||
|
/// Start the background service
|
||||||
|
Future<void> startService({
|
||||||
|
required PrefController prefController,
|
||||||
|
}) async {
|
||||||
|
_$__NpLog.log.info("[startService] Starting service");
|
||||||
|
final service = FlutterBackgroundService();
|
||||||
|
await service.configure(
|
||||||
|
androidConfiguration: AndroidConfiguration(
|
||||||
|
onStart: serviceMain,
|
||||||
|
autoStart: false,
|
||||||
|
isForegroundMode: true,
|
||||||
|
foregroundServiceNotificationTitle:
|
||||||
|
L10n.global().metadataTaskProcessingNotification,
|
||||||
|
),
|
||||||
|
iosConfiguration: IosConfiguration(
|
||||||
|
onForeground: () => throw UnimplementedError(),
|
||||||
|
onBackground: () => throw UnimplementedError(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// sync settings
|
||||||
|
await ServiceConfig.setProcessExifWifiOnly(
|
||||||
|
prefController.shouldProcessExifWifiOnlyValue);
|
||||||
|
await ServiceConfig.setEnableClientExif(
|
||||||
|
prefController.isEnableClientExifValue);
|
||||||
|
await service.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the background service to stop ASAP
|
||||||
|
void stopService() {
|
||||||
|
_$__NpLog.log.info("[stopService] Stopping service");
|
||||||
|
FlutterBackgroundService().sendData({
|
||||||
|
_dataKeyEvent: _eventStop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
@pragma("vm:entry-point")
|
||||||
|
Future<void> serviceMain() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await _Service()();
|
||||||
|
}
|
||||||
|
|
||||||
|
@npLog
|
||||||
|
class _Service {
|
||||||
|
Future<void> call() async {
|
||||||
|
final service = FlutterBackgroundService();
|
||||||
|
service.setForegroundMode(true);
|
||||||
|
|
||||||
|
await app_init.init(app_init.InitIsolateType.flutterIsolate);
|
||||||
|
await _L10n().init();
|
||||||
|
|
||||||
|
_log.info("[call] Service started");
|
||||||
|
final onCancelSubscription = service.onCancel.listen((_) {
|
||||||
|
_log.info("[call] User canceled");
|
||||||
|
_stopSelf();
|
||||||
|
});
|
||||||
|
final onDataSubscription =
|
||||||
|
service.onDataReceived.listen((event) => _onNotifAction(event ?? {}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _doWork();
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.shout("[call] Uncaught exception", e, stackTrace);
|
||||||
|
}
|
||||||
|
await onCancelSubscription.cancel();
|
||||||
|
await onDataSubscription.cancel();
|
||||||
|
await KiwiContainer().resolve<DiContainer>().npDb.dispose();
|
||||||
|
service.stopBackgroundService();
|
||||||
|
_log.info("[call] Service stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _doWork() async {
|
||||||
|
final c = KiwiContainer().resolve<DiContainer>();
|
||||||
|
final prefController = PrefController(c.pref);
|
||||||
|
final account = prefController.currentAccountValue;
|
||||||
|
if (account == null) {
|
||||||
|
_log.shout("[_doWork] account == null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final accountPrefController = AccountPrefController(account: account);
|
||||||
|
|
||||||
|
final wifiEnsurer = WifiEnsurer(
|
||||||
|
interrupter: _shouldRun.stream,
|
||||||
|
);
|
||||||
|
wifiEnsurer.isWaiting.listen((event) {
|
||||||
|
if (event) {
|
||||||
|
FlutterBackgroundService()
|
||||||
|
..setNotificationInfo(
|
||||||
|
title: _L10n.global().metadataTaskPauseNoWiFiNotification,
|
||||||
|
)
|
||||||
|
..pauseWakeLock();
|
||||||
|
} else {
|
||||||
|
FlutterBackgroundService().resumeWakeLock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
final batteryEnsurer = BatteryEnsurer(
|
||||||
|
interrupter: _shouldRun.stream,
|
||||||
|
);
|
||||||
|
batteryEnsurer.isWaiting.listen((event) {
|
||||||
|
if (event) {
|
||||||
|
FlutterBackgroundService()
|
||||||
|
..setNotificationInfo(
|
||||||
|
title: _L10n.global().metadataTaskPauseLowBatteryNotification,
|
||||||
|
)
|
||||||
|
..pauseWakeLock();
|
||||||
|
} else {
|
||||||
|
FlutterBackgroundService().resumeWakeLock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final service = FlutterBackgroundService();
|
||||||
|
final syncOp = SyncMetadata(
|
||||||
|
fileRepo: c.fileRepo,
|
||||||
|
fileRepo2: c.fileRepo2,
|
||||||
|
fileRepoRemote: c.fileRepoRemote,
|
||||||
|
db: c.npDb,
|
||||||
|
interrupter: _shouldRun.stream,
|
||||||
|
wifiEnsurer: wifiEnsurer,
|
||||||
|
batteryEnsurer: batteryEnsurer,
|
||||||
|
);
|
||||||
|
final processedIds = <int>[];
|
||||||
|
await for (final f in syncOp.syncAccount(account, accountPrefController)) {
|
||||||
|
processedIds.add(f.fdId);
|
||||||
|
service.setNotificationInfo(
|
||||||
|
title: _L10n.global().metadataTaskProcessingNotification,
|
||||||
|
content: f.strippedPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (processedIds.isNotEmpty) {
|
||||||
|
await MessageRelay.broadcast(
|
||||||
|
FileExifUpdatedEvent(processedIds).toEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onNotifAction(Map<String, dynamic> data) {
|
||||||
|
try {
|
||||||
|
final event = data[_dataKeyEvent];
|
||||||
|
switch (event) {
|
||||||
|
case _eventStop:
|
||||||
|
_stopSelf();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
_log.severe("[_onNotifAction] Unknown event: $event");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.shout("[_onNotifAction] Uncaught exception", e, stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopSelf() {
|
||||||
|
_log.info("[_stopSelf] Stopping service");
|
||||||
|
FlutterBackgroundService().setNotificationInfo(
|
||||||
|
title: _L10n.global().backgroundServiceStopping,
|
||||||
|
);
|
||||||
|
_shouldRun.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
final _shouldRun = StreamController<void>.broadcast();
|
||||||
|
}
|
||||||
|
|
||||||
|
@npLog
|
||||||
|
// ignore: camel_case_types
|
||||||
|
class __ {}
|
||||||
|
|
||||||
|
const _dataKeyEvent = "event";
|
||||||
|
const _eventStop = "stop";
|
|
@ -10,19 +10,19 @@ extension _$_ServiceNpLog on _Service {
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
Logger get _log => log;
|
Logger get _log => log;
|
||||||
|
|
||||||
static final log = Logger("service._Service");
|
static final log = Logger("service.service._Service");
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$__NpLog on __ {
|
||||||
|
// ignore: unused_element
|
||||||
|
Logger get _log => log;
|
||||||
|
|
||||||
|
static final log = Logger("service.service.__");
|
||||||
}
|
}
|
||||||
|
|
||||||
extension _$_L10nNpLog on _L10n {
|
extension _$_L10nNpLog on _L10n {
|
||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
Logger get _log => log;
|
Logger get _log => log;
|
||||||
|
|
||||||
static final log = Logger("service._L10n");
|
static final log = Logger("service.service._L10n");
|
||||||
}
|
|
||||||
|
|
||||||
extension _$_MetadataTaskNpLog on _MetadataTask {
|
|
||||||
// ignore: unused_element
|
|
||||||
Logger get _log => log;
|
|
||||||
|
|
||||||
static final log = Logger("service._MetadataTask");
|
|
||||||
}
|
}
|
35
app/lib/use_case/battery_ensurer.dart
Normal file
35
app/lib/use_case/battery_ensurer.dart
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import 'package:battery_plus/battery_plus.dart';
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
|
import 'package:rxdart/rxdart.dart';
|
||||||
|
|
||||||
|
class BatteryEnsurer {
|
||||||
|
BatteryEnsurer({
|
||||||
|
this.interrupter,
|
||||||
|
}) {
|
||||||
|
interrupter?.listen((event) {
|
||||||
|
_shouldRun = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> call() async {
|
||||||
|
while (await Battery().batteryLevel <= 15) {
|
||||||
|
if (!_shouldRun) {
|
||||||
|
throw const InterruptedException();
|
||||||
|
}
|
||||||
|
if (!_isWaiting.value) {
|
||||||
|
_isWaiting.add(true);
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(seconds: 5));
|
||||||
|
}
|
||||||
|
if (_isWaiting.value) {
|
||||||
|
_isWaiting.add(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ValueStream<bool> get isWaiting => _isWaiting.stream;
|
||||||
|
|
||||||
|
final Stream<void>? interrupter;
|
||||||
|
|
||||||
|
var _shouldRun = true;
|
||||||
|
final _isWaiting = BehaviorSubject.seeded(false);
|
||||||
|
}
|
|
@ -1,50 +0,0 @@
|
||||||
import 'package:nc_photos/account.dart';
|
|
||||||
import 'package:nc_photos/entity/file.dart';
|
|
||||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
|
||||||
import 'package:nc_photos/exception_event.dart';
|
|
||||||
import 'package:nc_photos/use_case/ls.dart';
|
|
||||||
import 'package:nc_photos/use_case/scan_dir.dart';
|
|
||||||
|
|
||||||
class ScanMissingMetadata {
|
|
||||||
ScanMissingMetadata(this.fileRepo);
|
|
||||||
|
|
||||||
/// List all files that support metadata but yet having one under a dir
|
|
||||||
///
|
|
||||||
/// The returned stream would emit either File data or ExceptionEvent
|
|
||||||
///
|
|
||||||
/// If [isRecursive] is true, [root] and its sub dirs will be listed,
|
|
||||||
/// otherwise only [root] will be listed. Default to true
|
|
||||||
Stream<dynamic> call(
|
|
||||||
Account account,
|
|
||||||
File root, {
|
|
||||||
bool isRecursive = true,
|
|
||||||
}) async* {
|
|
||||||
if (isRecursive) {
|
|
||||||
yield* _doRecursive(account, root);
|
|
||||||
} else {
|
|
||||||
yield* _doSingle(account, root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Stream<dynamic> _doRecursive(Account account, File root) async* {
|
|
||||||
final dataStream = ScanDir(fileRepo)(account, root);
|
|
||||||
await for (final d in dataStream) {
|
|
||||||
if (d is ExceptionEvent) {
|
|
||||||
yield d;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (final f in (d as List<File>).where(file_util.isMissingMetadata)) {
|
|
||||||
yield f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Stream<dynamic> _doSingle(Account account, File root) async* {
|
|
||||||
final files = await Ls(fileRepo)(account, root);
|
|
||||||
for (final f in files.where(file_util.isMissingMetadata)) {
|
|
||||||
yield f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final FileRepo fileRepo;
|
|
||||||
}
|
|
120
app/lib/use_case/sync_metadata/sync_by_app.dart
Normal file
120
app/lib/use_case/sync_metadata/sync_by_app.dart
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
part of 'sync_metadata.dart';
|
||||||
|
|
||||||
|
/// Sync metadata using the client side logic
|
||||||
|
@npLog
|
||||||
|
class _SyncByApp {
|
||||||
|
_SyncByApp({
|
||||||
|
required this.account,
|
||||||
|
required this.fileRepo,
|
||||||
|
required this.fileRepo2,
|
||||||
|
required this.db,
|
||||||
|
this.interrupter,
|
||||||
|
required this.wifiEnsurer,
|
||||||
|
required this.batteryEnsurer,
|
||||||
|
}) {
|
||||||
|
interrupter?.listen((event) {
|
||||||
|
_shouldRun = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
await _geocoder.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<File> syncFiles({
|
||||||
|
required List<int> fileIds,
|
||||||
|
}) async* {
|
||||||
|
for (final ids in partition(fileIds, 100)) {
|
||||||
|
yield* _syncGroup(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<File> _syncGroup(List<int> fileIds) async* {
|
||||||
|
final files = await db.getFilesByFileIds(
|
||||||
|
account: account.toDb(),
|
||||||
|
fileIds: fileIds,
|
||||||
|
);
|
||||||
|
for (final dbF in files) {
|
||||||
|
final f = DbFileConverter.fromDb(
|
||||||
|
account.userId.toCaseInsensitiveString(),
|
||||||
|
dbF,
|
||||||
|
);
|
||||||
|
final result = await syncOne(f);
|
||||||
|
if (result != null) {
|
||||||
|
yield result;
|
||||||
|
}
|
||||||
|
if (!_shouldRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File?> syncOne(File file) async {
|
||||||
|
_log.fine("[syncOne] Syncing ${file.path}");
|
||||||
|
try {
|
||||||
|
OrNull<Metadata>? metadataUpdate;
|
||||||
|
OrNull<ImageLocation>? locationUpdate;
|
||||||
|
if (file.metadata == null) {
|
||||||
|
// since we need to download multiple images in their original size,
|
||||||
|
// we only do it with WiFi
|
||||||
|
await wifiEnsurer();
|
||||||
|
await batteryEnsurer();
|
||||||
|
if (!_shouldRun) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_log.fine("[syncOne] Updating metadata for ${file.path}");
|
||||||
|
final binary = await GetFileBinary(fileRepo)(account, file);
|
||||||
|
final metadata =
|
||||||
|
(await LoadMetadata().loadRemote(account, file, binary)).copyWith(
|
||||||
|
fileEtag: file.etag,
|
||||||
|
);
|
||||||
|
metadataUpdate = OrNull(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
final lat = (metadataUpdate?.obj ?? file.metadata)?.exif?.gpsLatitudeDeg;
|
||||||
|
final lng = (metadataUpdate?.obj ?? file.metadata)?.exif?.gpsLongitudeDeg;
|
||||||
|
try {
|
||||||
|
ImageLocation? location;
|
||||||
|
if (lat != null && lng != null) {
|
||||||
|
_log.fine("[syncOne] Reverse geocoding for ${file.path}");
|
||||||
|
final l = await _geocoder(lat, lng);
|
||||||
|
if (l != null) {
|
||||||
|
location = l.toImageLocation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
locationUpdate = OrNull(location ?? ImageLocation.empty());
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.severe("[syncOne] Failed while reverse geocoding: ${file.path}", e,
|
||||||
|
stackTrace);
|
||||||
|
// if failed, we skip updating the location
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadataUpdate != null || locationUpdate != null) {
|
||||||
|
await UpdateProperty(fileRepo: fileRepo2)(
|
||||||
|
account,
|
||||||
|
file,
|
||||||
|
metadata: metadataUpdate,
|
||||||
|
location: locationUpdate,
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.severe("[syncOne] Failed while updating metadata: ${file.path}", e,
|
||||||
|
stackTrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
final FileRepo2 fileRepo2;
|
||||||
|
final NpDb db;
|
||||||
|
final Stream<void>? interrupter;
|
||||||
|
final WifiEnsurer wifiEnsurer;
|
||||||
|
final BatteryEnsurer batteryEnsurer;
|
||||||
|
|
||||||
|
final _geocoder = ReverseGeocoder();
|
||||||
|
var _shouldRun = true;
|
||||||
|
}
|
115
app/lib/use_case/sync_metadata/sync_by_server.dart
Normal file
115
app/lib/use_case/sync_metadata/sync_by_server.dart
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
part of 'sync_metadata.dart';
|
||||||
|
|
||||||
|
/// Sync metadata using the client side logic
|
||||||
|
@npLog
|
||||||
|
class _SyncByServer {
|
||||||
|
_SyncByServer({
|
||||||
|
required this.account,
|
||||||
|
required this.fileRepoRemote,
|
||||||
|
required this.fileRepo2,
|
||||||
|
required this.db,
|
||||||
|
this.interrupter,
|
||||||
|
required this.fallback,
|
||||||
|
}) {
|
||||||
|
interrupter?.listen((event) {
|
||||||
|
_shouldRun = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
await _geocoder.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<File> syncFiles({
|
||||||
|
required List<int> fileIds,
|
||||||
|
required List<String> relativePaths,
|
||||||
|
}) async* {
|
||||||
|
final dirs = relativePaths.map(dirname).toSet();
|
||||||
|
for (final dir in dirs) {
|
||||||
|
yield* _syncDir(
|
||||||
|
fileIds: fileIds,
|
||||||
|
dir: File(path: file_util.unstripPath(account, dir)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<File> _syncDir({
|
||||||
|
required List<int> fileIds,
|
||||||
|
required File dir,
|
||||||
|
}) async* {
|
||||||
|
try {
|
||||||
|
_log.fine("[_syncDir] Syncing dir $dir");
|
||||||
|
final files = await fileRepoRemote.list(account, dir);
|
||||||
|
await FileSqliteCacheUpdater(db)(account, dir, remote: files);
|
||||||
|
final isEnableClientExif = await ServiceConfig.isEnableClientExif();
|
||||||
|
for (final f in files.where((e) => fileIds.contains(e.fdId))) {
|
||||||
|
File? result;
|
||||||
|
if (!_supportedMimes.contains(f.fdMime)) {
|
||||||
|
if (isEnableClientExif) {
|
||||||
|
_log.info(
|
||||||
|
"[_syncDir] File ${f.path} (mime: ${f.fdMime}) not supported by server, fallback to client");
|
||||||
|
result = await fallback.syncOne(f);
|
||||||
|
} else {
|
||||||
|
_log.info(
|
||||||
|
"[_syncDir] File ${f.path} (mime: ${f.fdMime}) not supported by server");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (f.metadata != null && f.location == null) {
|
||||||
|
result = await _syncOne(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result != null) {
|
||||||
|
yield result;
|
||||||
|
}
|
||||||
|
if (!_shouldRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.severe("[_syncDir] Failed to sync dir: $dir", e, stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File?> _syncOne(File file) async {
|
||||||
|
_log.fine("[_syncOne] Syncing ${file.path}");
|
||||||
|
try {
|
||||||
|
final lat = file.metadata!.exif?.gpsLatitudeDeg;
|
||||||
|
final lng = file.metadata!.exif?.gpsLongitudeDeg;
|
||||||
|
ImageLocation? location;
|
||||||
|
if (lat != null && lng != null) {
|
||||||
|
_log.fine("[_syncOne] Reverse geocoding for ${file.path}");
|
||||||
|
final l = await _geocoder(lat, lng);
|
||||||
|
if (l != null) {
|
||||||
|
location = l.toImageLocation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final locationUpdate = OrNull(location ?? ImageLocation.empty());
|
||||||
|
await UpdateProperty(fileRepo: fileRepo2)(
|
||||||
|
account,
|
||||||
|
file,
|
||||||
|
metadata: OrNull(file.metadata),
|
||||||
|
location: locationUpdate,
|
||||||
|
);
|
||||||
|
return file;
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_log.severe("[_syncOne] Failed while updating location: ${file.path}", e,
|
||||||
|
stackTrace);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Account account;
|
||||||
|
final FileRepo fileRepoRemote;
|
||||||
|
final FileRepo2 fileRepo2;
|
||||||
|
final NpDb db;
|
||||||
|
final Stream<void>? interrupter;
|
||||||
|
final _SyncByApp fallback;
|
||||||
|
|
||||||
|
final _geocoder = ReverseGeocoder();
|
||||||
|
var _shouldRun = true;
|
||||||
|
|
||||||
|
static const _supportedMimes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
}
|
139
app/lib/use_case/sync_metadata/sync_metadata.dart
Normal file
139
app/lib/use_case/sync_metadata/sync_metadata.dart
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:nc_photos/account.dart';
|
||||||
|
import 'package:nc_photos/controller/account_pref_controller.dart';
|
||||||
|
import 'package:nc_photos/controller/server_controller.dart';
|
||||||
|
import 'package:nc_photos/db/entity_converter.dart';
|
||||||
|
import 'package:nc_photos/entity/exif_util.dart';
|
||||||
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file/file_cache_manager.dart';
|
||||||
|
import 'package:nc_photos/entity/file/repo.dart';
|
||||||
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
import 'package:nc_photos/geocoder_util.dart';
|
||||||
|
import 'package:nc_photos/service/service.dart';
|
||||||
|
import 'package:nc_photos/use_case/battery_ensurer.dart';
|
||||||
|
import 'package:nc_photos/use_case/get_file_binary.dart';
|
||||||
|
import 'package:nc_photos/use_case/load_metadata.dart';
|
||||||
|
import 'package:nc_photos/use_case/update_property.dart';
|
||||||
|
import 'package:nc_photos/use_case/wifi_ensurer.dart';
|
||||||
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
import 'package:np_collection/np_collection.dart';
|
||||||
|
import 'package:np_common/or_null.dart';
|
||||||
|
import 'package:np_db/np_db.dart';
|
||||||
|
import 'package:np_geocoder/np_geocoder.dart';
|
||||||
|
import 'package:path/path.dart';
|
||||||
|
|
||||||
|
part 'sync_by_app.dart';
|
||||||
|
part 'sync_by_server.dart';
|
||||||
|
part 'sync_metadata.g.dart';
|
||||||
|
|
||||||
|
@npLog
|
||||||
|
class SyncMetadata {
|
||||||
|
const SyncMetadata({
|
||||||
|
required this.fileRepo,
|
||||||
|
required this.fileRepo2,
|
||||||
|
required this.fileRepoRemote,
|
||||||
|
required this.db,
|
||||||
|
this.interrupter,
|
||||||
|
required this.wifiEnsurer,
|
||||||
|
required this.batteryEnsurer,
|
||||||
|
});
|
||||||
|
|
||||||
|
Stream<File> syncAccount(
|
||||||
|
Account account,
|
||||||
|
AccountPrefController accountPrefController,
|
||||||
|
) async* {
|
||||||
|
final bool isNcMetadataSupported;
|
||||||
|
try {
|
||||||
|
isNcMetadataSupported =
|
||||||
|
(await _isNcMetadataSupported(account, accountPrefController))!;
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe("[syncAccount] Failed to get server version", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final files = await db.getFilesByMissingMetadata(
|
||||||
|
account: account.toDb(),
|
||||||
|
mimes: file_util.supportedImageFormatMimes,
|
||||||
|
ownerId: account.userId.toCaseInsensitiveString(),
|
||||||
|
);
|
||||||
|
_log.info("[syncAccount] Missing count: ${files.items.length}");
|
||||||
|
if (isNcMetadataSupported) {
|
||||||
|
yield* _doWithServer(account, files);
|
||||||
|
} else {
|
||||||
|
yield* _doWithApp(account, files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<File> _doWithApp(
|
||||||
|
Account account, DbFileMissingMetadataResult files) async* {
|
||||||
|
final op = _SyncByApp(
|
||||||
|
account: account,
|
||||||
|
fileRepo: fileRepo,
|
||||||
|
fileRepo2: fileRepo2,
|
||||||
|
db: db,
|
||||||
|
interrupter: interrupter,
|
||||||
|
wifiEnsurer: wifiEnsurer,
|
||||||
|
batteryEnsurer: batteryEnsurer,
|
||||||
|
);
|
||||||
|
await op.init();
|
||||||
|
final stream = op.syncFiles(
|
||||||
|
fileIds: files.items.map((e) => e.fileId).toList(),
|
||||||
|
);
|
||||||
|
yield* stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<File> _doWithServer(
|
||||||
|
Account account, DbFileMissingMetadataResult files) async* {
|
||||||
|
final fallback = _SyncByApp(
|
||||||
|
account: account,
|
||||||
|
fileRepo: fileRepo,
|
||||||
|
fileRepo2: fileRepo2,
|
||||||
|
db: db,
|
||||||
|
interrupter: interrupter,
|
||||||
|
wifiEnsurer: wifiEnsurer,
|
||||||
|
batteryEnsurer: batteryEnsurer,
|
||||||
|
);
|
||||||
|
await fallback.init();
|
||||||
|
final op = _SyncByServer(
|
||||||
|
account: account,
|
||||||
|
fileRepoRemote: fileRepoRemote,
|
||||||
|
fileRepo2: fileRepo2,
|
||||||
|
db: db,
|
||||||
|
interrupter: interrupter,
|
||||||
|
fallback: fallback,
|
||||||
|
);
|
||||||
|
await op.init();
|
||||||
|
final fileIds = <int>[];
|
||||||
|
final relativePaths = <String>[];
|
||||||
|
for (final f in files.items) {
|
||||||
|
fileIds.add(f.fileId);
|
||||||
|
relativePaths.add(f.relativePath);
|
||||||
|
}
|
||||||
|
final stream = op.syncFiles(
|
||||||
|
fileIds: fileIds,
|
||||||
|
relativePaths: relativePaths,
|
||||||
|
);
|
||||||
|
yield* stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _isNcMetadataSupported(
|
||||||
|
Account account,
|
||||||
|
AccountPrefController accountPrefController,
|
||||||
|
) async {
|
||||||
|
final serverController = ServerController(
|
||||||
|
account: account,
|
||||||
|
accountPrefController: accountPrefController,
|
||||||
|
);
|
||||||
|
await serverController.status.first.timeout(const Duration(seconds: 15));
|
||||||
|
return serverController.isSupported(ServerFeature.ncMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileRepo fileRepo;
|
||||||
|
final FileRepo2 fileRepo2;
|
||||||
|
final FileRepo fileRepoRemote;
|
||||||
|
final NpDb db;
|
||||||
|
final Stream<void>? interrupter;
|
||||||
|
final WifiEnsurer wifiEnsurer;
|
||||||
|
final BatteryEnsurer batteryEnsurer;
|
||||||
|
}
|
30
app/lib/use_case/sync_metadata/sync_metadata.g.dart
Normal file
30
app/lib/use_case/sync_metadata/sync_metadata.g.dart
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'sync_metadata.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// NpLogGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
extension _$SyncMetadataNpLog on SyncMetadata {
|
||||||
|
// ignore: unused_element
|
||||||
|
Logger get _log => log;
|
||||||
|
|
||||||
|
static final log =
|
||||||
|
Logger("use_case.sync_metadata.sync_metadata.SyncMetadata");
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_SyncByAppNpLog on _SyncByApp {
|
||||||
|
// ignore: unused_element
|
||||||
|
Logger get _log => log;
|
||||||
|
|
||||||
|
static final log = Logger("use_case.sync_metadata.sync_metadata._SyncByApp");
|
||||||
|
}
|
||||||
|
|
||||||
|
extension _$_SyncByServerNpLog on _SyncByServer {
|
||||||
|
// ignore: unused_element
|
||||||
|
Logger get _log => log;
|
||||||
|
|
||||||
|
static final log =
|
||||||
|
Logger("use_case.sync_metadata.sync_metadata._SyncByServer");
|
||||||
|
}
|
|
@ -1,173 +0,0 @@
|
||||||
import 'package:battery_plus/battery_plus.dart';
|
|
||||||
import 'package:event_bus/event_bus.dart';
|
|
||||||
import 'package:kiwi/kiwi.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:nc_photos/account.dart';
|
|
||||||
import 'package:nc_photos/connectivity_util.dart' as connectivity_util;
|
|
||||||
import 'package:nc_photos/di_container.dart';
|
|
||||||
import 'package:nc_photos/entity/exif_extension.dart';
|
|
||||||
import 'package:nc_photos/entity/file.dart';
|
|
||||||
import 'package:nc_photos/event/event.dart';
|
|
||||||
import 'package:nc_photos/exception.dart';
|
|
||||||
import 'package:nc_photos/exception_event.dart';
|
|
||||||
import 'package:nc_photos/geocoder_util.dart';
|
|
||||||
import 'package:nc_photos/use_case/get_file_binary.dart';
|
|
||||||
import 'package:nc_photos/use_case/load_metadata.dart';
|
|
||||||
import 'package:nc_photos/use_case/scan_missing_metadata.dart';
|
|
||||||
import 'package:nc_photos/use_case/update_property.dart';
|
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
|
||||||
import 'package:np_common/or_null.dart';
|
|
||||||
import 'package:np_geocoder/np_geocoder.dart';
|
|
||||||
|
|
||||||
part 'update_missing_metadata.g.dart';
|
|
||||||
|
|
||||||
abstract class UpdateMissingMetadataConfigProvider {
|
|
||||||
Future<bool> isWifiOnly();
|
|
||||||
}
|
|
||||||
|
|
||||||
@npLog
|
|
||||||
class UpdateMissingMetadata {
|
|
||||||
UpdateMissingMetadata(this._c, this.configProvider, this.geocoder);
|
|
||||||
|
|
||||||
/// Update metadata for all files that support one under a dir
|
|
||||||
///
|
|
||||||
/// The returned stream would emit either File data (for each updated files)
|
|
||||||
/// or ExceptionEvent
|
|
||||||
///
|
|
||||||
/// If [isRecursive] is true, [root] and its sub dirs will be scanned,
|
|
||||||
/// otherwise only [root] will be scanned. Default to true
|
|
||||||
///
|
|
||||||
/// [filter] can be used to filter files -- return true if a file should be
|
|
||||||
/// included. If [filter] is null, all files will be included.
|
|
||||||
Stream<dynamic> call(
|
|
||||||
Account account,
|
|
||||||
File root, {
|
|
||||||
bool isRecursive = true,
|
|
||||||
bool Function(File file)? filter,
|
|
||||||
}) async* {
|
|
||||||
final dataStream = ScanMissingMetadata(_c.fileRepo)(
|
|
||||||
account,
|
|
||||||
root,
|
|
||||||
isRecursive: isRecursive,
|
|
||||||
);
|
|
||||||
await for (final d in dataStream) {
|
|
||||||
if (!_shouldRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (d is ExceptionEvent) {
|
|
||||||
yield d;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
final File file = d;
|
|
||||||
// check if this is a federation share. Nextcloud doesn't support
|
|
||||||
// properties for such files
|
|
||||||
if (file.ownerId?.contains("/") == true || filter?.call(d) == false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
OrNull<Metadata>? metadataUpdate;
|
|
||||||
OrNull<ImageLocation>? locationUpdate;
|
|
||||||
if (file.metadata == null) {
|
|
||||||
// since we need to download multiple images in their original size,
|
|
||||||
// we only do it with WiFi
|
|
||||||
await _ensureWifi();
|
|
||||||
await _ensureBattery();
|
|
||||||
KiwiContainer().resolve<EventBus>().fire(
|
|
||||||
const MetadataTaskStateChangedEvent(
|
|
||||||
MetadataTaskState.prcoessing));
|
|
||||||
if (!_shouldRun) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_log.fine("[call] Updating metadata for ${file.path}");
|
|
||||||
final binary = await GetFileBinary(_c.fileRepo)(account, file);
|
|
||||||
final metadata =
|
|
||||||
(await LoadMetadata().loadRemote(account, file, binary)).copyWith(
|
|
||||||
fileEtag: file.etag,
|
|
||||||
);
|
|
||||||
metadataUpdate = OrNull(metadata);
|
|
||||||
} else {
|
|
||||||
_log.finer("[call] Skip updating metadata for ${file.path}");
|
|
||||||
KiwiContainer().resolve<EventBus>().fire(
|
|
||||||
const MetadataTaskStateChangedEvent(
|
|
||||||
MetadataTaskState.prcoessing));
|
|
||||||
}
|
|
||||||
|
|
||||||
final lat =
|
|
||||||
(metadataUpdate?.obj ?? file.metadata)?.exif?.gpsLatitudeDeg;
|
|
||||||
final lng =
|
|
||||||
(metadataUpdate?.obj ?? file.metadata)?.exif?.gpsLongitudeDeg;
|
|
||||||
try {
|
|
||||||
ImageLocation? location;
|
|
||||||
if (lat != null && lng != null) {
|
|
||||||
_log.fine("[call] Reverse geocoding for ${file.path}");
|
|
||||||
final l = await geocoder(lat, lng);
|
|
||||||
if (l != null) {
|
|
||||||
location = l.toImageLocation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
locationUpdate = OrNull(location ?? ImageLocation.empty());
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
_log.severe("[call] Failed while reverse geocoding: ${file.path}", e,
|
|
||||||
stackTrace);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadataUpdate != null || locationUpdate != null) {
|
|
||||||
await UpdateProperty(_c)(
|
|
||||||
account,
|
|
||||||
file,
|
|
||||||
metadata: metadataUpdate,
|
|
||||||
location: locationUpdate,
|
|
||||||
);
|
|
||||||
yield file;
|
|
||||||
}
|
|
||||||
|
|
||||||
// slow down a bit to give some space for the main isolate
|
|
||||||
await Future.delayed(const Duration(milliseconds: 10));
|
|
||||||
} on InterruptedException catch (_) {
|
|
||||||
return;
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
_log.severe("[call] Failed while updating metadata: ${file.path}", e,
|
|
||||||
stackTrace);
|
|
||||||
yield ExceptionEvent(e, stackTrace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void stop() {
|
|
||||||
_shouldRun = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _ensureWifi() async {
|
|
||||||
var count = 0;
|
|
||||||
while (await configProvider.isWifiOnly() &&
|
|
||||||
!await connectivity_util.isWifi()) {
|
|
||||||
if (!_shouldRun) {
|
|
||||||
throw const InterruptedException();
|
|
||||||
}
|
|
||||||
// give a chance to reconnect with the WiFi network
|
|
||||||
if (++count >= 6) {
|
|
||||||
KiwiContainer().resolve<EventBus>().fire(
|
|
||||||
const MetadataTaskStateChangedEvent(
|
|
||||||
MetadataTaskState.waitingForWifi));
|
|
||||||
}
|
|
||||||
await Future.delayed(const Duration(seconds: 5));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _ensureBattery() async {
|
|
||||||
while (await Battery().batteryLevel <= 15) {
|
|
||||||
if (!_shouldRun) {
|
|
||||||
throw const InterruptedException();
|
|
||||||
}
|
|
||||||
KiwiContainer().resolve<EventBus>().fire(
|
|
||||||
const MetadataTaskStateChangedEvent(MetadataTaskState.lowBattery));
|
|
||||||
await Future.delayed(const Duration(seconds: 5));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final DiContainer _c;
|
|
||||||
final UpdateMissingMetadataConfigProvider configProvider;
|
|
||||||
final ReverseGeocoder geocoder;
|
|
||||||
|
|
||||||
bool _shouldRun = true;
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'update_missing_metadata.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// NpLogGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
extension _$UpdateMissingMetadataNpLog on UpdateMissingMetadata {
|
|
||||||
// ignore: unused_element
|
|
||||||
Logger get _log => log;
|
|
||||||
|
|
||||||
static final log =
|
|
||||||
Logger("use_case.update_missing_metadata.UpdateMissingMetadata");
|
|
||||||
}
|
|
|
@ -1,11 +1,8 @@
|
||||||
import 'package:event_bus/event_bus.dart';
|
|
||||||
import 'package:kiwi/kiwi.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:nc_photos/account.dart';
|
import 'package:nc_photos/account.dart';
|
||||||
import 'package:nc_photos/di_container.dart';
|
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
|
import 'package:nc_photos/entity/file/repo.dart';
|
||||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||||
import 'package:nc_photos/event/event.dart';
|
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
import 'package:np_common/or_null.dart';
|
import 'package:np_common/or_null.dart';
|
||||||
|
|
||||||
|
@ -13,7 +10,11 @@ part 'update_property.g.dart';
|
||||||
|
|
||||||
@npLog
|
@npLog
|
||||||
class UpdateProperty {
|
class UpdateProperty {
|
||||||
const UpdateProperty(this._c);
|
const UpdateProperty({
|
||||||
|
required this.fileRepo,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FileRepo2 fileRepo;
|
||||||
|
|
||||||
Future<void> call(
|
Future<void> call(
|
||||||
Account account,
|
Account account,
|
||||||
|
@ -34,17 +35,7 @@ class UpdateProperty {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _c.fileRepo2.updateProperty(
|
await fileRepo.updateProperty(
|
||||||
account,
|
|
||||||
file,
|
|
||||||
metadata: metadata,
|
|
||||||
isArchived: isArchived,
|
|
||||||
overrideDateTime: overrideDateTime,
|
|
||||||
favorite: favorite,
|
|
||||||
location: location,
|
|
||||||
);
|
|
||||||
|
|
||||||
_notify(
|
|
||||||
account,
|
account,
|
||||||
file,
|
file,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
@ -54,40 +45,6 @@ class UpdateProperty {
|
||||||
location: location,
|
location: location,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("legacy")
|
|
||||||
void _notify(
|
|
||||||
Account account,
|
|
||||||
FileDescriptor file, {
|
|
||||||
OrNull<Metadata>? metadata,
|
|
||||||
OrNull<bool>? isArchived,
|
|
||||||
OrNull<DateTime>? overrideDateTime,
|
|
||||||
bool? favorite,
|
|
||||||
OrNull<ImageLocation>? location,
|
|
||||||
}) {
|
|
||||||
int properties = 0;
|
|
||||||
if (metadata != null) {
|
|
||||||
properties |= FilePropertyUpdatedEvent.propMetadata;
|
|
||||||
}
|
|
||||||
if (isArchived != null) {
|
|
||||||
properties |= FilePropertyUpdatedEvent.propIsArchived;
|
|
||||||
}
|
|
||||||
if (overrideDateTime != null) {
|
|
||||||
properties |= FilePropertyUpdatedEvent.propOverrideDateTime;
|
|
||||||
}
|
|
||||||
if (favorite != null) {
|
|
||||||
properties |= FilePropertyUpdatedEvent.propFavorite;
|
|
||||||
}
|
|
||||||
if (location != null) {
|
|
||||||
properties |= FilePropertyUpdatedEvent.propImageLocation;
|
|
||||||
}
|
|
||||||
assert(properties != 0);
|
|
||||||
KiwiContainer()
|
|
||||||
.resolve<EventBus>()
|
|
||||||
.fire(FilePropertyUpdatedEvent(account, file, properties));
|
|
||||||
}
|
|
||||||
|
|
||||||
final DiContainer _c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension UpdatePropertyExtension on UpdateProperty {
|
extension UpdatePropertyExtension on UpdateProperty {
|
||||||
|
|
41
app/lib/use_case/wifi_ensurer.dart
Normal file
41
app/lib/use_case/wifi_ensurer.dart
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import 'package:nc_photos/connectivity_util.dart' as connectivity_util;
|
||||||
|
import 'package:nc_photos/exception.dart';
|
||||||
|
import 'package:nc_photos/service/service.dart';
|
||||||
|
import 'package:rxdart/rxdart.dart';
|
||||||
|
|
||||||
|
class WifiEnsurer {
|
||||||
|
WifiEnsurer({
|
||||||
|
this.interrupter,
|
||||||
|
}) {
|
||||||
|
interrupter?.listen((event) {
|
||||||
|
_shouldRun = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> call() async {
|
||||||
|
var count = 0;
|
||||||
|
while (await ServiceConfig.isProcessExifWifiOnly() &&
|
||||||
|
!await connectivity_util.isWifi()) {
|
||||||
|
if (!_shouldRun) {
|
||||||
|
throw const InterruptedException();
|
||||||
|
}
|
||||||
|
// give a chance to reconnect with the WiFi network
|
||||||
|
if (++count >= 6) {
|
||||||
|
if (!_isWaiting.value) {
|
||||||
|
_isWaiting.add(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(seconds: 5));
|
||||||
|
}
|
||||||
|
if (_isWaiting.value) {
|
||||||
|
_isWaiting.add(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ValueStream<bool> get isWaiting => _isWaiting.stream;
|
||||||
|
|
||||||
|
final Stream<void>? interrupter;
|
||||||
|
|
||||||
|
var _shouldRun = true;
|
||||||
|
final _isWaiting = BehaviorSubject.seeded(false);
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ class _Bloc extends Bloc<_Event, _State>
|
||||||
required this.syncController,
|
required this.syncController,
|
||||||
required this.personsController,
|
required this.personsController,
|
||||||
required this.metadataController,
|
required this.metadataController,
|
||||||
|
required this.serverController,
|
||||||
}) : super(_State.init(
|
}) : super(_State.init(
|
||||||
zoom: prefController.homePhotosZoomLevelValue,
|
zoom: prefController.homePhotosZoomLevelValue,
|
||||||
isEnableMemoryCollection:
|
isEnableMemoryCollection:
|
||||||
|
@ -711,6 +712,7 @@ class _Bloc extends Bloc<_Event, _State>
|
||||||
final SyncController syncController;
|
final SyncController syncController;
|
||||||
final PersonsController personsController;
|
final PersonsController personsController;
|
||||||
final MetadataController metadataController;
|
final MetadataController metadataController;
|
||||||
|
final ServerController serverController;
|
||||||
|
|
||||||
final _itemTransformerQueue =
|
final _itemTransformerQueue =
|
||||||
ComputeQueue<_ItemTransformerArgument, _ItemTransformerResult>();
|
ComputeQueue<_ItemTransformerArgument, _ItemTransformerResult>();
|
||||||
|
|
|
@ -21,6 +21,7 @@ import 'package:nc_photos/controller/files_controller.dart';
|
||||||
import 'package:nc_photos/controller/metadata_controller.dart';
|
import 'package:nc_photos/controller/metadata_controller.dart';
|
||||||
import 'package:nc_photos/controller/persons_controller.dart';
|
import 'package:nc_photos/controller/persons_controller.dart';
|
||||||
import 'package:nc_photos/controller/pref_controller.dart';
|
import 'package:nc_photos/controller/pref_controller.dart';
|
||||||
|
import 'package:nc_photos/controller/server_controller.dart';
|
||||||
import 'package:nc_photos/controller/sync_controller.dart';
|
import 'package:nc_photos/controller/sync_controller.dart';
|
||||||
import 'package:nc_photos/db/entity_converter.dart';
|
import 'package:nc_photos/db/entity_converter.dart';
|
||||||
import 'package:nc_photos/di_container.dart';
|
import 'package:nc_photos/di_container.dart';
|
||||||
|
@ -97,6 +98,7 @@ class HomePhotos2 extends StatelessWidget {
|
||||||
syncController: accountController.syncController,
|
syncController: accountController.syncController,
|
||||||
personsController: accountController.personsController,
|
personsController: accountController.personsController,
|
||||||
metadataController: accountController.metadataController,
|
metadataController: accountController.metadataController,
|
||||||
|
serverController: accountController.serverController,
|
||||||
),
|
),
|
||||||
child: const _WrappedHomePhotos(),
|
child: const _WrappedHomePhotos(),
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,7 +6,7 @@ class _Bloc extends Bloc<_Event, _State>
|
||||||
_Bloc({
|
_Bloc({
|
||||||
required this.prefController,
|
required this.prefController,
|
||||||
}) : super(_State(
|
}) : super(_State(
|
||||||
isEnable: prefController.isEnableExifValue,
|
isEnable: prefController.isEnableClientExifValue,
|
||||||
isWifiOnly: prefController.shouldProcessExifWifiOnlyValue,
|
isWifiOnly: prefController.shouldProcessExifWifiOnlyValue,
|
||||||
)) {
|
)) {
|
||||||
on<_Init>(_onInit);
|
on<_Init>(_onInit);
|
||||||
|
@ -22,7 +22,7 @@ class _Bloc extends Bloc<_Event, _State>
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
forEach(
|
forEach(
|
||||||
emit,
|
emit,
|
||||||
prefController.isEnableExifChange,
|
prefController.isEnableClientExifChange,
|
||||||
onData: (data) => state.copyWith(isEnable: data),
|
onData: (data) => state.copyWith(isEnable: data),
|
||||||
onError: (e, stackTrace) {
|
onError: (e, stackTrace) {
|
||||||
_log.severe("[_onInit] Uncaught exception", e, stackTrace);
|
_log.severe("[_onInit] Uncaught exception", e, stackTrace);
|
||||||
|
@ -43,7 +43,7 @@ class _Bloc extends Bloc<_Event, _State>
|
||||||
|
|
||||||
void _onSetEnable(_SetEnable ev, Emitter<_State> emit) {
|
void _onSetEnable(_SetEnable ev, Emitter<_State> emit) {
|
||||||
_log.info(ev);
|
_log.info(ev);
|
||||||
prefController.setEnableExif(ev.value);
|
prefController.setEnableClientExif(ev.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onSetWifiOnly(_SetWifiOnly ev, Emitter<_State> emit) async {
|
Future<void> _onSetWifiOnly(_SetWifiOnly ev, Emitter<_State> emit) async {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import 'package:nc_photos/app_localizations.dart';
|
||||||
import 'package:nc_photos/bloc_util.dart';
|
import 'package:nc_photos/bloc_util.dart';
|
||||||
import 'package:nc_photos/controller/pref_controller.dart';
|
import 'package:nc_photos/controller/pref_controller.dart';
|
||||||
import 'package:nc_photos/exception_event.dart';
|
import 'package:nc_photos/exception_event.dart';
|
||||||
import 'package:nc_photos/service.dart';
|
import 'package:nc_photos/service/service.dart';
|
||||||
import 'package:nc_photos/snack_bar_manager.dart';
|
import 'package:nc_photos/snack_bar_manager.dart';
|
||||||
import 'package:nc_photos/widget/page_visibility_mixin.dart';
|
import 'package:nc_photos/widget/page_visibility_mixin.dart';
|
||||||
import 'package:np_codegen/np_codegen.dart';
|
import 'package:np_codegen/np_codegen.dart';
|
||||||
|
@ -77,7 +77,7 @@ class _WrappedMetadataSettingsState extends State<_WrappedMetadataSettings>
|
||||||
selector: (state) => state.isEnable,
|
selector: (state) => state.isEnable,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return SwitchListTile(
|
return SwitchListTile(
|
||||||
title: Text(L10n.global().settingsExifSupportTitle),
|
title: Text(L10n.global().settingsExifSupportTitle2),
|
||||||
subtitle: state
|
subtitle: state
|
||||||
? Text(
|
? Text(
|
||||||
L10n.global().settingsExifSupportTrueSubtitle)
|
L10n.global().settingsExifSupportTrueSubtitle)
|
||||||
|
@ -122,8 +122,15 @@ class _WrappedMetadataSettingsState extends State<_WrappedMetadataSettings>
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(L10n.global().exifSupportConfirmationDialogTitle),
|
title: Text(L10n.global().exifSupportConfirmationDialogTitle2),
|
||||||
content: Text(L10n.global().exifSupportDetails),
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(L10n.global().exifSupportDetails),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(L10n.global().exifSupportNextcloud28Notes),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|
|
@ -150,7 +150,7 @@ class _ExifState extends State<_Exif> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: Text(L10n.global().settingsExifSupportTitle),
|
title: Text(L10n.global().settingsExifSupportTitle2),
|
||||||
value: _isEnableExif,
|
value: _isEnableExif,
|
||||||
onChanged: _onValueChanged,
|
onChanged: _onValueChanged,
|
||||||
),
|
),
|
||||||
|
@ -160,6 +160,11 @@ class _ExifState extends State<_Exif> {
|
||||||
child: Text(L10n.global().exifSupportDetails),
|
child: Text(L10n.global().exifSupportDetails),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(L10n.global().exifSupportNextcloud28Notes),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
|
@ -14,7 +14,7 @@ import 'package:nc_photos/di_container.dart';
|
||||||
import 'package:nc_photos/entity/collection.dart';
|
import 'package:nc_photos/entity/collection.dart';
|
||||||
import 'package:nc_photos/entity/collection/adapter.dart';
|
import 'package:nc_photos/entity/collection/adapter.dart';
|
||||||
import 'package:nc_photos/entity/collection_item.dart';
|
import 'package:nc_photos/entity/collection_item.dart';
|
||||||
import 'package:nc_photos/entity/exif_extension.dart';
|
import 'package:nc_photos/entity/exif_util.dart';
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||||
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
import 'package:nc_photos/entity/file_util.dart' as file_util;
|
||||||
|
@ -505,7 +505,7 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await UpdateProperty(_c)
|
await UpdateProperty(fileRepo: _c.fileRepo2)
|
||||||
.updateOverrideDateTime(widget.account, _file!, value);
|
.updateOverrideDateTime(widget.account, _file!, value);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
110
app/test/entity/exif_util_test.dart
Normal file
110
app/test/entity/exif_util_test.dart
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
import 'package:exifdart/exifdart.dart';
|
||||||
|
import 'package:nc_photos/entity/exif_util.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group("exif_util", () {
|
||||||
|
group("gpsDmsToDouble", () {
|
||||||
|
test("United Nations HQ", () {
|
||||||
|
// 40° 44′ 58″ N, 73° 58′ 5″ W
|
||||||
|
final lat = gpsDmsToDouble([
|
||||||
|
Rational(40, 1),
|
||||||
|
Rational(44, 1),
|
||||||
|
Rational(58, 1),
|
||||||
|
]);
|
||||||
|
final lng = gpsDmsToDouble([
|
||||||
|
Rational(73, 1),
|
||||||
|
Rational(58, 1),
|
||||||
|
Rational(5, 1),
|
||||||
|
]);
|
||||||
|
expect(lat, closeTo(40.749444, .00001));
|
||||||
|
expect(lng, closeTo(73.968056, .00001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("East Cape Lighthouse", () {
|
||||||
|
// 37° 41′ 20.2″ S, 178° 32′ 53.3″ E
|
||||||
|
final lat = gpsDmsToDouble([
|
||||||
|
Rational(37, 1),
|
||||||
|
Rational(41, 1),
|
||||||
|
Rational(202, 10),
|
||||||
|
]);
|
||||||
|
final lng = gpsDmsToDouble([
|
||||||
|
Rational(178, 1),
|
||||||
|
Rational(32, 1),
|
||||||
|
Rational(533, 10),
|
||||||
|
]);
|
||||||
|
expect(lat, closeTo(37.688944, .00001));
|
||||||
|
expect(lng, closeTo(178.548139, .00001));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("gpsDoubleToDms", () {
|
||||||
|
test("United Nations HQ", () {
|
||||||
|
// 40.749444, -73.968056
|
||||||
|
final lat = gpsDoubleToDms(40.749444);
|
||||||
|
final lng = gpsDoubleToDms(-73.968056);
|
||||||
|
expect(
|
||||||
|
lat.map((e) => e.toString()),
|
||||||
|
[
|
||||||
|
Rational(40, 1).toString(),
|
||||||
|
Rational(44, 1).toString(),
|
||||||
|
Rational(5799, 100).toString(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
lng.map((e) => e.toString()),
|
||||||
|
[
|
||||||
|
Rational(73, 1).toString(),
|
||||||
|
Rational(58, 1).toString(),
|
||||||
|
Rational(500, 100).toString(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("East Cape Lighthouse", () {
|
||||||
|
// -37.688944, 178.548139
|
||||||
|
final lat = gpsDoubleToDms(-37.688944);
|
||||||
|
final lng = gpsDoubleToDms(178.548139);
|
||||||
|
expect(
|
||||||
|
lat.map((e) => e.toString()),
|
||||||
|
[
|
||||||
|
Rational(37, 1).toString(),
|
||||||
|
Rational(41, 1).toString(),
|
||||||
|
Rational(2019, 100).toString(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
lng.map((e) => e.toString()),
|
||||||
|
[
|
||||||
|
Rational(178, 1).toString(),
|
||||||
|
Rational(32, 1).toString(),
|
||||||
|
Rational(5330, 100).toString(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group("doubleToRational", () {
|
||||||
|
test("<1000", () {
|
||||||
|
expect(
|
||||||
|
doubleToRational(123.456789123).toString(),
|
||||||
|
Rational(12345678, 100000).toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(">1000 <100000", () {
|
||||||
|
expect(
|
||||||
|
doubleToRational(12345.6789123).toString(),
|
||||||
|
Rational(12345678, 1000).toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(">100000", () {
|
||||||
|
expect(
|
||||||
|
doubleToRational(12345678.9123).toString(),
|
||||||
|
Rational(12345678, 1).toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -205,7 +205,7 @@ Future<void> _updaterIdentical() async {
|
||||||
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final updater = FileSqliteCacheUpdater(c);
|
final updater = FileSqliteCacheUpdater(c.npDb);
|
||||||
await updater(account, files[0], remote: files.slice(0, 3));
|
await updater(account, files[0], remote: files.slice(0, 3));
|
||||||
expect(
|
expect(
|
||||||
await util.listSqliteDbFiles(c.sqliteDb),
|
await util.listSqliteDbFiles(c.sqliteDb),
|
||||||
|
@ -240,7 +240,7 @@ Future<void> _updaterNewFile() async {
|
||||||
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final updater = FileSqliteCacheUpdater(c);
|
final updater = FileSqliteCacheUpdater(c.npDb);
|
||||||
await updater(account, files[0], remote: [...files.slice(0, 3), newFile]);
|
await updater(account, files[0], remote: [...files.slice(0, 3), newFile]);
|
||||||
expect(
|
expect(
|
||||||
await util.listSqliteDbFiles(c.sqliteDb),
|
await util.listSqliteDbFiles(c.sqliteDb),
|
||||||
|
@ -271,7 +271,7 @@ Future<void> _updaterDeleteFile() async {
|
||||||
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final updater = FileSqliteCacheUpdater(c);
|
final updater = FileSqliteCacheUpdater(c.npDb);
|
||||||
await updater(account, files[0], remote: [files[0], files[2]]);
|
await updater(account, files[0], remote: [files[0], files[2]]);
|
||||||
expect(
|
expect(
|
||||||
await util.listSqliteDbFiles(c.sqliteDb),
|
await util.listSqliteDbFiles(c.sqliteDb),
|
||||||
|
@ -305,7 +305,7 @@ Future<void> _updaterDeleteDir() async {
|
||||||
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final updater = FileSqliteCacheUpdater(c);
|
final updater = FileSqliteCacheUpdater(c.npDb);
|
||||||
await updater(account, files[0], remote: files.slice(0, 2));
|
await updater(account, files[0], remote: files.slice(0, 2));
|
||||||
expect(
|
expect(
|
||||||
await util.listSqliteDbFiles(c.sqliteDb),
|
await util.listSqliteDbFiles(c.sqliteDb),
|
||||||
|
@ -343,7 +343,7 @@ Future<void> _updaterUpdateFile() async {
|
||||||
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final updater = FileSqliteCacheUpdater(c);
|
final updater = FileSqliteCacheUpdater(c.npDb);
|
||||||
await updater(account, files[0],
|
await updater(account, files[0],
|
||||||
remote: [files[0], newFile, ...files.slice(2)]);
|
remote: [files[0], newFile, ...files.slice(2)]);
|
||||||
expect(
|
expect(
|
||||||
|
@ -382,7 +382,7 @@ Future<void> _updaterNewSharedFile() async {
|
||||||
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final updater = FileSqliteCacheUpdater(c);
|
final updater = FileSqliteCacheUpdater(c.npDb);
|
||||||
await updater(user1Account, user1Files[0], remote: user1Files);
|
await updater(user1Account, user1Files[0], remote: user1Files);
|
||||||
expect(
|
expect(
|
||||||
await util.listSqliteDbFiles(c.sqliteDb),
|
await util.listSqliteDbFiles(c.sqliteDb),
|
||||||
|
@ -419,7 +419,7 @@ Future<void> _updaterNewSharedDir() async {
|
||||||
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final updater = FileSqliteCacheUpdater(c);
|
final updater = FileSqliteCacheUpdater(c.npDb);
|
||||||
await updater(user1Account, user1Files[0], remote: user1Files);
|
await updater(user1Account, user1Files[0], remote: user1Files);
|
||||||
expect(
|
expect(
|
||||||
await util.listSqliteDbFiles(c.sqliteDb),
|
await util.listSqliteDbFiles(c.sqliteDb),
|
||||||
|
@ -461,7 +461,7 @@ Future<void> _updaterDeleteSharedFile() async {
|
||||||
c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]);
|
c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final updater = FileSqliteCacheUpdater(c);
|
final updater = FileSqliteCacheUpdater(c.npDb);
|
||||||
await updater(user1Account, user1Files[0], remote: [user1Files[0]]);
|
await updater(user1Account, user1Files[0], remote: [user1Files[0]]);
|
||||||
expect(
|
expect(
|
||||||
await util.listSqliteDbFiles(c.sqliteDb),
|
await util.listSqliteDbFiles(c.sqliteDb),
|
||||||
|
@ -504,7 +504,7 @@ Future<void> _updaterDeleteSharedDir() async {
|
||||||
c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]);
|
c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final updater = FileSqliteCacheUpdater(c);
|
final updater = FileSqliteCacheUpdater(c.npDb);
|
||||||
await updater(user1Account, user1Files[0], remote: [user1Files[0]]);
|
await updater(user1Account, user1Files[0], remote: [user1Files[0]]);
|
||||||
expect(
|
expect(
|
||||||
await util.listSqliteDbFiles(c.sqliteDb),
|
await util.listSqliteDbFiles(c.sqliteDb),
|
||||||
|
@ -541,7 +541,7 @@ Future<void> _updaterTooManyFiles() async {
|
||||||
await util.insertDirRelation(c.sqliteDb, account, files[2], files.slice(3));
|
await util.insertDirRelation(c.sqliteDb, account, files[2], files.slice(3));
|
||||||
});
|
});
|
||||||
|
|
||||||
final updater = FileSqliteCacheUpdater(c);
|
final updater = FileSqliteCacheUpdater(c.npDb);
|
||||||
await updater(account, files[2], remote: [...files.slice(2), ...newFiles]);
|
await updater(account, files[2], remote: [...files.slice(2), ...newFiles]);
|
||||||
// we are testing to make sure the above function won't throw, so nothing to
|
// we are testing to make sure the above function won't throw, so nothing to
|
||||||
// expect here
|
// expect here
|
||||||
|
@ -574,12 +574,12 @@ Future<void> _updaterMovedFileToFront() async {
|
||||||
final movedFile = files[3].copyWith(
|
final movedFile = files[3].copyWith(
|
||||||
path: "remote.php/dav/files/admin/test1/test1.jpg",
|
path: "remote.php/dav/files/admin/test1/test1.jpg",
|
||||||
);
|
);
|
||||||
await FileSqliteCacheUpdater(c)(
|
await FileSqliteCacheUpdater(c.npDb)(
|
||||||
account,
|
account,
|
||||||
files[1],
|
files[1],
|
||||||
remote: [files[1], movedFile],
|
remote: [files[1], movedFile],
|
||||||
);
|
);
|
||||||
await FileSqliteCacheUpdater(c)(
|
await FileSqliteCacheUpdater(c.npDb)(
|
||||||
account,
|
account,
|
||||||
files[2],
|
files[2],
|
||||||
remote: [files[2]],
|
remote: [files[2]],
|
||||||
|
@ -621,12 +621,12 @@ Future<void> _updaterMovedFileToBehind() async {
|
||||||
final movedFile = files[3].copyWith(
|
final movedFile = files[3].copyWith(
|
||||||
path: "remote.php/dav/files/admin/test2/test1.jpg",
|
path: "remote.php/dav/files/admin/test2/test1.jpg",
|
||||||
);
|
);
|
||||||
await FileSqliteCacheUpdater(c)(
|
await FileSqliteCacheUpdater(c.npDb)(
|
||||||
account,
|
account,
|
||||||
files[1],
|
files[1],
|
||||||
remote: [files[1]],
|
remote: [files[1]],
|
||||||
);
|
);
|
||||||
await FileSqliteCacheUpdater(c)(
|
await FileSqliteCacheUpdater(c.npDb)(
|
||||||
account,
|
account,
|
||||||
files[2],
|
files[2],
|
||||||
remote: [files[2], movedFile],
|
remote: [files[2], movedFile],
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:exifdart/exifdart.dart' hide Metadata;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:nc_photos/entity/exif.dart';
|
import 'package:nc_photos/entity/exif.dart';
|
||||||
import 'package:nc_photos/entity/file.dart';
|
import 'package:nc_photos/entity/file.dart';
|
||||||
import 'package:nc_photos/entity/file_descriptor.dart';
|
import 'package:nc_photos/entity/file_descriptor.dart';
|
||||||
|
@ -245,6 +247,14 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group("fromApi", () {
|
||||||
|
test("size", _fromApiSize);
|
||||||
|
group("gps", () {
|
||||||
|
test("place1", _fromApiGpsPlace1);
|
||||||
|
test("place2", _fromApiGpsPlace2);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group("MetadataUpgraderV1", () {
|
group("MetadataUpgraderV1", () {
|
||||||
|
@ -1217,3 +1227,116 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _fromApiSize() {
|
||||||
|
withClock(
|
||||||
|
Clock(() => DateTime(2020, 1, 2, 3, 4, 5)),
|
||||||
|
() {
|
||||||
|
final actual = Metadata.fromApi(
|
||||||
|
etag: null,
|
||||||
|
size: {
|
||||||
|
"width": "1234",
|
||||||
|
"height": "5678",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
actual,
|
||||||
|
Metadata(
|
||||||
|
imageWidth: 1234,
|
||||||
|
imageHeight: 5678,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fromApiGpsPlace1() {
|
||||||
|
final actual = Metadata.fromApi(
|
||||||
|
etag: null,
|
||||||
|
size: {
|
||||||
|
"width": "1234",
|
||||||
|
"height": "5678",
|
||||||
|
},
|
||||||
|
gps: {
|
||||||
|
"latitude": "40.749444",
|
||||||
|
"longitude": "-73.968056",
|
||||||
|
"altitude": "12.345678",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
actual?.exif,
|
||||||
|
_MetadataGpsMatcher(Exif({
|
||||||
|
"GPSLatitude": [Rational(40, 1), Rational(44, 1), Rational(5799, 100)],
|
||||||
|
"GPSLatitudeRef": "N",
|
||||||
|
"GPSLongitude": [Rational(73, 1), Rational(58, 1), Rational(500, 100)],
|
||||||
|
"GPSLongitudeRef": "W",
|
||||||
|
"GPSAltitude": Rational(1234567, 100000),
|
||||||
|
"GPSAltitudeRef": 0,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fromApiGpsPlace2() {
|
||||||
|
final actual = Metadata.fromApi(
|
||||||
|
etag: null,
|
||||||
|
size: {
|
||||||
|
"width": "1234",
|
||||||
|
"height": "5678",
|
||||||
|
},
|
||||||
|
gps: {
|
||||||
|
"latitude": "-37.688944",
|
||||||
|
"longitude": "178.5481396",
|
||||||
|
"altitude": "-12.345678",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
actual?.exif,
|
||||||
|
_MetadataGpsMatcher(Exif({
|
||||||
|
"GPSLatitude": [Rational(37, 1), Rational(41, 1), Rational(2019, 100)],
|
||||||
|
"GPSLatitudeRef": "S",
|
||||||
|
"GPSLongitude": [Rational(178, 1), Rational(32, 1), Rational(5330, 100)],
|
||||||
|
"GPSLongitudeRef": "E",
|
||||||
|
"GPSAltitude": Rational(1234567, 100000),
|
||||||
|
"GPSAltitudeRef": 1,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetadataGpsMatcher extends Matcher {
|
||||||
|
const _MetadataGpsMatcher(this.expected);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool matches(Object? item, Map matchState) {
|
||||||
|
final actual = item as Exif;
|
||||||
|
final gpsLatitude = listEquals(
|
||||||
|
actual["GPSLatitude"]?.map((e) => e.toString()).toList(),
|
||||||
|
expected["GPSLatitude"]?.map((e) => e.toString()).toList(),
|
||||||
|
);
|
||||||
|
final gpsLatitudeRef =
|
||||||
|
actual["GPSLatitudeRef"] == expected["GPSLatitudeRef"];
|
||||||
|
final gpsLongitude = listEquals(
|
||||||
|
actual["GPSLongitude"]?.map((e) => e.toString()).toList(),
|
||||||
|
expected["GPSLongitude"]?.map((e) => e.toString()).toList(),
|
||||||
|
);
|
||||||
|
final gpsLongitudeRef =
|
||||||
|
actual["GPSLongitudeRef"] == expected["GPSLongitudeRef"];
|
||||||
|
final gpsAltitude = actual["GPSAltitude"]?.toString() ==
|
||||||
|
expected["GPSAltitude"]?.toString();
|
||||||
|
final gpsAltitudeRef =
|
||||||
|
actual["GPSAltitudeRef"] == expected["GPSAltitudeRef"];
|
||||||
|
|
||||||
|
return gpsLatitude &&
|
||||||
|
gpsLatitudeRef &&
|
||||||
|
gpsLongitude &&
|
||||||
|
gpsLongitudeRef &&
|
||||||
|
gpsAltitude &&
|
||||||
|
gpsAltitudeRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Description describe(Description description) {
|
||||||
|
return description.add(expected.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
final Exif expected;
|
||||||
|
}
|
||||||
|
|
|
@ -84,6 +84,10 @@ class File with EquatableMixin {
|
||||||
this.trashbinFilename,
|
this.trashbinFilename,
|
||||||
this.trashbinOriginalLocation,
|
this.trashbinOriginalLocation,
|
||||||
this.trashbinDeletionTime,
|
this.trashbinDeletionTime,
|
||||||
|
this.metadataPhotosIfd0,
|
||||||
|
this.metadataPhotosExif,
|
||||||
|
this.metadataPhotosGps,
|
||||||
|
this.metadataPhotosSize,
|
||||||
this.customProperties,
|
this.customProperties,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -106,6 +110,10 @@ class File with EquatableMixin {
|
||||||
trashbinFilename,
|
trashbinFilename,
|
||||||
trashbinOriginalLocation,
|
trashbinOriginalLocation,
|
||||||
trashbinDeletionTime,
|
trashbinDeletionTime,
|
||||||
|
metadataPhotosIfd0,
|
||||||
|
metadataPhotosExif,
|
||||||
|
metadataPhotosGps,
|
||||||
|
metadataPhotosSize,
|
||||||
customProperties,
|
customProperties,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -123,6 +131,10 @@ class File with EquatableMixin {
|
||||||
final String? trashbinFilename;
|
final String? trashbinFilename;
|
||||||
final String? trashbinOriginalLocation;
|
final String? trashbinOriginalLocation;
|
||||||
final DateTime? trashbinDeletionTime;
|
final DateTime? trashbinDeletionTime;
|
||||||
|
final Map<String, String>? metadataPhotosIfd0;
|
||||||
|
final Map<String, String>? metadataPhotosExif;
|
||||||
|
final Map<String, String>? metadataPhotosGps;
|
||||||
|
final Map<String, String>? metadataPhotosSize;
|
||||||
final Map<String, String>? customProperties;
|
final Map<String, String>? customProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ extension _$FavoriteToString on Favorite {
|
||||||
extension _$FileToString on File {
|
extension _$FileToString on File {
|
||||||
String _$toString() {
|
String _$toString() {
|
||||||
// ignore: unnecessary_string_interpolations
|
// ignore: unnecessary_string_interpolations
|
||||||
return "File {href: $href, ${lastModified == null ? "" : "lastModified: $lastModified, "}${etag == null ? "" : "etag: $etag, "}${contentType == null ? "" : "contentType: $contentType, "}${isCollection == null ? "" : "isCollection: $isCollection, "}${contentLength == null ? "" : "contentLength: $contentLength, "}${fileId == null ? "" : "fileId: $fileId, "}${favorite == null ? "" : "favorite: $favorite, "}${ownerId == null ? "" : "ownerId: $ownerId, "}${ownerDisplayName == null ? "" : "ownerDisplayName: $ownerDisplayName, "}${hasPreview == null ? "" : "hasPreview: $hasPreview, "}${trashbinFilename == null ? "" : "trashbinFilename: $trashbinFilename, "}${trashbinOriginalLocation == null ? "" : "trashbinOriginalLocation: $trashbinOriginalLocation, "}${trashbinDeletionTime == null ? "" : "trashbinDeletionTime: $trashbinDeletionTime, "}${customProperties == null ? "" : "customProperties: $customProperties"}}";
|
return "File {href: $href, ${lastModified == null ? "" : "lastModified: $lastModified, "}${etag == null ? "" : "etag: $etag, "}${contentType == null ? "" : "contentType: $contentType, "}${isCollection == null ? "" : "isCollection: $isCollection, "}${contentLength == null ? "" : "contentLength: $contentLength, "}${fileId == null ? "" : "fileId: $fileId, "}${favorite == null ? "" : "favorite: $favorite, "}${ownerId == null ? "" : "ownerId: $ownerId, "}${ownerDisplayName == null ? "" : "ownerDisplayName: $ownerDisplayName, "}${hasPreview == null ? "" : "hasPreview: $hasPreview, "}${trashbinFilename == null ? "" : "trashbinFilename: $trashbinFilename, "}${trashbinOriginalLocation == null ? "" : "trashbinOriginalLocation: $trashbinOriginalLocation, "}${trashbinDeletionTime == null ? "" : "trashbinDeletionTime: $trashbinDeletionTime, "}${metadataPhotosIfd0 == null ? "" : "metadataPhotosIfd0: $metadataPhotosIfd0, "}${metadataPhotosExif == null ? "" : "metadataPhotosExif: $metadataPhotosExif, "}${metadataPhotosGps == null ? "" : "metadataPhotosGps: $metadataPhotosGps, "}${metadataPhotosSize == null ? "" : "metadataPhotosSize: $metadataPhotosSize, "}${customProperties == null ? "" : "customProperties: $customProperties"}}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -33,6 +33,10 @@ class FileParser extends XmlResponseParser {
|
||||||
String? trashbinFilename;
|
String? trashbinFilename;
|
||||||
String? trashbinOriginalLocation;
|
String? trashbinOriginalLocation;
|
||||||
DateTime? trashbinDeletionTime;
|
DateTime? trashbinDeletionTime;
|
||||||
|
Map<String, String>? metadataPhotosIfd0;
|
||||||
|
Map<String, String>? metadataPhotosExif;
|
||||||
|
Map<String, String>? metadataPhotosGps;
|
||||||
|
Map<String, String>? metadataPhotosSize;
|
||||||
Map<String, String>? customProperties;
|
Map<String, String>? customProperties;
|
||||||
|
|
||||||
for (final child in element.children.whereType<XmlElement>()) {
|
for (final child in element.children.whereType<XmlElement>()) {
|
||||||
|
@ -66,6 +70,10 @@ class FileParser extends XmlResponseParser {
|
||||||
trashbinFilename = propParser.trashbinFilename;
|
trashbinFilename = propParser.trashbinFilename;
|
||||||
trashbinOriginalLocation = propParser.trashbinOriginalLocation;
|
trashbinOriginalLocation = propParser.trashbinOriginalLocation;
|
||||||
trashbinDeletionTime = propParser.trashbinDeletionTime;
|
trashbinDeletionTime = propParser.trashbinDeletionTime;
|
||||||
|
metadataPhotosIfd0 = propParser.metadataPhotosIfd0;
|
||||||
|
metadataPhotosExif = propParser.metadataPhotosExif;
|
||||||
|
metadataPhotosGps = propParser.metadataPhotosGps;
|
||||||
|
metadataPhotosSize = propParser.metadataPhotosSize;
|
||||||
customProperties = propParser.customProperties;
|
customProperties = propParser.customProperties;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -85,6 +93,10 @@ class FileParser extends XmlResponseParser {
|
||||||
trashbinFilename: trashbinFilename,
|
trashbinFilename: trashbinFilename,
|
||||||
trashbinOriginalLocation: trashbinOriginalLocation,
|
trashbinOriginalLocation: trashbinOriginalLocation,
|
||||||
trashbinDeletionTime: trashbinDeletionTime,
|
trashbinDeletionTime: trashbinDeletionTime,
|
||||||
|
metadataPhotosIfd0: metadataPhotosIfd0,
|
||||||
|
metadataPhotosExif: metadataPhotosExif,
|
||||||
|
metadataPhotosGps: metadataPhotosGps,
|
||||||
|
metadataPhotosSize: metadataPhotosSize,
|
||||||
customProperties: customProperties,
|
customProperties: customProperties,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -140,6 +152,27 @@ class _PropParser {
|
||||||
prefix: "http://nextcloud.org/ns", namespaces: namespaces)) {
|
prefix: "http://nextcloud.org/ns", namespaces: namespaces)) {
|
||||||
_trashbinDeletionTime = DateTime.fromMillisecondsSinceEpoch(
|
_trashbinDeletionTime = DateTime.fromMillisecondsSinceEpoch(
|
||||||
int.parse(child.innerText) * 1000);
|
int.parse(child.innerText) * 1000);
|
||||||
|
} else if (child.matchQualifiedName("metadata-photos-ifd0",
|
||||||
|
prefix: "http://nextcloud.org/ns", namespaces: namespaces)) {
|
||||||
|
for (final ifd0Child in child.children.whereType<XmlElement>()) {
|
||||||
|
(_metadataPhotosIfd0 ??= {})[ifd0Child.localName] =
|
||||||
|
ifd0Child.innerText;
|
||||||
|
}
|
||||||
|
} else if (child.matchQualifiedName("metadata-photos-exif",
|
||||||
|
prefix: "http://nextcloud.org/ns", namespaces: namespaces)) {
|
||||||
|
for (final c in child.children.whereType<XmlElement>()) {
|
||||||
|
(_metadataPhotosExif ??= {})[c.localName] = c.innerText;
|
||||||
|
}
|
||||||
|
} else if (child.matchQualifiedName("metadata-photos-gps",
|
||||||
|
prefix: "http://nextcloud.org/ns", namespaces: namespaces)) {
|
||||||
|
for (final c in child.children.whereType<XmlElement>()) {
|
||||||
|
(_metadataPhotosGps ??= {})[c.localName] = c.innerText;
|
||||||
|
}
|
||||||
|
} else if (child.matchQualifiedName("metadata-photos-size",
|
||||||
|
prefix: "http://nextcloud.org/ns", namespaces: namespaces)) {
|
||||||
|
for (final c in child.children.whereType<XmlElement>()) {
|
||||||
|
(_metadataPhotosSize ??= {})[c.localName] = c.innerText;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
final key = child.name.prefix == null
|
final key = child.name.prefix == null
|
||||||
? child.localName
|
? child.localName
|
||||||
|
@ -162,6 +195,10 @@ class _PropParser {
|
||||||
String? get trashbinFilename => _trashbinFilename;
|
String? get trashbinFilename => _trashbinFilename;
|
||||||
String? get trashbinOriginalLocation => _trashbinOriginalLocation;
|
String? get trashbinOriginalLocation => _trashbinOriginalLocation;
|
||||||
DateTime? get trashbinDeletionTime => _trashbinDeletionTime;
|
DateTime? get trashbinDeletionTime => _trashbinDeletionTime;
|
||||||
|
Map<String, String>? get metadataPhotosIfd0 => _metadataPhotosIfd0;
|
||||||
|
Map<String, String>? get metadataPhotosExif => _metadataPhotosExif;
|
||||||
|
Map<String, String>? get metadataPhotosGps => _metadataPhotosGps;
|
||||||
|
Map<String, String>? get metadataPhotosSize => _metadataPhotosSize;
|
||||||
Map<String, String>? get customProperties => _customProperties;
|
Map<String, String>? get customProperties => _customProperties;
|
||||||
|
|
||||||
final Map<String, String> namespaces;
|
final Map<String, String> namespaces;
|
||||||
|
@ -179,6 +216,10 @@ class _PropParser {
|
||||||
String? _trashbinFilename;
|
String? _trashbinFilename;
|
||||||
String? _trashbinOriginalLocation;
|
String? _trashbinOriginalLocation;
|
||||||
DateTime? _trashbinDeletionTime;
|
DateTime? _trashbinDeletionTime;
|
||||||
|
Map<String, String>? _metadataPhotosIfd0;
|
||||||
|
Map<String, String>? _metadataPhotosExif;
|
||||||
|
Map<String, String>? _metadataPhotosGps;
|
||||||
|
Map<String, String>? _metadataPhotosSize;
|
||||||
Map<String, String>? _customProperties;
|
Map<String, String>? _customProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -72,6 +72,10 @@ class ApiFiles {
|
||||||
trashbinFilename,
|
trashbinFilename,
|
||||||
trashbinOriginalLocation,
|
trashbinOriginalLocation,
|
||||||
trashbinDeletionTime,
|
trashbinDeletionTime,
|
||||||
|
metadataPhotosIfd0,
|
||||||
|
metadataPhotosExif,
|
||||||
|
metadataPhotosGps,
|
||||||
|
metadataPhotosSize,
|
||||||
Map<String, String>? customNamespaces,
|
Map<String, String>? customNamespaces,
|
||||||
List<String>? customProperties,
|
List<String>? customProperties,
|
||||||
}) async {
|
}) async {
|
||||||
|
@ -96,7 +100,11 @@ class ApiFiles {
|
||||||
richWorkspace != null ||
|
richWorkspace != null ||
|
||||||
trashbinFilename != null ||
|
trashbinFilename != null ||
|
||||||
trashbinOriginalLocation != null ||
|
trashbinOriginalLocation != null ||
|
||||||
trashbinDeletionTime != null);
|
trashbinDeletionTime != null ||
|
||||||
|
metadataPhotosIfd0 != null ||
|
||||||
|
metadataPhotosExif != null ||
|
||||||
|
metadataPhotosGps != null ||
|
||||||
|
metadataPhotosSize != null);
|
||||||
if (!hasDavNs && !hasOcNs && !hasNcNs) {
|
if (!hasDavNs && !hasOcNs && !hasNcNs) {
|
||||||
// no body
|
// no body
|
||||||
return await _api.request("PROPFIND", path);
|
return await _api.request("PROPFIND", path);
|
||||||
|
@ -175,6 +183,18 @@ class ApiFiles {
|
||||||
if (trashbinDeletionTime != null) {
|
if (trashbinDeletionTime != null) {
|
||||||
builder.element("nc:trashbin-deletion-time");
|
builder.element("nc:trashbin-deletion-time");
|
||||||
}
|
}
|
||||||
|
if (metadataPhotosIfd0 != null) {
|
||||||
|
builder.element("nc:metadata-photos-ifd0");
|
||||||
|
}
|
||||||
|
if (metadataPhotosExif != null) {
|
||||||
|
builder.element("nc:metadata-photos-exif");
|
||||||
|
}
|
||||||
|
if (metadataPhotosGps != null) {
|
||||||
|
builder.element("nc:metadata-photos-gps");
|
||||||
|
}
|
||||||
|
if (metadataPhotosSize != null) {
|
||||||
|
builder.element("nc:metadata-photos-size");
|
||||||
|
}
|
||||||
for (final p in customProperties ?? []) {
|
for (final p in customProperties ?? []) {
|
||||||
builder.element(p);
|
builder.element(p);
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,10 @@ void main() {
|
||||||
test("multiple files", _filesMultiple);
|
test("multiple files", _filesMultiple);
|
||||||
test("directory", _filesDir);
|
test("directory", _filesDir);
|
||||||
test("nextcloud hosted in subdir", _filesServerHostedInSubdir);
|
test("nextcloud hosted in subdir", _filesServerHostedInSubdir);
|
||||||
|
test("file w/ metadata-photos-ifd0", _filesNc28MetadataIfd0);
|
||||||
|
test("file w/ metadata-photos-exif", _filesNc28MetadataExif);
|
||||||
|
test("file w/ metadata-photos-gps", _filesNc28MetadataGps);
|
||||||
|
test("file w/ metadata-photos-size", _filesNc28MetadataSize);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -380,3 +384,255 @@ Future<void> _filesServerHostedInSubdir() async {
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _filesNc28MetadataIfd0() async {
|
||||||
|
const xml = """
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:"
|
||||||
|
xmlns:s="http://sabredav.org/ns"
|
||||||
|
xmlns:oc="http://owncloud.org/ns"
|
||||||
|
xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/nextcloud/remote.php/dav/files/admin/1.jpg</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Fri, 01 Jan 2021 02:03:04 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"1324f58d4d5c8d81bed6e4ed9d5ea862"</d:getetag>
|
||||||
|
<d:getcontenttype>image/jpeg</d:getcontenttype>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<oc:fileid>123</oc:fileid>
|
||||||
|
<d:getcontentlength>3963036</d:getcontentlength>
|
||||||
|
<nc:has-preview>false</nc:has-preview>
|
||||||
|
<nc:metadata-photos-ifd0>
|
||||||
|
<Make>SUPER</Make>
|
||||||
|
<Model>Phone 1</Model>
|
||||||
|
<Orientation>1</Orientation>
|
||||||
|
<XResolution>72/1</XResolution>
|
||||||
|
<YResolution>72/1</YResolution>
|
||||||
|
<ResolutionUnit>2</ResolutionUnit>
|
||||||
|
<Software>1.0</Software>
|
||||||
|
<DateTime>2020:01:02 03:04:05</DateTime>
|
||||||
|
<YCbCrPositioning>1</YCbCrPositioning>
|
||||||
|
</nc:metadata-photos-ifd0>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
""";
|
||||||
|
final results = await FileParser().parse(xml);
|
||||||
|
expect(results, [
|
||||||
|
File(
|
||||||
|
href: "/nextcloud/remote.php/dav/files/admin/1.jpg",
|
||||||
|
contentLength: 3963036,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
etag: "1324f58d4d5c8d81bed6e4ed9d5ea862",
|
||||||
|
lastModified: DateTime.utc(2021, 1, 1, 2, 3, 4),
|
||||||
|
hasPreview: false,
|
||||||
|
fileId: 123,
|
||||||
|
isCollection: false,
|
||||||
|
metadataPhotosIfd0: {
|
||||||
|
"Make": "SUPER",
|
||||||
|
"Model": "Phone 1",
|
||||||
|
"Orientation": "1",
|
||||||
|
"XResolution": "72/1",
|
||||||
|
"YResolution": "72/1",
|
||||||
|
"ResolutionUnit": "2",
|
||||||
|
"Software": "1.0",
|
||||||
|
"DateTime": "2020:01:02 03:04:05",
|
||||||
|
"YCbCrPositioning": "1",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _filesNc28MetadataExif() async {
|
||||||
|
const xml = """
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:"
|
||||||
|
xmlns:s="http://sabredav.org/ns"
|
||||||
|
xmlns:oc="http://owncloud.org/ns"
|
||||||
|
xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/nextcloud/remote.php/dav/files/admin/1.jpg</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Fri, 01 Jan 2021 02:03:04 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"1324f58d4d5c8d81bed6e4ed9d5ea862"</d:getetag>
|
||||||
|
<d:getcontenttype>image/jpeg</d:getcontenttype>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<oc:fileid>123</oc:fileid>
|
||||||
|
<d:getcontentlength>3963036</d:getcontentlength>
|
||||||
|
<nc:has-preview>false</nc:has-preview>
|
||||||
|
<nc:metadata-photos-exif>
|
||||||
|
<ExposureTime>1/381</ExposureTime>
|
||||||
|
<FNumber>9/5</FNumber>
|
||||||
|
<ExposureProgram>2</ExposureProgram>
|
||||||
|
<ISOSpeedRatings>20</ISOSpeedRatings>
|
||||||
|
<ExifVersion>0231</ExifVersion>
|
||||||
|
<DateTimeOriginal>2020:01:02 03:04:05</DateTimeOriginal>
|
||||||
|
<DateTimeDigitized>2020:01:02 03:04:05</DateTimeDigitized>
|
||||||
|
<UndefinedTag__x____>+01:00</UndefinedTag__x____>
|
||||||
|
<ComponentsConfiguration/>
|
||||||
|
<ShutterSpeedValue>126682/14777</ShutterSpeedValue>
|
||||||
|
<ApertureValue>54823/32325</ApertureValue>
|
||||||
|
<BrightnessValue>69659/9080</BrightnessValue>
|
||||||
|
<ExposureBiasValue>0/1</ExposureBiasValue>
|
||||||
|
<MeteringMode>5</MeteringMode>
|
||||||
|
<Flash>16</Flash>
|
||||||
|
<FocalLength>4/1</FocalLength>
|
||||||
|
<MakerNote>SUPER</MakerNote>
|
||||||
|
<ColorSpace>65535</ColorSpace>
|
||||||
|
<ExifImageWidth>4032</ExifImageWidth>
|
||||||
|
<ExifImageLength>3024</ExifImageLength>
|
||||||
|
<SensingMethod>2</SensingMethod>
|
||||||
|
<SceneType/>
|
||||||
|
<ExposureMode>0</ExposureMode>
|
||||||
|
<WhiteBalance>0</WhiteBalance>
|
||||||
|
<FocalLengthIn__mmFilm>28</FocalLengthIn__mmFilm>
|
||||||
|
<SceneCaptureType>0</SceneCaptureType>
|
||||||
|
</nc:metadata-photos-exif>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
""";
|
||||||
|
final results = await FileParser().parse(xml);
|
||||||
|
expect(results, [
|
||||||
|
File(
|
||||||
|
href: "/nextcloud/remote.php/dav/files/admin/1.jpg",
|
||||||
|
contentLength: 3963036,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
etag: "1324f58d4d5c8d81bed6e4ed9d5ea862",
|
||||||
|
lastModified: DateTime.utc(2021, 1, 1, 2, 3, 4),
|
||||||
|
hasPreview: false,
|
||||||
|
fileId: 123,
|
||||||
|
isCollection: false,
|
||||||
|
metadataPhotosExif: {
|
||||||
|
"ExposureTime": "1/381",
|
||||||
|
"FNumber": "9/5",
|
||||||
|
"ExposureProgram": "2",
|
||||||
|
"ISOSpeedRatings": "20",
|
||||||
|
"ExifVersion": "0231",
|
||||||
|
"DateTimeOriginal": "2020:01:02 03:04:05",
|
||||||
|
"DateTimeDigitized": "2020:01:02 03:04:05",
|
||||||
|
"UndefinedTag__x____": "+01:00",
|
||||||
|
"ComponentsConfiguration": "",
|
||||||
|
"ShutterSpeedValue": "126682/14777",
|
||||||
|
"ApertureValue": "54823/32325",
|
||||||
|
"BrightnessValue": "69659/9080",
|
||||||
|
"ExposureBiasValue": "0/1",
|
||||||
|
"MeteringMode": "5",
|
||||||
|
"Flash": "16",
|
||||||
|
"FocalLength": "4/1",
|
||||||
|
"MakerNote": "SUPER",
|
||||||
|
"ColorSpace": "65535",
|
||||||
|
"ExifImageWidth": "4032",
|
||||||
|
"ExifImageLength": "3024",
|
||||||
|
"SensingMethod": "2",
|
||||||
|
"SceneType": "",
|
||||||
|
"ExposureMode": "0",
|
||||||
|
"WhiteBalance": "0",
|
||||||
|
"FocalLengthIn__mmFilm": "28",
|
||||||
|
"SceneCaptureType": "0",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _filesNc28MetadataGps() async {
|
||||||
|
const xml = """
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:"
|
||||||
|
xmlns:s="http://sabredav.org/ns"
|
||||||
|
xmlns:oc="http://owncloud.org/ns"
|
||||||
|
xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/nextcloud/remote.php/dav/files/admin/1.jpg</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Fri, 01 Jan 2021 02:03:04 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"1324f58d4d5c8d81bed6e4ed9d5ea862"</d:getetag>
|
||||||
|
<d:getcontenttype>image/jpeg</d:getcontenttype>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<oc:fileid>123</oc:fileid>
|
||||||
|
<d:getcontentlength>3963036</d:getcontentlength>
|
||||||
|
<nc:has-preview>false</nc:has-preview>
|
||||||
|
<nc:metadata-photos-gps>
|
||||||
|
<latitude>1.23456</latitude>
|
||||||
|
<longitude>2.34567</longitude>
|
||||||
|
<altitude>3.45678</altitude>
|
||||||
|
</nc:metadata-photos-gps>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
""";
|
||||||
|
final results = await FileParser().parse(xml);
|
||||||
|
expect(results, [
|
||||||
|
File(
|
||||||
|
href: "/nextcloud/remote.php/dav/files/admin/1.jpg",
|
||||||
|
contentLength: 3963036,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
etag: "1324f58d4d5c8d81bed6e4ed9d5ea862",
|
||||||
|
lastModified: DateTime.utc(2021, 1, 1, 2, 3, 4),
|
||||||
|
hasPreview: false,
|
||||||
|
fileId: 123,
|
||||||
|
isCollection: false,
|
||||||
|
metadataPhotosGps: {
|
||||||
|
"latitude": "1.23456",
|
||||||
|
"longitude": "2.34567",
|
||||||
|
"altitude": "3.45678",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _filesNc28MetadataSize() async {
|
||||||
|
const xml = """
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:"
|
||||||
|
xmlns:s="http://sabredav.org/ns"
|
||||||
|
xmlns:oc="http://owncloud.org/ns"
|
||||||
|
xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/nextcloud/remote.php/dav/files/admin/1.jpg</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Fri, 01 Jan 2021 02:03:04 GMT</d:getlastmodified>
|
||||||
|
<d:getetag>"1324f58d4d5c8d81bed6e4ed9d5ea862"</d:getetag>
|
||||||
|
<d:getcontenttype>image/jpeg</d:getcontenttype>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<oc:fileid>123</oc:fileid>
|
||||||
|
<d:getcontentlength>3963036</d:getcontentlength>
|
||||||
|
<nc:has-preview>false</nc:has-preview>
|
||||||
|
<nc:metadata-photos-size>
|
||||||
|
<width>4032</width>
|
||||||
|
<height>3024</height>
|
||||||
|
</nc:metadata-photos-size>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
""";
|
||||||
|
final results = await FileParser().parse(xml);
|
||||||
|
expect(results, [
|
||||||
|
File(
|
||||||
|
href: "/nextcloud/remote.php/dav/files/admin/1.jpg",
|
||||||
|
contentLength: 3963036,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
etag: "1324f58d4d5c8d81bed6e4ed9d5ea862",
|
||||||
|
lastModified: DateTime.utc(2021, 1, 1, 2, 3, 4),
|
||||||
|
hasPreview: false,
|
||||||
|
fileId: 123,
|
||||||
|
isCollection: false,
|
||||||
|
metadataPhotosSize: {
|
||||||
|
"width": "4032",
|
||||||
|
"height": "3024",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
library np_collection;
|
library np_collection;
|
||||||
|
|
||||||
|
export 'package:quiver/iterables.dart' show partition;
|
||||||
|
|
||||||
export 'src/iterable_extension.dart';
|
export 'src/iterable_extension.dart';
|
||||||
export 'src/iterator_extension.dart';
|
export 'src/iterator_extension.dart';
|
||||||
export 'src/list_extension.dart';
|
export 'src/list_extension.dart';
|
||||||
|
|
|
@ -201,6 +201,19 @@ class DbFilesMemory {
|
||||||
final Map<int, List<DbFileDescriptor>> memories;
|
final Map<int, List<DbFileDescriptor>> memories;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@genCopyWith
|
||||||
|
@toString
|
||||||
|
class DbFileMissingMetadataResult {
|
||||||
|
const DbFileMissingMetadataResult({
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => _$toString();
|
||||||
|
|
||||||
|
final List<({int fileId, String relativePath})> items;
|
||||||
|
}
|
||||||
|
|
||||||
@npLog
|
@npLog
|
||||||
abstract class NpDb {
|
abstract class NpDb {
|
||||||
factory NpDb() => NpDbSqlite();
|
factory NpDb() => NpDbSqlite();
|
||||||
|
@ -351,6 +364,14 @@ abstract class NpDb {
|
||||||
Future<int> countFilesByMissingMetadata({
|
Future<int> countFilesByMissingMetadata({
|
||||||
required DbAccount account,
|
required DbAccount account,
|
||||||
required List<String> mimes,
|
required List<String> mimes,
|
||||||
|
required String ownerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Return files without metadata
|
||||||
|
Future<DbFileMissingMetadataResult> getFilesByMissingMetadata({
|
||||||
|
required DbAccount account,
|
||||||
|
required List<String> mimes,
|
||||||
|
required String ownerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Delete a file or dir from db
|
/// Delete a file or dir from db
|
||||||
|
@ -411,6 +432,7 @@ abstract class NpDb {
|
||||||
Future<DbFilesSummary> getFilesSummary({
|
Future<DbFilesSummary> getFilesSummary({
|
||||||
required DbAccount account,
|
required DbAccount account,
|
||||||
List<String>? includeRelativeRoots,
|
List<String>? includeRelativeRoots,
|
||||||
|
List<String>? includeRelativeDirs,
|
||||||
List<String>? excludeRelativeRoots,
|
List<String>? excludeRelativeRoots,
|
||||||
List<String>? mimes,
|
List<String>? mimes,
|
||||||
});
|
});
|
||||||
|
|
|
@ -81,6 +81,31 @@ extension $DbFilesMemoryCopyWith on DbFilesMemory {
|
||||||
_$DbFilesMemoryCopyWithWorkerImpl(this);
|
_$DbFilesMemoryCopyWithWorkerImpl(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract class $DbFileMissingMetadataResultCopyWithWorker {
|
||||||
|
DbFileMissingMetadataResult call(
|
||||||
|
{List<({int fileId, String relativePath})>? items});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _$DbFileMissingMetadataResultCopyWithWorkerImpl
|
||||||
|
implements $DbFileMissingMetadataResultCopyWithWorker {
|
||||||
|
_$DbFileMissingMetadataResultCopyWithWorkerImpl(this.that);
|
||||||
|
|
||||||
|
@override
|
||||||
|
DbFileMissingMetadataResult call({dynamic items}) {
|
||||||
|
return DbFileMissingMetadataResult(
|
||||||
|
items:
|
||||||
|
items as List<({int fileId, String relativePath})>? ?? that.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DbFileMissingMetadataResult that;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension $DbFileMissingMetadataResultCopyWith on DbFileMissingMetadataResult {
|
||||||
|
$DbFileMissingMetadataResultCopyWithWorker get copyWith => _$copyWith;
|
||||||
|
$DbFileMissingMetadataResultCopyWithWorker get _$copyWith =>
|
||||||
|
_$DbFileMissingMetadataResultCopyWithWorkerImpl(this);
|
||||||
|
}
|
||||||
|
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
// NpLogGenerator
|
// NpLogGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
@ -151,3 +176,10 @@ extension _$DbFilesMemoryToString on DbFilesMemory {
|
||||||
return "DbFilesMemory {memories: $memories}";
|
return "DbFilesMemory {memories: $memories}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension _$DbFileMissingMetadataResultToString on DbFileMissingMetadataResult {
|
||||||
|
String _$toString() {
|
||||||
|
// ignore: unnecessary_string_interpolations
|
||||||
|
return "DbFileMissingMetadataResult {items: [length: ${items.length}]}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -340,9 +340,10 @@ extension SqliteDbFileExtension on SqliteDb {
|
||||||
required ByAccount account,
|
required ByAccount account,
|
||||||
bool? isMissingMetadata,
|
bool? isMissingMetadata,
|
||||||
List<String>? mimes,
|
List<String>? mimes,
|
||||||
|
String? ownerId,
|
||||||
}) async {
|
}) async {
|
||||||
_log.info(
|
_log.info(
|
||||||
"[countFiles] isMissingMetadata: $isMissingMetadata, mimes: $mimes");
|
"[countFiles] isMissingMetadata: $isMissingMetadata, mimes: $mimes, ownerId: $ownerId");
|
||||||
Expression<bool>? filter;
|
Expression<bool>? filter;
|
||||||
if (isMissingMetadata != null) {
|
if (isMissingMetadata != null) {
|
||||||
if (isMissingMetadata) {
|
if (isMissingMetadata) {
|
||||||
|
@ -380,9 +381,75 @@ extension SqliteDbFileExtension on SqliteDb {
|
||||||
if (mimes != null) {
|
if (mimes != null) {
|
||||||
query.where(files.contentType.isIn(mimes));
|
query.where(files.contentType.isIn(mimes));
|
||||||
}
|
}
|
||||||
|
if (ownerId != null) {
|
||||||
|
query.where(files.ownerId.equals(ownerId));
|
||||||
|
}
|
||||||
return await query.map((r) => r.read(count)!).getSingle();
|
return await query.map((r) => r.read(count)!).getSingle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<({int fileId, String relativePath})>>
|
||||||
|
queryFileIdPathsByMissingMetadata({
|
||||||
|
required ByAccount account,
|
||||||
|
bool? isMissingMetadata,
|
||||||
|
List<String>? mimes,
|
||||||
|
String? ownerId,
|
||||||
|
int? offset,
|
||||||
|
int? limit,
|
||||||
|
}) async {
|
||||||
|
_log.info(
|
||||||
|
"[queryFileIdPathsByMissingMetadata] isMissingMetadata: $isMissingMetadata, mimes: $mimes, ownerId: $ownerId");
|
||||||
|
final query = selectOnly(files).join([
|
||||||
|
innerJoin(accountFiles, accountFiles.file.equalsExp(files.rowId),
|
||||||
|
useColumns: false),
|
||||||
|
if (account.dbAccount != null) ...[
|
||||||
|
innerJoin(accounts, accounts.rowId.equalsExp(accountFiles.account),
|
||||||
|
useColumns: false),
|
||||||
|
innerJoin(servers, servers.rowId.equalsExp(accounts.server),
|
||||||
|
useColumns: false),
|
||||||
|
],
|
||||||
|
leftOuterJoin(images, images.accountFile.equalsExp(accountFiles.rowId),
|
||||||
|
useColumns: false),
|
||||||
|
leftOuterJoin(imageLocations,
|
||||||
|
imageLocations.accountFile.equalsExp(accountFiles.rowId),
|
||||||
|
useColumns: false),
|
||||||
|
]);
|
||||||
|
query.addColumns([files.fileId, accountFiles.relativePath]);
|
||||||
|
if (account.sqlAccount != null) {
|
||||||
|
query.where(accountFiles.account.equals(account.sqlAccount!.rowId));
|
||||||
|
} else if (account.dbAccount != null) {
|
||||||
|
query
|
||||||
|
..where(servers.address.equals(account.dbAccount!.serverAddress))
|
||||||
|
..where(accounts.userId
|
||||||
|
.equals(account.dbAccount!.userId.toCaseInsensitiveString()));
|
||||||
|
}
|
||||||
|
if (mimes != null) {
|
||||||
|
query.where(files.contentType.isIn(mimes));
|
||||||
|
}
|
||||||
|
if (ownerId != null) {
|
||||||
|
query.where(files.ownerId.equals(ownerId));
|
||||||
|
}
|
||||||
|
if (isMissingMetadata != null) {
|
||||||
|
if (isMissingMetadata) {
|
||||||
|
query.where(
|
||||||
|
images.lastUpdated.isNull() | imageLocations.version.isNull());
|
||||||
|
} else {
|
||||||
|
query.where(images.lastUpdated.isNotNull() &
|
||||||
|
imageLocations.version.isNotNull());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy([OrderingTerm.desc(files.fileId)]);
|
||||||
|
if (limit != null) {
|
||||||
|
query.limit(limit, offset: offset);
|
||||||
|
}
|
||||||
|
return await query
|
||||||
|
.map((r) => (
|
||||||
|
fileId: r.read(files.fileId)!,
|
||||||
|
relativePath: r.read(accountFiles.relativePath)!,
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<FileDescriptor>> queryFileDescriptors({
|
Future<List<FileDescriptor>> queryFileDescriptors({
|
||||||
required ByAccount account,
|
required ByAccount account,
|
||||||
List<int>? fileIds,
|
List<int>? fileIds,
|
||||||
|
@ -616,6 +683,7 @@ extension SqliteDbFileExtension on SqliteDb {
|
||||||
Future<CountFileGroupsByDateResult> countFileGroupsByDate({
|
Future<CountFileGroupsByDateResult> countFileGroupsByDate({
|
||||||
required ByAccount account,
|
required ByAccount account,
|
||||||
List<String>? includeRelativeRoots,
|
List<String>? includeRelativeRoots,
|
||||||
|
List<String>? includeRelativeDirs,
|
||||||
List<String>? excludeRelativeRoots,
|
List<String>? excludeRelativeRoots,
|
||||||
List<String>? mimes,
|
List<String>? mimes,
|
||||||
bool? isArchived,
|
bool? isArchived,
|
||||||
|
@ -623,10 +691,23 @@ extension SqliteDbFileExtension on SqliteDb {
|
||||||
_log.info(
|
_log.info(
|
||||||
"[countFileGroupsByDate] "
|
"[countFileGroupsByDate] "
|
||||||
"includeRelativeRoots: $includeRelativeRoots, "
|
"includeRelativeRoots: $includeRelativeRoots, "
|
||||||
|
"includeRelativeDirs: $includeRelativeDirs, "
|
||||||
"excludeRelativeRoots: $excludeRelativeRoots, "
|
"excludeRelativeRoots: $excludeRelativeRoots, "
|
||||||
"mimes: $mimes",
|
"mimes: $mimes",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
List<int>? dirIds;
|
||||||
|
if (includeRelativeDirs?.isNotEmpty == true) {
|
||||||
|
final sqlAccount = await accountOf(account);
|
||||||
|
final result = await _accountFileRowIdsOf(ByAccount.sql(sqlAccount),
|
||||||
|
includeRelativeDirs!.map((e) => DbFileKey.byPath(e)).toList())
|
||||||
|
.notNull();
|
||||||
|
dirIds = result.values.map((e) => e.fileRowId).toList();
|
||||||
|
if (dirIds.length != includeRelativeDirs.length) {
|
||||||
|
_log.warning("Some dirs not found: $includeRelativeDirs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final count = countAll();
|
final count = countAll();
|
||||||
final localDate = accountFiles.bestDateTime
|
final localDate = accountFiles.bestDateTime
|
||||||
.modify(const DateTimeModifier.localTime())
|
.modify(const DateTimeModifier.localTime())
|
||||||
|
@ -643,6 +724,17 @@ extension SqliteDbFileExtension on SqliteDb {
|
||||||
for (final r in includeRelativeRoots) {
|
for (final r in includeRelativeRoots) {
|
||||||
q.byOrRelativePathPattern("$r/%");
|
q.byOrRelativePathPattern("$r/%");
|
||||||
}
|
}
|
||||||
|
if (dirIds != null) {
|
||||||
|
for (final i in dirIds) {
|
||||||
|
q.byOrDirRowId(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (dirIds != null) {
|
||||||
|
for (final i in dirIds) {
|
||||||
|
q.byOrDirRowId(i);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return q.build();
|
return q.build();
|
||||||
|
|
|
@ -383,16 +383,46 @@ class NpDbSqlite implements NpDb {
|
||||||
Future<int> countFilesByMissingMetadata({
|
Future<int> countFilesByMissingMetadata({
|
||||||
required DbAccount account,
|
required DbAccount account,
|
||||||
required List<String> mimes,
|
required List<String> mimes,
|
||||||
|
required String ownerId,
|
||||||
}) async {
|
}) async {
|
||||||
return _db.use((db) async {
|
return _db.use((db) async {
|
||||||
return await db.countFiles(
|
return await db.countFiles(
|
||||||
account: ByAccount.db(account),
|
account: ByAccount.db(account),
|
||||||
isMissingMetadata: true,
|
isMissingMetadata: true,
|
||||||
mimes: mimes,
|
mimes: mimes,
|
||||||
|
ownerId: ownerId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<DbFileMissingMetadataResult> getFilesByMissingMetadata({
|
||||||
|
required DbAccount account,
|
||||||
|
required List<String> mimes,
|
||||||
|
required String ownerId,
|
||||||
|
}) async {
|
||||||
|
return _db.use((db) async {
|
||||||
|
final results = <({int fileId, String relativePath})>[];
|
||||||
|
var i = 0;
|
||||||
|
while (true) {
|
||||||
|
final sqlObjs = await db.queryFileIdPathsByMissingMetadata(
|
||||||
|
account: ByAccount.db(account),
|
||||||
|
isMissingMetadata: true,
|
||||||
|
mimes: mimes,
|
||||||
|
ownerId: ownerId,
|
||||||
|
limit: 10000,
|
||||||
|
offset: i,
|
||||||
|
);
|
||||||
|
if (sqlObjs.isEmpty) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
results.addAll(sqlObjs);
|
||||||
|
i += 10000;
|
||||||
|
}
|
||||||
|
return DbFileMissingMetadataResult(items: results);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteFile({
|
Future<void> deleteFile({
|
||||||
required DbAccount account,
|
required DbAccount account,
|
||||||
|
@ -472,6 +502,7 @@ class NpDbSqlite implements NpDb {
|
||||||
Future<DbFilesSummary> getFilesSummary({
|
Future<DbFilesSummary> getFilesSummary({
|
||||||
required DbAccount account,
|
required DbAccount account,
|
||||||
List<String>? includeRelativeRoots,
|
List<String>? includeRelativeRoots,
|
||||||
|
List<String>? includeRelativeDirs,
|
||||||
List<String>? excludeRelativeRoots,
|
List<String>? excludeRelativeRoots,
|
||||||
List<String>? mimes,
|
List<String>? mimes,
|
||||||
}) async {
|
}) async {
|
||||||
|
@ -479,6 +510,7 @@ class NpDbSqlite implements NpDb {
|
||||||
return await db.countFileGroupsByDate(
|
return await db.countFileGroupsByDate(
|
||||||
account: ByAccount.db(account),
|
account: ByAccount.db(account),
|
||||||
includeRelativeRoots: includeRelativeRoots,
|
includeRelativeRoots: includeRelativeRoots,
|
||||||
|
includeRelativeDirs: includeRelativeDirs,
|
||||||
excludeRelativeRoots: excludeRelativeRoots,
|
excludeRelativeRoots: excludeRelativeRoots,
|
||||||
mimes: mimes,
|
mimes: mimes,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
|
|
Loading…
Reference in a new issue