mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(kobo): convert chapters listed by percent-encoded hrefs
kepubify looks a manifest href up exactly as written, so a chapter stored as "chapter one.xhtml" but listed as "chapter%20one.xhtml" shipped with no KoboSpans and every synced position snapped back to the chapter start. Convert a copy with those hrefs decoded and restore the book's spelling afterwards; only affected books get a new cache key. Position conversion now matches and loads such chapters too. Fixes #1136
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
vi.mock('fs/promises', () => ({ mkdir: vi.fn(), mkdtemp: vi.fn(), rename: vi.fn(), rm: vi.fn(), stat: vi.fn() }));
|
||||
vi.mock('child_process', () => ({ execFile: vi.fn() }));
|
||||
vi.mock('./kepub-href-decoding', () => ({ findDecodableHrefs: vi.fn(), writeWithDecodedHrefs: vi.fn(), restoreEncodedHrefs: vi.fn() }));
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { storageConfig } from '../../../config/config';
|
||||
@@ -7,6 +8,7 @@ import { execFile } from 'child_process';
|
||||
import { mkdir, mkdtemp, rename, rm, stat } from 'fs/promises';
|
||||
|
||||
import { KepubConversionService } from './kepub-conversion.service';
|
||||
import { findDecodableHrefs, restoreEncodedHrefs, writeWithDecodedHrefs } from './kepub-href-decoding';
|
||||
import { KepubifyBinaryService } from './kepubify-binary.service';
|
||||
|
||||
const execMock = vi.mocked(execFile);
|
||||
@@ -36,6 +38,9 @@ describe('KepubConversionService', () => {
|
||||
vi.mocked(rename).mockResolvedValue(undefined);
|
||||
vi.mocked(rm).mockResolvedValue(undefined);
|
||||
successfulConversion();
|
||||
vi.mocked(findDecodableHrefs).mockResolvedValue(new Map());
|
||||
vi.mocked(writeWithDecodedHrefs).mockResolvedValue(undefined);
|
||||
vi.mocked(restoreEncodedHrefs).mockResolvedValue(1);
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
KepubConversionService,
|
||||
@@ -137,4 +142,42 @@ describe('KepubConversionService', () => {
|
||||
expect(rename).not.toHaveBeenCalled();
|
||||
expect(rm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('books listing chapters by percent-encoded hrefs (#1136)', () => {
|
||||
const decoded = new Map([['text/chapter one.xhtml', 'text/chapter%20one.xhtml']]);
|
||||
|
||||
it('converts them through a decoded copy, restores their hrefs, and caches them under their own key', async () => {
|
||||
vi.mocked(findDecodableHrefs).mockResolvedValue(decoded);
|
||||
|
||||
await expect(service.getKepubPath(input)).resolves.toBe(`${cacheDir}/abc-hrefs.kepub.epub`);
|
||||
|
||||
expect(writeWithDecodedHrefs).toHaveBeenCalledWith(input.sourcePath, `${tempDir}/source.epub`, decoded);
|
||||
expect(execMock).toHaveBeenCalledWith(
|
||||
'/tools/kepubify',
|
||||
['--output', `${tempDir}/converted.kepub.epub`, `${tempDir}/source.epub`],
|
||||
{ timeout: 60_000 },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(restoreEncodedHrefs).toHaveBeenCalledWith(`${tempDir}/converted.kepub.epub`, tempPath, decoded);
|
||||
expect(rename).toHaveBeenCalledExactlyOnceWith(tempPath, `${cacheDir}/abc-hrefs.kepub.epub`);
|
||||
});
|
||||
|
||||
it('inspects a source once, not on every lookup', async () => {
|
||||
statMock.mockResolvedValue({} as never);
|
||||
|
||||
await service.getKepubPath(input);
|
||||
await service.getKepubPath(input);
|
||||
|
||||
expect(findDecodableHrefs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts a source it cannot inspect as is, under the key it always had', async () => {
|
||||
vi.mocked(findDecodableHrefs).mockRejectedValue(new Error('not a zip'));
|
||||
|
||||
await expect(service.getKepubPath(input)).resolves.toBe(`${cacheDir}/abc.kepub.epub`);
|
||||
|
||||
expect(writeWithDecodedHrefs).not.toHaveBeenCalled();
|
||||
expect(execMock).toHaveBeenCalledWith('/tools/kepubify', ['--output', tempPath, input.sourcePath], { timeout: 60_000 }, expect.any(Function));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,16 +4,18 @@ import { mkdir, mkdtemp, rename, rm, stat, opendir, lstat, realpath } from 'fs/p
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { Inject, Injectable, BadRequestException, Logger } from '@nestjs/common';
|
||||
import type { ConfigType } from '@nestjs/config';
|
||||
|
||||
import { storageConfig } from '../../../config/config';
|
||||
|
||||
import { KepubifyBinaryService } from './kepubify-binary.service';
|
||||
import { findDecodableHrefs, restoreEncodedHrefs, writeWithDecodedHrefs, type DecodedHrefs } from './kepub-href-decoding';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const KEPUBIFY_TIMEOUT_MS = 60_000;
|
||||
const AUDIOLESS_EPUB_CACHE_VERSION = 1;
|
||||
const HREF_DECODING_MEMO_LIMIT = 256;
|
||||
|
||||
interface KepubConversionInput {
|
||||
sourcePath: string;
|
||||
@@ -30,6 +32,7 @@ interface KepubConversionInput {
|
||||
|
||||
@Injectable()
|
||||
export class KepubConversionService {
|
||||
private readonly logger = new Logger(KepubConversionService.name);
|
||||
private readonly kepubCachePath: string;
|
||||
private readonly appDataPath: string;
|
||||
private active = 0;
|
||||
@@ -37,6 +40,7 @@ export class KepubConversionService {
|
||||
private readonly leases = new Map<string, number>();
|
||||
private publication: Promise<void> = Promise.resolve();
|
||||
private readonly conversions = new Map<string, Promise<string>>();
|
||||
private readonly hrefDecodings = new Map<string, Promise<DecodedHrefs>>();
|
||||
|
||||
constructor(
|
||||
@Inject(storageConfig.KEY) storage: ConfigType<typeof storageConfig>,
|
||||
@@ -67,10 +71,13 @@ export class KepubConversionService {
|
||||
const cacheDir = join(this.kepubCachePath, String(input.bookId));
|
||||
const fileHash = input.fileHash ?? 'nohash';
|
||||
// Keep the original two key shapes byte for byte so existing cache entries still hit.
|
||||
const legacyKey = `${fileHash}${input.audioless ? `-noaudio-v${AUDIOLESS_EPUB_CACHE_VERSION}` : ''}${input.hyphenate ? '-hyph' : ''}`;
|
||||
// A book kepubify reads as is keeps its key byte for byte, so only the books it used to leave
|
||||
// unconverted are converted again.
|
||||
const decodedHrefs = await this.decodableHrefs(input);
|
||||
const legacyKey = `${fileHash}${input.audioless ? `-noaudio-v${AUDIOLESS_EPUB_CACHE_VERSION}` : ''}${input.hyphenate ? '-hyph' : ''}${decodedHrefs.size ? '-hrefs' : ''}`;
|
||||
const cacheKey = input.sourceRevision
|
||||
? createHash('sha256')
|
||||
.update(`${input.sourceRevision}:${await this.kepubifyBinaryService.getVersion()}:${input.hyphenate}`)
|
||||
.update(`${input.sourceRevision}:${await this.kepubifyBinaryService.getVersion()}:${input.hyphenate}${decodedHrefs.size ? ':hrefs' : ''}`)
|
||||
.digest('hex')
|
||||
: legacyKey;
|
||||
const cachedPath = join(cacheDir, `${cacheKey}.kepub.epub`);
|
||||
@@ -88,7 +95,7 @@ export class KepubConversionService {
|
||||
|
||||
const concurrent = this.conversions.get(cachedPath);
|
||||
if (concurrent) return concurrent;
|
||||
const conversion = this.convertToCache(input, cacheDir, cachedPath);
|
||||
const conversion = this.convertToCache(input, cacheDir, cachedPath, decodedHrefs);
|
||||
this.conversions.set(cachedPath, conversion);
|
||||
try {
|
||||
return await conversion;
|
||||
@@ -97,7 +104,7 @@ export class KepubConversionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async convertToCache(input: KepubConversionInput, cacheDir: string, cachedPath: string): Promise<string> {
|
||||
private async convertToCache(input: KepubConversionInput, cacheDir: string, cachedPath: string, decodedHrefs: DecodedHrefs): Promise<string> {
|
||||
if (this.active >= 2) {
|
||||
if (this.waiters.length >= 32) throw new BadRequestException({ code: 'DELIVERY_BUSY' });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -124,8 +131,18 @@ export class KepubConversionService {
|
||||
const tempDir = await mkdtemp(join(cacheDir, '.conversion-'));
|
||||
const outputPath = join(tempDir, 'book.kepub.epub');
|
||||
try {
|
||||
const args = input.hyphenate ? ['--hyphenate', '--output', outputPath, input.sourcePath] : ['--output', outputPath, input.sourcePath];
|
||||
await execFileAsync(binaryPath, args, { timeout: KEPUBIFY_TIMEOUT_MS });
|
||||
if (decodedHrefs.size) {
|
||||
const sourcePath = join(tempDir, 'source.epub');
|
||||
const convertedPath = join(tempDir, 'converted.kepub.epub');
|
||||
await writeWithDecodedHrefs(input.sourcePath, sourcePath, decodedHrefs);
|
||||
await this.runKepubify(binaryPath, sourcePath, convertedPath, input.hyphenate);
|
||||
const restored = await restoreEncodedHrefs(convertedPath, outputPath, decodedHrefs);
|
||||
this.logger.log(
|
||||
`[kobo.kepub_href_decoding] [end] bookId=${input.bookId} decoded=${decodedHrefs.size} restored=${restored} - converted chapters kepubify could not find by their encoded href`,
|
||||
);
|
||||
} else {
|
||||
await this.runKepubify(binaryPath, input.sourcePath, outputPath, input.hyphenate);
|
||||
}
|
||||
// Readers and the cache recovery pass must never see a partially written conversion.
|
||||
const publication = this.publication.then(async () => {
|
||||
if (input.sourceRevision) await this.evict(cachedPath, (await stat(outputPath)).size);
|
||||
@@ -143,6 +160,22 @@ export class KepubConversionService {
|
||||
else this.active--;
|
||||
}
|
||||
}
|
||||
private async runKepubify(binaryPath: string, sourcePath: string, outputPath: string, hyphenate: boolean): Promise<void> {
|
||||
const args = hyphenate ? ['--hyphenate', '--output', outputPath, sourcePath] : ['--output', outputPath, sourcePath];
|
||||
await execFileAsync(binaryPath, args, { timeout: KEPUBIFY_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
private decodableHrefs(input: KepubConversionInput): Promise<DecodedHrefs> {
|
||||
const key = `${input.sourcePath}\0${input.sourceRevision ?? input.fileHash ?? ''}`;
|
||||
let pending = this.hrefDecodings.get(key);
|
||||
if (!pending) {
|
||||
pending = findDecodableHrefs(input.sourcePath).catch(() => new Map<string, string>());
|
||||
if (this.hrefDecodings.size >= HREF_DECODING_MEMO_LIMIT) this.hrefDecodings.delete(this.hrefDecodings.keys().next().value!);
|
||||
this.hrefDecodings.set(key, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async evict(publishingPath: string, requiredBytes: number): Promise<void> {
|
||||
let total = requiredBytes;
|
||||
let count = 1;
|
||||
@@ -157,7 +190,7 @@ export class KepubConversionService {
|
||||
if (Date.now() - info.mtimeMs > 20 * 60_000) await rm(path, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !/^[a-f0-9]{32,64}(?:-noaudio-v\d+)?(?:-hyph)?\.kepub\.epub$/.test(entry.name)) continue;
|
||||
if (!entry.isFile() || !/^[a-f0-9]{32,64}(?:-noaudio-v\d+)?(?:-hyph)?(?:-hrefs)?\.kepub\.epub$/.test(entry.name)) continue;
|
||||
const info = await stat(path);
|
||||
total += info.size;
|
||||
count++;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { ZipArchive } from 'archiver';
|
||||
import * as unzipper from 'unzipper';
|
||||
|
||||
import { findDecodableHrefs, restoreEncodedHrefs, writeWithDecodedHrefs } from './kepub-href-decoding';
|
||||
import { KepubifyBinaryService } from './kepubify-binary.service';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CONTAINER_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||
</container>`;
|
||||
|
||||
// Hrefs are URLs, so files stored with spaces, `&` or `#` in their names are listed percent-encoded.
|
||||
const CONTENT_OPF = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="uid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Encoded hrefs</dc:title><dc:identifier id="uid">x</dc:identifier><dc:language>en</dc:language></metadata>
|
||||
<manifest>
|
||||
<item id="spaces" href="text/chapter%20one.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="amp" href="text/notes%20%26%20more.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="hash" href="text/part%231.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="literal" href="text/stored%20as%20written.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="plain" href="text/plain.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="spaces"/><itemref idref="amp"/><itemref idref="hash"/><itemref idref="literal"/><itemref idref="plain"/></spine>
|
||||
<guide><reference type="text" title="Start" href="text/chapter%20one.xhtml#start"/></guide>
|
||||
</package>`;
|
||||
|
||||
function chapter(text: string): string {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>t</title></head>
|
||||
<body><p id="start">${text} The first sentence of the chapter.</p><p>A second paragraph follows it.</p></body></html>`;
|
||||
}
|
||||
|
||||
const ENTRIES: [string, string][] = [
|
||||
['OEBPS/text/chapter one.xhtml', chapter('Spaces.')],
|
||||
['OEBPS/text/notes & more.xhtml', chapter('Ampersand.')],
|
||||
['OEBPS/text/part#1.xhtml', chapter('Hash.')],
|
||||
// Stored with the escapes in its name, so the href already resolves as written.
|
||||
['OEBPS/text/stored%20as%20written.xhtml', chapter('Literal.')],
|
||||
['OEBPS/text/plain.xhtml', chapter('Plain.')],
|
||||
];
|
||||
|
||||
async function buildEpub(path: string): Promise<void> {
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } });
|
||||
const chunks: Buffer[] = [];
|
||||
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
archive.on('end', resolve);
|
||||
archive.on('error', reject);
|
||||
});
|
||||
archive.append('application/epub+zip', { name: 'mimetype', store: true });
|
||||
archive.append(CONTAINER_XML, { name: 'META-INF/container.xml' });
|
||||
archive.append(CONTENT_OPF, { name: 'OEBPS/content.opf' });
|
||||
for (const [name, body] of ENTRIES) archive.append(body, { name });
|
||||
await archive.finalize();
|
||||
await done;
|
||||
await writeFile(path, Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
async function readEntries(path: string): Promise<Map<string, string>> {
|
||||
const zip = await unzipper.Open.file(path);
|
||||
const entries = new Map<string, string>();
|
||||
for (const file of zip.files) if (file.type === 'File') entries.set(file.path, (await file.buffer()).toString('utf8'));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function hrefAttributes(opf: string): string[] {
|
||||
return [...opf.matchAll(/\bhref="([^"]*)"/g)].map((match) => match[1]!);
|
||||
}
|
||||
|
||||
function hasSpans(xhtml: string | undefined): boolean {
|
||||
return (xhtml ?? '').includes('koboSpan');
|
||||
}
|
||||
|
||||
describe('kepub href decoding (real kepubify)', () => {
|
||||
let dir: string;
|
||||
let epubPath: string;
|
||||
let kepubify: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'bookorbit-kepub-hrefs-'));
|
||||
epubPath = join(dir, 'book.epub');
|
||||
await buildEpub(epubPath);
|
||||
kepubify = await new KepubifyBinaryService().getBinaryPath();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('finds the hrefs that resolve only once decoded, keyed by the stored name', async () => {
|
||||
const decoded = await findDecodableHrefs(epubPath);
|
||||
|
||||
expect(Object.fromEntries(decoded)).toEqual({
|
||||
'text/chapter one.xhtml': 'text/chapter%20one.xhtml',
|
||||
'text/chapter one.xhtml#start': 'text/chapter%20one.xhtml#start',
|
||||
'text/notes & more.xhtml': 'text/notes%20%26%20more.xhtml',
|
||||
'text/part#1.xhtml': 'text/part%231.xhtml',
|
||||
});
|
||||
});
|
||||
|
||||
it('documents why: kepubify leaves a chapter listed by its encoded href unconverted', async () => {
|
||||
const out = join(dir, 'as-is.kepub.epub');
|
||||
await execFileAsync(kepubify, ['--output', out, epubPath], { timeout: 60_000 });
|
||||
const entries = await readEntries(out);
|
||||
|
||||
expect(hasSpans(entries.get('OEBPS/text/chapter one.xhtml'))).toBe(false);
|
||||
expect(hasSpans(entries.get('OEBPS/text/plain.xhtml'))).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it('converts every chapter and hands back the package with the book’s own hrefs', async () => {
|
||||
const decoded = await findDecodableHrefs(epubPath);
|
||||
const source = join(dir, 'decoded.epub');
|
||||
const converted = join(dir, 'converted.kepub.epub');
|
||||
const restoredPath = join(dir, 'restored.kepub.epub');
|
||||
|
||||
await writeWithDecodedHrefs(epubPath, source, decoded);
|
||||
const decodedOpf = (await readEntries(source)).get('OEBPS/content.opf')!;
|
||||
expect(decodedOpf).toContain('href="text/notes & more.xhtml"');
|
||||
expect(decodedOpf).toContain('href="text/stored%20as%20written.xhtml"');
|
||||
|
||||
await execFileAsync(kepubify, ['--output', converted, source], { timeout: 60_000 });
|
||||
const restored = await restoreEncodedHrefs(converted, restoredPath, decoded);
|
||||
const entries = await readEntries(restoredPath);
|
||||
|
||||
expect(restored).toBe(4);
|
||||
for (const name of ['OEBPS/text/chapter one.xhtml', 'OEBPS/text/notes & more.xhtml', 'OEBPS/text/part#1.xhtml', 'OEBPS/text/plain.xhtml']) {
|
||||
expect(hasSpans(entries.get(name)), name).toBe(true);
|
||||
}
|
||||
// kepubify adds a title page of its own once it can read the first chapter; every other href is the book's.
|
||||
const hrefs = hrefAttributes(entries.get('OEBPS/content.opf')!);
|
||||
expect(hrefs.filter((href) => !href.startsWith('kepubify-'))).toEqual(hrefAttributes(CONTENT_OPF));
|
||||
const first = (await unzipper.Open.file(restoredPath)).files[0]!;
|
||||
expect(first.path).toBe('mimetype');
|
||||
expect(first.compressionMethod).toBe(0);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects input that is not an archive, so the caller falls back to converting it as is', async () => {
|
||||
const notAnEpub = join(dir, 'not-an.epub');
|
||||
await writeFile(notAnEpub, 'plain text');
|
||||
|
||||
await expect(findDecodableHrefs(notAnEpub)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createWriteStream } from 'fs';
|
||||
import { posix } from 'path';
|
||||
import { ZipArchive } from 'archiver';
|
||||
import * as unzipper from 'unzipper';
|
||||
|
||||
// kepubify looks a manifest href up in the archive exactly as written, so a chapter listed as
|
||||
// `Text/My%20Chapter.xhtml` but stored as `Text/My Chapter.xhtml` is shipped unconverted. With no
|
||||
// KoboSpans in it a Kobo can only bookmark the chapter's start, and every synced position snaps
|
||||
// back there (#1136). The fix converts a copy whose OPF names those files as stored, then puts the
|
||||
// book's own spelling back so the Kobo still reports the paths it always did.
|
||||
|
||||
const HREF_ATTRIBUTE = /\bhref=(["'])([^"']*)\1/g;
|
||||
const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/;
|
||||
const MAX_PACKAGE_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_ENTRIES = 20_000;
|
||||
|
||||
/** The book's own href attribute text, keyed by the href as stored in the archive. */
|
||||
export type DecodedHrefs = ReadonlyMap<string, string>;
|
||||
|
||||
interface PackageDocument {
|
||||
files: unzipper.File[];
|
||||
opfPath: string;
|
||||
opf: string;
|
||||
}
|
||||
|
||||
/** The hrefs that resolve only once decoded, or an empty map when kepubify can read the book as is. */
|
||||
export async function findDecodableHrefs(epubPath: string): Promise<Map<string, string>> {
|
||||
const pkg = await readPackageDocument(epubPath);
|
||||
const decoded = new Map<string, string>();
|
||||
if (!pkg) return decoded;
|
||||
const names = new Set(pkg.files.map((file) => file.path));
|
||||
const opfDir = posix.dirname(pkg.opfPath);
|
||||
for (const [, , raw] of pkg.opf.matchAll(HREF_ATTRIBUTE)) {
|
||||
if (!PERCENT_ESCAPE.test(raw)) continue;
|
||||
const href = unescapeXml(raw);
|
||||
const plain = decodeHref(href);
|
||||
if (!plain || plain === href) continue;
|
||||
if (names.has(resolveHref(opfDir, hrefPath(href))) || !names.has(resolveHref(opfDir, decodeHref(hrefPath(href)) ?? ''))) continue;
|
||||
decoded.set(plain, raw);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/** Copies the book with the given hrefs decoded in its OPF, for kepubify to convert. */
|
||||
export async function writeWithDecodedHrefs(epubPath: string, outputPath: string, decoded: DecodedHrefs): Promise<void> {
|
||||
await rewriteOpf(epubPath, outputPath, (opf) =>
|
||||
opf.replace(HREF_ATTRIBUTE, (match, quote: string, raw: string) => {
|
||||
const plain = decodeHref(unescapeXml(raw));
|
||||
return plain !== null && decoded.get(plain) === raw ? `href=${quote}${escapeXml(plain)}${quote}` : match;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Puts the book's own href spelling back into kepubify's output; returns how many it restored. */
|
||||
export async function restoreEncodedHrefs(kepubPath: string, outputPath: string, decoded: DecodedHrefs): Promise<number> {
|
||||
let restored = 0;
|
||||
await rewriteOpf(kepubPath, outputPath, (opf) =>
|
||||
// kepubify re-serializes the package, so match on the attribute's value rather than its text.
|
||||
opf.replace(HREF_ATTRIBUTE, (match, quote: string, raw: string) => {
|
||||
const original = decoded.get(unescapeXml(raw));
|
||||
if (original === undefined) return match;
|
||||
restored++;
|
||||
return `href=${quote}${original}${quote}`;
|
||||
}),
|
||||
);
|
||||
return restored;
|
||||
}
|
||||
|
||||
async function rewriteOpf(epubPath: string, outputPath: string, rewrite: (opf: string) => string): Promise<void> {
|
||||
const pkg = await readPackageDocument(epubPath);
|
||||
if (!pkg) throw new Error('EPUB package document not found');
|
||||
const archive = new ZipArchive({ zlib: { level: 6 } });
|
||||
const output = createWriteStream(outputPath);
|
||||
let fail!: (error: Error) => void;
|
||||
const failure = new Promise<never>((_resolve, reject) => {
|
||||
fail = reject;
|
||||
});
|
||||
failure.catch(() => {});
|
||||
archive.on('error', fail);
|
||||
output.on('error', fail);
|
||||
const closed = new Promise<void>((resolve) => output.once('close', resolve));
|
||||
archive.pipe(output);
|
||||
|
||||
// One entry at a time: appending them all up front opens a read stream per entry at once.
|
||||
const append = (source: Buffer | NodeJS.ReadableStream, options: { name: string; store?: boolean }) =>
|
||||
Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
archive.once('entry', () => resolve());
|
||||
if (!Buffer.isBuffer(source)) source.once('error', fail);
|
||||
archive.append(source as Buffer, options);
|
||||
}),
|
||||
failure,
|
||||
]);
|
||||
|
||||
try {
|
||||
const mimetype = pkg.files.find((file) => file.path === 'mimetype');
|
||||
if (mimetype) await append(await readBounded(mimetype), { name: 'mimetype', store: true });
|
||||
for (const file of pkg.files) {
|
||||
if (file.type !== 'File' || file.path === 'mimetype') continue;
|
||||
await append(file.path === pkg.opfPath ? Buffer.from(rewrite(pkg.opf), 'utf8') : file.stream(), { name: file.path });
|
||||
}
|
||||
void archive.finalize();
|
||||
await Promise.race([closed, failure]);
|
||||
} catch (error) {
|
||||
output.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readPackageDocument(epubPath: string): Promise<PackageDocument | null> {
|
||||
const zip = await unzipper.Open.file(epubPath);
|
||||
if (zip.files.length > MAX_ENTRIES) return null;
|
||||
const container = zip.files.find((file) => file.path === 'META-INF/container.xml');
|
||||
if (!container) return null;
|
||||
const opfPath = /full-path=(["'])([^"']+)\1/.exec((await readBounded(container)).toString('utf8'))?.[2];
|
||||
const opfEntry = opfPath ? zip.files.find((file) => file.path === opfPath) : undefined;
|
||||
if (!opfPath || !opfEntry) return null;
|
||||
return { files: zip.files, opfPath, opf: (await readBounded(opfEntry)).toString('utf8') };
|
||||
}
|
||||
|
||||
async function readBounded(file: unzipper.File): Promise<Buffer> {
|
||||
if (file.uncompressedSize > MAX_PACKAGE_BYTES) throw new Error(`EPUB entry ${file.path} is too large to read`);
|
||||
return file.buffer();
|
||||
}
|
||||
|
||||
/** Decodes the path of an href; a fragment is left as written. */
|
||||
function decodeHref(href: string): string | null {
|
||||
const path = hrefPath(href);
|
||||
try {
|
||||
return decodeURIComponent(path) + href.slice(path.length);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const XML_ESCAPES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
const XML_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char] ?? char);
|
||||
}
|
||||
|
||||
function unescapeXml(value: string): string {
|
||||
return value.replace(/&(#x[0-9A-Fa-f]+|#\d+|amp|lt|gt|quot|apos);/g, (match, entity: string) => {
|
||||
if (!entity.startsWith('#')) return XML_ENTITIES[entity] ?? match;
|
||||
const codePoint = entity.startsWith('#x') ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10);
|
||||
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : match;
|
||||
});
|
||||
}
|
||||
|
||||
/** The href without its fragment, cut at the first literal `#` so an encoded `%23` stays in the path. */
|
||||
function hrefPath(href: string): string {
|
||||
const hash = href.indexOf('#');
|
||||
return hash === -1 ? href : href.slice(0, hash);
|
||||
}
|
||||
|
||||
function resolveHref(opfDir: string, path: string): string {
|
||||
return posix.normalize(posix.join(opfDir, path));
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { KoboDownloadRepository } from '../kobo-download.repository';
|
||||
const SETTING_KEY = 'kobo_download_hash_backfill';
|
||||
const VERSION = 1;
|
||||
const EVENT = 'kobo.download_hash_backfill';
|
||||
const CACHE_FILE = /^([0-9a-f]{32})(?:-noaudio-v[1-9]\d*)?(?:-hyph)?\.kepub\.epub$/;
|
||||
const CACHE_FILE = /^([0-9a-f]{32})(?:-noaudio-v[1-9]\d*)?(?:-hyph)?(?:-hrefs)?\.kepub\.epub$/;
|
||||
|
||||
@Injectable()
|
||||
export class KoboDownloadHashBackfillService implements OnApplicationBootstrap {
|
||||
|
||||
@@ -51,7 +51,18 @@ function resolveHref(href: string, basePath: string): string {
|
||||
}
|
||||
|
||||
function findInZip(files: unzipper.File[], path: string): unzipper.File | undefined {
|
||||
const clean = normalizeZipPath(path);
|
||||
const entry = matchZipEntry(files, normalizeZipPath(path));
|
||||
if (entry || !path.includes('%')) return entry;
|
||||
// Manifest hrefs are URLs, so a chapter stored as `My Chapter.xhtml` is listed as `My%20Chapter.xhtml`.
|
||||
try {
|
||||
const decoded = decodeURIComponent(path);
|
||||
return decoded === path ? undefined : matchZipEntry(files, normalizeZipPath(decoded));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function matchZipEntry(files: unzipper.File[], clean: string): unzipper.File | undefined {
|
||||
const cleanLower = clean.toLowerCase();
|
||||
let ciMatch: unzipper.File | undefined;
|
||||
for (const file of files) {
|
||||
|
||||
@@ -29,7 +29,7 @@ const CH2 = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>2</title></head>
|
||||
<body><div id="book-columns"><div id="book-inner"><p><span class="koboSpan" id="kobo.1.1">Second chapter text.</span></p></div></div></body></html>`;
|
||||
|
||||
async function buildKepub(path: string): Promise<void> {
|
||||
async function buildKepub(path: string, opf = CONTENT_OPF, chapterNames = ['OEBPS/text/ch1.xhtml', 'OEBPS/text/ch2.xhtml']): Promise<void> {
|
||||
const archive = new ZipArchive({ zlib: { level: 0 } });
|
||||
const chunks: Buffer[] = [];
|
||||
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
@@ -39,9 +39,9 @@ async function buildKepub(path: string): Promise<void> {
|
||||
});
|
||||
archive.append('application/epub+zip', { name: 'mimetype', store: true });
|
||||
archive.append(CONTAINER_XML, { name: 'META-INF/container.xml' });
|
||||
archive.append(CONTENT_OPF, { name: 'OEBPS/content.opf' });
|
||||
archive.append(CH1, { name: 'OEBPS/text/ch1.xhtml' });
|
||||
archive.append(CH2, { name: 'OEBPS/text/ch2.xhtml' });
|
||||
archive.append(opf, { name: 'OEBPS/content.opf' });
|
||||
archive.append(CH1, { name: chapterNames[0] });
|
||||
archive.append(CH2, { name: chapterNames[1] });
|
||||
await archive.finalize();
|
||||
await done;
|
||||
await writeFile(path, Buffer.concat(chunks));
|
||||
@@ -89,3 +89,37 @@ describe('KepubDomService', () => {
|
||||
expect(await service.getSpine(join(dir, 'nope.kepub.epub'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// A chapter stored as `chapter one.xhtml` is listed, and reported by the Kobo, as `chapter%20one.xhtml` (#1136).
|
||||
describe('KepubDomService with percent-encoded manifest hrefs', () => {
|
||||
let dir: string;
|
||||
let kepubPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'bookorbit-kepub-encoded-'));
|
||||
kepubPath = join(dir, 'encoded.kepub.epub');
|
||||
await buildKepub(
|
||||
kepubPath,
|
||||
CONTENT_OPF.replace('text/ch1.xhtml', 'text/chapter%20one.xhtml').replace('text/ch2.xhtml', 'text/notes%20%26%20more.xhtml'),
|
||||
['OEBPS/text/chapter one.xhtml', 'OEBPS/text/notes & more.xhtml'],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('loads a chapter whose stored name only matches its href once decoded', async () => {
|
||||
const doc = await new KepubDomService().getChapterByIndex(kepubPath, 1);
|
||||
|
||||
expect(doc?.index.collapsed).toBe('Second chapter text.');
|
||||
});
|
||||
|
||||
it('matches the chapter the Kobo reports, encoded or not', async () => {
|
||||
const service = new KepubDomService();
|
||||
|
||||
expect(await service.findChapterIndexByFilename(kepubPath, 'OEBPS/text/chapter%20one.xhtml')).toBe(0);
|
||||
expect(await service.findChapterIndexByFilename(kepubPath, 'OEBPS/text/chapter one.xhtml')).toBe(0);
|
||||
expect(await service.findChapterIndexByFilename(kepubPath, 'OEBPS/text/notes%20%26%20more.xhtml')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,8 @@ export class KepubDomService {
|
||||
const normalized = normalizeChapterPath(chapterFilename);
|
||||
if (!normalized) return null;
|
||||
const lower = normalized.toLowerCase();
|
||||
const hrefs = entry.spine.hrefs;
|
||||
// The Kobo reports a chapter by its manifest href, encoded or not, so compare both sides decoded.
|
||||
const hrefs = entry.spine.hrefs.map(normalizeChapterPath);
|
||||
|
||||
let index = hrefs.findIndex((href) => href === normalized);
|
||||
if (index < 0) index = hrefs.findIndex((href) => href.toLowerCase() === lower);
|
||||
|
||||
Reference in New Issue
Block a user