Merge pull request #3880 from mikiher/rich-text-book-descriptionss

Support rich text book descriptions
This commit is contained in:
advplyr 2025-01-25 13:42:37 -06:00 committed by GitHub
commit a4d0f95ecc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 134 additions and 83 deletions

View File

@ -53,3 +53,16 @@
text-align: start !important; text-align: start !important;
text-align-last: start !important; text-align-last: start !important;
} }
.default-style.less-spacing p {
margin-block-start: 0;
}
.default-style.less-spacing ul {
margin-block-start: 0;
}
.default-style.less-spacing ol {
margin-block-start: 0;
}

View File

@ -446,7 +446,7 @@ trix-editor .attachment__metadata .attachment__size {
} }
.trix-content { .trix-content {
line-height: 1.5; line-height: inherit;
} }
.trix-content * { .trix-content * {
@ -455,6 +455,13 @@ trix-editor .attachment__metadata .attachment__size {
padding: 0; padding: 0;
} }
.trix-content p {
box-sizing: border-box;
margin-top: 0;
margin-bottom: 0.5em;
padding: 0;
}
.trix-content h1 { .trix-content h1 {
font-size: 1.2em; font-size: 1.2em;
line-height: 1.2; line-height: 1.2;

View File

@ -24,7 +24,7 @@
</div> </div>
</div> </div>
<div class="w-full max-h-12 overflow-hidden"> <div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs">{{ book.description }}</p> <p class="text-gray-500 text-xs">{{ book.descriptionPlain }}</p>
</div> </div>
</div> </div>
<div v-else class="px-4 flex-grow"> <div v-else class="px-4 flex-grow">

View File

@ -94,9 +94,9 @@
<div v-if="selectedMatchOrig.description" class="flex items-center py-2"> <div v-if="selectedMatchOrig.description" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" /> <ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
<div class="flex-grow ml-4"> <div class="flex-grow ml-4">
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" /> <ui-rich-text-editor v-model="selectedMatch.description" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60"> <p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">
{{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</a> {{ $strings.LabelCurrently }} <a title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.descriptionPlain.substr(0, 100) + (mediaMetadata.descriptionPlain.length > 100 ? '...' : '') }}</a>
</p> </p>
</div> </div>
</div> </div>

View File

@ -16,7 +16,7 @@
</div> </div>
</div> </div>
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p> <p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
<div v-if="description" dir="auto" class="default-style" v-html="description" /> <div v-if="description" dir="auto" class="default-style less-spacing" v-html="description" />
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p> <p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
<div class="w-full h-px bg-white/5 my-4" /> <div class="w-full h-px bg-white/5 my-4" />

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="default-style"> <div class="default-style">
<p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }"> <p v-if="label" class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }" style="margin-top: 0; margin-bottom: 0.125em">
{{ label }} {{ label }}
</p> </p>
<ui-vue-trix v-model="content" :config="config" :disabled-editor="disabled" @trix-file-accept="trixFileAccept" /> <ui-vue-trix ref="input" v-model="content" :disabled-editor="disabled" @trix-file-accept="trixFileAccept" />
</div> </div>
</template> </template>
@ -12,7 +12,10 @@ export default {
props: { props: {
value: String, value: String,
label: String, label: String,
disabled: Boolean disabled: {
type: Boolean,
default: false
}
}, },
data() { data() {
return {} return {}
@ -25,46 +28,16 @@ export default {
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
} }
},
config() {
return {
toolbar: {
getDefaultHTML: () => `<div class="trix-button-row">
<span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" title="${this.$strings.LabelFontBold}" tabindex="-1">${this.$strings.LabelFontBold}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" title="${this.$strings.LabelFontItalic}" tabindex="-1">${this.$strings.LabelFontItalic}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" title="${this.$strings.LabelFontStrikethrough}" tabindex="-1">${this.$strings.LabelFontStrikethrough}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="${this.$strings.LabelTextEditorLink}" tabindex="-1">${this.$strings.LabelTextEditorLink}</button>
</span>
<span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" title="${this.$strings.LabelTextEditorBulletedList}" tabindex="-1">${this.$strings.LabelTextEditorBulletedList}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" title="${this.$strings.LabelTextEditorNumberedList}" tabindex="-1">${this.$strings.LabelTextEditorNumberedList}</button>
</span>
<span class="trix-button-group-spacer"></span>
<span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" title="${this.$strings.LabelUndo}" tabindex="-1">${this.$strings.LabelUndo}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" title="${this.$strings.LabelRedo}" tabindex="-1">${this.$strings.LabelRedo}</button>
</span>
</div>
<div class="trix-dialogs" data-trix-dialogs>
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
<div class="trix-dialog__link-fields">
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="" aria-label="URL" required data-trix-input>
<div class="trix-button-group">
<input type="button" class="trix-button trix-button--dialog" value="${this.$strings.LabelTextEditorLink}" data-trix-method="setAttribute">
<input type="button" class="trix-button trix-button--dialog" value="${this.$strings.LabelTextEditorUnlink}" data-trix-method="removeAttribute">
</div>
</div>
</div>
</div>`
}
}
} }
}, },
methods: { methods: {
trixFileAccept(e) { trixFileAccept(e) {
e.preventDefault() e.preventDefault()
},
blur() {
if (this.$refs.input && this.$refs.input.blur) {
this.$refs.input.blur()
}
} }
}, },
mounted() {}, mounted() {},

View File

@ -1,6 +1,37 @@
<template> <template>
<div> <div>
<trix-editor :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" /> <trix-toolbar :id="toolbarId">
<div v-show="!disabledEditor" class="trix-button-row">
<span class="trix-button-group trix-button-group--text-tools" data-trix-button-group="text-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-bold" data-trix-attribute="bold" data-trix-key="b" :title="$strings.LabelFontBold" tabindex="-1">{{ $strings.LabelFontBold }}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-italic" data-trix-attribute="italic" data-trix-key="i" :title="$strings.LabelFontItalic" tabindex="-1">{{ $strings.LabelFontItalic }}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-strike" data-trix-attribute="strike" :title="$strings.LabelFontStrikethrough" tabindex="-1">{{ $strings.LabelFontStrikethrough }}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-link" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" :title="$strings.LabelTextEditorLink" tabindex="-1">{{ $strings.LabelTextEditorLink }}</button>
</span>
<span class="trix-button-group trix-button-group--block-tools" data-trix-button-group="block-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-bullet-list" data-trix-attribute="bullet" :title="$strings.LabelTextEditorBulletedList" tabindex="-1">{{ $strings.LabelTextEditorBulletedList }}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-number-list" data-trix-attribute="number" :title="$strings.LabelTextEditorNumberedList" tabindex="-1">{{ $strings.LabelTextEditorNumberedList }}</button>
</span>
<span class="trix-button-group-spacer"></span>
<span class="trix-button-group trix-button-group--history-tools" data-trix-button-group="history-tools">
<button type="button" class="trix-button trix-button--icon trix-button--icon-undo" data-trix-action="undo" data-trix-key="z" :title="$strings.LabelUndo" tabindex="-1">{{ $strings.LabelUndo }}</button>
<button type="button" class="trix-button trix-button--icon trix-button--icon-redo" data-trix-action="redo" data-trix-key="shift+z" :title="$strings.LabelRedo" tabindex="-1">{{ $strings.LabelRedo }}</button>
</span>
</div>
<div class="trix-dialogs" data-trix-dialogs>
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
<div class="trix-dialog__link-fields">
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="" aria-label="URL" required data-trix-input />
<div class="trix-button-group">
<input type="button" class="trix-button trix-button--dialog" :value="$strings.LabelTextEditorLink" data-trix-method="setAttribute" />
<input type="button" class="trix-button trix-button--dialog" :value="$strings.LabelTextEditorUnlink" data-trix-method="removeAttribute" />
</div>
</div>
</div>
</div>
</trix-toolbar>
<trix-editor :toolbar="toolbarId" :contenteditable="!disabledEditor" :class="['trix-content']" ref="trix" :input="computedId" :placeholder="placeholder" @trix-change="handleContentChange" @trix-initialize="handleInitialize" @trix-focus="processTrixFocus" @trix-blur="processTrixBlur" />
<input type="hidden" :name="inputName" :id="computedId" :value="editorContent" /> <input type="hidden" :name="inputName" :id="computedId" :value="editorContent" />
</div> </div>
</template> </template>
@ -14,6 +45,30 @@
import Trix from 'trix' import Trix from 'trix'
import '@/assets/trix.css' import '@/assets/trix.css'
function enableBreakParagraphOnReturn() {
// Trix works with divs by default, we want paragraphs instead
Trix.config.blockAttributes.default.tagName = 'p'
// Enable break paragraph on Enter (Shift + Enter will still create a line break)
Trix.config.blockAttributes.default.breakOnReturn = true
// Hack to fix buggy paragraph breaks
// Copied from https://github.com/basecamp/trix/issues/680#issuecomment-735742942
Trix.Block.prototype.breaksOnReturn = function () {
const attr = this.getLastAttribute()
const config = Trix.getBlockConfig(attr ? attr : 'default')
return config ? config.breakOnReturn : false
}
Trix.LineBreakInsertion.prototype.shouldInsertBlockBreak = function () {
if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) {
return this.startLocation.offset > 0
} else {
return !this.shouldBreakFormattedBlock() ? this.breaksOnReturn : false
}
}
}
enableBreakParagraphOnReturn()
export default { export default {
name: 'vue-trix', name: 'vue-trix',
model: { model: {
@ -134,6 +189,9 @@ export default {
* Compute a random id of hidden input * Compute a random id of hidden input
* when it haven't been specified. * when it haven't been specified.
*/ */
toolbarId() {
return `trix-toolbar-${this.generateId}`
},
generateId() { generateId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
var r = (Math.random() * 16) | 0 var r = (Math.random() * 16) | 0
@ -223,13 +281,17 @@ export default {
decorateDisabledEditor(editorState) { decorateDisabledEditor(editorState) {
/** Disable toolbar and editor by pointer events styling */ /** Disable toolbar and editor by pointer events styling */
if (editorState) { if (editorState) {
this.$refs.trix.toolbarElement.style['pointer-events'] = 'none' this.$refs.trix.disabled = true
this.$refs.trix.contentEditable = false this.$refs.trix.contentEditable = false
this.$refs.trix.style['background'] = '#e9ecef' this.$refs.trix.style['pointer-events'] = 'none'
this.$refs.trix.style['background-color'] = '#444'
this.$refs.trix.style['color'] = '#bbb'
} else { } else {
this.$refs.trix.toolbarElement.style['pointer-events'] = 'unset' this.$refs.trix.disabled = false
this.$refs.trix.contentEditable = true
this.$refs.trix.style['pointer-events'] = 'unset' this.$refs.trix.style['pointer-events'] = 'unset'
this.$refs.trix.style['background'] = 'transparent' this.$refs.trix.style['background-color'] = ''
this.$refs.trix.style['color'] = ''
} }
}, },
overrideConfig(config) { overrideConfig(config) {
@ -250,32 +312,15 @@ export default {
} }
return target return target
}, },
enableBreakParagraphOnReturn() { blur() {
// Trix works with divs by default, we want paragraphs instead if (this.$refs.trix && this.$refs.trix.blur) {
Trix.config.blockAttributes.default.tagName = 'p' this.$refs.trix.blur()
// Enable break paragraph on Enter (Shift + Enter will still create a line break)
Trix.config.blockAttributes.default.breakOnReturn = true
// Hack to fix buggy paragraph breaks
// Copied from https://github.com/basecamp/trix/issues/680#issuecomment-735742942
Trix.Block.prototype.breaksOnReturn = function () {
const attr = this.getLastAttribute()
const config = Trix.getBlockConfig(attr ? attr : 'default')
return config ? config.breakOnReturn : false
}
Trix.LineBreakInsertion.prototype.shouldInsertBlockBreak = function () {
if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) {
return this.startLocation.offset > 0
} else {
return !this.shouldBreakFormattedBlock() ? this.breaksOnReturn : false
}
} }
} }
}, },
mounted() { mounted() {
/** Override editor configuration */ /** Override editor configuration */
this.overrideConfig(this.config) this.overrideConfig(this.config)
this.enableBreakParagraphOnReturn()
/** Check if editor read-only mode is required */ /** Check if editor read-only mode is required */
this.decorateDisabledEditor(this.disabledEditor) this.decorateDisabledEditor(this.disabledEditor)
this.$nextTick(() => { this.$nextTick(() => {
@ -305,4 +350,12 @@ export default {
.trix_container .trix-content { .trix_container .trix-content {
background-color: white; background-color: white;
} }
trix-editor {
max-height: calc(4 * 1lh);
overflow-y: auto;
}
trix-editor * {
pointer-events: inherit;
}
</style> </style>

View File

@ -26,7 +26,7 @@
</div> </div>
</div> </div>
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" /> <ui-rich-text-editor ref="descriptionInput" v-model="details.description" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" />
<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/2 px-1"> <div class="w-full md:w-1/2 px-1">

View File

@ -123,7 +123,7 @@
</div> </div>
<div class="my-4 w-full"> <div class="my-4 w-full">
<p ref="description" id="item-description" dir="auto" class="text-base text-gray-100 whitespace-pre-line mb-1" :class="{ 'show-full': showFullDescription }">{{ description }}</p> <p ref="description" id="item-description" dir="auto" class="default-style less-spacing text-base text-gray-100 whitespace-pre-line mb-1" :class="{ 'show-full': showFullDescription }" v-html="description" />
<button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">{{ showFullDescription ? $strings.ButtonReadLess : $strings.ButtonReadMore }} <span class="material-symbols text-xl pl-1" v-html="showFullDescription ? 'expand_less' : '&#xe313;'" /></button> <button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">{{ showFullDescription ? $strings.ButtonReadLess : $strings.ButtonReadMore }} <span class="material-symbols text-xl pl-1" v-html="showFullDescription ? 'expand_less' : '&#xe313;'" /></button>
</div> </div>
@ -804,8 +804,7 @@ export default {
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
max-height: 6.25rem; max-height: calc(6 * 1lh);
transition: all 0.3s ease-in-out;
} }
#item-description.show-full { #item-description.show-full {
-webkit-line-clamp: unset; -webkit-line-clamp: unset;

View File

@ -8,6 +8,7 @@ const AudiobookCovers = require('../providers/AudiobookCovers')
const CustomProviderAdapter = require('../providers/CustomProviderAdapter') const CustomProviderAdapter = require('../providers/CustomProviderAdapter')
const Logger = require('../Logger') const Logger = require('../Logger')
const { levenshteinDistance, escapeRegExp } = require('../utils/index') const { levenshteinDistance, escapeRegExp } = require('../utils/index')
const htmlSanitizer = require('../utils/htmlSanitizer')
class BookFinder { class BookFinder {
#providerResponseTimeout = 30000 #providerResponseTimeout = 30000
@ -463,6 +464,12 @@ class BookFinder {
} else { } else {
books = await this.getGoogleBooksResults(title, author) books = await this.getGoogleBooksResults(title, author)
} }
books.forEach((book) => {
if (book.description) {
book.description = htmlSanitizer.sanitize(book.description)
book.descriptionPlain = htmlSanitizer.stripAllTags(book.description)
}
})
return books return books
} }

View File

@ -2,6 +2,7 @@ const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const parseNameString = require('../utils/parsers/parseNameString') const parseNameString = require('../utils/parsers/parseNameString')
const htmlSanitizer = require('../utils/htmlSanitizer')
/** /**
* @typedef EBookFileObject * @typedef EBookFileObject
@ -579,6 +580,7 @@ class Book extends Model {
oldMetadataJSON.authorNameLF = this.authorNameLF oldMetadataJSON.authorNameLF = this.authorNameLF
oldMetadataJSON.narratorName = (this.narrators || []).join(', ') oldMetadataJSON.narratorName = (this.narrators || []).join(', ')
oldMetadataJSON.seriesName = this.seriesName oldMetadataJSON.seriesName = this.seriesName
oldMetadataJSON.descriptionPlain = this.description ? htmlSanitizer.stripAllTags(this.description) : null
return oldMetadataJSON return oldMetadataJSON
} }

View File

@ -1,5 +1,4 @@
const axios = require('axios').default const axios = require('axios').default
const htmlSanitizer = require('../utils/htmlSanitizer')
const Logger = require('../Logger') const Logger = require('../Logger')
const { isValidASIN } = require('../utils/index') const { isValidASIN } = require('../utils/index')
@ -68,7 +67,7 @@ class Audible {
narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null, narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null,
publisher: publisherName, publisher: publisherName,
publishedYear: releaseDate ? releaseDate.split('-')[0] : null, publishedYear: releaseDate ? releaseDate.split('-')[0] : null,
description: summary ? htmlSanitizer.stripAllTags(summary) : null, description: summary || null,
cover: image, cover: image,
asin, asin,
genres: genresFiltered.length ? genresFiltered : null, genres: genresFiltered.length ? genresFiltered : null,

View File

@ -112,7 +112,7 @@ class iTunes {
artistId: data.artistId, artistId: data.artistId,
title: data.collectionName, title: data.collectionName,
author, author,
description: htmlSanitizer.stripAllTags(data.description || ''), description: data.description || null,
publishedYear: data.releaseDate ? data.releaseDate.split('-')[0] : null, publishedYear: data.releaseDate ? data.releaseDate.split('-')[0] : null,
genres: data.primaryGenreName ? [data.primaryGenreName] : null, genres: data.primaryGenreName ? [data.primaryGenreName] : null,
cover: this.getCoverArtwork(data) cover: this.getCoverArtwork(data)

View File

@ -1,11 +1,9 @@
const sanitizeHtml = require('../libs/sanitizeHtml') const sanitizeHtml = require('../libs/sanitizeHtml')
const { entities } = require("./htmlEntities"); const { entities } = require('./htmlEntities')
function sanitize(html) { function sanitize(html) {
const sanitizerOptions = { const sanitizerOptions = {
allowedTags: [ allowedTags: ['p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del', 'br', 'b', 'i'],
'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del', 'br'
],
disallowedTagsMode: 'discard', disallowedTagsMode: 'discard',
allowedAttributes: { allowedAttributes: {
a: ['href', 'name', 'target'] a: ['href', 'name', 'target']
@ -34,6 +32,6 @@ function decodeHTMLEntities(strToDecode) {
if (entity in entities) { if (entity in entities) {
return entities[entity] return entities[entity]
} }
return entity; return entity
}) })
} }