ask to rename/replace/skip when converting items with name conflict

This commit is contained in:
Thibault Deckers 2024-06-22 17:21:41 +02:00
parent d890d9d9ae
commit 87cfae1e9a
6 changed files with 81 additions and 19 deletions

View file

@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Collection: stack RAW and JPEG with same file names - Collection: stack RAW and JPEG with same file names
- Collection: ask to rename/replace/skip when converting items with name conflict
## <a id="v1.11.3"></a>[v1.11.3] - 2024-06-17 ## <a id="v1.11.3"></a>[v1.11.3] - 2024-06-17

View file

@ -1,5 +1,7 @@
package deckers.thibault.aves.model package deckers.thibault.aves.model
import java.io.File
enum class NameConflictStrategy { enum class NameConflictStrategy {
RENAME, REPLACE, SKIP; RENAME, REPLACE, SKIP;
@ -9,4 +11,6 @@ enum class NameConflictStrategy {
return valueOf(name.uppercase()) return valueOf(name.uppercase())
} }
} }
} }
class NameConflictResolution(var nameWithoutExtension: String?, var replacementFile: File?)

View file

@ -41,6 +41,7 @@ import deckers.thibault.aves.metadata.xmp.GoogleXMP
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictResolution
import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils
@ -147,13 +148,14 @@ abstract class ImageProvider {
val oldFile = File(sourcePath) val oldFile = File(sourcePath)
if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) { if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) {
oldFile.parent?.let { dir -> oldFile.parent?.let { dir ->
resolveTargetFileNameWithoutExtension( val resolution = resolveTargetFileNameWithoutExtension(
contextWrapper = activity, contextWrapper = activity,
dir = dir, dir = dir,
desiredNameWithoutExtension = desiredNameWithoutExtension, desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType, mimeType = mimeType,
conflictStrategy = NameConflictStrategy.RENAME, conflictStrategy = NameConflictStrategy.RENAME,
)?.let { targetNameWithoutExtension -> )
resolution.nameWithoutExtension?.let { targetNameWithoutExtension ->
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}" val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
val newFile = File(dir, targetFileName) val newFile = File(dir, targetFileName)
if (oldFile != newFile) { if (oldFile != newFile) {
@ -266,7 +268,7 @@ abstract class ImageProvider {
exportMimeType: String, exportMimeType: String,
): FieldMap { ): FieldMap {
val sourceMimeType = sourceEntry.mimeType val sourceMimeType = sourceEntry.mimeType
val sourceUri = sourceEntry.uri var sourceUri = sourceEntry.uri
val pageId = sourceEntry.pageId val pageId = sourceEntry.pageId
var desiredNameWithoutExtension = if (sourceEntry.path != null) { var desiredNameWithoutExtension = if (sourceEntry.path != null) {
@ -279,13 +281,17 @@ abstract class ImageProvider {
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
} }
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( val resolution = resolveTargetFileNameWithoutExtension(
contextWrapper = activity, contextWrapper = activity,
dir = targetDir, dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension, desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = exportMimeType, mimeType = exportMimeType,
conflictStrategy = nameConflictStrategy, conflictStrategy = nameConflictStrategy,
) ?: return skippedFieldMap )
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
resolution.replacementFile?.let { file ->
sourceUri = Uri.fromFile(file)
}
val targetMimeType: String val targetMimeType: String
val write: (OutputStream) -> Unit val write: (OutputStream) -> Unit
@ -391,6 +397,8 @@ abstract class ImageProvider {
} finally { } finally {
// clearing Glide target should happen after effectively writing the bitmap // clearing Glide target should happen after effectively writing the bitmap
Glide.with(activity).clear(target) Glide.with(activity).clear(target)
resolution.replacementFile?.delete()
} }
} }
@ -470,7 +478,7 @@ abstract class ImageProvider {
} }
val captureMimeType = MimeTypes.JPEG val captureMimeType = MimeTypes.JPEG
val targetNameWithoutExtension = try { val resolution = try {
resolveTargetFileNameWithoutExtension( resolveTargetFileNameWithoutExtension(
contextWrapper = contextWrapper, contextWrapper = contextWrapper,
dir = targetDir, dir = targetDir,
@ -483,6 +491,7 @@ abstract class ImageProvider {
return return
} }
val targetNameWithoutExtension = resolution.nameWithoutExtension
if (targetNameWithoutExtension == null) { if (targetNameWithoutExtension == null) {
// skip it // skip it
callback.onSuccess(skippedFieldMap) callback.onSuccess(skippedFieldMap)
@ -568,10 +577,13 @@ abstract class ImageProvider {
desiredNameWithoutExtension: String, desiredNameWithoutExtension: String,
mimeType: String, mimeType: String,
conflictStrategy: NameConflictStrategy, conflictStrategy: NameConflictStrategy,
): String? { ): NameConflictResolution {
var resolvedName: String? = desiredNameWithoutExtension
var replacementFile: File? = null
val extension = extensionFor(mimeType) val extension = extensionFor(mimeType)
val targetFile = File(dir, "$desiredNameWithoutExtension$extension") val targetFile = File(dir, "$desiredNameWithoutExtension$extension")
return when (conflictStrategy) { when (conflictStrategy) {
NameConflictStrategy.RENAME -> { NameConflictStrategy.RENAME -> {
var nameWithoutExtension = desiredNameWithoutExtension var nameWithoutExtension = desiredNameWithoutExtension
var i = 0 var i = 0
@ -579,24 +591,28 @@ abstract class ImageProvider {
i++ i++
nameWithoutExtension = "$desiredNameWithoutExtension ($i)" nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
} }
nameWithoutExtension resolvedName = nameWithoutExtension
} }
NameConflictStrategy.REPLACE -> { NameConflictStrategy.REPLACE -> {
if (targetFile.exists()) { if (targetFile.exists()) {
// move replaced file to temp storage
// so that it can be used as a source for conversion or metadata copy
replacementFile = StorageUtils.createTempFile(contextWrapper).apply {
targetFile.transferTo(outputStream())
}
deletePath(contextWrapper, targetFile.path, mimeType) deletePath(contextWrapper, targetFile.path, mimeType)
} }
desiredNameWithoutExtension
} }
NameConflictStrategy.SKIP -> { NameConflictStrategy.SKIP -> {
if (targetFile.exists()) { if (targetFile.exists()) {
null resolvedName = null
} else {
desiredNameWithoutExtension
} }
} }
} }
return NameConflictResolution(resolvedName, replacementFile)
} }
// cf `MetadataFetchHandler.getCatalogMetadataByMetadataExtractor()` for a more thorough check // cf `MetadataFetchHandler.getCatalogMetadataByMetadataExtractor()` for a more thorough check

View file

@ -562,13 +562,14 @@ class MediaStoreImageProvider : ImageProvider() {
} }
val desiredNameWithoutExtension = desiredName.substringBeforeLast(".") val desiredNameWithoutExtension = desiredName.substringBeforeLast(".")
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( val resolution = resolveTargetFileNameWithoutExtension(
contextWrapper = activity, contextWrapper = activity,
dir = targetDir, dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension, desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType, mimeType = mimeType,
conflictStrategy = nameConflictStrategy, conflictStrategy = nameConflictStrategy,
) ?: return skippedFieldMap )
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri) val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
val targetPath = createSingle( val targetPath = createSingle(

View file

@ -134,4 +134,15 @@ class MimeTypes {
} }
return null; return null;
} }
static const Map<String, String> _defaultExtensions = {
bmp: '.bmp',
gif: '.gif',
jpeg: '.jpg',
png: '.png',
svg: '.svg',
webp: '.webp',
};
static String? extensionFor(String mimeType) => _defaultExtensions[mimeType];
} }

View file

@ -13,6 +13,7 @@ import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
@ -70,6 +71,34 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
} }
}); });
final l10n = context.l10n;
var nameConflictStrategy = NameConflictStrategy.rename;
final destinationDirectory = Directory(destinationAlbum);
final destinationExtension = MimeTypes.extensionFor(options.mimeType);
final names = [
...selection.map((v) => '${v.filenameWithoutExtension}$destinationExtension'),
// do not guard up front based on directory existence,
// as conflicts could be within moved entries scattered across multiple albums
if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)),
].map((v) => v.toLowerCase()).toList();
// case insensitive comparison
final uniqueNames = names.toSet();
if (uniqueNames.length < names.length) {
final value = await showDialog<NameConflictStrategy>(
context: context,
builder: (context) => AvesSingleSelectionDialog<NameConflictStrategy>(
initialValue: nameConflictStrategy,
options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))),
message: l10n.nameConflictDialogSingleSourceMessage,
confirmationButtonLabel: l10n.continueButtonLabel,
),
routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName),
);
if (value == null) return;
nameConflictStrategy = value;
}
final selectionCount = selection.length; final selectionCount = selection.length;
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
source.pauseMonitoring(); source.pauseMonitoring();
@ -79,7 +108,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
selection, selection,
options: options, options: options,
destinationAlbum: destinationAlbum, destinationAlbum: destinationAlbum,
nameConflictStrategy: NameConflictStrategy.rename, nameConflictStrategy: nameConflictStrategy,
), ),
itemCount: selectionCount, itemCount: selectionCount,
onDone: (processed) async { onDone: (processed) async {
@ -91,7 +120,6 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
source.resumeMonitoring(); source.resumeMonitoring();
unawaited(source.refreshUris(newUris)); unawaited(source.refreshUris(newUris));
final l10n = context.l10n;
// get navigator beforehand because // get navigator beforehand because
// local context may be deactivated when action is triggered after navigation // local context may be deactivated when action is triggered after navigation
final navigator = Navigator.maybeOf(context); final navigator = Navigator.maybeOf(context);
@ -173,7 +201,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
// do not guard up front based on directory existence, // do not guard up front based on directory existence,
// as conflicts could be within moved entries scattered across multiple albums // as conflicts could be within moved entries scattered across multiple albums
if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)), if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)),
]; ].map((v) => v.toLowerCase()).toList();
// case insensitive comparison
final uniqueNames = names.toSet(); final uniqueNames = names.toSet();
if (uniqueNames.length < names.length) { if (uniqueNames.length < names.length) {
final value = await showDialog<NameConflictStrategy>( final value = await showDialog<NameConflictStrategy>(