Fixed: Sort authors by lastname, firstname

pull/1039/head
ta264 3 years ago
parent 62221c2a7f
commit 96db74494a

@ -78,7 +78,7 @@ class BlacklistRow extends Component {
return null;
}
if (name === 'authors.sortName') {
if (name === 'authorMetadata.sortName') {
return (
<TableRowCell key={name}>
<AuthorNameLink

@ -93,7 +93,7 @@ class HistoryRow extends Component {
);
}
if (name === 'authors.sortName') {
if (name === 'authorMetadata.sortName') {
return (
<TableRowCell key={name}>
<AuthorNameLink

@ -138,7 +138,7 @@ class QueueRow extends Component {
);
}
if (name === 'authors.sortName') {
if (name === 'authorMetadata.sortName') {
return (
<TableRowCell key={name}>
{

@ -31,7 +31,7 @@ export const defaultState = {
columns: [
{
name: 'authors.sortName',
name: 'authorMetadata.sortName',
label: 'Author Name',
isSortable: true,
isVisible: true

@ -34,7 +34,7 @@ export const defaultState = {
isModifiable: false
},
{
name: 'authors.sortName',
name: 'authorMetadata.sortName',
label: 'Author',
isSortable: true,
isVisible: true

@ -63,7 +63,7 @@ export const defaultState = {
isModifiable: false
},
{
name: 'authors.sortName',
name: 'authorMetadata.sortName',
label: 'Author',
isSortable: true,
isVisible: true

@ -28,7 +28,7 @@ export const defaultState = {
columns: [
{
name: 'authors.sortName',
name: 'authorMetadata.sortName',
label: 'Author',
isSortable: true,
isVisible: true
@ -91,7 +91,7 @@ export const defaultState = {
columns: [
{
name: 'authors.sortName',
name: 'authorMetadata.sortName',
label: 'Author',
isSortable: true,
isVisible: true

@ -1,5 +1,7 @@
import migrateAddAuthorDefaults from './migrateAddAuthorDefaults';
import migrateAuthorSortKey from './migrateAuthorSortKey';
export default function migrate(persistedState) {
migrateAddAuthorDefaults(persistedState);
migrateAuthorSortKey(persistedState);
}

@ -0,0 +1,15 @@
import { get, set } from 'lodash';
const TABLES_TO_MIGRATE = ['blacklist', 'history', 'queue.paged', 'wanted.missing', 'wanted.cutoffUnmet'];
export default function migrateAuthorSortKey(persistedState) {
for (const table of TABLES_TO_MIGRATE) {
const key = `${table}.sortKey`;
const sortKey = get(persistedState, key);
if (sortKey === 'authors.sortName') {
set(persistedState, key, 'authorMetadata.sortName');
}
}
}

@ -48,7 +48,7 @@ function CutoffUnmetRow(props) {
return null;
}
if (name === 'authors.sortName') {
if (name === 'authorMetadata.sortName') {
return (
<TableRowCell key={name}>
<AuthorNameLink

@ -45,7 +45,7 @@ function MissingRow(props) {
return null;
}
if (name === 'authors.sortName') {
if (name === 'authorMetadata.sortName') {
return (
<TableRowCell key={name}>
<AuthorNameLink

@ -0,0 +1,42 @@
using FluentAssertions;
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
namespace NzbDrone.Common.Test.ExtensionTests.StringExtensionTests
{
[TestFixture]
public class ToSortNameFixture
{
[TestCase("a[b]c(d)e{f}g<h>i", "aceg<h>i")]
[TestCase("a[[b]c(d)e{f}]g(h(i)j[k]l{m})n{{{o}}}p", "agnp")]
[TestCase("a[b(c]d)e", "ae")]
[TestCase("a{b(c}d)e", "ae")]
[TestCase("a]b}c)d", "abcd")]
[TestCase("a[b]c]d(e)f{g)h}i}j)k]l", "acdfijkl")]
[TestCase("a]b[c", "ab")]
[TestCase("a(b[c]d{e}f", "a")]
[TestCase("a{b}c{d[e]f(g)h", "ac")]
public void should_remove_brackets(string input, string expected)
{
input.RemoveBracketedText().Should().Be(expected);
}
[TestCase("Aristotle", "Aristotle")]
[TestCase("Mr. Dr Prof.", "Mr. Dr Prof.")]
[TestCase("Senior Inc", "Senior Inc")]
[TestCase("Don \"Team\" Smith", "Smith, Don \"Team\"")]
[TestCase("Don Team Smith", "Don Team Smith")]
[TestCase("National Lampoon", "National Lampoon")]
[TestCase("Jane Doe", "Doe, Jane")]
[TestCase("Mrs. Jane Q. Doe III", "Doe, Jane Q. III")]
[TestCase("Leonardo Da Vinci", "Da Vinci, Leonardo")]
[TestCase("Van Gogh", "Van Gogh")]
[TestCase("Van", "Van")]
[TestCase("John [x]von Neumann (III)", "von Neumann, John")]
public void should_get_sort_name(string input, string expected)
{
input.ToSortName().Should().Be(expected);
}
}
}

@ -210,5 +210,150 @@ namespace NzbDrone.Common.Extensions
{
return 1.0 - ((double)a.LevenshteinDistance(b) / Math.Max(a.Length, b.Length));
}
private static readonly HashSet<string> Copywords = new HashSet<string>
{
"agency", "corporation", "company", "co.", "council",
"committee", "inc.", "institute", "national",
"society", "club", "team"
};
private static readonly HashSet<string> SurnamePrefixes = new HashSet<string>
{
"da", "de", "di", "la", "le", "van", "von"
};
private static readonly HashSet<string> Prefixes = new HashSet<string>
{
"mr", "mr.", "mrs", "mrs.", "ms", "ms.", "dr", "dr.", "prof", "prof."
};
private static readonly HashSet<string> Suffixes = new HashSet<string>
{
"jr", "sr", "inc", "ph.d", "phd",
"md", "m.d", "i", "ii", "iii", "iv",
"junior", "senior"
};
private static readonly Dictionary<char, char> Brackets = new Dictionary<char, char>
{
{ '(', ')' },
{ '[', ']' },
{ '{', '}' }
};
private static readonly Dictionary<char, char> RMap = Brackets.ToDictionary(x => x.Value, x => x.Key);
public static string RemoveBracketedText(this string input)
{
var counts = Brackets.ToDictionary(x => x.Key, y => 0);
var total = 0;
var buf = new List<char>(input.Length);
foreach (var c in input)
{
if (Brackets.ContainsKey(c))
{
counts[c] += 1;
total += 1;
}
else if (RMap.ContainsKey(c))
{
var idx = RMap[c];
if (counts[idx] > 0)
{
counts[idx] -= 1;
total -= 1;
}
}
else if (total < 1)
{
buf.Add(c);
}
}
return new string(buf.ToArray());
}
public static string ToSortName(this string author)
{
// ported from https://github.com/kovidgoyal/calibre/blob/master/src/calibre/ebooks/metadata/__init__.py
if (author == null)
{
return null;
}
var sauthor = author.RemoveBracketedText().Trim();
var tokens = sauthor.Split();
if (tokens.Length < 2)
{
return author;
}
var ltoks = tokens.Select(x => x.ToLowerInvariant()).ToHashSet();
if (ltoks.Intersect(Copywords).Any())
{
return author;
}
if (tokens.Length == 2 && SurnamePrefixes.Contains(tokens[0].ToLowerInvariant()))
{
return author;
}
int first;
for (first = 0; first < tokens.Length; first++)
{
if (!Prefixes.Contains(tokens[first].ToLowerInvariant()))
{
break;
}
}
if (first == tokens.Length)
{
return author;
}
int last;
for (last = tokens.Length - 1; last >= first; last--)
{
if (!Suffixes.Contains(tokens[last].ToLowerInvariant()))
{
break;
}
}
if (last < first)
{
return author;
}
var suffix = tokens.TakeLast(tokens.Length - last - 1).ConcatToString(" ");
if (last > first && SurnamePrefixes.Contains(tokens[last - 1].ToLowerInvariant()))
{
tokens[last - 1] += ' ' + tokens[last];
last -= 1;
}
var atokens = new[] { tokens[last] }.Concat(tokens.Skip(first).Take(last - first)).ToList();
var addComma = atokens.Count > 1;
if (suffix.IsNotNullOrWhiteSpace())
{
atokens.Add(suffix);
}
if (addComma)
{
atokens[0] += ',';
}
return atokens.ConcatToString(" ");
}
}
}

@ -4,6 +4,7 @@ using System.Linq;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Books;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MetadataSource.Goodreads;
@ -83,7 +84,6 @@ namespace NzbDrone.Core.Test.MetadataSource.Goodreads
author.Should().NotBeNull();
author.Name.Should().NotBeNullOrWhiteSpace();
author.CleanName.Should().Be(Parser.Parser.CleanAuthorName(author.Name));
author.SortName.Should().Be(Parser.Parser.NormalizeTitle(author.Name));
author.Metadata.Value.TitleSlug.Should().NotBeNullOrWhiteSpace();
author.Metadata.Value.Overview.Should().NotBeNullOrWhiteSpace();
author.Metadata.Value.Images.Should().NotBeEmpty();

@ -34,9 +34,13 @@ namespace NzbDrone.Core.Blacklisting
return Query(b => b.AuthorId == authorId);
}
protected override SqlBuilder PagedBuilder() => new SqlBuilder().Join<Blacklist, Author>((b, m) => b.AuthorId == m.Id);
protected override IEnumerable<Blacklist> PagedQuery(SqlBuilder builder) => _database.QueryJoined<Blacklist, Author>(builder, (bl, author) =>
protected override SqlBuilder PagedBuilder() => new SqlBuilder()
.Join<Blacklist, Author>((b, m) => b.AuthorId == m.Id)
.Join<Author, AuthorMetadata>((l, r) => l.AuthorMetadataId == r.Id);
protected override IEnumerable<Blacklist> PagedQuery(SqlBuilder builder) => _database.QueryJoined<Blacklist, Author, AuthorMetadata>(builder,
(bl, author, metadata) =>
{
author.Metadata = metadata;
bl.Author = author;
return bl;
});

@ -19,7 +19,6 @@ namespace NzbDrone.Core.Books
// These correspond to columns in the Authors table
public int AuthorMetadataId { get; set; }
public string CleanName { get; set; }
public string SortName { get; set; }
public bool Monitored { get; set; }
public DateTime? LastInfoSync { get; set; }
public string Path { get; set; }
@ -64,7 +63,6 @@ namespace NzbDrone.Core.Books
public override void UseMetadataFrom(Author other)
{
CleanName = other.CleanName;
SortName = other.SortName;
}
public override void UseDbFieldsFrom(Author other)

@ -19,6 +19,7 @@ namespace NzbDrone.Core.Books
public string ForeignAuthorId { get; set; }
public string TitleSlug { get; set; }
public string Name { get; set; }
public string SortName { get; set; }
public List<string> Aliases { get; set; }
public string Overview { get; set; }
public string Disambiguation { get; set; }
@ -42,6 +43,7 @@ namespace NzbDrone.Core.Books
ForeignAuthorId = other.ForeignAuthorId;
TitleSlug = other.TitleSlug;
Name = other.Name;
SortName = other.SortName;
Aliases = other.Aliases;
Overview = other.Overview.IsNullOrWhiteSpace() ? Overview : other.Overview;
Disambiguation = other.Disambiguation;

@ -91,6 +91,7 @@ namespace NzbDrone.Core.Books
#pragma warning disable CS0472
private SqlBuilder BooksWithoutFilesBuilder(DateTime currentTime) => Builder()
.Join<Book, Author>((l, r) => l.AuthorMetadataId == r.AuthorMetadataId)
.Join<Author, AuthorMetadata>((l, r) => l.AuthorMetadataId == r.Id)
.Join<Book, Edition>((b, e) => b.Id == e.BookId)
.LeftJoin<Edition, BookFile>((t, f) => t.Id == f.EditionId)
.Where<BookFile>(f => f.Id == null)
@ -110,6 +111,7 @@ namespace NzbDrone.Core.Books
private SqlBuilder BooksWhereCutoffUnmetBuilder(List<QualitiesBelowCutoff> qualitiesBelowCutoff) => Builder()
.Join<Book, Author>((l, r) => l.AuthorMetadataId == r.AuthorMetadataId)
.Join<Author, AuthorMetadata>((l, r) => l.AuthorMetadataId == r.Id)
.Join<Book, Edition>((b, e) => b.Id == e.BookId)
.LeftJoin<Edition, BookFile>((t, f) => t.Id == f.EditionId)
.Where<Edition>(e => e.Monitored == true)

@ -146,7 +146,6 @@ namespace NzbDrone.Core.Books
newAuthor.Path = path;
newAuthor.CleanName = newAuthor.Metadata.Value.Name.CleanAuthorName();
newAuthor.SortName = Parser.Parser.NormalizeTitle(newAuthor.Metadata.Value.Name).ToLower();
newAuthor.Added = DateTime.UtcNow;
if (newAuthor.AddOptions != null && newAuthor.AddOptions.Monitor == MonitorTypes.None)

@ -0,0 +1,41 @@
using System.Data;
using System.Linq;
using Dapper;
using FluentMigrator;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(009)]
public class update_author_sort_name : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Alter.Table("AuthorMetadata").AddColumn("SortName").AsString().Nullable();
Execute.WithConnection(MigrateAuthorSortName);
Alter.Table("AuthorMetadata").AlterColumn("SortName").AsString().NotNullable();
Delete.Column("SortName").FromTable("Authors");
}
private void MigrateAuthorSortName(IDbConnection conn, IDbTransaction tran)
{
var rows = conn.Query<AuthorName>("SELECT AuthorMetadata.Id, AuthorMetadata.Name FROM AuthorMetadata", transaction: tran);
foreach (var row in rows)
{
row.SortName = row.Name.ToSortName().ToLower();
}
var sql = "UPDATE AuthorMetadata SET SortName = @SortName WHERE Id = @Id";
conn.Execute(sql, rows, transaction: tran);
}
private class AuthorName : ModelBase
{
public string Name { get; set; }
public string SortName { get; set; }
}
}
}

@ -104,11 +104,13 @@ namespace NzbDrone.Core.History
protected override SqlBuilder PagedBuilder() => new SqlBuilder()
.Join<History, Author>((h, a) => h.AuthorId == a.Id)
.Join<Author, AuthorMetadata>((l, r) => l.AuthorMetadataId == r.Id)
.Join<History, Book>((h, a) => h.BookId == a.Id);
protected override IEnumerable<History> PagedQuery(SqlBuilder builder) =>
_database.QueryJoined<History, Author, Book>(builder, (history, author, book) =>
_database.QueryJoined<History, Author, AuthorMetadata, Book>(builder, (history, author, metadata, book) =>
{
author.Metadata = metadata;
history.Author = author;
history.Book = book;
return history;

@ -109,7 +109,6 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
Metadata = MapAuthor(resource)
};
author.CleanName = Parser.Parser.CleanAuthorName(author.Metadata.Value.Name);
author.SortName = Parser.Parser.NormalizeTitle(author.Metadata.Value.Name);
// we can only get a rating from the author list page...
var listResource = GetAuthorBooksPageResource(foreignAuthorId, 10, 1);
@ -532,6 +531,8 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
Status = resource.DiedOnDate < DateTime.UtcNow ? AuthorStatusType.Ended : AuthorStatusType.Continuing
};
author.SortName = author.Name.ToSortName().ToLower();
if (!NoPhotoRegex.IsMatch(resource.LargeImageUrl))
{
author.Images.Add(new MediaCover.MediaCover
@ -555,6 +556,8 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
TitleSlug = resource.Id.ToString()
};
author.SortName = author.Name.ToSortName().ToLower();
if (resource.RatingsCount.HasValue)
{
author.Ratings = new Ratings
@ -704,6 +707,7 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
{
ForeignAuthorId = resource.BestBook.AuthorId.ToString(),
Name = resource.BestBook.AuthorName,
SortName = resource.BestBook.AuthorName.ToSortName().ToLower(),
TitleSlug = resource.BestBook.AuthorId.ToString()
}
};

@ -276,7 +276,6 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
{
Metadata = metadata,
CleanName = Parser.Parser.CleanAuthorName(metadata.Name),
SortName = Parser.Parser.NormalizeTitle(metadata.Name),
Books = books,
Series = series
};
@ -316,6 +315,8 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
Ratings = new Ratings { Votes = resource.RatingsCount, Value = (decimal)resource.AverageRating }
};
author.SortName = author.Name.ToSortName().ToLower();
if (resource.ImageUrl.IsNotNullOrWhiteSpace())
{
author.Images.Add(new MediaCover.MediaCover

@ -576,11 +576,6 @@ namespace NzbDrone.Core.Parser
return null;
}
public static string ToSortName(this string name)
{
return name.Split(' ', 2).Reverse().ConcatToString(", ");
}
public static string CleanAuthorName(this string name)
{
// If Title only contains numbers return it as is.

@ -72,7 +72,7 @@ namespace Readarr.Api.V1.Author
AuthorName = model.Name,
//AlternateTitles
SortName = model.SortName,
SortName = model.Metadata.Value.SortName,
Status = model.Metadata.Value.Status,
Overview = model.Metadata.Value.Overview,
@ -119,6 +119,7 @@ namespace Readarr.Api.V1.Author
ForeignAuthorId = resource.ForeignAuthorId,
TitleSlug = resource.TitleSlug,
Name = resource.AuthorName,
SortName = resource.SortName,
Status = resource.Status,
Overview = resource.Overview,
Links = resource.Links,
@ -128,7 +129,6 @@ namespace Readarr.Api.V1.Author
},
//AlternateTitles
SortName = resource.SortName,
Path = resource.Path,
QualityProfileId = resource.QualityProfileId,
MetadataProfileId = resource.MetadataProfileId,

@ -172,7 +172,7 @@ namespace Readarr.Api.V1.Queue
case "status":
return q => q.Status;
case "authors.sortName":
return q => q.Author?.SortName ?? string.Empty;
return q => q.Author?.Metadata.Value.SortName ?? string.Empty;
case "title":
return q => q.Title;
case "book":

Loading…
Cancel
Save