Compare commits

..

10 Commits

Author SHA1 Message Date
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
19 changed files with 181 additions and 41 deletions
@@ -6,7 +6,7 @@
</div>
</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 class="w-full overflow-y-auto overflow-x-hidden max-h-96">
<div class="flex px-8 items-center py-2">
@@ -6,7 +6,7 @@
</div>
</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 class="py-4 px-4">
<h1 v-if="!showBatchCollectionModal" class="text-2xl">{{ $strings.LabelAddToCollection }}</h1>
@@ -1,5 +1,5 @@
<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 class="w-20 max-w-20 text-center">
<covers-collection-cover :book-items="books" :width="80" :height="40 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
@@ -6,7 +6,7 @@
</div>
</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 class="py-4 px-4">
<h1 v-if="!isBatch" class="text-2xl">{{ $strings.LabelAddToPlaylist }}</h1>
@@ -1,5 +1,5 @@
<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 class="w-16 max-w-16 text-center">
<covers-playlist-cover :items="items" :width="64" :height="64" />
@@ -2,10 +2,10 @@
<div>
<div class="flex flex-wrap">
<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 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 class="w-1/5 p-1">
<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" />
</div>
<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 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 class="w-full p-1">
<ui-rich-text-editor :label="$strings.LabelDescription" v-model="newEpisode.description" />
+13 -3
View File
@@ -215,6 +215,10 @@ export default {
inputBlur() {
if (!this.isFocused) return
if (typeof this.textInput === 'string') {
this.textInput = this.textInput.trim()
}
setTimeout(() => {
if (document.activeElement === this.$refs.input) {
return
@@ -231,6 +235,11 @@ export default {
},
forceBlur() {
this.isFocused = false
if (typeof this.textInput === 'string') {
this.textInput = this.textInput.trim()
}
if (this.textInput) this.submitForm()
if (this.$refs.input) this.$refs.input.blur()
},
@@ -289,11 +298,12 @@ export default {
this.selectedMenuItemIndex = null
},
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) => {
return i.name === cleaned
return i.name === this.textInput
})
if (matchesItem) {
+6 -1
View File
@@ -40,7 +40,8 @@ export default {
showCopy: Boolean,
step: [String, Number],
min: [String, Number],
customInputClass: String
customInputClass: String,
trimWhitespace: Boolean
},
data() {
return {
@@ -101,9 +102,13 @@ export default {
this.$emit('focus')
},
blurred() {
if (this.trimWhitespace && typeof this.inputValue === 'string') {
this.inputValue = this.inputValue.trim()
}
this.isFocused = false
this.$emit('blur')
},
change(e) {
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>
</label>
</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>
</template>
@@ -24,7 +24,8 @@ export default {
readonly: Boolean,
disabled: Boolean,
inputClass: String,
showCopy: Boolean
showCopy: Boolean,
trimWhitespace: Boolean
},
data() {
return {}
+3 -1
View File
@@ -351,8 +351,10 @@ export default {
background-color: white;
}
trix-editor {
max-height: calc(4 * 1lh);
height: calc(4 * 1lh);
min-height: calc(4 * 1lh);
overflow-y: auto;
resize: vertical;
}
trix-editor * {
@@ -3,10 +3,10 @@
<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="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 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>
@@ -42,19 +42,19 @@
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" :label="$strings.LabelNarrators" :items="narrators" @input="handleInputChange" />
</div>
<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 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 class="flex flex-wrap mt-2 -mx-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 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 class="flex-grow px-1 pt-6 mt-2 md:mt-0">
<div class="flex justify-center">
@@ -3,14 +3,14 @@
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
<div class="flex -mx-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 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>
<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" />
@@ -25,13 +25,13 @@
<div class="flex mt-2 -mx-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 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 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 class="flex-grow px-1 pt-6">
<div class="flex justify-center">
+4 -4
View File
@@ -22,7 +22,7 @@
<div v-if="openMapOptions" class="flex flex-wrap">
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<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 v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.authors" />
@@ -31,7 +31,7 @@
</div>
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<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 v-if="!isPodcastLibrary" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.series" />
@@ -51,11 +51,11 @@
</div>
<div v-if="!isPodcastLibrary && !isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<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 v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<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 v-if="!isMapAppend" class="flex items-center px-4 h-18 w-1/2">
<ui-checkbox v-model="selectedBatchUsage.explicit" />
+1 -1
View File
@@ -365,7 +365,7 @@ class Book extends Model {
if (payload.metadata) {
const metadataStringKeys = ['title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language']
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
if (key === 'title') {
+3 -2
View File
@@ -202,8 +202,9 @@ class Podcast extends Model {
} else if (key === 'itunesPageUrl') {
newKey = 'itunesPageURL'
}
if (typeof payload.metadata[key] === 'string' && payload.metadata[key] !== this[newKey]) {
this[newKey] = payload.metadata[key]
if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && payload.metadata[key] !== this[newKey]) {
this[newKey] = payload.metadata[key] || null
if (key === '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'] || ''}"`))
}
const totalSize = parseInt(response.headers['content-length'], 10)
let downloadedSize = 0
// Write to filepath
const writer = fs.createWriteStream(filepath)
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('error', reject)
})
+14 -6
View File
@@ -35,11 +35,18 @@ module.exports.nameToLastFirst = (firstLast) => {
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) => {
if (!nameString) return null
var splitNames = []
let splitNames = []
const isCommaSeparated = nameString.includes(',')
// Example &LF: Friedman, Milton & Friedman, Rose
if (nameString.includes('&')) {
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
if (splitNames.length === 1) {
names.push(parseName(nameString))
} else {
var firstChunkIsALastName = checkIsALastName(splitNames[0])
var isEvenNum = splitNames.length % 2 === 0
// Determines whether this is formatted as last, first or first last (only if using comma separator)
// Example: "Smith; James Jones" -> ["Smith", "James Jones"]
let firstChunkIsALastName = !isCommaSeparated ? false : checkIsALastName(splitNames[0])
let isEvenNum = splitNames.length % 2 === 0
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)
}
+1
View File
@@ -311,6 +311,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
responseType: 'arraybuffer',
headers: {
Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8',
'Accept-Encoding': 'gzip, compress, deflate',
'User-Agent': userAgent
},
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')
})
})
})