Implement interactive consolidation conflict resolution with merge and rename options

This commit is contained in:
Tiberiu Ichim 2026-02-17 15:48:28 +02:00
parent ca85e4af43
commit 86b036cb7c
7 changed files with 260 additions and 20 deletions

View file

@ -12,6 +12,7 @@ The standard consolidation format is:
- **Batch Consolidation**: Allows selecting multiple books in the library listing to consolidate them all at once.
- **Metadata Synchronization**: When a book is consolidated (or its metadata is updated), ABS ensures that denormalized database fields (Title, Author) are synchronized with the media record.
- **Interactive Indicators**: Books that are not consolidated display a yellow "Not Consolidated" button/badge. For authorized users, this badge acts as a shortcut to trigger the consolidation process directly from the bookshelf.
- **Interactive Conflict Resolution**: Detects folder collisions and prompts the user to either merge items or rename the target folder.
- **Empty Directory Cleanup**: After moving an item, the feature recursively deletes any parent directories that have become empty.
## How it Works
@ -26,12 +27,19 @@ The target folder name is generated using the first author listed and the book t
- **Backend API**: The `LibraryItem` model's serialization methods (`toOldJSON`) return the persisted `isNotConsolidated` flag. This maintains perfect parity between the items shown in a filtered listing and the status indicators visible on their cards.
- **Frontend**: Computed properties in `LazyBookCard.vue` and `item/_id/index.vue` rely on the server-provided `isNotConsolidated` property, ensuring consistent behavior across the application.
### 3. Path Validation
Before moving any files, the system checks if the destination folder already exists. If it exists and is not the current folder, the operation will fail to prevent overwriting or merging items unintentionally.
### 3. Path Validation and Conflict Resolution
Before moving any files, the system checks if the destination folder already exists.
- **Normal Flow**: If the destination does not exist, the item is moved.
- **Conflict Detection**: If the destination already exists, the server returns a `409 Conflict` error containing information about the existing path and any library item already located there.
- **Interactive Resolution**: The frontend catches this conflict and presents a **Consolidation Conflict Dialog**, offering two strategies:
- **Merge Contents**: Moves all files from the current item into the existing folder.
- **Collision Handling**: If a file with the same name already exists in the destination folder, the incoming file is automatically renamed with a timestamp suffix (e.g., `audio_1708174523.mp3`) to prevent data loss.
- **Rename Destination**: Allows the user to provide a custom folder name (e.g., adding " (Digital)" or " (v2)") to avoid the collision.
### 4. File Movement (`handleMoveLibraryItem`)
- **For Folders**: The entire directory is moved to the new path.
- **For Folders**: The directory is moved to the new path. If merging, contents are moved individually.
- **For Single Files**: A new directory is created at the destination, and the file is moved into that directory. The item's `isFile` status is updated from `true` to `false`.
- **Force Merge**: When explicitly requested (after user confirmation), the move operation will bypass the existence check and combine the file contents.
### 5. Cleanup
The system identifies the previous parent directory of the book. If that directory is now empty (and is not a root library folder), it is deleted. This process repeats upwards until it hits a non-empty directory or a library root.
@ -54,6 +62,12 @@ The system identifies the previous parent directory of the book. If that directo
### Batch Action
1. Select multiple books using the selection tool (or Ctrl+Click/Shift+Click).
2. Click the **Consolidate** option in the batch action bar at the top of the listing.
3. In the confirmation dialog, you can check **"Merge contents on conflict"** to automatically apply the merge strategy to all items. If unchecked, conflicting items will be skipped and reported in a summary toast.
### Conflict Resolution Dialog (Single Item)
If the target consolidation folder already exists for a single item, an interactive dialog will appear:
- **Merge Contents**: Combine all files into the existing folder (renaming on collision).
- **Rename Destination**: Provide a custom alternative folder name.
## Technical Notes
- **File System**: Requires write permissions on the library directories.

View file

@ -273,16 +273,25 @@ export default {
batchConsolidate() {
const payload = {
message: this.$getString('MessageConfirmConsolidate', [this.$getString('MessageItemsSelected', [this.numMediaItemsSelected]), 'Author - Title']),
callback: (confirmed) => {
checkboxLabel: 'Merge contents on conflict',
checkboxType: 'checkbox',
callback: (confirmed, merge) => {
if (confirmed) {
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post('/api/items/batch/consolidate', {
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
libraryItemIds: this.selectedMediaItems.map((i) => i.id),
merge
})
.then((data) => {
this.$toast.success(this.$strings.ToastBatchConsolidateSuccess)
if (this.numMediaItemsSelected === 1) {
if (data.success) {
this.$toast.success(this.$strings.ToastBatchConsolidateSuccess)
} else {
const numFailed = data.results.filter((r) => !r.success).length
this.$toast.warning(`${numFailed} items failed to consolidate. They may already exist or have other errors.`)
}
if (this.numMediaItemsSelected === 1 && data.success) {
this.$router.push(`/item/${this.selectedMediaItems[0].id}`)
}
this.cancelSelectionMode()

View file

@ -830,7 +830,19 @@ export default {
})
.catch((error) => {
console.error('Failed to consolidate', error)
this.$toast.error(error.response?.data || this.$strings.ToastConsolidateFailed || 'Consolidate failed')
if (error.response?.status === 409) {
const data = error.response.data
const author = this.mediaMetadata.authorName?.split(',')[0]?.trim() || 'Unknown Author'
const title = this.mediaMetadata.title || 'Unknown Title'
this.$eventBus.$emit('show-consolidation-conflict', {
item: this._libraryItem,
path: data.path,
folderName: this.$getConsolidatedFolderName(author, title),
existingLibraryItemId: data.existingLibraryItemId
})
} else {
this.$toast.error(error.response?.data?.error || error.response?.data || this.$strings.ToastConsolidateFailed || 'Consolidate failed')
}
})
.finally(() => {
this.processing = false

View file

@ -0,0 +1,107 @@
<template>
<modals-modal v-model="show" name="consolidation-conflict" :width="500" :processing="processing">
<div class="px-6 py-6 font-sans">
<div class="flex items-center mb-4">
<span class="material-symbols text-yellow-500 text-3xl mr-3 font-bold">warning</span>
<h2 class="text-xl font-semibold text-white">Consolidation Conflict</h2>
</div>
<div class="text-gray-200 mb-6 text-sm">
<p class="mb-2">The destination folder already exists:</p>
<div class="bg-black/30 p-3 rounded-md font-mono text-xs break-all border border-white/10 mb-4 text-gray-300">
{{ folderPath }}
</div>
<div v-if="existingLibraryItemId" class="flex items-center text-xs text-yellow-400/80 mb-4 bg-yellow-400/5 p-2 rounded-sm border border-yellow-400/10">
<span class="material-symbols text-sm mr-2">info</span>
Another library item is already at this location.
</div>
<p class="text-base text-white/90">How would you like to resolve this?</p>
</div>
<div class="space-y-3 mb-8">
<div class="bg-white/5 rounded-lg border border-white/5 p-1">
<label class="flex items-start p-3 cursor-pointer group hover:bg-white/5 rounded-md transition-colors duration-200" :class="{ 'bg-white/5 border-white/10': resolution === 'merge' }">
<input v-model="resolution" type="radio" value="merge" class="mt-1 mr-4 accent-yellow-500" />
<div class="flex-1">
<span class="text-white font-medium block mb-0.5 group-hover:text-yellow-400 transition-colors">Merge Contents</span>
<p class="text-xs text-gray-400 leading-relaxed">Move all files from this book into the existing folder. Files with identical names will be automatically renamed with a timestamp.</p>
</div>
</label>
<label class="flex items-start p-3 cursor-pointer group hover:bg-white/5 rounded-md transition-colors duration-200" :class="{ 'bg-white/5 border-white/10': resolution === 'rename' }">
<input v-model="resolution" type="radio" value="rename" class="mt-1 mr-4 accent-yellow-500" />
<div class="flex-1">
<span class="text-white font-medium block mb-0.5 group-hover:text-yellow-400 transition-colors">Rename Destination</span>
<p class="text-xs text-gray-400 leading-relaxed mb-3">Save this book to a different folder name instead.</p>
<div v-if="resolution === 'rename'" class="mt-2 pl-1">
<ui-text-input v-model="newName" class="w-full" placeholder="Enter new folder name" @keyup.enter="submit" />
</div>
</div>
</label>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4 border-t border-white/10">
<ui-btn @click="show = false">Cancel</ui-btn>
<ui-btn color="success" class="px-6" :loading="processing" @click="submit">Confirm Resolution</ui-btn>
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
item: {
type: Object,
default: () => ({})
},
folderPath: String,
folderName: String,
existingLibraryItemId: String,
processing: Boolean
},
data() {
return {
resolution: 'merge',
newName: ''
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
watch: {
folderName(val) {
if (val) this.newName = val
},
show(val) {
if (val) {
this.resolution = 'merge'
this.newName = this.folderName || ''
}
}
},
methods: {
submit() {
if (this.resolution === 'rename' && !this.newName.trim()) {
this.$toast.error('New folder name is required')
return
}
const payload = {
merge: this.resolution === 'merge',
newName: this.resolution === 'rename' ? this.newName.trim() : null
}
this.$emit('confirm', payload)
}
}
}
</script>

View file

@ -22,6 +22,15 @@
<modals-raw-cover-preview-modal />
<modals-share-modal />
<modals-item-move-to-library-modal />
<modals-consolidation-conflict-modal
v-model="showConsolidationConflictModal"
:item="consolidationConflictItem"
:folder-path="consolidationConflictPath"
:folder-name="consolidationConflictFolderName"
:existing-library-item-id="consolidationConflictExistingItemId"
:processing="processingConsolidationConflict"
@confirm="resolveConsolidationConflict"
/>
<prompt-confirm />
<readers-reader />
</div>
@ -39,7 +48,13 @@ export default {
socketConnectionToastId: null,
currentLang: null,
multiSessionOtherSessionId: null, // Used for multiple sessions open warning toast
multiSessionCurrentSessionId: null // Used for multiple sessions open warning toast
multiSessionCurrentSessionId: null, // Used for multiple sessions open warning toast
showConsolidationConflictModal: false,
consolidationConflictItem: null,
consolidationConflictPath: '',
consolidationConflictFolderName: '',
consolidationConflictExistingItemId: null,
processingConsolidationConflict: false
}
},
watch: {
@ -600,6 +615,27 @@ export default {
console.log('Changed lang', code)
this.currentLang = code
document.documentElement.lang = code
},
openConsolidationConflict(data) {
this.consolidationConflictItem = data.item
this.consolidationConflictPath = data.path
this.consolidationConflictFolderName = data.folderName
this.consolidationConflictExistingItemId = data.existingLibraryItemId
this.showConsolidationConflictModal = true
},
async resolveConsolidationConflict(payload) {
this.processingConsolidationConflict = true
const axios = this.$axios || this.$nuxt.$axios
try {
await axios.$post(`/api/items/${this.consolidationConflictItem.id}/consolidate`, payload)
this.$toast.success(this.$strings.ToastConsolidateSuccess || 'Consolidation successful')
this.showConsolidationConflictModal = false
} catch (error) {
console.error('Failed to resolve consolidation conflict', error)
this.$toast.error(error.response?.data?.error || error.response?.data || 'Failed to resolve conflict')
} finally {
this.processingConsolidationConflict = false
}
}
},
beforeMount() {
@ -610,6 +646,7 @@ export default {
this.resize()
this.$eventBus.$on('change-lang', this.changeLanguage)
this.$eventBus.$on('token_refreshed', this.tokenRefreshed)
this.$eventBus.$on('show-consolidation-conflict', this.openConsolidationConflict)
window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown)
@ -634,6 +671,7 @@ export default {
beforeDestroy() {
this.$eventBus.$off('change-lang', this.changeLanguage)
this.$eventBus.$off('token_refreshed', this.tokenRefreshed)
this.$eventBus.$off('show-consolidation-conflict', this.openConsolidationConflict)
window.removeEventListener('resize', this.resize)
window.removeEventListener('keydown', this.keyDown)
}

View file

@ -812,7 +812,19 @@ export default {
})
.catch((error) => {
console.error('Failed to consolidate', error)
this.$toast.error(error.response?.data || this.$strings.ToastConsolidateFailed)
if (error.response?.status === 409) {
const data = error.response.data
const author = this.mediaMetadata.authorName?.split(',')[0]?.trim() || 'Unknown Author'
const title = this.mediaMetadata.title || 'Unknown Title'
this.$eventBus.$emit('show-consolidation-conflict', {
item: this.libraryItem,
path: data.path,
folderName: this.$getConsolidatedFolderName(author, title),
existingLibraryItemId: data.existingLibraryItemId
})
} else {
this.$toast.error(error.response?.data?.error || error.response?.data || this.$strings.ToastConsolidateFailed)
}
})
.finally(() => {
this.processing = false

View file

@ -47,7 +47,7 @@ const ShareManager = require('../managers/ShareManager')
* @param {import('../models/Library')} targetLibrary
* @param {import('../models/LibraryFolder')} targetFolder
*/
async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, newItemFolderName = null) {
async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, newItemFolderName = null, forceMerge = false) {
const oldPath = libraryItem.path
const oldLibraryId = libraryItem.libraryId
const oldIsFile = libraryItem.isFile
@ -60,8 +60,11 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n
// Check if destination already exists
const destinationExists = await fs.pathExists(newPath)
const isSamePath = oldPath === newPath
if (destinationExists && !isSamePath) {
throw new Error(`Destination already exists: ${newPath}`)
if (destinationExists && !isSamePath && !forceMerge) {
const error = new Error(`Destination already exists: ${newPath}`)
error.code = 'EEXIST'
error.path = newPath
throw error
}
try {
@ -70,15 +73,36 @@ async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, n
const oldRelPath = libraryItem.relPath
// Move files on disk
if (!isSamePath) {
Logger.info(`[LibraryItemController] Moving item "${libraryItem.media.title}" from "${oldPath}" to "${newPath}"`)
Logger.info(`[LibraryItemController] Moving item "${libraryItem.media.title}" from "${oldPath}" to "${newPath}" (forceMerge: ${forceMerge})`)
if (libraryItem.isFile && newItemFolderName) {
// Handle single file consolidation: create folder and move file inside
await fs.ensureDir(newPath)
const destPath = Path.join(newPath, Path.basename(oldPath))
let destPath = Path.join(newPath, Path.basename(oldPath))
if (await fs.pathExists(destPath)) {
const filename = Path.basename(oldPath)
const name = Path.parse(filename).name
const ext = Path.parse(filename).ext
destPath = Path.join(newPath, `${name}_${Date.now()}${ext}`)
}
await fs.move(oldPath, destPath)
libraryItem.isFile = false
} else if (forceMerge && destinationExists) {
// Move all files from this directory to target directory
const files = await fs.readdir(oldPath)
for (const file of files) {
const srcFile = Path.join(oldPath, file)
let destFile = Path.join(newPath, file)
if (await fs.pathExists(destFile)) {
const name = Path.parse(file).name
const ext = Path.parse(file).ext
destFile = Path.join(newPath, `${name}_${Date.now()}${ext}`)
}
await fs.move(srcFile, destFile)
}
// Remove the now empty directory
await fs.remove(oldPath)
} else {
await fs.move(oldPath, newPath)
}
@ -1691,16 +1715,35 @@ class LibraryItemController {
return res.status(400).send('Consolidate only available for books')
}
const { merge, newName } = req.body
const author = req.libraryItem.media.authors?.[0]?.name || 'Unknown Author'
const title = req.libraryItem.media.title || 'Unknown Title'
const sanitizedFolderName = Database.libraryItemModel.getConsolidatedFolderName(author, title)
const targetFolderName = newName || sanitizedFolderName
const library = await Database.libraryModel.findByIdWithFolders(req.libraryItem.libraryId)
// Find the library folder that currently contains this item
const targetFolder = library.libraryFolders.find((f) => req.libraryItem.path.startsWith(f.path)) || library.libraryFolders[0]
const expectedPath = Path.join(targetFolder.path, targetFolderName)
const isSamePath = req.libraryItem.path === expectedPath
if (!isSamePath && !merge && (await fs.pathExists(expectedPath))) {
// Find existing library item at this path if any
const existingItem = await Database.libraryItemModel.findOne({
where: {
path: expectedPath
}
})
return res.status(409).json({
error: 'Destination already exists',
path: expectedPath,
existingLibraryItemId: existingItem?.id || null
})
}
try {
await handleMoveLibraryItem(req.libraryItem, library, targetFolder, sanitizedFolderName)
await handleMoveLibraryItem(req.libraryItem, library, targetFolder, targetFolderName, !!merge)
// Recursively remove empty parent directories
let parentDir = Path.dirname(req.libraryItem.path)
@ -1737,7 +1780,7 @@ class LibraryItemController {
* @param {Response} res
*/
async batchConsolidate(req, res) {
const { libraryItemIds } = req.body
const { libraryItemIds, merge } = req.body
if (!Array.isArray(libraryItemIds) || !libraryItemIds.length) {
return res.status(400).send('Invalid request')
}
@ -1747,6 +1790,7 @@ class LibraryItemController {
})
const results = []
let numSuccess = 0
for (const libraryItem of libraryItems) {
if (libraryItem.mediaType !== 'book') {
results.push({ id: libraryItem.id, success: false, error: 'Not a book' })
@ -1762,7 +1806,7 @@ class LibraryItemController {
const currentLibraryFolder = library.libraryFolders.find((lf) => libraryItem.path.startsWith(lf.path)) || library.libraryFolders[0]
const oldPath = libraryItem.path
await handleMoveLibraryItem(libraryItem, library, currentLibraryFolder, sanitizedFolderName)
await handleMoveLibraryItem(libraryItem, library, currentLibraryFolder, sanitizedFolderName, !!merge)
// Recursively remove empty parent directories
let parentDir = Path.dirname(oldPath)
@ -1782,13 +1826,17 @@ class LibraryItemController {
}
results.push({ id: libraryItem.id, success: true })
numSuccess++
} catch (error) {
Logger.error(`[LibraryItemController] Batch Consolidate: Failed to consolidate "${libraryItem.media?.title}"`, error)
results.push({ id: libraryItem.id, success: false, error: error.message })
}
}
res.json({ results })
res.json({
success: numSuccess === libraryItems.length,
results
})
}
/**