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:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user