Skip to content

Commit

Permalink
Update:Remove image path input from author modal, add API endpoints f…
Browse files Browse the repository at this point in the history
…or uploading and removing author image
  • Loading branch information
advplyr committed Oct 13, 2023
1 parent 290a377 commit 656c81a
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 80 deletions.
90 changes: 60 additions & 30 deletions client/components/modals/authors/EditModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,23 @@
<p class="text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<form v-if="author" @submit.prevent="submitForm">
<div class="flex">
<div class="w-40 p-2">
<div class="w-full h-45 relative">
<covers-author-image :author="author" />
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
</div>
<div v-if="author" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
<div class="flex">
<div class="w-40 p-2">
<div class="w-full h-45 relative">
<covers-author-image :author="author" />
<div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
</div>
</div>
<div class="flex-grow">
</div>
<div class="flex-grow">
<form @submit.prevent="submitUploadCover" class="flex flex-grow mb-2 p-2">
<ui-text-input v-model="imageUrl" :placeholder="$strings.LabelImageURLFromTheWeb" class="h-9 w-full" />
<ui-btn color="success" type="submit" :padding-x="4" :disabled="!imageUrl" class="ml-2 sm:ml-3 w-24 h-9">{{ $strings.ButtonSubmit }}</ui-btn>
</form>

<form v-if="author" @submit.prevent="submitForm">
<div class="flex">
<div class="w-3/4 p-2">
<ui-text-input-with-label v-model="authorCopy.name" :disabled="processing" :label="$strings.LabelName" />
Expand All @@ -25,9 +30,9 @@
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
</div>
</div>
<div class="p-2">
<!-- <div class="p-2">
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
</div>
</div> -->
<div class="p-2">
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
</div>
Expand All @@ -39,9 +44,9 @@

<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</div>
</form>
</div>
</form>
</div>
</div>
</modals-modal>
</template>
Expand All @@ -53,9 +58,9 @@ export default {
authorCopy: {
name: '',
asin: '',
description: '',
imagePath: ''
description: ''
},
imageUrl: '',
processing: false
}
},
Expand Down Expand Up @@ -100,10 +105,10 @@ export default {
},
methods: {
init() {
this.imageUrl = ''
this.authorCopy.name = this.author.name
this.authorCopy.asin = this.author.asin
this.authorCopy.description = this.author.description
this.authorCopy.imagePath = this.author.imagePath
},
removeClick() {
const payload = {
Expand Down Expand Up @@ -131,7 +136,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload)
},
async submitForm() {
var keysToCheck = ['name', 'asin', 'description', 'imagePath']
var keysToCheck = ['name', 'asin', 'description']
var updatePayload = {}
keysToCheck.forEach((key) => {
if (this.authorCopy[key] !== this.author[key]) {
Expand Down Expand Up @@ -160,21 +165,46 @@ export default {
}
this.processing = false
},
async removeCover() {
var updatePayload = {
imagePath: null
removeCover() {
this.processing = true
this.$axios
.$delete(`/api/authors/${this.authorId}/image`)
.then((data) => {
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
this.$store.commit('globals/showEditAuthorModal', data.author)
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
})
.finally(() => {
this.processing = false
})
},
submitUploadCover() {
if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) {
this.$toast.error('Invalid image url')
return
}
this.processing = true
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
console.error('Failed', error)
this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed)
return null
})
if (result && result.updated) {
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
this.$store.commit('globals/showEditAuthorModal', result.author)
const updatePayload = {
url: this.imageUrl
}
this.processing = false
this.$axios
.$post(`/api/authors/${this.authorId}/image`, updatePayload)
.then((data) => {
this.imageUrl = ''
this.$toast.success('Author image updated')
this.$store.commit('globals/showEditAuthorModal', data.author)
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error(error.response.data || 'Failed to remove author image')
})
.finally(() => {
this.processing = false
})
},
async searchAuthor() {
if (!this.authorCopy.name && !this.authorCopy.asin) {
Expand Down
103 changes: 76 additions & 27 deletions server/controllers/AuthorController.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,30 +67,10 @@ class AuthorController {
const payload = req.body
let hasUpdated = false

// Updating/removing cover image
if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) {
if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file
await CacheManager.purgeImageCache(req.author.id) // Purge cache
await CoverManager.removeFile(req.author.imagePath)
} else if (payload.imagePath.startsWith('http')) { // Check if image path is a url
const imageData = await AuthorFinder.saveAuthorImage(req.author.id, payload.imagePath)
if (imageData) {
if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
payload.imagePath = imageData.path
hasUpdated = true
}
} else if (payload.imagePath && payload.imagePath !== req.author.imagePath) { // Changing image path locally
if (!await fs.pathExists(payload.imagePath)) { // Make sure image path exists
Logger.error(`[AuthorController] Image path does not exist: "${payload.imagePath}"`)
return res.status(400).send('Author image path does not exist')
}

if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}
}
// author imagePath must be set through other endpoints as of v2.4.5
if (payload.imagePath !== undefined) {
Logger.warn(`[AuthorController] Updating local author imagePath is not supported`)
delete payload.imagePath
}

const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name
Expand Down Expand Up @@ -131,7 +111,7 @@ class AuthorController {
Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id)

// Send updated num books for merged author
const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length
const numBooks = (await Database.libraryItemModel.getForAuthor(existingAuthor)).length
SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks))

res.json({
Expand Down Expand Up @@ -191,6 +171,75 @@ class AuthorController {
res.sendStatus(200)
}

/**
* POST: /api/authors/:id/image
* Upload author image from web URL
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async uploadImage(req, res) {
if (!req.user.canUpload) {
Logger.warn('User attempted to upload an image without permission', req.user)
return res.sendStatus(403)
}
if (!req.body.url) {
Logger.error(`[AuthorController] Invalid request payload. 'url' not in request body`)
return res.status(400).send(`Invalid request payload. 'url' not in request body`)
}
if (!req.body.url.startsWith?.('http:') && !req.body.url.startsWith?.('https:')) {
Logger.error(`[AuthorController] Invalid request payload. Invalid url "${req.body.url}"`)
return res.status(400).send(`Invalid request payload. Invalid url "${req.body.url}"`)
}

Logger.debug(`[AuthorController] Requesting download author image from url "${req.body.url}"`)
const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url)

if (result?.error) {
return res.status(400).send(result.error)
} else if (!result?.path) {
return res.status(500).send('Unknown error occurred')
}

if (req.author.imagePath) {
await CacheManager.purgeImageCache(req.author.id) // Purge cache
}

req.author.imagePath = result.path
await Database.authorModel.updateFromOld(req.author)

const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
res.json({
author: req.author.toJSON()
})
}

/**
* DELETE: /api/authors/:id/image
* Remove author image & delete image file
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async deleteImage(req, res) {
if (!req.author.imagePath) {
Logger.error(`[AuthorController] Author "${req.author.imagePath}" has no imagePath set`)
return res.status(400).send('Author has no image path set')
}
Logger.info(`[AuthorController] Removing image for author "${req.author.name}" at "${req.author.imagePath}"`)
await CacheManager.purgeImageCache(req.author.id) // Purge cache
await CoverManager.removeFile(req.author.imagePath)
req.author.imagePath = null
await Database.authorModel.updateFromOld(req.author)

const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
res.json({
author: req.author.toJSON()
})
}

async match(req, res) {
let authorData = null
const region = req.body.region || 'us'
Expand All @@ -215,7 +264,7 @@ class AuthorController {
await CacheManager.purgeImageCache(req.author.id)

const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image)
if (imageData) {
if (imageData?.path) {
req.author.imagePath = imageData.path
hasUpdates = true
}
Expand All @@ -231,7 +280,7 @@ class AuthorController {

await Database.updateAuthor(req.author)

const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length
const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length
SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks))
}

Expand Down
47 changes: 24 additions & 23 deletions server/finders/AuthorFinder.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@ class AuthorFinder {
this.audnexus = new Audnexus()
}

async downloadImage(url, outputPath) {
return downloadFile(url, outputPath).then(() => true).catch((error) => {
Logger.error('[AuthorFinder] Failed to download author image', error)
return null
})
}

findAuthorByASIN(asin, region) {
if (!asin) return null
return this.audnexus.findAuthorByASIN(asin, region)
Expand All @@ -33,28 +26,36 @@ class AuthorFinder {
return author
}

/**
* Download author image from url and save in authors folder
*
* @param {string} authorId
* @param {string} url
* @returns {Promise<{path:string, error:string}>}
*/
async saveAuthorImage(authorId, url) {
var authorDir = Path.join(global.MetadataPath, 'authors')
var relAuthorDir = Path.posix.join('/metadata', 'authors')
const authorDir = Path.join(global.MetadataPath, 'authors')

if (!await fs.pathExists(authorDir)) {
await fs.ensureDir(authorDir)
}

var imageExtension = url.toLowerCase().split('.').pop()
var ext = imageExtension === 'png' ? 'png' : 'jpg'
var filename = authorId + '.' + ext
var outputPath = Path.posix.join(authorDir, filename)
var relPath = Path.posix.join(relAuthorDir, filename)

var success = await this.downloadImage(url, outputPath)
if (!success) {
return null
}
return {
path: outputPath,
relPath
}
const imageExtension = url.toLowerCase().split('.').pop()
const ext = imageExtension === 'png' ? 'png' : 'jpg'
const filename = authorId + '.' + ext
const outputPath = Path.posix.join(authorDir, filename)

return downloadFile(url, outputPath).then(() => {
return {
path: outputPath
}
}).catch((err) => {
let errorMsg = err.message || 'Unknown error'
Logger.error(`[AuthorFinder] Download image file failed for "${url}"`, errorMsg)
return {
error: errorMsg
}
})
}
}
module.exports = new AuthorFinder()
2 changes: 2 additions & 0 deletions server/routers/ApiRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ class ApiRouter {
this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this))
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this))
this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this))
this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this))

//
// Series Routes
Expand Down

0 comments on commit 656c81a

Please sign in to comment.