From b3cdd880e19c40b3c7e669999e1348844c4da9fc Mon Sep 17 00:00:00 2001 From: Tiberiu Ichim Date: Sun, 15 Feb 2026 08:35:42 +0200 Subject: [PATCH] Add consolidate badge --- AGENTS.md | 3 ++ artifacts/2026-02-15/consolidation_badge.md | 35 +++++++++++++++++++++ client/components/cards/LazyBookCard.vue | 10 ++++++ client/pages/item/_id/index.vue | 20 +++++++----- server/models/LibraryItem.js | 13 +++++++- 5 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 artifacts/2026-02-15/consolidation_badge.md diff --git a/AGENTS.md b/AGENTS.md index b075289cc..c1c532278 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -238,6 +238,9 @@ Artifact specifications should serve as a source of truth for the feature's life ### Best Practices +- **Use for Every Feature**: For *every* new feature or significant development, start by creating a specification file in today's dated folder. +- **Initialization**: Always run `make` (or `make today`) in the `artifacts/` directory before starting work on a new specification to ensure the correct dated folder exists. +- **Relevant Naming**: Name the specification file according to the task/feature (e.g., `feature_name_specification.md`). - **Update as you go**: The artifact should be updated during implementation if the plan changes. - **Be Specific**: Avoid vague descriptions. If a function is moved to a controller, name the function and the controller. - **Use Tables**: Tables are great for listing files or comparing before/after states. diff --git a/artifacts/2026-02-15/consolidation_badge.md b/artifacts/2026-02-15/consolidation_badge.md new file mode 100644 index 000000000..836fc4e2f --- /dev/null +++ b/artifacts/2026-02-15/consolidation_badge.md @@ -0,0 +1,35 @@ +# Not Consolidated Badge Specification + +## Overview +Add a visual indicator (badge) to the book thumbnail card in listings to identify books that are not "consolidated". Consolidation means the book's folder name matches the standard `Author - Title` format. + +## Requirements +- The badge should only appear if the folder name does not match the sanitized `Author - Title` format. +- Only applicable to Books (not Podcasts). +- Only applicable to library items in folders (not single files). +- The badge should have a descriptive tooltip ("Not Consolidated"). +- The badge should be clearly visible but not obstructive. +- The badge position should account for other status indicators (RSS, shared icon) to avoid overlap. + +## Implementation Details + +### Backend (Server) +- **Model**: `LibraryItem` (`server/models/LibraryItem.js`) +- **Logic**: Added `checkIsNotConsolidated()` which: + 1. Checks if the item is a book folder. + 2. Sanitizes the `Author - Title` name using `sanitizeFilename`. + 3. Compares the sanitized name with the folder's name (`Path.basename(this.path)`). +- **API**: The flag `isNotConsolidated` is included in the JSON response for library items. + +### Frontend (Client) +- **Component**: `LazyBookCard` (`client/components/cards/LazyBookCard.vue`) +- **UI**: Added a folder icon badge with a yellow background (`bg-warning`). +- **Logic**: Toggles visibility based on the `libraryItem.isNotConsolidated` flag. +- **Positioning**: Absolute positioning on the bottom-left side (`bottom: 0.375em`, `left: 0`). + +### View Book Page +- **Component**: `client/pages/item/_id/index.vue` +- **UI (Badge)**: Badge added next to the book title when `isNotConsolidated` is true. +- **UI (Button)**: "Consolidate" button added to the primary action row (after Edit and Mark as Finished). +- **Behavior**: The "Consolidate" button is disabled if the book is already consolidated. +- **Cleanup**: The "Consolidate" option has been removed from the context menu on this page. diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 1703833d2..12649eae6 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -115,6 +115,13 @@

{{ numEpisodesIncomplete }}

+ + + +
+ folder_open +
+
@@ -447,6 +454,9 @@ export default { isInvalid() { return this._libraryItem.isInvalid }, + isNotConsolidated() { + return !!this._libraryItem.isNotConsolidated + }, errorText() { if (this.isMissing) return 'Item directory is missing!' else if (this.isInvalid) { diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 57aa93f0e..d67c8e152 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -29,6 +29,11 @@ {{ title }} + +
+ folder_open +
+
@@ -108,6 +113,10 @@ + + + + @@ -222,6 +231,9 @@ export default { isInvalid() { return this.libraryItem.isInvalid }, + isNotConsolidated() { + return !!this.libraryItem.isNotConsolidated + }, isExplicit() { return !!this.mediaMetadata.explicit }, @@ -428,12 +440,6 @@ 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' @@ -837,8 +843,6 @@ 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/server/models/LibraryItem.js b/server/models/LibraryItem.js index 16a521615..5e5870d71 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -3,7 +3,7 @@ const { DataTypes, Model } = require('sequelize') const fsExtra = require('../libs/fsExtra') const Logger = require('../Logger') const libraryFilters = require('../utils/queries/libraryFilters') -const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') +const { filePathToPOSIX, getFileTimestampsWithIno, sanitizeFilename } = require('../utils/fileUtils') const LibraryFile = require('../objects/files/LibraryFile') const Book = require('./Book') const Podcast = require('./Podcast') @@ -916,6 +916,14 @@ class LibraryItem extends Model { return this.libraryFiles.map((lf) => new LibraryFile(lf).toJSON()) } + checkIsNotConsolidated() { + if (this.isFile || this.mediaType !== 'book' || !this.media) return false + const author = this.media.authors?.[0]?.name || 'Unknown Author' + const title = this.media.title || 'Unknown Title' + const folderName = sanitizeFilename(`${author} - ${title}`) + return Path.basename(this.path) !== folderName + } + toOldJSON() { if (!this.media) { throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`) @@ -939,6 +947,7 @@ class LibraryItem extends Model { scanVersion: this.lastScanVersion, isMissing: !!this.isMissing, isInvalid: !!this.isInvalid, + isNotConsolidated: this.checkIsNotConsolidated(), mediaType: this.mediaType, media: this.media.toOldJSON(this.id), // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database @@ -967,6 +976,7 @@ class LibraryItem extends Model { updatedAt: this.updatedAt.valueOf(), isMissing: !!this.isMissing, isInvalid: !!this.isInvalid, + isNotConsolidated: this.checkIsNotConsolidated(), mediaType: this.mediaType, media: this.media.toOldJSONMinified(), numFiles: this.libraryFiles.length, @@ -993,6 +1003,7 @@ class LibraryItem extends Model { scanVersion: this.lastScanVersion, isMissing: !!this.isMissing, isInvalid: !!this.isInvalid, + isNotConsolidated: this.checkIsNotConsolidated(), mediaType: this.mediaType, media: this.media.toOldJSONExpanded(this.id), // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database