diff --git a/client/components/controls/FilterSelect.vue b/client/components/controls/FilterSelect.vue
index 2c6294f3..a87fb3d8 100644
--- a/client/components/controls/FilterSelect.vue
+++ b/client/components/controls/FilterSelect.vue
@@ -76,6 +76,11 @@ export default {
text: 'Tag',
value: 'tags',
sublist: true
+ },
+ {
+ text: 'Series',
+ value: 'series',
+ sublist: true
}
]
}
@@ -116,6 +121,9 @@ export default {
tags() {
return this.$store.state.audiobooks.tags
},
+ series() {
+ return this.$store.state.audiobooks.series
+ },
sublistItems() {
return this[this.sublist] || []
}
diff --git a/client/components/modals/edit-tabs/Details.vue b/client/components/modals/edit-tabs/Details.vue
index a1183e7d..1f8c172f 100644
--- a/client/components/modals/edit-tabs/Details.vue
+++ b/client/components/modals/edit-tabs/Details.vue
@@ -21,11 +21,20 @@
-
+
+
+
-
+
Remove
@@ -55,8 +64,8 @@ export default {
publishYear: null,
genres: []
},
- resettingProgress: false,
- genres: ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
+ newTags: [],
+ resettingProgress: false
}
},
watch: {
@@ -87,21 +96,25 @@ export default {
},
userProgress() {
return this.userAudiobook ? this.userAudiobook.progress : 0
+ },
+ genres() {
+ return this.$store.state.audiobooks.genres
+ },
+ tags() {
+ return this.$store.state.audiobooks.tags
+ },
+ series() {
+ return this.$store.state.audiobooks.series
}
},
methods: {
- addGenre(genre) {
- this.genres.push({
- text: genre,
- value: genre
- })
- },
async submitForm() {
- console.log('Submit form', this.details)
this.isProcessing = true
const updatePayload = {
- book: this.details
+ book: this.details,
+ tags: this.newTags
}
+
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
@@ -120,6 +133,8 @@ export default {
this.details.genres = this.book.genres || []
this.details.series = this.book.series
this.details.publishYear = this.book.publishYear
+
+ this.newTags = this.audiobook.tags || []
},
resetProgress() {
if (confirm(`Are you sure you want to reset your progress?`)) {
diff --git a/client/components/ui/InputDropdown.vue b/client/components/ui/InputDropdown.vue
new file mode 100644
index 00000000..879c8b8d
--- /dev/null
+++ b/client/components/ui/InputDropdown.vue
@@ -0,0 +1,112 @@
+
+
+
{{ label }}
+
+
+
+
+
+ -
+
+ {{ item }}
+
+
+ checkmark
+
+
+
+ -
+
+ No items
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/ui/MultiSelect.vue b/client/components/ui/MultiSelect.vue
index 3a1c62ad..0158d7ee 100644
--- a/client/components/ui/MultiSelect.vue
+++ b/client/components/ui/MultiSelect.vue
@@ -4,7 +4,7 @@
@@ -13,7 +13,7 @@
- {{ snakeToNormal(item) }}
+ {{ $snakeToNormal(item) }}
checkmark
@@ -47,7 +47,6 @@ export default {
return {
textInput: null,
currentSearch: null,
- isTyping: false,
typingTimeout: null,
isFocused: false,
menu: null
@@ -71,38 +70,15 @@ export default {
}
return this.items.filter((i) => {
- var normie = this.snakeToNormal(i)
+ var normie = this.$snakeToNormal(i)
var iValue = String(normie).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
}
},
methods: {
- snakeToNormal(kebab) {
- if (!kebab) {
- return 'err'
- }
- return String(kebab)
- .split('_')
- .map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
- .join(' ')
- },
- normalToSnake(normie) {
- return normie
- .trim()
- .split(' ')
- .map((t) => t.toLowerCase())
- .join('_')
- },
- setMatchingItems() {
- if (!this.textInput) {
- return
- }
- this.currentSearch = this.textInput
- },
keydownInput() {
clearTimeout(this.typingTimeout)
- this.isTyping = true
this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput
}, 100)
@@ -122,6 +98,7 @@ export default {
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
this.menu.style.left = boundingBox.x + 'px'
this.menu.style.width = boundingBox.width + 'px'
+ console.log('Recalc menu pos', boundingBox.height)
},
unmountMountMenu() {
if (!this.$refs.menu) return
@@ -169,6 +146,9 @@ export default {
this.textInput = null
this.currentSearch = null
this.$emit('input', newSelected)
+ this.$nextTick(() => {
+ this.recalcMenuPos()
+ })
},
clickWrapper() {
if (this.showMenu) {
@@ -177,9 +157,8 @@ export default {
this.focus()
},
insertNewItem(item) {
- var kebabItem = this.normalToSnake(item)
+ var kebabItem = this.$normalToSnake(item)
this.selected.push(kebabItem)
- this.$emit('addOption', kebabItem)
this.$emit('input', this.selected)
this.textInput = null
this.currentSearch = null
@@ -191,7 +170,7 @@ export default {
if (!this.textInput) return
var cleaned = this.textInput.toLowerCase().trim()
- var cleanedKebab = this.normalToSnake(cleaned)
+ var cleanedKebab = this.$normalToSnake(cleaned)
var matchesItem = this.items.find((i) => {
return i === cleaned || cleanedKebab === i
})
diff --git a/client/layouts/default.vue b/client/layouts/default.vue
index 17ef8cbb..cad1a7b1 100644
--- a/client/layouts/default.vue
+++ b/client/layouts/default.vue
@@ -145,6 +145,15 @@ export default {
},
mounted() {
this.initializeSocket()
+ // var test1 = 'crab rangoon'
+ // var code = this.$stringToCode(test1)
+ // var str = this.$codeToString(code)
+ // console.log(code, str, test1 === str)
+
+ // var test2 = 'pig~iN.A._BlNan190a Fry em like b**&& A!@#%$&acn()'
+ // var code2 = this.$stringToCode(test2)
+ // var str2 = this.$codeToString(code2)
+ // console.log(code2, code2.length, str2, str2.length, test2 === str2)
}
}
\ No newline at end of file
diff --git a/client/package.json b/client/package.json
index 1316abb4..c8a3ed86 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
- "version": "0.9.62-beta",
+ "version": "0.9.64-beta",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js
index a8f9acca..15d70bac 100644
--- a/client/plugins/init.client.js
+++ b/client/plugins/init.client.js
@@ -38,6 +38,77 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
}
+Vue.prototype.$snakeToNormal = (snake) => {
+ if (!snake) {
+ return ''
+ }
+ return String(snake)
+ .split('_')
+ .map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
+ .join(' ')
+}
+
+Vue.prototype.$normalToSnake = (normie) => {
+ if (!normie) return ''
+ return normie
+ .trim()
+ .split(' ')
+ .map((t) => t.toLowerCase())
+ .join('_')
+}
+
+const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
+const getCharCode = (char) => availableChars.indexOf(char)
+const getCharFromCode = (code) => availableChars[Number(code)] || -1
+const cleanChar = (char) => getCharCode(char) < 0 ? '?' : char
+
+Vue.prototype.$cleanString = (str) => {
+ if (!str) return ''
+
+ // replace accented characters: https://stackoverflow.com/a/49901740/7431543
+ str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
+
+ var cleaned = ''
+ for (let i = 0; i < str.length; i++) {
+ cleaned += cleanChar(str[i])
+ }
+ return cleaned
+}
+
+Vue.prototype.$stringToCode = (str) => {
+ if (!str) return ''
+ var numcode = [...str].map(s => {
+ return String(getCharCode(s)).padStart(2, '0')
+ }).join('')
+ return BigInt(numcode).toString(36)
+}
+
+Vue.prototype.$codeToString = (code) => {
+ if (!code) return ''
+ var numcode = ''
+ try {
+ numcode = [...code].reduce((acc, curr) => {
+ return BigInt(parseInt(curr, 36)) + BigInt(36) * acc
+ }, 0n)
+ } catch (err) {
+ console.error('numcode fialed', code, err)
+ }
+ var numcodestr = String(numcode)
+
+ var remainder = numcodestr.length % 2
+ numcodestr = numcodestr.padStart(numcodestr.length - 1 + remainder, '0')
+
+ var finalform = ''
+ var numChunks = Math.floor(numcodestr.length / 2)
+ var remaining = numcodestr
+ for (let i = 0; i < numChunks; i++) {
+ var chunk = remaining.slice(0, 2)
+ remaining = remaining.slice(2)
+ finalform += getCharFromCode(chunk)
+ }
+ return finalform
+}
+
function loadImageBlob(uri) {
return new Promise((resolve) => {
const img = document.createElement('img')
diff --git a/client/store/audiobooks.js b/client/store/audiobooks.js
index 31ce3e8b..db648991 100644
--- a/client/store/audiobooks.js
+++ b/client/store/audiobooks.js
@@ -6,7 +6,8 @@ export const state = () => ({
audiobooks: [],
listeners: [],
genres: [...STANDARD_GENRES],
- tags: []
+ tags: [],
+ series: []
})
export const getters = {
@@ -14,19 +15,15 @@ export const getters = {
var filtered = state.audiobooks
var settings = rootState.settings.settings || {}
var filterBy = settings.filterBy || ''
- var filterByParts = filterBy.split('.')
- if (filterByParts.length > 1) {
- var primary = filterByParts[0]
- var secondary = filterByParts[1]
- if (primary === 'genres') {
- filtered = filtered.filter(ab => {
- return ab.book && ab.book.genres.includes(secondary)
- })
- } else if (primary === 'tags') {
- filtered = filtered.filter(ab => ab.tags.includes(secondary))
- }
+
+ var searchGroups = ['genres', 'tags', 'series']
+ var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
+ if (group) {
+ var filter = filterBy.replace(`${group}.`, '')
+ if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
+ else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
+ else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
}
- // TODO: Add filters
return filtered
},
getFilteredAndSorted: (state, getters, rootState) => () => {
@@ -65,6 +62,7 @@ export const mutations = {
genres = genres.concat(ab.book.genres)
})
state.genres = [...new Set(genres)] // Remove Duplicates
+ state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
// TAGS
var tags = []
@@ -72,6 +70,16 @@ export const mutations = {
tags = tags.concat(ab.tags)
})
state.tags = [...new Set(tags)] // Remove Duplicates
+ state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
+
+ // SERIES
+ var series = []
+ audiobooks.forEach((ab) => {
+ if (!ab.book || !ab.book.series || series.includes(ab.book.series)) return
+ series.push(ab.book.series)
+ })
+ state.series = series
+ state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
state.audiobooks = audiobooks
state.listeners.forEach((listener) => {
@@ -80,19 +88,34 @@ export const mutations = {
},
addUpdate(state, audiobook) {
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
+ var origAudiobook = null
if (index >= 0) {
+ origAudiobook = { ...state.audiobooks[index] }
state.audiobooks.splice(index, 1, audiobook)
} else {
state.audiobooks.push(audiobook)
}
- // GENRES
if (audiobook.book) {
+ // GENRES
var newGenres = []
audiobook.book.genres.forEach((genre) => {
if (!state.genres.includes(genre)) newGenres.push(genre)
})
- if (newGenres.length) state.genres = state.genres.concat(newGenres)
+ if (newGenres.length) {
+ state.genres = state.genres.concat(newGenres)
+ state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
+ }
+
+ // SERIES
+ if (audiobook.book.series && !state.series.includes(audiobook.book.series)) {
+ state.series.push(audiobook.book.series)
+ state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
+ }
+ if (origAudiobook && origAudiobook.book && origAudiobook.book.series) {
+ var isInAB = state.audiobooks.find(ab => ab.book && ab.book.series === origAudiobook.book.series)
+ if (!isInAB) state.series = state.series.filter(series => series !== origAudiobook.book.series)
+ }
}
// TAGS
@@ -100,8 +123,10 @@ export const mutations = {
audiobook.tags.forEach((tag) => {
if (!state.tags.includes(tag)) newTags.push(tag)
})
- if (newTags.length) state.tags = state.tags.concat(newTags)
-
+ if (newTags.length) {
+ state.tags = state.tags.concat(newTags)
+ state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
+ }
state.listeners.forEach((listener) => {
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
@@ -112,8 +137,8 @@ export const mutations = {
remove(state, audiobook) {
state.audiobooks = state.audiobooks.filter(a => a.id !== audiobook.id)
- // GENRES
if (audiobook.book) {
+ // GENRES
audiobook.book.genres.forEach((genre) => {
if (!STANDARD_GENRES.includes(genre)) {
var isInOtherAB = state.audiobooks.find(ab => {
@@ -125,6 +150,15 @@ export const mutations = {
}
}
})
+
+ // SERIES
+ if (audiobook.book.series) {
+ var isInOtherAB = state.audiobooks.find(ab => ab.book && ab.book.series === audiobook.book.series)
+ if (!isInOtherAB) {
+ // Series not used in any other audiobook - remove it
+ state.series = state.series.filter(s => s !== audiobook.book.series)
+ }
+ }
}
// TAGS
@@ -138,7 +172,6 @@ export const mutations = {
}
})
-
state.listeners.forEach((listener) => {
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
listener.meth()
diff --git a/package.json b/package.json
index 556551df..c0e21a58 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "0.9.62-beta",
+ "version": "0.9.64-beta",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js",
"scripts": {
diff --git a/server/utils/index.js b/server/utils/index.js
index c740c222..1ac2f642 100644
--- a/server/utils/index.js
+++ b/server/utils/index.js
@@ -23,4 +23,21 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
}
return track[str2.length][str1.length];
}
-module.exports.levenshteinDistance = levenshteinDistance
\ No newline at end of file
+module.exports.levenshteinDistance = levenshteinDistance
+
+const cleanString = (str) => {
+ if (!str) return ''
+
+ // replace accented characters: https://stackoverflow.com/a/49901740/7431543
+ str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
+
+ const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
+ const cleanChar = (char) => availableChars.indexOf(char) < 0 ? '?' : char
+
+ var cleaned = ''
+ for (let i = 0; i < str.length; i++) {
+ cleaned += cleanChar(str[i])
+ }
+ return cleaned
+}
+module.exports.cleanString = cleanString
\ No newline at end of file
diff --git a/server/utils/scandir.js b/server/utils/scandir.js
index a5490638..bbd00099 100644
--- a/server/utils/scandir.js
+++ b/server/utils/scandir.js
@@ -1,6 +1,7 @@
const Path = require('path')
const dir = require('node-dir')
const Logger = require('../Logger')
+const { cleanString } = require('./index')
const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3']
const INFO_FORMATS = ['nfo']
@@ -64,7 +65,7 @@ async function getAllAudiobookFiles(abRootPath) {
audiobooks[path] = {
author: author,
title: title,
- series: series,
+ series: cleanString(series),
publishYear: publishYear,
path: relpath,
fullPath: Path.join(abRootPath, path),