Add initial Author name normalization and calculated column updates

This commit is contained in:
Nicholas Wallace 2026-05-11 19:40:46 -07:00
parent aacdcc47ec
commit bf76128203
2 changed files with 77 additions and 23 deletions

View file

@ -1,4 +1,4 @@
const { DataTypes, Model, where, fn, col } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const parseNameString = require('../utils/parsers/parseNameString') const parseNameString = require('../utils/parsers/parseNameString')
class Author extends Model { class Author extends Model {
@ -12,6 +12,8 @@ class Author extends Model {
/** @type {string} */ /** @type {string} */
this.lastFirst this.lastFirst
/** @type {string} */ /** @type {string} */
this.searchName
/** @type {string} */
this.asin this.asin
/** @type {string} */ /** @type {string} */
this.description this.description
@ -35,6 +37,35 @@ class Author extends Model {
return parseNameString.nameToLastFirst(name) 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 * Check if author exists
* @param {string} authorId * @param {string} authorId
@ -53,13 +84,13 @@ class Author extends Model {
* @returns {Promise<Author>} * @returns {Promise<Author>}
*/ */
static async getByNameAndLibrary(authorName, libraryId) { static async getByNameAndLibrary(authorName, libraryId) {
const searchName = this.normalizeSearchName(authorName)
if (!searchName) return null
return this.findOne({ return this.findOne({
where: [ where: {
where(fn('lower', col('name')), authorName.toLowerCase()), searchName,
{ libraryId
libraryId }
}
]
}) })
} }
@ -114,14 +145,23 @@ class Author extends Model {
* @returns {Promise<{ author: Author, created: boolean }>} * @returns {Promise<{ author: Author, created: boolean }>}
*/ */
static async findOrCreateByNameAndLibrary(name, libraryId) { static async findOrCreateByNameAndLibrary(name, libraryId) {
const author = await this.getByNameAndLibrary(name, libraryId) const searchName = this.normalizeSearchName(name)
if (author) return { author, created: false } if (!searchName) {
const newAuthor = await this.create({ return { author: null, created: false }
name, }
lastFirst: this.getLastFirst(name),
libraryId 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, name: DataTypes.STRING,
lastFirst: DataTypes.STRING, lastFirst: DataTypes.STRING,
searchName: DataTypes.STRING,
asin: DataTypes.STRING, asin: DataTypes.STRING,
description: DataTypes.TEXT, description: DataTypes.TEXT,
imagePath: DataTypes.STRING imagePath: DataTypes.STRING
@ -160,6 +201,19 @@ class Author extends Model {
// collate: 'NOCASE' // collate: 'NOCASE'
// }] // }]
// }, // },
{
fields: [
{
name: 'searchName',
collate: 'NOCASE'
}
]
},
{
fields: ['searchName', 'libraryId'],
unique: true,
name: 'unique_author_search_name_per_library'
},
{ {
fields: ['libraryId'] fields: ['libraryId']
} }
@ -167,6 +221,10 @@ class Author extends Model {
} }
) )
Author.beforeSave((author) => {
Object.assign(author, Author.buildAuthorDerivedFields(author.name))
})
const { library } = sequelize.models const { library } = sequelize.models
library.hasMany(Author, { library.hasMany(Author, {
onDelete: 'CASCADE' onDelete: 'CASCADE'

View file

@ -247,15 +247,11 @@ class Scanner {
} }
const authorIdsRemoved = [] const authorIdsRemoved = []
for (const authorName of matchData.author) { 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) { if (!existingAuthor) {
let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId) const { author, created: isCreated } = await Database.authorModel.findOrCreateByNameAndLibrary(authorName, libraryItem.libraryId)
if (!author) { if (!author) continue
author = await Database.authorModel.create({ if (isCreated) {
name: authorName,
lastFirst: Database.authorModel.getLastFirst(authorName),
libraryId: libraryItem.libraryId
})
SocketAuthority.emitter('author_added', author.toOldJSON()) SocketAuthority.emitter('author_added', author.toOldJSON())
// Update filter data // Update filter data
Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id) Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id)
@ -271,7 +267,7 @@ class Scanner {
hasAuthorUpdates = true 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) { if (authorsRemoved.length) {
for (const author of authorsRemoved) { for (const author of authorsRemoved) {
await Database.bookAuthorModel.destroy({ where: { authorId: author.id, bookId: libraryItem.media.id } }) await Database.bookAuthorModel.destroy({ where: { authorId: author.id, bookId: libraryItem.media.id } })