Merge branch 'dev-nc28-exif'

This commit is contained in:
Ming Ming 2024-11-24 00:24:09 +08:00
commit 651a48efa8
57 changed files with 1887 additions and 935 deletions

View file

@ -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

View 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;

View file

@ -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))));
} }

View file

@ -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;
} }

View file

@ -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);
}
}
} }

View file

@ -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,
); );

View file

@ -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;

View file

@ -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));

View file

@ -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;

View file

@ -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) =>

View file

@ -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;
}
}
}

View file

@ -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");

View file

@ -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);
}
}

View file

@ -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();

View file

@ -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;
} }

View file

@ -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 {

View file

@ -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";
} }
} }
} }

View file

@ -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;

View file

@ -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);

View file

@ -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",

View file

@ -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",

View file

@ -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();
}

View file

@ -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}";
}
}

View file

@ -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";

View 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
View 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._();
}

View 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";

View file

@ -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");
} }

View 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);
}

View file

@ -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;
}

View 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;
}

View 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",
];
}

View 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;
}

View 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");
}

View file

@ -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;
}

View file

@ -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");
}

View file

@ -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 {

View 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);
}

View file

@ -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>();

View file

@ -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(),
); );

View file

@ -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 {

View file

@ -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: () {

View file

@ -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(

View file

@ -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(() {

View 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(),
);
});
});
});
}

View file

@ -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],

View file

@ -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;
}

View file

@ -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;
} }

View file

@ -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"}}";
} }
} }

View file

@ -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;
} }

View file

@ -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);
} }

View file

@ -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>&quot;1324f58d4d5c8d81bed6e4ed9d5ea862&quot;</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>&quot;1324f58d4d5c8d81bed6e4ed9d5ea862&quot;</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>&quot;1324f58d4d5c8d81bed6e4ed9d5ea862&quot;</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>&quot;1324f58d4d5c8d81bed6e4ed9d5ea862&quot;</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",
},
),
]);
}

View file

@ -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';

View file

@ -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,
}); });

View file

@ -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}]}";
}
}

View file

@ -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();

View file

@ -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,