diff --git a/lib/api/api.dart b/lib/api/api.dart index 2bee4105..0e5c80db 100644 --- a/lib/api/api.dart +++ b/lib/api/api.dart @@ -33,6 +33,7 @@ class Api { _Files files() => _Files(this); _Ocs ocs() => _Ocs(this); + _SystemtagsRelations systemtagsRelations() => _SystemtagsRelations(this); static String getAuthorizationHeaderValue(Account account) { final auth = @@ -716,3 +717,82 @@ class _OcsFilesSharingSharees { static final _log = Logger("api.api._OcsFilesSharingSharees"); } + +class _SystemtagsRelations { + const _SystemtagsRelations(this.api); + + _SystemtagsRelationsFiles files(int fileId) => + _SystemtagsRelationsFiles(this, fileId); + + final Api api; +} + +class _SystemtagsRelationsFiles { + const _SystemtagsRelationsFiles(this.systemtagsRelations, this.fileId); + + /// List systemtags associated with a file + /// + /// Warning: this Nextcloud API is undocumented + Future propfind({ + id, + displayName, + userVisible, + userAssignable, + canAssign, + }) async { + final endpoint = "remote.php/dav/systemtags-relations/files/$fileId"; + try { + if (id == null && + displayName == null && + userVisible == null && + userAssignable == null && + canAssign == null) { + // no body + return await systemtagsRelations.api.request("PROPFIND", endpoint); + } + + final namespaces = { + "DAV:": "d", + "http://owncloud.org/ns": "oc", + }; + final builder = XmlBuilder(); + builder + ..processing("xml", "version=\"1.0\"") + ..element("d:propfind", namespaces: namespaces, nest: () { + builder.element("d:prop", nest: () { + if (id != null) { + builder.element("oc:id"); + } + if (displayName != null) { + builder.element("oc:display-name"); + } + if (userVisible != null) { + builder.element("oc:user-visible"); + } + if (userAssignable != null) { + builder.element("oc:user-assignable"); + } + if (canAssign != null) { + builder.element("oc:can-assign"); + } + }); + }); + return await systemtagsRelations.api.request( + "PROPFIND", + endpoint, + header: { + "Content-Type": "application/xml", + }, + body: builder.buildDocument().toXmlString(), + ); + } catch (e) { + _log.severe("[propfind] Failed while propfind", e); + rethrow; + } + } + + final _SystemtagsRelations systemtagsRelations; + final int fileId; + + static final _log = Logger("api.api._SystemtagsRelationsFiles"); +} diff --git a/lib/di_container.dart b/lib/di_container.dart index c8d568a0..743deb86 100644 --- a/lib/di_container.dart +++ b/lib/di_container.dart @@ -6,6 +6,7 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/person.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/sharee.dart'; +import 'package:nc_photos/entity/tag.dart'; import 'package:nc_photos/or_null.dart'; import 'package:nc_photos/pref.dart'; @@ -17,6 +18,7 @@ enum DiType { shareRepo, shareeRepo, favoriteRepo, + tagRepo, appDb, pref, } @@ -30,6 +32,7 @@ class DiContainer { ShareRepo? shareRepo, ShareeRepo? shareeRepo, FavoriteRepo? favoriteRepo, + TagRepo? tagRepo, AppDb? appDb, Pref? pref, }) : _albumRepo = albumRepo, @@ -39,6 +42,7 @@ class DiContainer { _shareRepo = shareRepo, _shareeRepo = shareeRepo, _favoriteRepo = favoriteRepo, + _tagRepo = tagRepo, _appDb = appDb, _pref = pref; @@ -58,6 +62,8 @@ class DiContainer { return contianer._shareeRepo != null; case DiType.favoriteRepo: return contianer._favoriteRepo != null; + case DiType.tagRepo: + return contianer._tagRepo != null; case DiType.appDb: return contianer._appDb != null; case DiType.pref: @@ -73,6 +79,7 @@ class DiContainer { OrNull? shareRepo, OrNull? shareeRepo, OrNull? favoriteRepo, + OrNull? tagRepo, OrNull? appDb, OrNull? pref, }) { @@ -84,6 +91,7 @@ class DiContainer { shareRepo: shareRepo == null ? _shareRepo : shareRepo.obj, shareeRepo: shareeRepo == null ? _shareeRepo : shareeRepo.obj, favoriteRepo: favoriteRepo == null ? _favoriteRepo : favoriteRepo.obj, + tagRepo: tagRepo == null ? _tagRepo : tagRepo.obj, appDb: appDb == null ? _appDb : appDb.obj, pref: pref == null ? _pref : pref.obj, ); @@ -96,6 +104,7 @@ class DiContainer { ShareRepo get shareRepo => _shareRepo!; ShareeRepo get shareeRepo => _shareeRepo!; FavoriteRepo get favoriteRepo => _favoriteRepo!; + TagRepo get tagRepo => _tagRepo!; AppDb get appDb => _appDb!; Pref get pref => _pref!; @@ -107,6 +116,7 @@ class DiContainer { final ShareRepo? _shareRepo; final ShareeRepo? _shareeRepo; final FavoriteRepo? _favoriteRepo; + final TagRepo? _tagRepo; final AppDb? _appDb; final Pref? _pref; diff --git a/lib/entity/tag.dart b/lib/entity/tag.dart new file mode 100644 index 00000000..bf9de649 --- /dev/null +++ b/lib/entity/tag.dart @@ -0,0 +1,107 @@ +import 'package:equatable/equatable.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/json_util.dart' as json_util; +import 'package:nc_photos/or_null.dart'; +import 'package:nc_photos/type.dart'; + +class Tag with EquatableMixin { + const Tag({ + required this.id, + required this.displayName, + this.userVisible, + this.userAssignable, + this.canAssign, + }); + + factory Tag.fromJson(JsonObj json) => Tag( + id: json["id"], + displayName: json["displayName"], + userVisible: json_util.boolFromJson(json["userVisible"]), + userAssignable: json_util.boolFromJson(json["userAssignable"]), + canAssign: json_util.boolFromJson(json["canAssign"]), + ); + + JsonObj toJson() => { + "id": id, + "displayName": displayName, + if (userVisible != null) "userVisible": userVisible, + if (userAssignable != null) "userAssignable": userAssignable, + if (canAssign != null) "canAssign": canAssign, + }; + + @override + toString() { + final buffer = StringBuffer(); + buffer.write("$runtimeType {" + "id: $id, " + "displayName: '$displayName', "); + if (userVisible != null) { + buffer.write("userVisible: $userVisible, "); + } + if (userAssignable != null) { + buffer.write("userAssignable: $userAssignable, "); + } + if (canAssign != null) { + buffer.write("canAssign: $canAssign, "); + } + buffer.write("}"); + return buffer.toString(); + } + + Tag copyWith({ + int? id, + String? displayName, + OrNull? userVisible, + OrNull? userAssignable, + OrNull? canAssign, + }) => + Tag( + id: id ?? this.id, + displayName: displayName ?? this.displayName, + userVisible: userVisible == null ? this.userVisible : userVisible.obj, + userAssignable: + userAssignable == null ? this.userAssignable : userAssignable.obj, + canAssign: canAssign == null ? this.canAssign : canAssign.obj, + ); + + @override + get props => [ + id, + displayName, + userVisible, + userAssignable, + canAssign, + ]; + + final int id; + final String displayName; + final bool? userVisible; + final bool? userAssignable; + final bool? canAssign; +} + +extension TagExtension on Tag { + /// Compare the server identity of two Tags + /// + /// Return true if two Tags point to the same tag on server. Be careful that + /// this does NOT mean that the two Tags are identical + bool compareServerIdentity(Tag other) { + return id == other.id && displayName == other.displayName; + } +} + +class TagRepo { + const TagRepo(this.dataSrc); + + /// See [TagDataSource.listByFile] + Future> listByFile(Account account, File file) => + dataSrc.listByFile(account, file); + + final TagDataSource dataSrc; +} + +abstract class TagDataSource { + /// List all tags associated with [file] + Future> listByFile(Account account, File file); +} diff --git a/lib/entity/tag/data_source.dart b/lib/entity/tag/data_source.dart new file mode 100644 index 00000000..e975f068 --- /dev/null +++ b/lib/entity/tag/data_source.dart @@ -0,0 +1,36 @@ +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/tag.dart'; +import 'package:nc_photos/entity/webdav_response_parser.dart'; +import 'package:nc_photos/exception.dart'; +import 'package:xml/xml.dart'; + +class TagRemoteDataSource implements TagDataSource { + const TagRemoteDataSource(); + + @override + listByFile(Account account, File file) async { + _log.info("[listByFile] ${file.path}"); + final response = + await Api(account).systemtagsRelations().files(file.fileId!).propfind( + id: 1, + displayName: 1, + userVisible: 1, + userAssignable: 1, + canAssign: 1, + ); + if (!response.isGood) { + _log.severe("[listByFile] Failed requesting server: $response"); + throw ApiException( + response: response, + message: "Failed communicating with server: ${response.statusCode}"); + } + + final xml = XmlDocument.parse(response.body); + return WebdavResponseParser().parseTags(xml); + } + + static final _log = Logger("entity.tag.data_source.TagRemoteDataSource"); +} diff --git a/lib/entity/webdav_response_parser.dart b/lib/entity/webdav_response_parser.dart index 1a597651..ea605a27 100644 --- a/lib/entity/webdav_response_parser.dart +++ b/lib/entity/webdav_response_parser.dart @@ -5,6 +5,7 @@ import 'package:logging/logging.dart'; import 'package:nc_photos/ci_string.dart'; import 'package:nc_photos/entity/favorite.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/tag.dart'; import 'package:nc_photos/string_extension.dart'; import 'package:xml/xml.dart'; @@ -14,9 +15,11 @@ class WebdavResponseParser { List parseFavorites(XmlDocument xml) => _parse(xml, _toFavorite); + List parseTags(XmlDocument xml) => _parse(xml, _toTag); + Map get namespaces => _namespaces; - List _parse(XmlDocument xml, T Function(XmlElement) mapper) { + List _parse(XmlDocument xml, T? Function(XmlElement) mapper) { _namespaces = _parseNamespaces(xml); final body = () { try { @@ -174,6 +177,56 @@ class WebdavResponseParser { ); } + /// Map contents to Tag + Tag? _toTag(XmlElement element) { + String? path; + int? id; + String? displayName; + bool? userVisible; + bool? userAssignable; + bool? canAssign; + + for (final child in element.children.whereType()) { + if (child.matchQualifiedName("href", + prefix: "DAV:", namespaces: _namespaces)) { + path = _hrefToPath(child); + } else if (child.matchQualifiedName("propstat", + prefix: "DAV:", namespaces: _namespaces)) { + final status = child.children + .whereType() + .firstWhere((element) => element.matchQualifiedName("status", + prefix: "DAV:", namespaces: _namespaces)) + .innerText; + if (!status.contains(" 200 ")) { + continue; + } + final prop = child.children.whereType().firstWhere( + (element) => element.matchQualifiedName("prop", + prefix: "DAV:", namespaces: _namespaces)); + final propParser = + _TagPropParser(namespaces: _namespaces, logFilePath: path); + propParser.parse(prop); + id = propParser.id; + displayName = propParser.displayName; + userVisible = propParser.userVisible; + userAssignable = propParser.userAssignable; + canAssign = propParser.canAssign; + } + } + if (id == null) { + // the first returned item is not a valid tag + return null; + } + + return Tag( + id: id, + displayName: displayName!, + userVisible: userVisible!, + userAssignable: userAssignable!, + canAssign: canAssign!, + ); + } + String _hrefToPath(XmlElement href) { final rawPath = Uri.decodeComponent(href.innerText).trimLeftAny("/"); final pos = rawPath.indexOf("remote.php"); @@ -336,6 +389,52 @@ class _FileIdPropParser { int? _fileId; } +class _TagPropParser { + _TagPropParser({ + this.namespaces = const {}, + this.logFilePath, + }); + + /// Parse element contents + void parse(XmlElement element) { + for (final child in element.children.whereType()) { + if (child.matchQualifiedName("id", + prefix: "http://owncloud.org/ns", namespaces: namespaces)) { + _id = int.parse(child.innerText); + } else if (child.matchQualifiedName("display-name", + prefix: "http://owncloud.org/ns", namespaces: namespaces)) { + _displayName = child.innerText; + } else if (child.matchQualifiedName("user-visible", + prefix: "http://owncloud.org/ns", namespaces: namespaces)) { + _userVisible = child.innerText == "true"; + } else if (child.matchQualifiedName("user-assignable", + prefix: "http://owncloud.org/ns", namespaces: namespaces)) { + _userAssignable = child.innerText == "true"; + } else if (child.matchQualifiedName("can-assign", + prefix: "http://owncloud.org/ns", namespaces: namespaces)) { + _canAssign = child.innerText == "true"; + } + } + } + + int? get id => _id; + String? get displayName => _displayName; + bool? get userVisible => _userVisible; + bool? get userAssignable => _userAssignable; + bool? get canAssign => _canAssign; + + final Map namespaces; + + /// File path for logging only + final String? logFilePath; + + int? _id; + String? _displayName; + bool? _userVisible; + bool? _userAssignable; + bool? _canAssign; +} + extension on XmlElement { bool matchQualifiedName( String local, { diff --git a/lib/main.dart b/lib/main.dart index 7a163390..98d10f7d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,8 @@ import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/share/data_source.dart'; import 'package:nc_photos/entity/sharee.dart'; import 'package:nc_photos/entity/sharee/data_source.dart'; +import 'package:nc_photos/entity/tag.dart'; +import 'package:nc_photos/entity/tag/data_source.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/mobile/android/android_info.dart'; import 'package:nc_photos/mobile/self_signed_cert_manager.dart'; @@ -157,6 +159,7 @@ void _initDiContainer() { shareRepo: ShareRepo(ShareRemoteDataSource()), shareeRepo: ShareeRepo(ShareeRemoteDataSource()), favoriteRepo: const FavoriteRepo(FavoriteRemoteDataSource()), + tagRepo: const TagRepo(TagRemoteDataSource()), appDb: AppDb(), pref: Pref(), )); diff --git a/lib/theme.dart b/lib/theme.dart index 3962a820..7d619e55 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -65,6 +65,11 @@ class AppTheme extends StatelessWidget { : Colors.white60; } + static Color getPrimaryTextColorInverse(BuildContext context) => + Theme.of(context).brightness == Brightness.light + ? primaryTextColorDark + : primaryTextColorLight; + static Color getAppBarDarkModeSwitchColor(BuildContext context) { return Colors.black87; } diff --git a/lib/use_case/list_file_tag.dart b/lib/use_case/list_file_tag.dart new file mode 100644 index 00000000..0d40d737 --- /dev/null +++ b/lib/use_case/list_file_tag.dart @@ -0,0 +1,16 @@ +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/tag.dart'; + +class ListFileTag { + ListFileTag(this._c) : assert(require(_c)); + + static bool require(DiContainer c) => DiContainer.has(c, DiType.tagRepo); + + /// Return list of tags associated with [file] + Future> call(Account account, File file) => + _c.tagRepo.listByFile(account, file); + + final DiContainer _c; +} diff --git a/lib/widget/viewer_detail_pane.dart b/lib/widget/viewer_detail_pane.dart index c349981f..d00c6837 100644 --- a/lib/widget/viewer_detail_pane.dart +++ b/lib/widget/viewer_detail_pane.dart @@ -24,6 +24,7 @@ import 'package:nc_photos/platform/features.dart' as features; import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; +import 'package:nc_photos/use_case/list_file_tag.dart'; import 'package:nc_photos/use_case/remove_from_album.dart'; import 'package:nc_photos/use_case/update_album.dart'; import 'package:nc_photos/use_case/update_property.dart'; @@ -71,6 +72,7 @@ class _ViewerDetailPaneState extends State { _initMetadata(); } } + _initTags(); // postpone loading map to improve responsiveness Future.delayed(const Duration(milliseconds: 750)).then((_) { @@ -196,6 +198,46 @@ class _ViewerDetailPaneState extends State { title: Text(widget.file.ownerId!.toString()), subtitle: Text(L10n.global().fileSharedByDescription), ), + if (_tags.isNotEmpty) + ListTile( + leading: Icon( + Icons.local_offer_outlined, + color: AppTheme.getSecondaryTextColor(context), + ), + title: SizedBox( + height: 40, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _tags.length, + itemBuilder: (context, index) => Center( + child: Wrap( + children: [ + Container( + decoration: BoxDecoration( + color: AppTheme.getUnfocusedIconColor(context), + borderRadius: + const BorderRadius.all(Radius.circular(8)), + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + alignment: Alignment.center, + child: Text( + _tags[index], + style: TextStyle( + fontSize: 12, + color: + AppTheme.getPrimaryTextColorInverse(context), + ), + ), + ), + ], + ), + ), + separatorBuilder: (context, index) => + const SizedBox(width: 8), + ), + ), + ), ListTile( leading: Icon( Icons.calendar_today_outlined, @@ -296,6 +338,16 @@ class _ViewerDetailPaneState extends State { } } + Future _initTags() async { + final c = KiwiContainer().resolve(); + try { + final tags = await ListFileTag(c)(widget.account, widget.file); + _tags.addAll(tags.map((t) => t.displayName)); + } catch (e, stackTrace) { + _log.shout("[_initTags] Failed while ListFileTag", e, stackTrace); + } + } + Future _onRemoveFromAlbumPressed(BuildContext context) async { assert(widget.album!.provider is AlbumStaticProvider); try { @@ -477,6 +529,8 @@ class _ViewerDetailPaneState extends State { int? _isoSpeedRatings; Tuple2? _gps; + final _tags = []; + late final bool _canRemoveFromAlbum = _checkCanRemoveFromAlbum(); var _shouldBlockGpsMap = true;