mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-03 00:06:46 +01:00
Add:Item metadata utils config page for managing tags #1163
This commit is contained in:
parent
54b41b15c2
commit
4d93e39fa9
@ -87,6 +87,11 @@ export default {
|
|||||||
id: 'config-notifications',
|
id: 'config-notifications',
|
||||||
title: this.$strings.HeaderNotifications,
|
title: this.$strings.HeaderNotifications,
|
||||||
path: '/config/notifications'
|
path: '/config/notifications'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-item-metadata-utils',
|
||||||
|
title: 'Item Metadata Utils',
|
||||||
|
path: '/config/item-metadata-utils'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="librariesTable">
|
<div>
|
||||||
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||||
<template v-for="library in libraryCopies">
|
<template v-for="library in libraryCopies">
|
||||||
<div :key="library.id" class="item">
|
<div :key="library.id" class="item">
|
||||||
|
35
client/pages/config/item-metadata-utils/index.vue
Normal file
35
client/pages/config/item-metadata-utils/index.vue
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<app-settings-content :header-text="'Item Metadata Utils'">
|
||||||
|
<nuxt-link to="/config/item-metadata-utils/tags" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 mt-6 mb-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<p>Manage Tags</p>
|
||||||
|
<span class="material-icons">arrow_forward</span>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
|
<!-- <nuxt-link to="/config/item-metadata-utils/tags" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<p>Manage Genres</p>
|
||||||
|
<span class="material-icons">arrow_forward</span>
|
||||||
|
</div>
|
||||||
|
</nuxt-link> -->
|
||||||
|
</app-settings-content>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
watch: {},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
init() {}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
171
client/pages/config/item-metadata-utils/tags.vue
Normal file
171
client/pages/config/item-metadata-utils/tags.vue
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8 relative" style="min-height: 200px">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center">
|
||||||
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
|
<h1 class="text-xl mx-2">Manage Tags</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!tags.length && !loading" class="text-center py-8 text-lg">No Tags</p>
|
||||||
|
|
||||||
|
<div class="border border-white/10">
|
||||||
|
<template v-for="(tag, index) in tags">
|
||||||
|
<div :key="tag" class="w-full p-2 flex items-center text-gray-400 hover:text-white" :class="{ 'bg-primary/20': index % 2 === 0 }">
|
||||||
|
<p v-if="editingTag !== tag" class="text-sm md:text-base text-gray-100">{{ tag }}</p>
|
||||||
|
<ui-text-input v-else v-model="newTagName" />
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<template v-if="editingTag !== tag">
|
||||||
|
<ui-icon-btn v-if="editingTag !== tag" icon="edit" borderless :size="8" icon-font-size="1.1rem" class="mx-1" @click="editTagClick(tag)" />
|
||||||
|
<ui-icon-btn v-if="editingTag !== tag" icon="delete" borderless :size="8" icon-font-size="1.1rem" @click="removeTagClick(tag)" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<ui-btn color="success" small class="mx-2" @click.stop="saveTagClick">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
|
<ui-btn small @click.stop="cancelEditClick">{{ $strings.ButtonCancel }}</ui-btn>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="absolute top-0 left-0 w-full h-full bg-black/25 flex items-center justify-center">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
tags: [],
|
||||||
|
editingTag: null,
|
||||||
|
newTagName: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {},
|
||||||
|
computed: {},
|
||||||
|
methods: {
|
||||||
|
cancelEditClick() {
|
||||||
|
this.newTagName = ''
|
||||||
|
this.editingTag = null
|
||||||
|
},
|
||||||
|
removeTagClick(tag) {
|
||||||
|
const payload = {
|
||||||
|
message: `Are you sure you want to remove tag "${tag}" from all items?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.removeTag(tag)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
saveTagClick() {
|
||||||
|
this.newTagName = this.newTagName.trim()
|
||||||
|
if (!this.newTagName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.editingTag === this.newTagName) {
|
||||||
|
this.cancelEditClick()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagNameExists = this.tags.find((t) => t !== this.editingTag && t === this.newTagName)
|
||||||
|
const tagNameExistsOfDifferentCase = !tagNameExists ? this.tags.find((t) => t !== this.editingTag && t.toLowerCase() === this.newTagName.toLowerCase()) : null
|
||||||
|
console.log('Tag name', this.newTagName, 'ExistS?', tagNameExists, tagNameExistsOfDifferentCase)
|
||||||
|
console.log('Saving tag', this.editingTag, 'with name', this.newTagName)
|
||||||
|
|
||||||
|
let message = `Are you sure you want to rename tag "${this.editingTag}" to "${this.newTagName}" for all items?`
|
||||||
|
if (tagNameExists) {
|
||||||
|
message += '<br><span class="text-sm">Note: This tag already exists so the two tags will be merged.</span>'
|
||||||
|
} else if (tagNameExistsOfDifferentCase) {
|
||||||
|
message += `<br><span class="text-warning text-sm">Warning! A similar tag with a different casing already exists "${tagNameExistsOfDifferentCase}".</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
message,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.renameTag()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
renameTag() {
|
||||||
|
this.loading = true
|
||||||
|
let _newTagName = this.newTagName
|
||||||
|
let _editingTag = this.editingTag
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
tag: _editingTag,
|
||||||
|
newTag: _newTagName
|
||||||
|
}
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/tags/rename', payload)
|
||||||
|
.then((res) => {
|
||||||
|
this.$toast.success(`${res.numItemsUpdated} Items Updated`)
|
||||||
|
if (res.tagMerged) {
|
||||||
|
this.tags = this.tags.filter((t) => t !== _newTagName)
|
||||||
|
}
|
||||||
|
this.tags = this.tags.map((t) => {
|
||||||
|
if (t === _editingTag) return _newTagName
|
||||||
|
return t
|
||||||
|
})
|
||||||
|
this.cancelEditClick()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to rename tag', error)
|
||||||
|
this.$toast.error('Failed to rename tag')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeTag(tag) {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/tags/${this.$encode(tag)}`)
|
||||||
|
.then((res) => {
|
||||||
|
this.$toast.success(`${res.numItemsUpdated} Items Updated`)
|
||||||
|
this.tags = this.tags.filter((t) => t !== tag)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove tag', error)
|
||||||
|
this.$toast.error('Failed to remove tag')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
editTagClick(tag) {
|
||||||
|
this.newTagName = tag
|
||||||
|
this.editingTag = tag
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.loading = true
|
||||||
|
this.$axios
|
||||||
|
.$get('/api/tags')
|
||||||
|
.then((data) => {
|
||||||
|
this.tags = data.tags || []
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load tags', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
@ -233,7 +233,7 @@ class Db {
|
|||||||
return null
|
return null
|
||||||
}))
|
}))
|
||||||
|
|
||||||
var libraryItemIds = libraryItems.map(li => li.id)
|
const libraryItemIds = libraryItems.map(li => li.id)
|
||||||
return this.libraryItemsDb.update((record) => libraryItemIds.includes(record.id), (record) => {
|
return this.libraryItemsDb.update((record) => libraryItemIds.includes(record.id), (record) => {
|
||||||
return libraryItems.find(li => li.id === record.id)
|
return libraryItems.find(li => li.id === record.id)
|
||||||
}).then((results) => {
|
}).then((results) => {
|
||||||
|
@ -206,6 +206,7 @@ class Server {
|
|||||||
'/library/:library/podcast/latest',
|
'/library/:library/podcast/latest',
|
||||||
'/config/users/:id',
|
'/config/users/:id',
|
||||||
'/config/users/:id/sessions',
|
'/config/users/:id/sessions',
|
||||||
|
'/config/item-metadata-utils/:id',
|
||||||
'/collection/:id',
|
'/collection/:id',
|
||||||
'/playlist/:id'
|
'/playlist/:id'
|
||||||
]
|
]
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
|
||||||
const filePerms = require('../utils/filePerms')
|
const filePerms = require('../utils/filePerms')
|
||||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||||
const { isObject } = require('../utils/index')
|
const { isObject } = require('../utils/index')
|
||||||
@ -124,12 +126,13 @@ class MiscController {
|
|||||||
res.json(userResponse)
|
res.json(userResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: api/tags
|
||||||
getAllTags(req, res) {
|
getAllTags(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
|
Logger.error(`[MiscController] Non-admin user attempted to getAllTags`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
var tags = []
|
const tags = []
|
||||||
this.db.libraryItems.forEach((li) => {
|
this.db.libraryItems.forEach((li) => {
|
||||||
if (li.media.tags && li.media.tags.length) {
|
if (li.media.tags && li.media.tags.length) {
|
||||||
li.media.tags.forEach((tag) => {
|
li.media.tags.forEach((tag) => {
|
||||||
@ -142,6 +145,73 @@ class MiscController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST: api/tags/rename
|
||||||
|
async renameTag(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[MiscController] Non-admin user attempted to renameTag`)
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = req.body.tag
|
||||||
|
const newTag = req.body.newTag
|
||||||
|
if (!tag || !newTag) {
|
||||||
|
Logger.error(`[MiscController] Invalid request body for renameTag`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
let tagMerged = false
|
||||||
|
let numItemsUpdated = 0
|
||||||
|
|
||||||
|
for (const li of this.db.libraryItems) {
|
||||||
|
if (!li.media.tags || !li.media.tags.length) continue
|
||||||
|
|
||||||
|
if (li.media.tags.includes(newTag)) tagMerged = true // new tag is an existing tag so this is a merge
|
||||||
|
|
||||||
|
if (li.media.tags.includes(tag)) {
|
||||||
|
li.media.tags = li.media.tags.filter(t => t !== tag) // Remove old tag
|
||||||
|
if (!li.media.tags.includes(newTag)) {
|
||||||
|
li.media.tags.push(newTag) // Add new tag
|
||||||
|
}
|
||||||
|
Logger.debug(`[MiscController] Rename tag "${tag}" to "${newTag}" for item "${li.media.metadata.title}"`)
|
||||||
|
await this.db.updateLibraryItem(li)
|
||||||
|
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||||
|
numItemsUpdated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
tagMerged,
|
||||||
|
numItemsUpdated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: api/tags/:tag
|
||||||
|
async deleteTag(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[MiscController] Non-admin user attempted to deleteTag`)
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = Buffer.from(decodeURIComponent(req.params.tag), 'base64').toString()
|
||||||
|
|
||||||
|
let numItemsUpdated = 0
|
||||||
|
for (const li of this.db.libraryItems) {
|
||||||
|
if (!li.media.tags || !li.media.tags.length) continue
|
||||||
|
|
||||||
|
if (li.media.tags.includes(tag)) {
|
||||||
|
li.media.tags = li.media.tags.filter(t => t !== tag)
|
||||||
|
Logger.debug(`[MiscController] Remove tag "${tag}" from item "${li.media.metadata.title}"`)
|
||||||
|
await this.db.updateLibraryItem(li)
|
||||||
|
SocketAuthority.emitter('item_updated', li.toJSONExpanded())
|
||||||
|
numItemsUpdated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
numItemsUpdated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
validateCronExpression(req, res) {
|
validateCronExpression(req, res) {
|
||||||
const expression = req.body.expression
|
const expression = req.body.expression
|
||||||
if (!expression) {
|
if (!expression) {
|
||||||
|
@ -272,6 +272,8 @@ class ApiRouter {
|
|||||||
this.router.patch('/settings', MiscController.updateServerSettings.bind(this))
|
this.router.patch('/settings', MiscController.updateServerSettings.bind(this))
|
||||||
this.router.post('/authorize', MiscController.authorize.bind(this))
|
this.router.post('/authorize', MiscController.authorize.bind(this))
|
||||||
this.router.get('/tags', MiscController.getAllTags.bind(this))
|
this.router.get('/tags', MiscController.getAllTags.bind(this))
|
||||||
|
this.router.post('/tags/rename', MiscController.renameTag.bind(this))
|
||||||
|
this.router.delete('/tags/:tag', MiscController.deleteTag.bind(this))
|
||||||
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
|
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user