Add consolidate badge

This commit is contained in:
Tiberiu Ichim 2026-02-15 08:35:42 +02:00
parent 5f599a9980
commit b3cdd880e1
5 changed files with 72 additions and 9 deletions

View file

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

View file

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

View file

@ -115,6 +115,13 @@
<div cy-id="numEpisodesIncomplete" v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodesIncomplete }}</p>
</div>
<!-- Not Consolidated Badge -->
<ui-tooltip v-if="isNotConsolidated && !isSelectionMode && !isHovering" text="Not Consolidated" direction="top" class="absolute left-0 z-10" :style="{ padding: 0.375 + 'em', bottom: 0.375 + 'em' }">
<div class="rounded-full bg-warning flex items-center justify-center border border-black/20 shadow-sm" :style="{ width: 1.25 + 'em', height: 1.25 + 'em' }">
<span class="material-symbols text-black" :style="{ fontSize: 0.9 + 'em' }">folder_open</span>
</div>
</ui-tooltip>
</div>
</div>
@ -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) {

View file

@ -29,6 +29,11 @@
{{ title }}
<widgets-explicit-indicator v-if="isExplicit" />
<widgets-abridged-indicator v-if="isAbridged" />
<ui-tooltip v-if="isNotConsolidated" text="Not Consolidated" direction="bottom" class="ml-2">
<div class="rounded-full bg-warning flex items-center justify-center border border-black/20 shadow-sm w-6 h-6">
<span class="material-symbols text-black text-sm">folder_open</span>
</div>
</ui-tooltip>
</div>
</h1>
@ -108,6 +113,10 @@
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" class="mx-0.5" @click="toggleFinished" />
</ui-tooltip>
<ui-tooltip v-if="userCanUpdate && !isFile && !isPodcast" :text="isNotConsolidated ? 'Consolidate' : 'Already Consolidated'" direction="top">
<ui-icon-btn icon="folder_open" class="mx-0.5" :class="isNotConsolidated ? 'text-warning' : 'opacity-50'" :disabled="!isNotConsolidated" @click="consolidate" />
</ui-tooltip>
<!-- Only admin or root user can download new episodes -->
<ui-tooltip v-if="isPodcast && userIsAdminOrUp" :text="$strings.LabelFindEpisodes" direction="top">
<ui-icon-btn icon="search" class="mx-0.5" :aria-label="$strings.LabelFindEpisodes" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
@ -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') {

View file

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