diff --git a/app/src/Book.jsx b/app/src/Book.jsx index 605d2bd..4a55585 100644 --- a/app/src/Book.jsx +++ b/app/src/Book.jsx @@ -251,10 +251,19 @@ function Clippings({ session, book, revision = 0 }) { ) } -const norm = (s) => (s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() -// Same normalized title (and author when both have one): probably the same book synced twice. +// Letters, marks and digits of every script: an ASCII-only pattern reduces Japanese titles to their +// digits, and dropping marks merges Thai or Hindi words that differ only in a tone or vowel sign. +const norm = (s) => (s ?? '').normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ').trim() +// Language only by its main subtag ("en" and "en-GB" match). Unknown on either side counts as a match. +const sameLanguage = (a, b) => !a || !b || a.split('-')[0].toLowerCase() === b.split('-')[0].toLowerCase() +// Same normalized title (and author and language when both have one): probably the same book synced +// twice. Editions in two languages can share a title, e.g. "1984" in English and in German. export const looksLikeSame = (a, b) => - a.document !== b.document && norm(a.title) && norm(a.title) === norm(b.title) && (!a.author || !b.author || norm(a.author) === norm(b.author)) + a.document !== b.document && + norm(a.title) && + norm(a.title) === norm(b.title) && + (!a.author || !b.author || norm(a.author) === norm(b.author)) && + sameLanguage(a.language, b.language) const field = 'h-11 w-full rounded-xl bg-surface px-3 text-base text-stone-900 ring-1 ring-stone-950/10 outline-none placeholder:text-stone-400 focus:ring-2 focus:ring-brand-500/60' diff --git a/app/src/Library.test.jsx b/app/src/Library.test.jsx index df60d78..9b86255 100644 --- a/app/src/Library.test.jsx +++ b/app/src/Library.test.jsx @@ -46,3 +46,40 @@ describe('Library completion updates', () => { expect(queryByText('Continue reading')).toBeNull() }) }) + +describe('duplicate notice', () => { + const book = (document, title) => ({ document, title, author: 'Unknown Author', status: 'finished', percentage: 1 }) + + it('tells different Japanese titles apart', () => { + const books = [ + book('grammar', '小学1年生 言葉と文法にぐーんと強くなる (くもんの国語集中学習)'), + book('kanji', '小学1年生 漢字にぐーんと強くなる (くもんの国語集中学習)'), + ] + const { container } = render() + expect(container.textContent).not.toContain('appears more than once') + }) + + it('tells Thai titles apart that differ only in a tone mark', () => { + const books = [book('forest', 'ป่า'), book('aunt', 'ป้า')] + const { container } = render() + expect(container.textContent).not.toContain('appears more than once') + }) + + it('tells the same title apart when the languages differ', () => { + const books = [{ ...book('en', '1984'), language: 'en' }, { ...book('de', '1984'), language: 'de' }] + const { container } = render() + expect(container.textContent).not.toContain('appears more than once') + }) + + it('treats a regional variant as the same language', () => { + const books = [{ ...book('a', '1984'), language: 'en' }, { ...book('b', '1984'), language: 'en-GB' }] + const { container } = render() + expect(container.textContent).toContain('appears more than once') + }) + + it('still finds a Japanese title synced twice, full-width forms included', () => { + const books = [book('a', '変な家2 ~11の間取り図~'), book('b', '変な家2 ~11の間取り図~')] + const { container } = render() + expect(container.textContent).toContain('appears more than once') + }) +})