From ae42dcb53aac1374c4c001eeaf03a031eb826b89 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 9 May 2026 11:34:56 +0800 Subject: [PATCH] fix(txt): parse author from txt filename and use edited metadata on fallback cover (#4095) (#4102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When importing a `.txt` file the author field stayed empty unless the text content itself contained an `作者:…` header, even when the filename already encoded it. Common Chinese naming patterns like `《书名》作者:张三.txt`, `《书名》[张三].txt`, or `《书名》张三.txt` now contribute the author when the file body doesn't. - Added `extractTxtFilenameMetadata` in `utils/txt.ts` and replaced the ad-hoc `extractBookTitle` regex used by both convertSmallFile and convertLargeFile. Content-extracted author still wins; the filename author is the next fallback before the caller-provided one. - `BookCover` now reads `book.author || book.metadata?.author` so the author typed into the metadata edit dialog shows on auto-generated fallback covers when the original `book.author` was empty. Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/components/BookCover.test.tsx | 11 +++ .../src/__tests__/utils/txt-converter.test.ts | 70 ++++++++++++++++++- apps/readest-app/src/components/BookCover.tsx | 2 +- apps/readest-app/src/utils/txt.ts | 57 ++++++++++++--- 4 files changed, 128 insertions(+), 12 deletions(-) diff --git a/apps/readest-app/src/__tests__/components/BookCover.test.tsx b/apps/readest-app/src/__tests__/components/BookCover.test.tsx index ec135b34..572c3a9b 100644 --- a/apps/readest-app/src/__tests__/components/BookCover.test.tsx +++ b/apps/readest-app/src/__tests__/components/BookCover.test.tsx @@ -38,4 +38,15 @@ describe('BookCover', () => { expect(img).toBeTruthy(); expect(img?.getAttribute('loading')).toBe('lazy'); }); + + it('falls back to metadata.author on the fallback cover when book.author is empty', () => { + const book = makeBook({ + author: '', + coverImageUrl: undefined, + metadata: { author: 'Edited Author' } as Book['metadata'], + }); + const { container } = render(); + const fallback = container.querySelector('.fallback-cover'); + expect(fallback?.textContent).toContain('Edited Author'); + }); }); diff --git a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts index 9a54e555..36897af5 100644 --- a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts +++ b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { describe, expect, it } from 'vitest'; -import { TxtToEpubConverter } from '@/utils/txt'; +import { TxtToEpubConverter, extractTxtFilenameMetadata } from '@/utils/txt'; type TestChapter = { title: string; @@ -345,3 +345,71 @@ describe('TxtToEpubConverter', () => { expect(cancelled).toBe(true); }); }); + +describe('extractTxtFilenameMetadata', () => { + it('extracts the title from CJK 《》 brackets', () => { + expect(extractTxtFilenameMetadata('《三体》.txt')).toEqual({ title: '三体' }); + }); + + it('extracts title and labeled author with full-width colon', () => { + expect(extractTxtFilenameMetadata('《书名》作者:张三.txt')).toEqual({ + title: '书名', + author: '张三', + }); + expect(extractTxtFilenameMetadata('《书名》作者:张三.txt')).toEqual({ + title: '书名', + author: '张三', + }); + }); + + it('extracts title and labeled author with leading whitespace', () => { + expect(extractTxtFilenameMetadata('《书名》 作者:张三.txt')).toEqual({ + title: '书名', + author: '张三', + }); + }); + + it('extracts title and bracketed author after the title', () => { + expect(extractTxtFilenameMetadata('《书名》[张三].txt')).toEqual({ + title: '书名', + author: '张三', + }); + expect(extractTxtFilenameMetadata('《书名》(张三).txt')).toEqual({ + title: '书名', + author: '张三', + }); + expect(extractTxtFilenameMetadata('《书名》【张三】.txt')).toEqual({ + title: '书名', + author: '张三', + }); + }); + + it('extracts title and bare author after the title', () => { + expect(extractTxtFilenameMetadata('《书名》张三.txt')).toEqual({ + title: '书名', + author: '张三', + }); + }); + + it('strips leading/trailing punctuation from the author', () => { + expect(extractTxtFilenameMetadata('《书名》 - 张三.txt')).toEqual({ + title: '书名', + author: '张三', + }); + }); + + it('handles paths with directories', () => { + expect(extractTxtFilenameMetadata('/inbox/《书名》作者:张三.txt')).toEqual({ + title: '书名', + author: '张三', + }); + }); + + it('falls back to the base filename when no 《》 are present', () => { + expect(extractTxtFilenameMetadata('plain-name.txt')).toEqual({ title: 'plain-name' }); + }); + + it('returns empty object for empty input', () => { + expect(extractTxtFilenameMetadata('')).toEqual({ title: '' }); + }); +}); diff --git a/apps/readest-app/src/components/BookCover.tsx b/apps/readest-app/src/components/BookCover.tsx index 43b73872..de76d928 100644 --- a/apps/readest-app/src/components/BookCover.tsx +++ b/apps/readest-app/src/components/BookCover.tsx @@ -138,7 +138,7 @@ const BookCover: React.FC = memo( isPreview ? 'text-[0.4em]' : mode === 'grid' ? 'text-base' : 'text-xs', )} > - {formatAuthors(book.author)} + {formatAuthors(book.author || book.metadata?.author || '')} diff --git a/apps/readest-app/src/utils/txt.ts b/apps/readest-app/src/utils/txt.ts index cc22b50f..d03bd80a 100644 --- a/apps/readest-app/src/utils/txt.ts +++ b/apps/readest-app/src/utils/txt.ts @@ -10,6 +10,46 @@ interface Metadata { identifier: string; } +// Pull a title and (optionally) an author out of a TXT filename. Recognized +// patterns center on Chinese conventions where books are named with the title +// in 《》 and an author tacked on, e.g. 《书名》作者:张三.txt, 《书名》[张三].txt, +// 《书名》张三.txt. Falls back to the base filename as the title when no +// 《》 are present. +export const extractTxtFilenameMetadata = ( + filename: string, +): { title: string; author?: string } => { + const base = getBaseFilename(filename); + const cjkMatch = base.match(/《([^》]+)》(.*)/); + if (!cjkMatch) { + return { title: base }; + } + const title = cjkMatch[1]!.trim(); + const rest = (cjkMatch[2] ?? '').trim(); + const author = parseAuthorFragment(rest); + return author ? { title, author } : { title }; +}; + +const parseAuthorFragment = (text: string): string => { + if (!text) return ''; + // 作者:X / 作者:X / 作者 X — labeled author wins + const labeled = text.match(/作者\s*[::\s]\s*(.+)$/); + if (labeled) return stripWrappingPunctuation(labeled[1]!); + // [X] (X) 【X】 (X)[X] — bracketed author + const bracketed = text.match(/[[((【[]\s*([^\]))】]]+?)\s*[\]))】]]/); + if (bracketed) return stripWrappingPunctuation(bracketed[1]!); + // bare token — strip any leading separator like " - " / "·" / "-" + return stripWrappingPunctuation(text); +}; + +const stripWrappingPunctuation = (text: string): string => { + const trimmed = text.trim(); + try { + return trimmed.replace(/^[\p{P}\p{S}\s]+|[\p{P}\p{S}\s]+$/gu, ''); + } catch { + return trimmed; + } +}; + interface Chapter { title: string; content: string; @@ -73,18 +113,19 @@ export class TxtToEpubConverter { const decoder = new TextDecoder(runtimeEncoding); const txtContent = decoder.decode(fileContent).trim(); - const bookTitle = this.extractBookTitle(getBaseFilename(txtFile.name)); + const filenameMeta = extractTxtFilenameMetadata(txtFile.name); + const bookTitle = filenameMeta.title; const fileName = `${bookTitle}.epub`; const fileHeader = txtContent.slice(0, 1024); const authorMatch = fileHeader.match(/[【\[]?作者[】\]]?[::\s]\s*(.+)\r?\n/) || fileHeader.match(/[【\[]?\s*(.+)\s+著\s*[】\]]?\r?\n/); - let matchedAuthor = authorMatch ? authorMatch[1]!.trim() : providedAuthor || ''; + let matchedAuthor = authorMatch ? authorMatch[1]!.trim() : ''; try { matchedAuthor = matchedAuthor.replace(/^[\p{P}\p{S}]+|[\p{P}\p{S}]+$/gu, ''); } catch {} - const author = matchedAuthor || providedAuthor || ''; + const author = matchedAuthor || filenameMeta.author || providedAuthor || ''; const language = providedLanguage || detectLanguage(fileHeader); // console.log(`Detected language: ${language}`); const identifier = await partialMD5(txtFile); @@ -126,7 +167,8 @@ export class TxtToEpubConverter { const runtimeEncoding = this.resolveSupportedEncoding(detectedEncoding); // console.log(`Detected encoding: ${detectedEncoding}, runtime encoding: ${runtimeEncoding}`); - const bookTitle = this.extractBookTitle(getBaseFilename(txtFile.name)); + const filenameMeta = extractTxtFilenameMetadata(txtFile.name); + const bookTitle = filenameMeta.title; const fileName = `${bookTitle}.epub`; const fileHeader = await this.readHeaderTextFromFile( txtFile, @@ -137,7 +179,7 @@ export class TxtToEpubConverter { const { author, language } = this.extractAuthorAndLanguage( fileHeader, - providedAuthor, + filenameMeta.author ?? providedAuthor, providedLanguage, ); // console.log(`Detected language: ${language}`); @@ -931,11 +973,6 @@ export class TxtToEpubConverter { return 'utf-8'; } - private extractBookTitle(filename: string): string { - const match = filename.match(/《([^》]+)》/); - return match ? match[1]! : filename.split('.')[0]!; - } - private assertStrictUtf8Sample(sample: Uint8Array): void { const decoder = new TextDecoder('utf-8', { fatal: true }); try {