diff --git a/artifacts/2026-02-12/book-merge.md b/artifacts/2026-02-12/book-merge.md index 30abb0725..92d1bbb42 100644 --- a/artifacts/2026-02-12/book-merge.md +++ b/artifacts/2026-02-12/book-merge.md @@ -88,11 +88,11 @@ I have implemented the "Merge Books" feature, which allows users to combine mult - **`server/routers/ApiRouter.js`**: Added `POST /api/items/batch/merge` route. ### Frontend - -- **`client/components/app/Appbar.vue`**: Added "Merge" option to the multi-select context menu. - - Enabled only when multiple books are selected. - - Shows a confirmation dialog before proceeding. -- **`client/strings/en-us.json`**: Added localization strings for the new feature. +- **`client/components/app/Appbar.vue`**: Added "Merge" option to the multi-select context menu. + - Enabled only when multiple books are selected. + - Shows a confirmation dialog before proceeding. + - Automatically navigates to the merged book upon success. +- **`client/strings/en-us.json`**: Added localization strings for the new feature. ## Verification diff --git a/artifacts/2026-02-13/consolidate.md b/artifacts/2026-02-13/consolidate.md new file mode 100644 index 000000000..203a0e0bc --- /dev/null +++ b/artifacts/2026-02-13/consolidate.md @@ -0,0 +1,54 @@ +# Consolidate Book Feature Specification + +## Overview + +The "Consolidate" feature allows users to organize their book library by renaming a book's folder to a standard `Author - Book Name` format and moving it to the root of the library folder. This helps in flattening nested structures and maintaining a consistent naming convention. + +## User Interface + +### Frontend + +- Context menu on Book Card (Library View). +- Context menu on Book View page. +- A new option "Consolidate" will be added to the book card's context menu (the "meatball" menu). +- **Visibility**: + - Only available for Books (not Podcasts). + - Only available if the user has "Update" permissions. + - Only available if the item is a folder (not a single file). +- **Interaction**: + - Clicking "Consolidate" triggers a confirmation dialog explaining the action. + - Upon confirmation, the operation is performed. + - A toast notification indicates success or failure. + +## Backend Logic + +- **Endpoint**: `POST /api/items/:id/consolidate` +- **Controller**: `LibraryItemController.consolidate` +- **Logic**: + 1. **Retrieve Item**: Fetch the library item by ID. Verify it is a book and the user has permissions. + 2. **Determine New Name**: Construct the folder name using the pattern `${Author} - ${Title}`. + - `Author`: Primary author name. + - `Title`: Book title. + - **Sanitization**: Ensure the name is safe for the file system (remove illegal characters). + 3. **Determine New Path**: + - `Target Library Folder`: The root path of the library the item belongs to. + - `New Path`: `Path.join(LibraryRoot, NewFolderName)`. + 4. **Validation**: + - Check if `New Path` already exists. + - If it exists and is the same as the current path, return success (no-op). + - If it exists and is different, return an error (or handle collision - for now, error). + 5. **Execution**: + - Move the directory from `Old Path` to `New Path`. + - Update the `path` and `relPath` in the `libraryItems` table. + - Update paths of all associated files (audio files, ebook files, cover, etc.) in the database. + - Update `libraryFolderId` to the root folder ID (if applicable/tracked). + 6. **Cleanup**: + - If the old folder was inside another folder (e.g., `Author/Series/Book`), check if the parent folders are now empty and delete them if so (similar to how Move or Delete handles it). _Note: The existing `move` logic might handle this or we can reuse `handleMoveLibraryItem` if we can trick it or modify it._ + - Actually, `handleMoveLibraryItem` takes a `targetFolder`. If we pass the library root as `targetFolder` and rename the directory before/during move? + - `handleMoveLibraryItem` assumes `itemFolderName` is `Path.basename(libraryItem.path)`. It does NOT rename the folder name itself. + - So we need a custom logic or a modified helper that supports renaming. +- **Response**: JSON object indicating success and the updated item. + +## Artifacts + +- This specification is saved as `artifacts/2026-02-13/consolidate.md`. diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index fa47d90ba..f2a949ee0 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -209,6 +209,13 @@ export default { action: 'merge' }) } + + if (this.isBookLibrary) { + options.push({ + text: 'Consolidate', + action: 'consolidate' + }) + } } return options @@ -252,8 +259,38 @@ export default { this.batchMoveToLibrary() } else if (action === 'merge') { this.batchMerge() + } else if (action === 'consolidate') { + this.batchConsolidate() } }, + batchConsolidate() { + const payload = { + message: this.$getString('MessageConfirmConsolidate', [this.$getString('MessageItemsSelected', [this.numMediaItemsSelected]), 'Author - Title']), + callback: (confirmed) => { + if (confirmed) { + this.$store.commit('setProcessingBatch', true) + this.$axios + .$post('/api/items/batch/consolidate', { + libraryItemIds: this.selectedMediaItems.map((i) => i.id) + }) + .then((data) => { + this.$toast.success(this.$strings.ToastBatchConsolidateSuccess) + this.cancelSelectionMode() + }) + .catch((error) => { + console.error('Batch consolidation failed', error) + const errorMsg = error.response?.data || this.$strings.ToastBatchConsolidateFailed + this.$toast.error(errorMsg) + }) + .finally(() => { + this.$store.commit('setProcessingBatch', false) + }) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, batchMerge() { const payload = { message: this.$strings.MessageConfirmBatchMerge, @@ -266,10 +303,14 @@ export default { .then((data) => { if (data.success) { this.$toast.success(this.$strings.ToastBatchMergeSuccess) + if (data.mergedItemId) { + this.$router.push(`/item/${data.mergedItemId}`) + } } else { this.$toast.warning(this.$strings.ToastBatchMergePartiallySuccess) } - this.cancelSelectionMode() + this.$store.commit('globals/resetSelectedMediaItems', []) + this.$eventBus.$emit('bookshelf_clear_selection') }) .catch((error) => { console.error('Batch merge failed', error) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index f96a83887..d54bc6e3e 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -565,6 +565,12 @@ export default { func: 'showEditModalMatch', text: this.$strings.HeaderMatch }) + if (!this.isFile && !this.isPodcast) { + items.push({ + func: 'consolidate', + text: 'Consolidate' + }) + } } if ((this.userIsAdminOrUp || this.userCanDelete) && !this.isFile) { items.push({ @@ -799,6 +805,31 @@ export default { // More menu func this.$emit('edit', this.libraryItem, 'match') }, + consolidate() { + const payload = { + message: this.$getString('MessageConfirmConsolidate', [this.title, `${this.author} - ${this.title}`]), + callback: (confirmed) => { + if (confirmed) { + this.processing = true + const axios = this.$axios || this.$nuxt.$axios + axios + .$post(`/api/items/${this.libraryItemId}/consolidate`) + .then(() => { + this.$toast.success(this.$strings.ToastConsolidateSuccess || 'Consolidate successful') + }) + .catch((error) => { + console.error('Failed to consolidate', error) + this.$toast.error(error.response?.data || this.$strings.ToastConsolidateFailed || 'Consolidate failed') + }) + .finally(() => { + this.processing = false + }) + } + }, + type: 'yesNo' + } + this.store.commit('globals/setConfirmPrompt', payload) + }, sendToDevice(deviceName) { // More menu func const payload = { diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 0307b48cb..57aa93f0e 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -428,6 +428,12 @@ export default { text: this.$strings.ButtonReScan, action: 'rescan' }) + if (!this.isFile && !this.isPodcast) { + items.push({ + text: 'Consolidate', + action: 'consolidate' + }) + } items.push({ text: this.$strings.ButtonMoveToLibrary, action: 'move' @@ -786,6 +792,31 @@ export default { this.processing = false }) }, + consolidate() { + const author = this.authors?.[0]?.name || 'Unknown Author' + const payload = { + message: this.$getString('MessageConfirmConsolidate', [this.title, `${author} - ${this.title}`]), + callback: (confirmed) => { + if (confirmed) { + this.processing = true + this.$axios + .$post(`/api/items/${this.libraryItemId}/consolidate`) + .then(() => { + this.$toast.success(this.$strings.ToastConsolidateSuccess) + }) + .catch((error) => { + console.error('Failed to consolidate', error) + this.$toast.error(error.response?.data || this.$strings.ToastConsolidateFailed) + }) + .finally(() => { + this.processing = false + }) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, contextMenuAction({ action, data }) { if (action === 'collections') { this.$store.commit('setSelectedLibraryItem', this.libraryItem) @@ -806,6 +837,8 @@ export default { } else if (action === 'move') { this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('globals/setShowMoveToLibraryModal', true) + } else if (action === 'consolidate') { + this.consolidate() } else if (action === 'sendToDevice') { this.sendToDevice(data) } else if (action === 'share') { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index d46cedb38..127159320 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -774,6 +774,7 @@ "MessageChaptersNotFound": "Chapters not found", "MessageCheckingCron": "Checking cron...", "MessageConfirmBatchMerge": "Are you sure you want to merge the selected books into a single book? The files will be moved to the first selected book's folder, and other books will be deleted.", + "MessageConfirmConsolidate": "Are you sure you want to consolidate \"{0}\"? This will rename the folder to \"{1}\" and move it to the library root.", "MessageConfirmCloseFeed": "Are you sure you want to close this feed?", "MessageConfirmDeleteApiKey": "Are you sure you want to delete API key \"{0}\"?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", @@ -1020,6 +1021,10 @@ "ToastBatchApplyDetailsToItemsSuccess": "Details applied to items", "ToastBatchDeleteFailed": "Batch delete failed", "ToastBatchDeleteSuccess": "Batch delete success", + "ToastConsolidateFailed": "Consolidate failed", + "ToastConsolidateSuccess": "Consolidate successful", + "ToastBatchConsolidateFailed": "Batch consolidate failed", + "ToastBatchConsolidateSuccess": "Batch consolidate successful", "ToastBatchMergeFailed": "Failed to merge books", "ToastBatchMergePartiallySuccess": "Books merged with some errors", "ToastBatchMergeSuccess": "Books merged successfully", diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index a5b32f3e1..a2406663c 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -9,7 +9,7 @@ const Database = require('../Database') const zipHelpers = require('../utils/zipHelpers') const { reqSupportsWebp } = require('../utils/index') const { ScanResult, AudioMimeType } = require('../utils/constants') -const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils') +const { getAudioMimeTypeFromExtname, encodeUriPath, sanitizeFilename } = require('../utils/fileUtils') const LibraryItemScanner = require('../scanner/LibraryItemScanner') const AudioFileScanner = require('../scanner/AudioFileScanner') const Scanner = require('../scanner/Scanner') @@ -47,12 +47,12 @@ const ShareManager = require('../managers/ShareManager') * @param {import('../models/Library')} targetLibrary * @param {import('../models/LibraryFolder')} targetFolder */ -async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder) { +async function handleMoveLibraryItem(libraryItem, targetLibrary, targetFolder, newItemFolderName = null) { const oldPath = libraryItem.path const oldLibraryId = libraryItem.libraryId // Calculate new paths - const itemFolderName = Path.basename(libraryItem.path) + const itemFolderName = newItemFolderName || Path.basename(libraryItem.path) const newPath = Path.join(targetFolder.path, itemFolderName) const newRelPath = itemFolderName @@ -1588,6 +1588,122 @@ class LibraryItemController { } } + /** + * POST: /api/items/:id/consolidate + * Rename book folder to Author - Title and move to library root + * + * @param {LibraryItemControllerRequest} req + * @param {Response} res + */ + async consolidate(req, res) { + if (!req.libraryItem.isBook) { + return res.status(400).send('Consolidate only available for books') + } + if (req.libraryItem.isFile) { + return res.status(400).send('Consolidate only available for books in a folder') + } + + const author = req.libraryItem.media.authors?.[0]?.name || 'Unknown Author' + const title = req.libraryItem.media.title || 'Unknown Title' + const newFolderName = `${author} - ${title}` + const sanitizedFolderName = sanitizeFilename(newFolderName) + + 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] + + try { + await handleMoveLibraryItem(req.libraryItem, library, targetFolder, sanitizedFolderName) + + // Recursively remove empty parent directories + let parentDir = Path.dirname(req.libraryItem.path) + while (parentDir && parentDir !== targetFolder.path && parentDir !== Path.dirname(parentDir)) { + try { + const files = await fs.readdir(parentDir) + if (files.length === 0) { + await fs.remove(parentDir) + parentDir = Path.dirname(parentDir) + } else { + break + } + } catch (err) { + Logger.error(`[LibraryItemController] Failed to cleanup parent directory "${parentDir}"`, err) + break + } + } + + res.json({ + success: true, + libraryItem: req.libraryItem.toOldJSONExpanded() + }) + } catch (error) { + Logger.error(`[LibraryItemController] Failed to consolidate item "${req.libraryItem.media.title}"`, error) + return res.status(500).send(error.message || 'Failed to consolidate item') + } + } + + /** + * POST: /api/items/batch/consolidate + * Consolidate multiple library items + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async batchConsolidate(req, res) { + const { libraryItemIds } = req.body + if (!Array.isArray(libraryItemIds) || !libraryItemIds.length) { + return res.status(400).send('Invalid request') + } + + const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ + id: libraryItemIds + }) + + const results = [] + for (const libraryItem of libraryItems) { + if (libraryItem.mediaType !== 'book' || libraryItem.isFile) { + results.push({ id: libraryItem.id, success: false, error: 'Not a book in a folder' }) + continue + } + + try { + const author = libraryItem.media.authors?.[0]?.name || 'Unknown Author' + const title = libraryItem.media.title || 'Unknown Title' + const newFolderName = `${author} - ${title}` + const sanitizedFolderName = sanitizeFilename(newFolderName) + + const library = await Database.libraryModel.findByIdWithFolders(libraryItem.libraryId) + const currentLibraryFolder = library.libraryFolders.find((lf) => libraryItem.path.startsWith(lf.path)) || library.libraryFolders[0] + + const oldPath = libraryItem.path + await handleMoveLibraryItem(libraryItem, library, currentLibraryFolder, sanitizedFolderName) + + // Recursively remove empty parent directories + let parentDir = Path.dirname(oldPath) + while (parentDir && parentDir !== currentLibraryFolder.path && parentDir !== Path.dirname(parentDir)) { + try { + const files = await fs.readdir(parentDir) + if (files.length === 0) { + await fs.remove(parentDir) + parentDir = Path.dirname(parentDir) + } else { + break + } + } catch (err) { + Logger.error(`[LibraryItemController] Failed to cleanup parent directory "${parentDir}"`, err) + break + } + } + + results.push({ id: libraryItem.id, success: true }) + } 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 }) + } /** * POST: /api/items/batch/merge @@ -1736,7 +1852,7 @@ class LibraryItemController { } // Rescan the target folder - // If moved to folder, tell scanner + // If moved to folder, tell scanner if (isPrimaryInRoot) { // We changed the structure of primary item await LibraryItemScanner.scanLibraryItem(primaryItem.id, { @@ -1765,6 +1881,7 @@ class LibraryItemController { res.json({ success: failIds.length === 0, + mergedItemId: primaryItem.id, successIds, failIds, errors: failedItems diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 14ac67fb9..a200ddf92 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -107,6 +107,7 @@ class ApiRouter { this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this)) this.router.post('/items/batch/move', LibraryItemController.batchMove.bind(this)) this.router.post('/items/batch/merge', LibraryItemController.batchMerge.bind(this)) + this.router.post('/items/batch/consolidate', LibraryItemController.batchConsolidate.bind(this)) this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this)) this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this)) @@ -130,6 +131,7 @@ class ApiRouter { this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this)) this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this)) this.router.post('/items/:id/move', LibraryItemController.middleware.bind(this), LibraryItemController.move.bind(this)) + this.router.post('/items/:id/consolidate', LibraryItemController.middleware.bind(this), LibraryItemController.consolidate.bind(this)) // // User Routes @@ -471,9 +473,7 @@ class ApiRouter { const transaction = await Database.sequelize.transaction() try { - const where = [ - sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0) - ] + const where = [sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)] if (!force) { where.push({