From 2605a8ab53807d55833b0536bf3cde0be08d40d9 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 16 Nov 2022 20:00:11 +0100 Subject: [PATCH] #403 info: export metadata to text file --- CHANGELOG.md | 1 + .../channel/calls/MetadataFetchHandler.kt | 5 +- lib/l10n/app_en.arb | 1 + lib/model/actions/entry_info_actions.dart | 6 + lib/model/entry_info.dart | 154 +++++++++++++++ .../action/entry_info_action_delegate.dart | 41 ++++ lib/widgets/viewer/info/info_app_bar.dart | 4 +- lib/widgets/viewer/info/info_page.dart | 1 + lib/widgets/viewer/info/info_search.dart | 2 +- .../viewer/info/metadata/metadata_dir.dart | 40 ++++ .../info/metadata/metadata_dir_tile.dart | 2 +- .../info/metadata/metadata_section.dart | 181 +----------------- untranslated.json | 49 ++++- 13 files changed, 302 insertions(+), 185 deletions(-) create mode 100644 lib/model/entry_info.dart create mode 100644 lib/widgets/viewer/info/metadata/metadata_dir.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a44dee6c..48f554795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- Info: export metadata to text file - Accessibility: apply bold font system setting ### Fixed diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt index d780a009e..c23c7d85a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt @@ -216,7 +216,6 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { (it.tagCount > 0 || it.errorCount > 0) && it !is FileTypeDirectory && it !is AviDirectory - && !(it is XmpDirectory && it.tagCount == 1 && it.containsTag(XmpDirectory.TAG_XMP_VALUE_COUNT)) }.groupBy { dir -> dir.name } for (dirEntry in dirByName) { @@ -386,8 +385,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler { fun fallbackProcessXmp(xmpMeta: XMPMeta) { val thisDirName = XmpDirectory().name val dirMap = metadataMap[thisDirName] ?: HashMap() - metadataMap[thisDirName] = dirMap processXmp(xmpMeta, dirMap) + if (dirMap.isNotEmpty()) { + metadataMap[thisDirName] = dirMap + } } XMP.checkHeic(context, mimeType, uri, foundXmp, ::fallbackProcessXmp) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9c0bd807b..4a7d61d98 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -122,6 +122,7 @@ "entryInfoActionEditRating": "Edit rating", "entryInfoActionEditTags": "Edit tags", "entryInfoActionRemoveMetadata": "Remove metadata", + "entryInfoActionExportMetadata": "Export metadata", "filterBinLabel": "Recycle bin", "filterFavouriteLabel": "Favorite", diff --git a/lib/model/actions/entry_info_actions.dart b/lib/model/actions/entry_info_actions.dart index d1b6a67b7..c0857c536 100644 --- a/lib/model/actions/entry_info_actions.dart +++ b/lib/model/actions/entry_info_actions.dart @@ -11,6 +11,7 @@ enum EntryInfoAction { editRating, editTags, removeMetadata, + exportMetadata, // GeoTIFF showGeoTiffOnMap, // motion photo @@ -28,6 +29,7 @@ class EntryInfoActions { EntryInfoAction.editRating, EntryInfoAction.editTags, EntryInfoAction.removeMetadata, + EntryInfoAction.exportMetadata, ]; static const formatSpecific = [ @@ -53,6 +55,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { return context.l10n.entryInfoActionEditTags; case EntryInfoAction.removeMetadata: return context.l10n.entryInfoActionRemoveMetadata; + case EntryInfoAction.exportMetadata: + return context.l10n.entryInfoActionExportMetadata; // GeoTIFF case EntryInfoAction.showGeoTiffOnMap: return context.l10n.entryActionShowGeoTiffOnMap; @@ -96,6 +100,8 @@ extension ExtraEntryInfoAction on EntryInfoAction { return AIcons.editTags; case EntryInfoAction.removeMetadata: return AIcons.clear; + case EntryInfoAction.exportMetadata: + return AIcons.fileExport; // GeoTIFF case EntryInfoAction.showGeoTiffOnMap: return AIcons.map; diff --git a/lib/model/entry_info.dart b/lib/model/entry_info.dart new file mode 100644 index 000000000..d0f6c030b --- /dev/null +++ b/lib/model/entry_info.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:aves/model/entry.dart'; +import 'package:aves/model/video/keys.dart'; +import 'package:aves/model/video/metadata.dart'; +import 'package:aves/ref/mime_types.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/services/metadata/svg_metadata_service.dart'; +import 'package:aves/theme/colors.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +extension ExtraAvesEntryInfo on AvesEntry { + // directory names may contain the name of their parent directory (as prefix + '/') + // directory names may contain an index (as suffix in '[]') + static final directoryNamePattern = RegExp(r'^((?.*?)/)?(?.*?)(\[(?\d+)\])?$'); + + Future>> getMetadataDirectories(BuildContext context) async { + final rawMetadata = await (isSvg ? SvgMetadataService.getAllMetadata(this) : metadataFetchService.getAllMetadata(this)); + final directories = rawMetadata.entries.map((dirKV) { + var directoryName = dirKV.key as String; + + String? parent; + int? index; + final match = directoryNamePattern.firstMatch(directoryName); + if (match != null) { + parent = match.namedGroup('parent'); + final nameMatch = match.namedGroup('name'); + if (nameMatch != null) { + directoryName = nameMatch; + } + final indexMatch = match.namedGroup('index'); + if (indexMatch != null) { + index = int.tryParse(indexMatch); + } + } + + final rawTags = dirKV.value as Map; + return MetadataDirectory( + directoryName, + _toSortedTags(rawTags), + parent: parent, + index: index, + ); + }).toList(); + + if (isVideo || (mimeType == MimeTypes.heif && isMultiPage)) { + directories.addAll(await _getStreamDirectories(context)); + } + + final titledDirectories = directories.map((dir) { + var title = dir.name; + if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) { + title = '${dir.parent}/$title'; + } + if (dir.index != null) { + title += ' ${dir.index}'; + } + return MapEntry(title, dir); + }).toList() + ..sort((a, b) => compareAsciiUpperCase(a.key, b.key)); + + return titledDirectories; + } + + Future> _getStreamDirectories(BuildContext context) async { + final directories = []; + final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(this); + + final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo); + if (formattedMediaTags.isNotEmpty) { + // overwrite generic directory found from the platform side + directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, _toSortedTags(formattedMediaTags))); + } + + if (mediaInfo.containsKey(Keys.streams)) { + String getTypeText(Map stream) { + final type = stream[Keys.streamType] ?? StreamTypes.unknown; + switch (type) { + case StreamTypes.attachment: + return 'Attachment'; + case StreamTypes.audio: + return 'Audio'; + case StreamTypes.metadata: + return 'Metadata'; + case StreamTypes.subtitle: + case StreamTypes.timedText: + return 'Text'; + case StreamTypes.video: + return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image'; + case StreamTypes.unknown: + default: + return 'Unknown'; + } + } + + final allStreams = (mediaInfo[Keys.streams] as List).cast(); + final attachmentStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.attachment).toList(); + final knownStreams = allStreams.whereNot(attachmentStreams.contains); + + // display known streams as separate directories (e.g. video, audio, subs) + if (knownStreams.isNotEmpty) { + final indexDigits = knownStreams.length.toString().length; + + final colors = context.read(); + for (final stream in knownStreams) { + final index = (stream[Keys.index] ?? 0) + 1; + final typeText = getTypeText(stream); + final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')} • $typeText'; + final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream); + if (formattedStreamTags.isNotEmpty) { + final color = colors.fromString(typeText); + directories.add(MetadataDirectory(dirName, _toSortedTags(formattedStreamTags), color: color)); + } + } + } + + // group attachments by format (e.g. TTF fonts) + if (attachmentStreams.isNotEmpty) { + final formatCount = >{}; + for (final stream in attachmentStreams) { + final codec = (stream[Keys.codecName] as String? ?? 'unknown').toUpperCase(); + if (!formatCount.containsKey(codec)) { + formatCount[codec] = []; + } + formatCount[codec]!.add(stream[Keys.filename]); + } + if (formatCount.isNotEmpty) { + final rawTags = formatCount.map((key, value) { + final count = value.length; + // remove duplicate names, so number of displayed names may not match displayed count + final names = value.whereNotNull().toSet().toList()..sort(compareAsciiUpperCase); + return MapEntry(key, '$count items: ${names.join(', ')}'); + }); + directories.add(MetadataDirectory('Attachments', _toSortedTags(rawTags))); + } + } + } + return directories; + } + + SplayTreeMap _toSortedTags(Map rawTags) { + final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) { + var value = (tagKV.value as String? ?? '').trim(); + if (value.isEmpty) return null; + final tagName = tagKV.key as String; + return MapEntry(tagName, value); + }).whereNotNull())); + return tags; + } +} diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index d44c97eca..77c7c4f0a 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -1,11 +1,14 @@ import 'dart:async'; +import 'dart:convert'; import 'package:aves/model/actions/entry_info_actions.dart'; import 'package:aves/model/actions/events.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/entry_info.dart'; import 'package:aves/model/entry_metadata_edition.dart'; import 'package:aves/model/geotiff.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; @@ -39,6 +42,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.editRating: case EntryInfoAction.editTags: case EntryInfoAction.removeMetadata: + case EntryInfoAction.exportMetadata: return true; // GeoTIFF case EntryInfoAction.showGeoTiffOnMap: @@ -68,6 +72,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi return entry.canEditTags; case EntryInfoAction.removeMetadata: return entry.canRemoveMetadata; + case EntryInfoAction.exportMetadata: + return true; // GeoTIFF case EntryInfoAction.showGeoTiffOnMap: return true; @@ -104,6 +110,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi case EntryInfoAction.removeMetadata: await _removeMetadata(context); break; + case EntryInfoAction.exportMetadata: + await _exportMetadata(context); + break; // GeoTIFF case EntryInfoAction.showGeoTiffOnMap: await _showGeoTiffOnMap(context); @@ -169,6 +178,38 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi await edit(context, () => entry.removeMetadata(types)); } + Future _exportMetadata(BuildContext context) async { + final lines = []; + final padding = ' ' * 2; + final titledDirectories = await entry.getMetadataDirectories(context); + titledDirectories.forEach((kv) { + final title = kv.key; + final dir = kv.value; + + lines.add('[$title]'); + final dirContent = dir.allTags; + final tags = dirContent.keys.toList()..sort(); + tags.forEach((tag) { + final value = dirContent[tag]; + lines.add('$padding$tag: $value'); + }); + }); + final metadataString = lines.join('\n'); + + final success = await storageService.createFile( + '${entry.filenameWithoutExtension}-metadata.txt', + MimeTypes.plainText, + Uint8List.fromList(utf8.encode(metadataString)), + ); + if (success != null) { + if (success) { + showFeedback(context, context.l10n.genericSuccessFeedback); + } else { + showFeedback(context, context.l10n.genericFailureFeedback); + } + } + } + Future _convertMotionPhotoToStillImage(BuildContext context) async { final confirmed = await showDialog( context: context, diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index 3290cf091..b28fbbace 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -3,12 +3,12 @@ import 'package:aves/model/entry.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; +import 'package:aves/widgets/common/app_bar/sliver_app_bar_title.dart'; import 'package:aves/widgets/common/basic/menu.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves/widgets/common/app_bar/sliver_app_bar_title.dart'; import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/info/info_search.dart'; -import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 31a4e8a96..5bb4f6384 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -14,6 +14,7 @@ import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; import 'package:aves/widgets/viewer/info/basic_section.dart'; import 'package:aves/widgets/viewer/info/info_app_bar.dart'; import 'package:aves/widgets/viewer/info/location_section.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:aves/widgets/viewer/multipage/conductor.dart'; import 'package:aves/widgets/viewer/notifications.dart'; diff --git a/lib/widgets/viewer/info/info_search.dart b/lib/widgets/viewer/info/info_search.dart index 8e9f4022c..4fc688ead 100644 --- a/lib/widgets/viewer/info/info_search.dart +++ b/lib/widgets/viewer/info/info_search.dart @@ -4,8 +4,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/viewer/embedded/embedded_data_opener.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; -import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/viewer/info/metadata/metadata_dir.dart b/lib/widgets/viewer/info/metadata/metadata_dir.dart new file mode 100644 index 000000000..c2737e8fe --- /dev/null +++ b/lib/widgets/viewer/info/metadata/metadata_dir.dart @@ -0,0 +1,40 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; + +class MetadataDirectory { + final String name; + final Color? color; + final String? parent; + final int? index; + final SplayTreeMap allTags; + final SplayTreeMap tags; + + // special directory names + static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor + static const xmpDirectory = 'XMP'; // from metadata-extractor + static const mediaDirectory = 'Media'; // custom + static const coverDirectory = 'Cover'; // custom + static const geoTiffDirectory = 'GeoTIFF'; // custom + + const MetadataDirectory( + this.name, + this.allTags, { + SplayTreeMap? tags, + this.color, + this.parent, + this.index, + }) : tags = tags ?? allTags; + + MetadataDirectory filterKeys(bool Function(String key) testKey) { + final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key)))); + return MetadataDirectory( + name, + tags, + tags: filteredTags, + color: color, + parent: parent, + index: index, + ); + } +} diff --git a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart index 4afeb60dc..f49c3aee6 100644 --- a/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart +++ b/lib/widgets/viewer/info/metadata/metadata_dir_tile.dart @@ -10,7 +10,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/viewer/embedded/notifications.dart'; import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/metadata/geotiff.dart'; -import 'package:aves/widgets/viewer/info/metadata/metadata_section.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_thumbnail.dart'; import 'package:aves/widgets/viewer/info/metadata/xmp_tile.dart'; import 'package:aves/widgets/viewer/source_viewer_page.dart'; diff --git a/lib/widgets/viewer/info/metadata/metadata_section.dart b/lib/widgets/viewer/info/metadata/metadata_section.dart index ecd0efa25..5bee4740c 100644 --- a/lib/widgets/viewer/info/metadata/metadata_section.dart +++ b/lib/widgets/viewer/info/metadata/metadata_section.dart @@ -1,18 +1,12 @@ import 'dart:async'; -import 'dart:collection'; import 'package:aves/model/entry.dart'; -import 'package:aves/model/video/keys.dart'; -import 'package:aves/model/video/metadata.dart'; -import 'package:aves/ref/mime_types.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:aves/services/metadata/svg_metadata_service.dart'; -import 'package:aves/theme/colors.dart'; +import 'package:aves/model/entry_info.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/viewer/info/common.dart'; +import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart'; import 'package:aves/widgets/viewer/info/metadata/metadata_dir_tile.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; @@ -39,10 +33,6 @@ class _MetadataSectionSliverState extends State { ValueNotifier> get metadataNotifier => widget.metadataNotifier; - // directory names may contain the name of their parent directory (as prefix + '/') - // directory names may contain an index (as suffix in '[]') - static final directoryNamePattern = RegExp(r'^((?.*?)/)?(?.*?)(\[(?\d+)\])?$'); - @override void initState() { super.initState(); @@ -132,173 +122,8 @@ class _MetadataSectionSliverState extends State { } Future _getMetadata() async { - final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : metadataFetchService.getAllMetadata(entry)); - final directories = rawMetadata.entries.map((dirKV) { - var directoryName = dirKV.key as String; - - String? parent; - int? index; - final match = directoryNamePattern.firstMatch(directoryName); - if (match != null) { - parent = match.namedGroup('parent'); - final nameMatch = match.namedGroup('name'); - if (nameMatch != null) { - directoryName = nameMatch; - } - final indexMatch = match.namedGroup('index'); - if (indexMatch != null) { - index = int.tryParse(indexMatch); - } - } - - final rawTags = dirKV.value as Map; - return MetadataDirectory( - directoryName, - _toSortedTags(rawTags), - parent: parent, - index: index, - ); - }).toList(); - - if (entry.isVideo || (entry.mimeType == MimeTypes.heif && entry.isMultiPage)) { - directories.addAll(await _getStreamDirectories()); - } - - final titledDirectories = directories.map((dir) { - var title = dir.name; - if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) { - title = '${dir.parent}/$title'; - } - if (dir.index != null) { - title += ' ${dir.index}'; - } - return MapEntry(title, dir); - }).toList() - ..sort((a, b) => compareAsciiUpperCase(a.key, b.key)); + final titledDirectories = await entry.getMetadataDirectories(context); metadataNotifier.value = Map.fromEntries(titledDirectories); _expandedDirectoryNotifier.value = null; } - - Future> _getStreamDirectories() async { - final directories = []; - final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(entry); - - final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo); - if (formattedMediaTags.isNotEmpty) { - // overwrite generic directory found from the platform side - directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, _toSortedTags(formattedMediaTags))); - } - - if (mediaInfo.containsKey(Keys.streams)) { - String getTypeText(Map stream) { - final type = stream[Keys.streamType] ?? StreamTypes.unknown; - switch (type) { - case StreamTypes.attachment: - return 'Attachment'; - case StreamTypes.audio: - return 'Audio'; - case StreamTypes.metadata: - return 'Metadata'; - case StreamTypes.subtitle: - case StreamTypes.timedText: - return 'Text'; - case StreamTypes.video: - return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image'; - case StreamTypes.unknown: - default: - return 'Unknown'; - } - } - - final allStreams = (mediaInfo[Keys.streams] as List).cast(); - final attachmentStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.attachment).toList(); - final knownStreams = allStreams.whereNot(attachmentStreams.contains); - - // display known streams as separate directories (e.g. video, audio, subs) - if (knownStreams.isNotEmpty) { - final indexDigits = knownStreams.length.toString().length; - - final colors = context.read(); - for (final stream in knownStreams) { - final index = (stream[Keys.index] ?? 0) + 1; - final typeText = getTypeText(stream); - final dirName = 'Stream ${index.toString().padLeft(indexDigits, '0')} • $typeText'; - final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream); - if (formattedStreamTags.isNotEmpty) { - final color = colors.fromString(typeText); - directories.add(MetadataDirectory(dirName, _toSortedTags(formattedStreamTags), color: color)); - } - } - } - - // group attachments by format (e.g. TTF fonts) - if (attachmentStreams.isNotEmpty) { - final formatCount = >{}; - for (final stream in attachmentStreams) { - final codec = (stream[Keys.codecName] as String? ?? 'unknown').toUpperCase(); - if (!formatCount.containsKey(codec)) { - formatCount[codec] = []; - } - formatCount[codec]!.add(stream[Keys.filename]); - } - if (formatCount.isNotEmpty) { - final rawTags = formatCount.map((key, value) { - final count = value.length; - // remove duplicate names, so number of displayed names may not match displayed count - final names = value.whereNotNull().toSet().toList()..sort(compareAsciiUpperCase); - return MapEntry(key, '$count items: ${names.join(', ')}'); - }); - directories.add(MetadataDirectory('Attachments', _toSortedTags(rawTags))); - } - } - } - return directories; - } - - SplayTreeMap _toSortedTags(Map rawTags) { - final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) { - var value = (tagKV.value as String? ?? '').trim(); - if (value.isEmpty) return null; - final tagName = tagKV.key as String; - return MapEntry(tagName, value); - }).whereNotNull())); - return tags; - } -} - -class MetadataDirectory { - final String name; - final Color? color; - final String? parent; - final int? index; - final SplayTreeMap allTags; - final SplayTreeMap tags; - - // special directory names - static const exifThumbnailDirectory = 'Exif Thumbnail'; // from metadata-extractor - static const xmpDirectory = 'XMP'; // from metadata-extractor - static const mediaDirectory = 'Media'; // custom - static const coverDirectory = 'Cover'; // custom - static const geoTiffDirectory = 'GeoTIFF'; // custom - - const MetadataDirectory( - this.name, - this.allTags, { - SplayTreeMap? tags, - this.color, - this.parent, - this.index, - }) : tags = tags ?? allTags; - - MetadataDirectory filterKeys(bool Function(String key) testKey) { - final filteredTags = SplayTreeMap.of(Map.fromEntries(allTags.entries.where((kv) => testKey(kv.key)))); - return MetadataDirectory( - name, - tags, - tags: filteredTags, - color: color, - parent: parent, - index: index, - ); - } } diff --git a/untranslated.json b/untranslated.json index fc2e63b04..eddc579da 100644 --- a/untranslated.json +++ b/untranslated.json @@ -1,8 +1,17 @@ { + "de": [ + "entryInfoActionExportMetadata" + ], + "el": [ + "entryInfoActionExportMetadata", "tagEditorSectionPlaceholders" ], + "es": [ + "entryInfoActionExportMetadata" + ], + "fa": [ "appName", "welcomeMessage", @@ -89,6 +98,7 @@ "entryInfoActionEditRating", "entryInfoActionEditTags", "entryInfoActionRemoveMetadata", + "entryInfoActionExportMetadata", "filterBinLabel", "filterFavouriteLabel", "filterNoDateLabel", @@ -585,7 +595,12 @@ "filePickerUseThisFolder" ], + "fr": [ + "entryInfoActionExportMetadata" + ], + "gl": [ + "entryInfoActionExportMetadata", "accessibilityAnimationsRemove", "accessibilityAnimationsKeep", "displayRefreshRatePreferHighest", @@ -1034,14 +1049,28 @@ "filePickerUseThisFolder" ], + "id": [ + "entryInfoActionExportMetadata" + ], + + "it": [ + "entryInfoActionExportMetadata" + ], + "ja": [ - "chipActionFilterIn" + "chipActionFilterIn", + "entryInfoActionExportMetadata" + ], + + "ko": [ + "entryInfoActionExportMetadata" ], "nb": [ "videoActionCaptureFrame", "videoActionSelectStreams", "entryInfoActionEditLocation", + "entryInfoActionExportMetadata", "coordinateFormatDms", "mapStyleHuaweiNormal", "mapStyleHuaweiTerrain", @@ -1151,12 +1180,17 @@ "tagPlaceholderPlace" ], + "nl": [ + "entryInfoActionExportMetadata" + ], + "pl": [ "itemCount", "timeSeconds", "timeMinutes", "timeDays", "focalLength", + "entryInfoActionExportMetadata", "filterTypeRawLabel", "filterTypeSphericalVideoLabel", "filterTypeGeotiffLabel", @@ -1640,7 +1674,20 @@ "filePickerUseThisFolder" ], + "pt": [ + "entryInfoActionExportMetadata" + ], + + "ru": [ + "entryInfoActionExportMetadata" + ], + + "tr": [ + "entryInfoActionExportMetadata" + ], + "zh": [ + "entryInfoActionExportMetadata", "editEntryLocationDialogSetCustom", "settingsAllowMediaManagement", "tagEditorSectionPlaceholders",