import 'dart:typed_data'; import 'package:clock/clock.dart'; import 'package:equatable/equatable.dart'; import 'package:logging/logging.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/file_descriptor.dart'; 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/or_null.dart'; import 'package:np_codegen/np_codegen.dart'; import 'package:np_common/ci_string.dart'; import 'package:np_common/string_extension.dart'; import 'package:np_common/type.dart'; import 'package:to_string/to_string.dart'; part 'file.g.dart'; int compareFileDateTimeDescending(File x, File y) => compareFileDescriptorDateTimeDescending(x, y); @ToString(ignoreNull: true) class ImageLocation with EquatableMixin { const ImageLocation({ this.version = appVersion, required this.name, required this.latitude, required this.longitude, required this.countryCode, this.admin1, this.admin2, }); factory ImageLocation.empty() => const ImageLocation( name: null, latitude: null, longitude: null, countryCode: null); static ImageLocation fromJson(JsonObj json) { return ImageLocation( version: json["v"], name: json["name"], latitude: json["lat"] == null ? null : json["lat"] / 10000, longitude: json["lng"] == null ? null : json["lng"] / 10000, countryCode: json["cc"], admin1: json["admin1"], admin2: json["admin2"], ); } JsonObj toJson() => { "v": version, if (name != null) "name": name, if (latitude != null) "lat": (latitude! * 10000).round(), if (longitude != null) "lng": (longitude! * 10000).round(), if (countryCode != null) "cc": countryCode, if (admin1 != null) "admin1": admin1, if (admin2 != null) "admin2": admin2, }; bool isEmpty() => name == null; @override String toString() => _$toString(); @override get props => [ version, name, latitude, longitude, countryCode, admin1, admin2, ]; final int version; final String? name; final double? latitude; final double? longitude; final String? countryCode; final String? admin1; final String? admin2; static const appVersion = 1; } /// Immutable object that hold metadata of a [File] @npLog @ToString(ignoreNull: true) class Metadata with EquatableMixin { Metadata({ DateTime? lastUpdated, this.fileEtag, this.imageWidth, this.imageHeight, this.exif, }) : lastUpdated = (lastUpdated ?? clock.now()).toUtc(); @override // ignore: hash_and_equals bool operator ==(Object? other) => equals(other, isDeep: true); bool equals(Object? other, {bool isDeep = false}) { if (other is Metadata) { return super == other && (exif == null) == (other.exif == null) && (exif?.equals(other.exif, isDeep: isDeep) ?? true); } else { return false; } } /// Parse Metadata from [json] /// /// If the version saved in json does not match the active one, the /// corresponding upgrader will be called one by one to upgrade the json, /// version by version until it reached the active version. If any upgrader /// in the chain is null, the upgrade process will fail static Metadata? fromJson( JsonObj json, { required MetadataUpgraderV1? upgraderV1, required MetadataUpgraderV2? upgraderV2, required MetadataUpgraderV3? upgraderV3, }) { final jsonVersion = json["version"]; JsonObj? result = json; if (jsonVersion < 2) { result = upgraderV1?.call(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; } } if (jsonVersion < 3) { result = upgraderV2?.call(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; } } if (jsonVersion < 4) { result = upgraderV3?.call(result); if (result == null) { _log.info("[fromJson] Version $jsonVersion not compatible"); return null; } } return Metadata( lastUpdated: result["lastUpdated"] == null ? null : DateTime.parse(result["lastUpdated"]), fileEtag: result["fileEtag"], imageWidth: result["imageWidth"], imageHeight: result["imageHeight"], exif: result["exif"] == null ? null : Exif.fromJson(result["exif"].cast()), ); } JsonObj toJson() { return { "version": version, "lastUpdated": lastUpdated.toIso8601String(), if (fileEtag != null) "fileEtag": fileEtag, if (imageWidth != null) "imageWidth": imageWidth, if (imageHeight != null) "imageHeight": imageHeight, if (exif != null) "exif": exif!.toJson(), }; } Metadata copyWith({ OrNull? lastUpdated, String? fileEtag, int? imageWidth, int? imageHeight, Exif? exif, }) { return Metadata( lastUpdated: lastUpdated == null ? null : (lastUpdated.obj ?? this.lastUpdated), fileEtag: fileEtag ?? this.fileEtag, imageWidth: imageWidth ?? this.imageWidth, imageHeight: imageHeight ?? this.imageHeight, exif: exif ?? this.exif, ); } @override String toString() => _$toString(); @override get props => [ lastUpdated, fileEtag, imageWidth, imageHeight, // exif is handled separately, see [equals] ]; final DateTime lastUpdated; /// Etag of the parent file when the metadata is saved final String? fileEtag; final int? imageWidth; final int? imageHeight; final Exif? exif; /// versioning of this class, use to upgrade old persisted metadata static const version = 4; static final _log = _$MetadataNpLog.log; } abstract class MetadataUpgrader { JsonObj? call(JsonObj json); } /// Upgrade v1 Metadata to v2 @npLog class MetadataUpgraderV1 implements MetadataUpgrader { MetadataUpgraderV1({ required this.fileContentType, this.logFilePath, }); @override JsonObj? call(JsonObj json) { if (fileContentType == "image/webp") { // Version 1 metadata for webp is bugged, drop it _log.fine("[call] Upgrade v1 metadata for file: $logFilePath"); return null; } else { return json; } } final String? fileContentType; /// File path for logging only final String? logFilePath; } /// Upgrade v2 Metadata to v3 @npLog class MetadataUpgraderV2 implements MetadataUpgrader { MetadataUpgraderV2({ required this.fileContentType, this.logFilePath, }); @override JsonObj? call(JsonObj json) { if (fileContentType == "image/jpeg") { // Version 2 metadata for jpeg doesn't consider orientation if (json["exif"] != null && json["exif"].containsKey("Orientation")) { // Check orientation final orientation = json["exif"]["Orientation"]; if (orientation >= 5 && orientation <= 8) { _log.fine("[call] Upgrade v2 metadata for file: $logFilePath"); final temp = json["imageWidth"]; json["imageWidth"] = json["imageHeight"]; json["imageHeight"] = temp; } } } return json; } final String? fileContentType; /// File path for logging only final String? logFilePath; } /// Upgrade v3 Metadata to v4 @npLog class MetadataUpgraderV3 implements MetadataUpgrader { const MetadataUpgraderV3({ required this.fileContentType, this.logFilePath, }); @override JsonObj? call(JsonObj json) { if (fileContentType == "image/heic") { // Version 3 metadata for heic may incorrectly have exif as null due to a // bug in exifdart if (json["exif"] == null) { _log.fine("[call] Remove v3 metadata for file: $logFilePath"); // return null to let the app parse the file again return null; } } return json; } final String? fileContentType; /// File path for logging only final String? logFilePath; } @ToString(ignoreNull: true) class File with EquatableMixin implements FileDescriptor { File({ required String path, this.contentLength, this.contentType, this.etag, this.lastModified, this.isCollection, this.usedBytes, this.hasPreview, this.fileId, this.isFavorite, this.ownerId, this.ownerDisplayName, this.metadata, this.isArchived, this.overrideDateTime, this.trashbinFilename, this.trashbinOriginalLocation, this.trashbinDeletionTime, this.location, }) : path = path.trimAny("/"); @override // ignore: hash_and_equals bool operator ==(Object? other) => equals(other, isDeep: true); bool equals(Object? other, {bool isDeep = false}) { if (other is File) { return super == other && (metadata == null) == (other.metadata == null) && (metadata?.equals(other.metadata, isDeep: isDeep) ?? true); } else { return false; } } factory File.fromJson(JsonObj json) { return File( path: json["path"], contentLength: json["contentLength"], contentType: json["contentType"], etag: json["etag"], lastModified: json["lastModified"] == null ? null : DateTime.parse(json["lastModified"]), isCollection: json["isCollection"], usedBytes: json["usedBytes"], hasPreview: json["hasPreview"], fileId: json["fileId"], isFavorite: json_util.boolFromJson(json["isFavorite"]), ownerId: json["ownerId"] == null ? null : CiString(json["ownerId"]), ownerDisplayName: json["ownerDisplayName"], trashbinFilename: json["trashbinFilename"], trashbinOriginalLocation: json["trashbinOriginalLocation"], trashbinDeletionTime: json["trashbinDeletionTime"] == null ? null : DateTime.parse(json["trashbinDeletionTime"]), metadata: json["metadata"] == null ? null : Metadata.fromJson( json["metadata"].cast(), upgraderV1: MetadataUpgraderV1( fileContentType: json["contentType"], logFilePath: json["path"], ), upgraderV2: MetadataUpgraderV2( fileContentType: json["contentType"], logFilePath: json["path"], ), upgraderV3: MetadataUpgraderV3( fileContentType: json["contentType"], logFilePath: json["path"], ), ), isArchived: json["isArchived"], overrideDateTime: json["overrideDateTime"] == null ? null : DateTime.parse(json["overrideDateTime"]), location: json["location"] == null ? null : ImageLocation.fromJson(json["location"]), ); } @override String toString() => _$toString(); JsonObj toJson() { return { "path": path, if (contentLength != null) "contentLength": contentLength, if (contentType != null) "contentType": contentType, if (etag != null) "etag": etag, if (lastModified != null) "lastModified": lastModified!.toUtc().toIso8601String(), if (isCollection != null) "isCollection": isCollection, if (usedBytes != null) "usedBytes": usedBytes, if (hasPreview != null) "hasPreview": hasPreview, if (fileId != null) "fileId": fileId, if (isFavorite != null) "isFavorite": json_util.boolToJson(isFavorite), if (ownerId != null) "ownerId": ownerId.toString(), if (ownerDisplayName != null) "ownerDisplayName": ownerDisplayName, if (trashbinFilename != null) "trashbinFilename": trashbinFilename, if (trashbinOriginalLocation != null) "trashbinOriginalLocation": trashbinOriginalLocation, if (trashbinDeletionTime != null) "trashbinDeletionTime": trashbinDeletionTime!.toUtc().toIso8601String(), if (metadata != null) "metadata": metadata!.toJson(), if (isArchived != null) "isArchived": isArchived, if (overrideDateTime != null) "overrideDateTime": overrideDateTime!.toUtc().toIso8601String(), if (location != null) "location": location!.toJson(), }; } @override JsonObj toFdJson() => FileDescriptor.toJson(this); File copyWith({ String? path, int? contentLength, String? contentType, OrNull? etag, DateTime? lastModified, bool? isCollection, int? usedBytes, bool? hasPreview, int? fileId, bool? isFavorite, CiString? ownerId, String? ownerDisplayName, String? trashbinFilename, String? trashbinOriginalLocation, DateTime? trashbinDeletionTime, OrNull? metadata, OrNull? isArchived, OrNull? overrideDateTime, OrNull? location, }) { return File( path: path ?? this.path, contentLength: contentLength ?? this.contentLength, contentType: contentType ?? this.contentType, etag: etag == null ? this.etag : etag.obj, lastModified: lastModified ?? this.lastModified, isCollection: isCollection ?? this.isCollection, usedBytes: usedBytes ?? this.usedBytes, hasPreview: hasPreview ?? this.hasPreview, fileId: fileId ?? this.fileId, isFavorite: isFavorite ?? this.isFavorite, ownerId: ownerId ?? this.ownerId, ownerDisplayName: ownerDisplayName ?? this.ownerDisplayName, trashbinFilename: trashbinFilename ?? this.trashbinFilename, trashbinOriginalLocation: trashbinOriginalLocation ?? this.trashbinOriginalLocation, trashbinDeletionTime: trashbinDeletionTime ?? this.trashbinDeletionTime, metadata: metadata == null ? this.metadata : metadata.obj, isArchived: isArchived == null ? this.isArchived : isArchived.obj, overrideDateTime: overrideDateTime == null ? this.overrideDateTime : overrideDateTime.obj, location: location == null ? this.location : location.obj, ); } @override get props => [ path, contentLength, contentType, etag, lastModified, isCollection, usedBytes, hasPreview, fileId, isFavorite, ownerId, ownerDisplayName, trashbinFilename, trashbinOriginalLocation, trashbinDeletionTime, // metadata is handled separately, see [equals] isArchived, overrideDateTime, location, ]; @override get fdPath => path; @override get fdId => fileId!; @override get fdMime => contentType; @override get fdIsArchived => isArchived ?? false; @override get fdIsFavorite => isFavorite ?? false; @override get fdDateTime => bestDateTime; final String path; final int? contentLength; final String? contentType; final String? etag; final DateTime? lastModified; final bool? isCollection; final int? usedBytes; final bool? hasPreview; final int? fileId; final bool? isFavorite; final CiString? ownerId; final String? ownerDisplayName; final String? trashbinFilename; final String? trashbinOriginalLocation; final DateTime? trashbinDeletionTime; // metadata final Metadata? metadata; final bool? isArchived; final DateTime? overrideDateTime; final ImageLocation? location; } extension FileExtension on File { DateTime get bestDateTime => file_util.getBestDateTime( overrideDateTime: overrideDateTime, dateTimeOriginal: metadata?.exif?.dateTimeOriginal, lastModified: lastModified, ); bool isOwned(CiString userId) => ownerId == null || ownerId == userId; FileDescriptor toDescriptor() => FileDescriptor( fdPath: path, fdId: fileId!, fdMime: contentType, fdIsArchived: isArchived ?? false, fdIsFavorite: isFavorite ?? false, fdDateTime: bestDateTime, ); } class FileServerIdentityComparator { const FileServerIdentityComparator(this.file); @override operator ==(Object other) { if (other is FileServerIdentityComparator) { return file.compareServerIdentity(other.file); } else if (other is File) { return file.compareServerIdentity(other); } else { return false; } } @override get hashCode => file.fileId?.hashCode ?? file.path.hashCode; final File file; } class FileRepo { const FileRepo(this.dataSrc); /// See [FileDataSource.list] Future> list(Account account, File root) => dataSrc.list(account, root); /// See [FileDataSource.listSingle] Future listSingle(Account account, File root) => dataSrc.listSingle(account, root); /// See [FileDataSource.listMinimal] Future> listMinimal(Account account, File dir) => dataSrc.listMinimal(account, dir); /// See [FileDataSource.remove] Future remove(Account account, FileDescriptor file) => dataSrc.remove(account, file); /// See [FileDataSource.getBinary] Future getBinary(Account account, File file) => dataSrc.getBinary(account, file); /// See [FileDataSource.putBinary] Future putBinary(Account account, String path, Uint8List content) => dataSrc.putBinary(account, path, content); /// See [FileDataSource.updateMetadata] Future updateProperty( Account account, File file, { OrNull? metadata, OrNull? isArchived, OrNull? overrideDateTime, bool? favorite, OrNull? location, }) => dataSrc.updateProperty( account, file, metadata: metadata, isArchived: isArchived, overrideDateTime: overrideDateTime, favorite: favorite, location: location, ); /// See [FileDataSource.copy] Future copy( Account account, File f, String destination, { bool? shouldOverwrite, }) => dataSrc.copy( account, f, destination, shouldOverwrite: shouldOverwrite, ); /// See [FileDataSource.move] Future move( Account account, File f, String destination, { bool? shouldOverwrite, }) => dataSrc.move( account, f, destination, shouldOverwrite: shouldOverwrite, ); /// See [FileDataSource.createDir] Future createDir(Account account, String path) => dataSrc.createDir(account, path); final FileDataSource dataSrc; } abstract class FileDataSource { /// List all files under [dir] Future> list(Account account, File dir); /// List a single file [f] Future listSingle(Account account, File f); /// List all files under [dir] with minimal data /// /// Only the following file data is guaranteed to be returned: /// - path /// - contentType /// - lastModified /// - isCollection /// - fileId Future> listMinimal(Account account, File dir); /// Remove file Future remove(Account account, FileDescriptor f); /// Read file as binary array Future getBinary(Account account, File f); /// Upload content to [path] Future putBinary(Account account, String path, Uint8List content); /// Update one or more properties of a file Future updateProperty( Account account, File f, { OrNull? metadata, OrNull? isArchived, OrNull? overrideDateTime, bool? favorite, OrNull? location, }); /// Copy [f] to [destination] /// /// [destination] should be a relative WebDAV path like /// remote.php/dav/files/admin/new/location Future copy( Account account, File f, String destination, { bool? shouldOverwrite, }); /// Move [f] to [destination] /// /// [destination] should be a relative WebDAV path like /// remote.php/dav/files/admin/new/location Future move( Account account, File f, String destination, { bool? shouldOverwrite, }); /// Create a directory at [path] /// /// [path] should be a relative WebDAV path like /// remote.php/dav/files/admin/new/dir Future createDir(Account account, String path); }