diff --git a/server/models/Author.js b/server/models/Author.js index 65561e211..aedeb8777 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -1,4 +1,4 @@ -const { DataTypes, Model, where, fn, col } = require('sequelize') +const { DataTypes, Model } = require('sequelize') const parseNameString = require('../utils/parsers/parseNameString') class Author extends Model { @@ -12,6 +12,8 @@ class Author extends Model { /** @type {string} */ this.lastFirst /** @type {string} */ + this.searchName + /** @type {string} */ this.asin /** @type {string} */ this.description @@ -35,6 +37,35 @@ class Author extends Model { return parseNameString.nameToLastFirst(name) } + static normalizeSearchName(name) { + if (!name?.trim()) return null + return name + .normalize('NFKC') // Standardize compatibility characters + .normalize('NFD') // Split accents into combining marks + .toLocaleLowerCase('und') + .replace(/[\p{P}\p{Z}\p{M}\s]+/gu, '') // Remove punctuation, whitespace, and diacritics + .trim() + } + + static buildAuthorDerivedFields(name) { + const searchName = this.normalizeSearchName(name) + if (!searchName) { + return { + lastFirst: null, + searchName: null + } + } + + return { + lastFirst: parseNameString.nameToLastFirst(name), + searchName + } + } + + static isAuthorNameMatch(leftName, rightName) { + return this.normalizeSearchName(leftName) === this.normalizeSearchName(rightName) + } + /** * Check if author exists * @param {string} authorId @@ -53,13 +84,13 @@ class Author extends Model { * @returns {Promise} */ static async getByNameAndLibrary(authorName, libraryId) { + const searchName = this.normalizeSearchName(authorName) + if (!searchName) return null return this.findOne({ - where: [ - where(fn('lower', col('name')), authorName.toLowerCase()), - { - libraryId - } - ] + where: { + searchName, + libraryId + } }) } @@ -114,14 +145,23 @@ class Author extends Model { * @returns {Promise<{ author: Author, created: boolean }>} */ static async findOrCreateByNameAndLibrary(name, libraryId) { - const author = await this.getByNameAndLibrary(name, libraryId) - if (author) return { author, created: false } - const newAuthor = await this.create({ - name, - lastFirst: this.getLastFirst(name), - libraryId + const searchName = this.normalizeSearchName(name) + if (!searchName) { + return { author: null, created: false } + } + + const [author, created] = await this.findOrCreate({ + where: { + searchName, + libraryId + }, + defaults: { + name, + libraryId, + ...this.buildAuthorDerivedFields(name) + } }) - return { author: newAuthor, created: true } + return { author, created } } /** @@ -138,6 +178,7 @@ class Author extends Model { }, name: DataTypes.STRING, lastFirst: DataTypes.STRING, + searchName: DataTypes.STRING, asin: DataTypes.STRING, description: DataTypes.TEXT, imagePath: DataTypes.STRING @@ -160,6 +201,19 @@ class Author extends Model { // collate: 'NOCASE' // }] // }, + { + fields: [ + { + name: 'searchName', + collate: 'NOCASE' + } + ] + }, + { + fields: ['searchName', 'libraryId'], + unique: true, + name: 'unique_author_search_name_per_library' + }, { fields: ['libraryId'] } @@ -167,6 +221,10 @@ class Author extends Model { } ) + Author.beforeSave((author) => { + Object.assign(author, Author.buildAuthorDerivedFields(author.name)) + }) + const { library } = sequelize.models library.hasMany(Author, { onDelete: 'CASCADE' diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index af4405987..ec466300d 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -247,15 +247,11 @@ class Scanner { } const authorIdsRemoved = [] for (const authorName of matchData.author) { - const existingAuthor = libraryItem.media.authors.find((a) => a.name.toLowerCase() === authorName.toLowerCase()) + const existingAuthor = libraryItem.media.authors.find((a) => Database.authorModel.isAuthorNameMatch(a.name, authorName)) if (!existingAuthor) { - let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId) - if (!author) { - author = await Database.authorModel.create({ - name: authorName, - lastFirst: Database.authorModel.getLastFirst(authorName), - libraryId: libraryItem.libraryId - }) + const { author, created: isCreated } = await Database.authorModel.findOrCreateByNameAndLibrary(authorName, libraryItem.libraryId) + if (!author) continue + if (isCreated) { SocketAuthority.emitter('author_added', author.toOldJSON()) // Update filter data Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id) @@ -271,7 +267,7 @@ class Scanner { hasAuthorUpdates = true }) } - const authorsRemoved = libraryItem.media.authors.filter((a) => !matchData.author.find((ma) => ma.toLowerCase() === a.name.toLowerCase())) + const authorsRemoved = libraryItem.media.authors.filter((a) => !matchData.author.find((ma) => Database.authorModel.isAuthorNameMatch(ma, a.name))) if (authorsRemoved.length) { for (const author of authorsRemoved) { await Database.bookAuthorModel.destroy({ where: { authorId: author.id, bookId: libraryItem.media.id } })