mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-03 17:30:39 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36ef675556 | |||
| 0dd57a8912 | |||
| 25ae6dd59a | |||
| a37fe3c3d2 | |||
| 59bcbe0dfa | |||
| b5e69630de | |||
| 0bba709124 | |||
| e93bb5cb07 | |||
| 3f7af8acfb | |||
| 5e5a604d03 | |||
| 201e12ecc3 | |||
| 24d6e390f0 | |||
| 0cf7a6abec | |||
| 76ac0d001b | |||
| 00343a953b |
@@ -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" />
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user