Compare commits

..

15 Commits

Author SHA1 Message Date
advplyr 36ef675556 Fix edit episode next/prev buttons showing when editing from home page 2025-02-08 13:05:50 -06:00
advplyr 0dd57a8912 Fix using next/prev in edit modals while rich text input is focused #3951 2025-02-08 13:02:27 -06:00
advplyr 25ae6dd59a Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2025-02-07 17:10:12 -06:00
advplyr a37fe3c3d2 Fix: Users with update permission unable to remove books from collection #3947 2025-02-07 17:09:48 -06:00
advplyr 59bcbe0dfa Merge pull request #3946 from advplyr/details_trim_whitespace
Trim whitespace from podcast/book/episode & batch edit text inputs
2025-02-06 17:51:49 -06:00
advplyr b5e69630de Update batch edit text inputs to trim whitespace 2025-02-06 17:29:27 -06:00
advplyr 0bba709124 Trim whitespace from book/podcast/episode details text inputs #3943 2025-02-06 17:27:33 -06:00
advplyr e93bb5cb07 Merge pull request #3941 from Vynce/accept-encoding
Add `Accept-Encoding` header to `getPodcastFeed()`
2025-02-06 17:01:31 -06:00
Michael Vincent 3f7af8acfb Add Accept-Encoding header to getPodcastFeed()
This commit adds the Accept-Encoding header to getPodcastFeed() with
gzip, compress, and deflate support. This allows servers to send a
compressed response that'll be decompressed by axios transparently.

Audiobookshelf is currently using axios v0.27.2, which enables the
decompress option by default. The decompress feature supports gzip,
compress, and deflate algorithms (see axios/lib/adapters/http.js).
axios v0.27.2 does not add the Accept-Encoding header to requests
automatically, so that's the responsibility of the caller.
2025-02-05 23:12:58 -06:00
advplyr 5e5a604d03 Fix name parser to not use "last, first" format when not using comma separators. Adds unit tests #3940 2025-02-05 17:25:31 -06:00
advplyr 201e12ecc3 Update downloadFile to debug log percentage complete 2025-02-05 16:15:00 -06:00
advplyr 24d6e390f0 Fix Book/Podcast updateFromRequest to support null values in string fields #3938 2025-02-05 15:31:57 -06:00
advplyr 0cf7a6abec Merge pull request #3929 from mikiher/fix-trix-resize
Add resize to trix editor
2025-02-04 17:22:30 -06:00
mikiher 76ac0d001b Add resize to trix editor 2025-02-04 09:54:28 +02:00
advplyr 00343a953b Update Collection/Playlist and batch quick match modal bg colors to be consistent with other modals 2025-02-03 17:47:10 -06:00
24 changed files with 201 additions and 43 deletions
+1
View File
@@ -99,6 +99,7 @@ export default {
this.$store.commit('showEditModal', libraryItem) this.$store.commit('showEditModal', libraryItem)
}, },
editEpisode({ libraryItem, episode }) { editEpisode({ libraryItem, episode }) {
this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])
this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('setSelectedLibraryItem', libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
@@ -6,7 +6,7 @@
</div> </div>
</template> </template>
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh"> <div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="show" class="w-full h-full py-4"> <div v-if="show" class="w-full h-full py-4">
<div class="w-full overflow-y-auto overflow-x-hidden max-h-96"> <div class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<div class="flex px-8 items-center py-2"> <div class="flex px-8 items-center py-2">
@@ -6,7 +6,7 @@
</div> </div>
</template> </template>
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh"> <div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="show" class="w-full h-full"> <div v-if="show" class="w-full h-full">
<div class="py-4 px-4"> <div class="py-4 px-4">
<h1 v-if="!showBatchCollectionModal" class="text-2xl">{{ $strings.LabelAddToCollection }}</h1> <h1 v-if="!showBatchCollectionModal" class="text-2xl">{{ $strings.LabelAddToCollection }}</h1>
@@ -1,5 +1,5 @@
<template> <template>
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave"> <div class="flex items-center px-4 py-2 justify-start relative hover:bg-black-400" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" /> <div v-if="isBookIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<div class="w-20 max-w-20 text-center"> <div class="w-20 max-w-20 text-center">
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
@@ -196,6 +196,9 @@ export default {
methods: { methods: {
async goPrevBook() { async goPrevBook() {
if (this.currentBookshelfIndex - 1 < 0) return if (this.currentBookshelfIndex - 1 < 0) return
// Remove focus from active input
document.activeElement?.blur?.()
var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1] var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1]
this.processing = true this.processing = true
var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => { var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => {
@@ -215,6 +218,9 @@ export default {
}, },
async goNextBook() { async goNextBook() {
if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return
// Remove focus from active input
document.activeElement?.blur?.()
this.processing = true this.processing = true
var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1] var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1]
var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => { var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => {
@@ -6,7 +6,7 @@
</div> </div>
</template> </template>
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh"> <div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<div v-if="show" class="w-full h-full"> <div v-if="show" class="w-full h-full">
<div class="py-4 px-4"> <div class="py-4 px-4">
<h1 v-if="!isBatch" class="text-2xl">{{ $strings.LabelAddToPlaylist }}</h1> <h1 v-if="!isBatch" class="text-2xl">{{ $strings.LabelAddToPlaylist }}</h1>
@@ -1,5 +1,5 @@
<template> <template>
<div class="flex items-center px-4 py-2 justify-start relative hover:bg-bg" @mouseover="mouseover" @mouseleave="mouseleave"> <div class="flex items-center px-4 py-2 justify-start relative hover:bg-black-400" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="isItemIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" /> <div v-if="isItemIncluded" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
<div class="w-16 max-w-16 text-center"> <div class="w-16 max-w-16 text-center">
<covers-playlist-cover :items="items" :width="64" :height="64" /> <covers-playlist-cover :items="items" :width="64" :height="64" />
@@ -117,8 +117,12 @@ export default {
methods: { methods: {
async goPrevEpisode() { async goPrevEpisode() {
if (this.currentEpisodeIndex - 1 < 0) return if (this.currentEpisodeIndex - 1 < 0) return
// Remove focus from active input
document.activeElement?.blur?.()
const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1] const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1]
this.processing = true this.processing = true
const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => { const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => {
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode' const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode'
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
@@ -134,8 +138,12 @@ export default {
}, },
async goNextEpisode() { async goNextEpisode() {
if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return
// Remove focus from active input
document.activeElement?.blur?.()
this.processing = true this.processing = true
const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1] const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1]
const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => { const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => {
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book' const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
this.$toast.error(errorMsg) this.$toast.error(errorMsg)
@@ -2,10 +2,10 @@
<div> <div>
<div class="flex flex-wrap"> <div class="flex flex-wrap">
<div class="w-1/5 p-1"> <div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.season" :label="$strings.LabelSeason" /> <ui-text-input-with-label v-model="newEpisode.season" trim-whitespace :label="$strings.LabelSeason" />
</div> </div>
<div class="w-1/5 p-1"> <div class="w-1/5 p-1">
<ui-text-input-with-label v-model="newEpisode.episode" :label="$strings.LabelEpisode" /> <ui-text-input-with-label v-model="newEpisode.episode" trim-whitespace :label="$strings.LabelEpisode" />
</div> </div>
<div class="w-1/5 p-1"> <div class="w-1/5 p-1">
<ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small /> <ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
@@ -14,10 +14,10 @@
<ui-text-input-with-label v-model="pubDateInput" ref="pubdate" type="datetime-local" :label="$strings.LabelPubDate" @input="updatePubDate" /> <ui-text-input-with-label v-model="pubDateInput" ref="pubdate" type="datetime-local" :label="$strings.LabelPubDate" @input="updatePubDate" />
</div> </div>
<div class="w-full p-1"> <div class="w-full p-1">
<ui-text-input-with-label v-model="newEpisode.title" :label="$strings.LabelTitle" /> <ui-text-input-with-label v-model="newEpisode.title" :label="$strings.LabelTitle" trim-whitespace />
</div> </div>
<div class="w-full p-1"> <div class="w-full p-1">
<ui-textarea-with-label v-model="newEpisode.subtitle" :label="$strings.LabelSubtitle" :rows="3" /> <ui-textarea-with-label v-model="newEpisode.subtitle" :label="$strings.LabelSubtitle" :rows="3" trim-whitespace />
</div> </div>
<div class="w-full p-1"> <div class="w-full p-1">
<ui-rich-text-editor :label="$strings.LabelDescription" v-model="newEpisode.description" /> <ui-rich-text-editor :label="$strings.LabelDescription" v-model="newEpisode.description" />
+13 -3
View File
@@ -215,6 +215,10 @@ export default {
inputBlur() { inputBlur() {
if (!this.isFocused) return if (!this.isFocused) return
if (typeof this.textInput === 'string') {
this.textInput = this.textInput.trim()
}
setTimeout(() => { setTimeout(() => {
if (document.activeElement === this.$refs.input) { if (document.activeElement === this.$refs.input) {
return return
@@ -231,6 +235,11 @@ export default {
}, },
forceBlur() { forceBlur() {
this.isFocused = false this.isFocused = false
if (typeof this.textInput === 'string') {
this.textInput = this.textInput.trim()
}
if (this.textInput) this.submitForm() if (this.textInput) this.submitForm()
if (this.$refs.input) this.$refs.input.blur() if (this.$refs.input) this.$refs.input.blur()
}, },
@@ -289,11 +298,12 @@ export default {
this.selectedMenuItemIndex = null this.selectedMenuItemIndex = null
}, },
submitForm() { submitForm() {
if (!this.textInput) return if (!this.textInput || !this.textInput.trim?.()) return
this.textInput = this.textInput.trim()
const cleaned = this.textInput.trim()
const matchesItem = this.items.find((i) => { const matchesItem = this.items.find((i) => {
return i.name === cleaned return i.name === this.textInput
}) })
if (matchesItem) { if (matchesItem) {
+6 -1
View File
@@ -40,7 +40,8 @@ export default {
showCopy: Boolean, showCopy: Boolean,
step: [String, Number], step: [String, Number],
min: [String, Number], min: [String, Number],
customInputClass: String customInputClass: String,
trimWhitespace: Boolean
}, },
data() { data() {
return { return {
@@ -101,9 +102,13 @@ export default {
this.$emit('focus') this.$emit('focus')
}, },
blurred() { blurred() {
if (this.trimWhitespace && typeof this.inputValue === 'string') {
this.inputValue = this.inputValue.trim()
}
this.isFocused = false this.isFocused = false
this.$emit('blur') this.$emit('blur')
}, },
change(e) { change(e) {
this.$emit('change', e.target.value) this.$emit('change', e.target.value)
}, },
+3 -2
View File
@@ -6,7 +6,7 @@
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em> <em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</label> </label>
</slot> </slot>
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :show-copy="showCopy" class="w-full" :class="inputClass" @blur="inputBlurred" /> <ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
</div> </div>
</template> </template>
@@ -24,7 +24,8 @@ export default {
readonly: Boolean, readonly: Boolean,
disabled: Boolean, disabled: Boolean,
inputClass: String, inputClass: String,
showCopy: Boolean showCopy: Boolean,
trimWhitespace: Boolean
}, },
data() { data() {
return {} return {}
+3 -1
View File
@@ -351,8 +351,10 @@ export default {
background-color: white; background-color: white;
} }
trix-editor { trix-editor {
max-height: calc(4 * 1lh); height: calc(4 * 1lh);
min-height: calc(4 * 1lh);
overflow-y: auto; overflow-y: auto;
resize: vertical;
} }
trix-editor * { trix-editor * {
@@ -3,10 +3,10 @@
<form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm"> <form class="w-full h-full px-2 md:px-4 py-6" @submit.prevent="submitForm">
<div class="flex flex-wrap -mx-1"> <div class="flex flex-wrap -mx-1">
<div class="w-full md:w-1/2 px-1"> <div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" @input="handleInputChange" /> <ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" trim-whitespace @input="handleInputChange" />
</div> </div>
<div class="flex-grow px-1 mt-2 md:mt-0"> <div class="flex-grow px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" :label="$strings.LabelSubtitle" @input="handleInputChange" /> <ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" :label="$strings.LabelSubtitle" trim-whitespace @input="handleInputChange" />
</div> </div>
</div> </div>
@@ -42,19 +42,19 @@
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" :label="$strings.LabelNarrators" :items="narrators" @input="handleInputChange" /> <ui-multi-select ref="narratorsSelect" v-model="details.narrators" :label="$strings.LabelNarrators" :items="narrators" @input="handleInputChange" />
</div> </div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0"> <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" @input="handleInputChange" /> <ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" trim-whitespace @input="handleInputChange" />
</div> </div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0"> <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" @input="handleInputChange" /> <ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" trim-whitespace @input="handleInputChange" />
</div> </div>
</div> </div>
<div class="flex flex-wrap mt-2 -mx-1"> <div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/4 px-1"> <div class="w-full md:w-1/4 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" @input="handleInputChange" /> <ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" trim-whitespace @input="handleInputChange" />
</div> </div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0"> <div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" @input="handleInputChange" /> <ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" trim-whitespace @input="handleInputChange" />
</div> </div>
<div class="flex-grow px-1 pt-6 mt-2 md:mt-0"> <div class="flex-grow px-1 pt-6 mt-2 md:mt-0">
<div class="flex justify-center"> <div class="flex justify-center">
+1
View File
@@ -124,6 +124,7 @@ export default {
this.updateSelectionMode(false) this.updateSelectionMode(false)
}, },
editEpisode({ libraryItem, episode }) { editEpisode({ libraryItem, episode }) {
this.$store.commit('setEpisodeTableEpisodeIds', [episode.id])
this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('setSelectedLibraryItem', libraryItem)
this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
@@ -3,14 +3,14 @@
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm"> <form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
<div class="flex -mx-1"> <div class="flex -mx-1">
<div class="w-1/2 px-1"> <div class="w-1/2 px-1">
<ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" @input="handleInputChange" /> <ui-text-input-with-label ref="titleInput" v-model="details.title" :label="$strings.LabelTitle" trim-whitespace @input="handleInputChange" />
</div> </div>
<div class="flex-grow px-1"> <div class="flex-grow px-1">
<ui-text-input-with-label ref="authorInput" v-model="details.author" :label="$strings.LabelAuthor" @input="handleInputChange" /> <ui-text-input-with-label ref="authorInput" v-model="details.author" :label="$strings.LabelAuthor" trim-whitespace @input="handleInputChange" />
</div> </div>
</div> </div>
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" class="mt-2" @input="handleInputChange" /> <ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" trim-whitespace class="mt-2" @input="handleInputChange" />
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" /> <ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" />
@@ -25,13 +25,13 @@
<div class="flex mt-2 -mx-1"> <div class="flex mt-2 -mx-1">
<div class="w-1/4 px-1"> <div class="w-1/4 px-1">
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" :label="$strings.LabelReleaseDate" @input="handleInputChange" /> <ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" :label="$strings.LabelReleaseDate" trim-whitespace @input="handleInputChange" />
</div> </div>
<div class="w-1/4 px-1"> <div class="w-1/4 px-1">
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" @input="handleInputChange" /> <ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" trim-whitespace @input="handleInputChange" />
</div> </div>
<div class="w-1/4 px-1"> <div class="w-1/4 px-1">
<ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" @input="handleInputChange" /> <ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" trim-whitespace @input="handleInputChange" />
</div> </div>
<div class="flex-grow px-1 pt-6"> <div class="flex-grow px-1 pt-6">
<div class="flex justify-center"> <div class="flex justify-center">
+4 -4
View File
@@ -22,7 +22,7 @@
<div v-if="openMapOptions" class="flex flex-wrap"> <div v-if="openMapOptions" class="flex flex-wrap">
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2"> <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.subtitle" /> <ui-checkbox v-model="selectedBatchUsage.subtitle" />
<ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" class="mb-5 ml-4" /> <ui-text-input-with-label ref="subtitleInput" v-model="batchDetails.subtitle" :disabled="!selectedBatchUsage.subtitle" :label="$strings.LabelSubtitle" trim-whitespace class="mb-5 ml-4" />
</div> </div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2"> <div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.authors" /> <ui-checkbox v-model="selectedBatchUsage.authors" />
@@ -31,7 +31,7 @@
</div> </div>
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2"> <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publishedYear" /> <ui-checkbox v-model="selectedBatchUsage.publishedYear" />
<ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" class="mb-5 ml-4" /> <ui-text-input-with-label ref="publishedYearInput" v-model="batchDetails.publishedYear" :disabled="!selectedBatchUsage.publishedYear" :label="$strings.LabelPublishYear" trim-whitespace class="mb-5 ml-4" />
</div> </div>
<div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2"> <div v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.series" /> <ui-checkbox v-model="selectedBatchUsage.series" />
@@ -51,11 +51,11 @@
</div> </div>
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2"> <div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.publisher" /> <ui-checkbox v-model="selectedBatchUsage.publisher" />
<ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" class="mb-5 ml-4" /> <ui-text-input-with-label ref="publisherInput" v-model="batchDetails.publisher" :disabled="!selectedBatchUsage.publisher" :label="$strings.LabelPublisher" trim-whitespace class="mb-5 ml-4" />
</div> </div>
<div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2"> <div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.language" /> <ui-checkbox v-model="selectedBatchUsage.language" />
<ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" class="mb-5 ml-4" /> <ui-text-input-with-label ref="languageInput" v-model="batchDetails.language" :disabled="!selectedBatchUsage.language" :label="$strings.LabelLanguage" trim-whitespace class="mb-5 ml-4" />
</div> </div>
<div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2"> <div v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.explicit" /> <ui-checkbox v-model="selectedBatchUsage.explicit" />
+3 -1
View File
@@ -251,6 +251,7 @@ class CollectionController {
/** /**
* DELETE: /api/collections/:id/book/:bookId * DELETE: /api/collections/:id/book/:bookId
* Remove a single book from a collection. Re-order books * Remove a single book from a collection. Re-order books
* Users with update permission can remove books from collections
* TODO: bookId is actually libraryItemId. Clients need updating to use bookId * TODO: bookId is actually libraryItemId. Clients need updating to use bookId
* *
* @param {CollectionControllerRequest} req * @param {CollectionControllerRequest} req
@@ -427,7 +428,8 @@ class CollectionController {
req.collection = collection req.collection = collection
} }
if (req.method == 'DELETE' && !req.user.canDelete) { // Users with update permission can remove books from collections
if (req.method == 'DELETE' && !req.params.bookId && !req.user.canDelete) {
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to delete without permission`) Logger.warn(`[CollectionController] User "${req.user.username}" attempted to delete without permission`)
return res.sendStatus(403) return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) { } else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
+1 -1
View File
@@ -365,7 +365,7 @@ class Book extends Model {
if (payload.metadata) { if (payload.metadata) {
const metadataStringKeys = ['title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language'] const metadataStringKeys = ['title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language']
metadataStringKeys.forEach((key) => { metadataStringKeys.forEach((key) => {
if (typeof payload.metadata[key] === 'string' && this[key] !== payload.metadata[key]) { if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && this[key] !== payload.metadata[key]) {
this[key] = payload.metadata[key] || null this[key] = payload.metadata[key] || null
if (key === 'title') { if (key === 'title') {
+3 -2
View File
@@ -202,8 +202,9 @@ class Podcast extends Model {
} else if (key === 'itunesPageUrl') { } else if (key === 'itunesPageUrl') {
newKey = 'itunesPageURL' newKey = 'itunesPageURL'
} }
if (typeof payload.metadata[key] === 'string' && payload.metadata[key] !== this[newKey]) { if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && payload.metadata[key] !== this[newKey]) {
this[newKey] = payload.metadata[key] this[newKey] = payload.metadata[key] || null
if (key === 'title') { if (key === 'title') {
this.titleIgnorePrefix = getTitleIgnorePrefix(this.title) this.titleIgnorePrefix = getTitleIgnorePrefix(this.title)
} }
+13
View File
@@ -286,10 +286,23 @@ module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => {
return reject(new Error(`Invalid content type "${response.headers?.['content-type'] || ''}"`)) return reject(new Error(`Invalid content type "${response.headers?.['content-type'] || ''}"`))
} }
const totalSize = parseInt(response.headers['content-length'], 10)
let downloadedSize = 0
// Write to filepath // Write to filepath
const writer = fs.createWriteStream(filepath) const writer = fs.createWriteStream(filepath)
response.data.pipe(writer) response.data.pipe(writer)
let lastProgress = 0
response.data.on('data', (chunk) => {
downloadedSize += chunk.length
const progress = totalSize ? Math.round((downloadedSize / totalSize) * 100) : 0
if (progress >= lastProgress + 5) {
Logger.debug(`[fileUtils] File "${Path.basename(filepath)}" download progress: ${progress}% (${downloadedSize}/${totalSize} bytes)`)
lastProgress = progress
}
})
writer.on('finish', resolve) writer.on('finish', resolve)
writer.on('error', reject) writer.on('error', reject)
}) })
+14 -6
View File
@@ -35,11 +35,18 @@ module.exports.nameToLastFirst = (firstLast) => {
return `${nameObj.last_name}, ${nameObj.first_name}` return `${nameObj.last_name}, ${nameObj.first_name}`
} }
// Handle any name string /**
* Parses a name string into an array of names
*
* @param {string} nameString - The name string to parse
* @returns {{ names: string[] }} Array of names
*/
module.exports.parse = (nameString) => { module.exports.parse = (nameString) => {
if (!nameString) return null if (!nameString) return null
var splitNames = [] let splitNames = []
const isCommaSeparated = nameString.includes(',')
// Example &LF: Friedman, Milton & Friedman, Rose // Example &LF: Friedman, Milton & Friedman, Rose
if (nameString.includes('&')) { if (nameString.includes('&')) {
nameString.split('&').forEach((asa) => (splitNames = splitNames.concat(asa.split(',')))) nameString.split('&').forEach((asa) => (splitNames = splitNames.concat(asa.split(','))))
@@ -59,17 +66,18 @@ module.exports.parse = (nameString) => {
} }
} }
var names = [] let names = []
// 1 name FIRST LAST // 1 name FIRST LAST
if (splitNames.length === 1) { if (splitNames.length === 1) {
names.push(parseName(nameString)) names.push(parseName(nameString))
} else { } else {
var firstChunkIsALastName = checkIsALastName(splitNames[0]) // Determines whether this is formatted as last, first or first last (only if using comma separator)
var isEvenNum = splitNames.length % 2 === 0 // Example: "Smith; James Jones" -> ["Smith", "James Jones"]
let firstChunkIsALastName = !isCommaSeparated ? false : checkIsALastName(splitNames[0])
let isEvenNum = splitNames.length % 2 === 0
if (!isEvenNum && firstChunkIsALastName) { if (!isEvenNum && firstChunkIsALastName) {
// console.error('Multi-name LAST,FIRST entry has a straggler (could be roman numerals or a suffix), ignore it')
splitNames = splitNames.slice(0, splitNames.length - 1) splitNames = splitNames.slice(0, splitNames.length - 1)
} }
+1
View File
@@ -311,6 +311,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
responseType: 'arraybuffer', responseType: 'arraybuffer',
headers: { headers: {
Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8', Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',
'Accept-Encoding': 'gzip, compress, deflate',
'User-Agent': userAgent 'User-Agent': userAgent
}, },
httpAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl), httpAgent: global.DisableSsrfRequestFilter?.(feedUrl) ? null : ssrfFilter(feedUrl),
@@ -0,0 +1,99 @@
const chai = require('chai')
const expect = chai.expect
const { parse, nameToLastFirst } = require('../../../../server/utils/parsers/parseNameString')
describe('parseNameString', () => {
describe('parse', () => {
it('returns null if nameString is empty', () => {
const result = parse('')
expect(result).to.be.null
})
it('parses single name in First Last format', () => {
const result = parse('John Smith')
expect(result.names).to.deep.equal(['John Smith'])
})
it('parses single name in Last, First format', () => {
const result = parse('Smith, John')
expect(result.names).to.deep.equal(['John Smith'])
})
it('parses multiple names separated by &', () => {
const result = parse('John Smith & Jane Doe')
expect(result.names).to.deep.equal(['John Smith', 'Jane Doe'])
})
it('parses multiple names separated by "and"', () => {
const result = parse('John Smith and Jane Doe')
expect(result.names).to.deep.equal(['John Smith', 'Jane Doe'])
})
it('parses multiple names separated by comma and "and"', () => {
const result = parse('John Smith, Jane Doe and John Doe')
expect(result.names).to.deep.equal(['John Smith', 'Jane Doe', 'John Doe'])
})
it('parses multiple names separated by semicolon', () => {
const result = parse('John Smith; Jane Doe')
expect(result.names).to.deep.equal(['John Smith', 'Jane Doe'])
})
it('parses multiple names in Last, First format', () => {
const result = parse('Smith, John, Doe, Jane')
expect(result.names).to.deep.equal(['John Smith', 'Jane Doe'])
})
it('parses multiple names with single word name', () => {
const result = parse('John Smith, Jones, James Doe, Ludwig von Mises')
expect(result.names).to.deep.equal(['John Smith', 'Jones', 'James Doe', 'Ludwig von Mises'])
})
it('parses multiple names with single word name listed first (semicolon separator)', () => {
const result = parse('Jones; John Smith; James Doe; Ludwig von Mises')
expect(result.names).to.deep.equal(['Jones', 'John Smith', 'James Doe', 'Ludwig von Mises'])
})
it('handles names with suffixes', () => {
const result = parse('Smith, John Jr.')
expect(result.names).to.deep.equal(['John Jr. Smith'])
})
it('handles compound last names', () => {
const result = parse('von Mises, Ludwig')
expect(result.names).to.deep.equal(['Ludwig von Mises'])
})
it('handles Chinese/Japanese/Korean names', () => {
const result = parse('张三, 李四')
expect(result.names).to.deep.equal(['张三', '李四'])
})
it('removes duplicate names', () => {
const result = parse('John Smith & John Smith')
expect(result.names).to.deep.equal(['John Smith'])
})
it('filters out empty names', () => {
const result = parse('John Smith,')
expect(result.names).to.deep.equal(['John Smith'])
})
})
describe('nameToLastFirst', () => {
it('converts First Last to Last, First format', () => {
const result = nameToLastFirst('John Smith')
expect(result).to.equal('Smith, John')
})
it('returns last name only when no first name', () => {
const result = nameToLastFirst('Smith')
expect(result).to.equal('Smith')
})
it('handles names with middle names', () => {
const result = nameToLastFirst('John Middle Smith')
expect(result).to.equal('Smith, John Middle')
})
})
})