ask to rename/replace/skip when converting items with name conflict
This commit is contained in:
parent
d890d9d9ae
commit
87cfae1e9a
6 changed files with 81 additions and 19 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
@ -10,3 +12,5 @@ enum class NameConflictStrategy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class NameConflictResolution(var nameWithoutExtension: String?, var replacementFile: 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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>(
|
||||||
|
|
Loading…
Reference in a new issue