Show file tags in detail pane

This commit is contained in:
Ming Ming 2022-01-29 03:34:38 +08:00
parent 64b1d92927
commit 693e7d6a58
9 changed files with 411 additions and 1 deletions

View file

@ -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<Response> 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 = <String, String>{
"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");
}

View file

@ -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>? shareRepo,
OrNull<ShareeRepo>? shareeRepo,
OrNull<FavoriteRepo>? favoriteRepo,
OrNull<TagRepo>? tagRepo,
OrNull<AppDb>? appDb,
OrNull<Pref>? 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;

107
lib/entity/tag.dart Normal file
View file

@ -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<bool>? userVisible,
OrNull<bool>? userAssignable,
OrNull<bool>? 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<List<Tag>> listByFile(Account account, File file) =>
dataSrc.listByFile(account, file);
final TagDataSource dataSrc;
}
abstract class TagDataSource {
/// List all tags associated with [file]
Future<List<Tag>> listByFile(Account account, File file);
}

View file

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

View file

@ -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<Favorite> parseFavorites(XmlDocument xml) =>
_parse<Favorite>(xml, _toFavorite);
List<Tag> parseTags(XmlDocument xml) => _parse<Tag>(xml, _toTag);
Map<String, String> get namespaces => _namespaces;
List<T> _parse<T>(XmlDocument xml, T Function(XmlElement) mapper) {
List<T> _parse<T>(XmlDocument xml, T? Function(XmlElement) mapper) {
_namespaces = _parseNamespaces(xml);
final body = () {
try {
@ -174,6 +177,56 @@ class WebdavResponseParser {
);
}
/// Map <DAV:response> 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<XmlElement>()) {
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<XmlElement>()
.firstWhere((element) => element.matchQualifiedName("status",
prefix: "DAV:", namespaces: _namespaces))
.innerText;
if (!status.contains(" 200 ")) {
continue;
}
final prop = child.children.whereType<XmlElement>().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 <DAV:prop> element contents
void parse(XmlElement element) {
for (final child in element.children.whereType<XmlElement>()) {
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<String, String> 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, {

View file

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

View file

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

View file

@ -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<List<Tag>> call(Account account, File file) =>
_c.tagRepo.listByFile(account, file);
final DiContainer _c;
}

View file

@ -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<ViewerDetailPane> {
_initMetadata();
}
}
_initTags();
// postpone loading map to improve responsiveness
Future.delayed(const Duration(milliseconds: 750)).then((_) {
@ -196,6 +198,46 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
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<ViewerDetailPane> {
}
}
Future<void> _initTags() async {
final c = KiwiContainer().resolve<DiContainer>();
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<void> _onRemoveFromAlbumPressed(BuildContext context) async {
assert(widget.album!.provider is AlbumStaticProvider);
try {
@ -477,6 +529,8 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
int? _isoSpeedRatings;
Tuple2<double, double>? _gps;
final _tags = <String>[];
late final bool _canRemoveFromAlbum = _checkCanRemoveFromAlbum();
var _shouldBlockGpsMap = true;