fix(txt): parse author from txt filename and use edited metadata on fallback cover (#4095) (#4102)

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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-09 11:34:56 +08:00
committed by GitHub
parent 302363a9fd
commit ae42dcb53a
4 changed files with 128 additions and 12 deletions
@@ -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(<BookCover book={book} coverFit='crop' />);
const fallback = container.querySelector('.fallback-cover');
expect(fallback?.textContent).toContain('Edited Author');
});
});
@@ -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: '' });
});
});
@@ -138,7 +138,7 @@ const BookCover: React.FC<BookCoverProps> = memo<BookCoverProps>(
isPreview ? 'text-[0.4em]' : mode === 'grid' ? 'text-base' : 'text-xs',
)}
>
{formatAuthors(book.author)}
{formatAuthors(book.author || book.metadata?.author || '')}
</span>
</div>
</div>
+47 -10
View File
@@ -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 {