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
- 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

View file

@ -1,5 +1,7 @@
package deckers.thibault.aves.model
import java.io.File
enum class NameConflictStrategy {
RENAME, REPLACE, SKIP;
@ -9,4 +11,6 @@ enum class NameConflictStrategy {
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.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictResolution
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.BitmapUtils
@ -147,13 +148,14 @@ abstract class ImageProvider {
val oldFile = File(sourcePath)
if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) {
oldFile.parent?.let { dir ->
resolveTargetFileNameWithoutExtension(
val resolution = resolveTargetFileNameWithoutExtension(
contextWrapper = activity,
dir = dir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType,
conflictStrategy = NameConflictStrategy.RENAME,
)?.let { targetNameWithoutExtension ->
)
resolution.nameWithoutExtension?.let { targetNameWithoutExtension ->
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
val newFile = File(dir, targetFileName)
if (oldFile != newFile) {
@ -266,7 +268,7 @@ abstract class ImageProvider {
exportMimeType: String,
): FieldMap {
val sourceMimeType = sourceEntry.mimeType
val sourceUri = sourceEntry.uri
var sourceUri = sourceEntry.uri
val pageId = sourceEntry.pageId
var desiredNameWithoutExtension = if (sourceEntry.path != null) {
@ -279,13 +281,17 @@ abstract class ImageProvider {
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
}
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
val resolution = resolveTargetFileNameWithoutExtension(
contextWrapper = activity,
dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = exportMimeType,
conflictStrategy = nameConflictStrategy,
) ?: return skippedFieldMap
)
val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap
resolution.replacementFile?.let { file ->
sourceUri = Uri.fromFile(file)
}
val targetMimeType: String
val write: (OutputStream) -> Unit
@ -391,6 +397,8 @@ abstract class ImageProvider {
} finally {
// clearing Glide target should happen after effectively writing the bitmap
Glide.with(activity).clear(target)
resolution.replacementFile?.delete()
}
}
@ -470,7 +478,7 @@ abstract class ImageProvider {
}
val captureMimeType = MimeTypes.JPEG
val targetNameWithoutExtension = try {
val resolution = try {
resolveTargetFileNameWithoutExtension(
contextWrapper = contextWrapper,
dir = targetDir,
@ -483,6 +491,7 @@ abstract class ImageProvider {
return
}
val targetNameWithoutExtension = resolution.nameWithoutExtension
if (targetNameWithoutExtension == null) {
// skip it
callback.onSuccess(skippedFieldMap)
@ -568,10 +577,13 @@ abstract class ImageProvider {
desiredNameWithoutExtension: String,
mimeType: String,
conflictStrategy: NameConflictStrategy,
): String? {
): NameConflictResolution {
var resolvedName: String? = desiredNameWithoutExtension
var replacementFile: File? = null
val extension = extensionFor(mimeType)
val targetFile = File(dir, "$desiredNameWithoutExtension$extension")
return when (conflictStrategy) {
when (conflictStrategy) {
NameConflictStrategy.RENAME -> {
var nameWithoutExtension = desiredNameWithoutExtension
var i = 0
@ -579,24 +591,28 @@ abstract class ImageProvider {
i++
nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
}
nameWithoutExtension
resolvedName = nameWithoutExtension
}
NameConflictStrategy.REPLACE -> {
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)
}
desiredNameWithoutExtension
}
NameConflictStrategy.SKIP -> {
if (targetFile.exists()) {
null
} else {
desiredNameWithoutExtension
resolvedName = null
}
}
}
return NameConflictResolution(resolvedName, replacementFile)
}
// cf `MetadataFetchHandler.getCatalogMetadataByMetadataExtractor()` for a more thorough check

View file

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

View file

@ -134,4 +134,15 @@ class MimeTypes {
}
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/source/collection_lens.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/services.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 source = context.read<CollectionSource>();
source.pauseMonitoring();
@ -79,7 +108,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
selection,
options: options,
destinationAlbum: destinationAlbum,
nameConflictStrategy: NameConflictStrategy.rename,
nameConflictStrategy: nameConflictStrategy,
),
itemCount: selectionCount,
onDone: (processed) async {
@ -91,7 +120,6 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
source.resumeMonitoring();
unawaited(source.refreshUris(newUris));
final l10n = context.l10n;
// get navigator beforehand because
// local context may be deactivated when action is triggered after navigation
final navigator = Navigator.maybeOf(context);
@ -173,7 +201,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
// 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>(