mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
198d36ae56
Fix the server and web gaps found while porting the PDF and comic readers and offline downloads to Android, checked against the iOS app: - KOReader: answer a pull for a PDF or comic with the page number when a BookOrbit client wrote last, instead of a percentage with no page - comics: key the zip offset, RAR and CB7 caches by file id and modification time, so pages are not served from stale offsets after a metadata write-back rewrites the archive - comics: answer an unreadable archive with 422 rather than a page count of 0, and extract CB7 with its folders so same-named pages in different folders no longer overwrite each other - book files: send an ETag and Last-Modified on serve and download and honour If-None-Match and If-Range, clamp a range that runs past the end, answer suffix ranges with 206, and honour Range on download, which advertised it but sent the whole file - book files: leave raw file routes uncompressed, so FB2 and unknown formats keep their Content-Length - progress: reject a fractional or negative page number with 400 rather than failing with 500 at the database - types: declare the comic page-count response shared by the web and the apps - web: open a percentage-only PDF position at its estimated page, and flush pending PDF and comic progress before the reading session ends
103 lines
4.3 KiB
TypeScript
103 lines
4.3 KiB
TypeScript
import { mkdtemp, rm, utimes, writeFile } from 'fs/promises';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
import { createSevenZipTempId, getSevenZip } from '../../../common/sevenzip';
|
|
import { CbzService, type ComicPageResult } from './cbz.service';
|
|
|
|
/**
|
|
* CB7 page listing walks a tree the real 7-Zip wrote, so these cases run against the real WASM
|
|
* module: the unit tests stand on a fake filesystem, and this is where the two would disagree.
|
|
*/
|
|
describe('CbzService CB7 pages against the real 7z module', () => {
|
|
const user = { id: 9, isSuperuser: false, permissions: [] } as any;
|
|
let workspace: string;
|
|
|
|
beforeEach(async () => {
|
|
workspace = await mkdtemp(join(tmpdir(), 'bookorbit-cb7-real-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(workspace, { recursive: true, force: true });
|
|
});
|
|
|
|
async function buildSevenZip(files: Record<string, string>): Promise<Uint8Array> {
|
|
const sevenZip = await getSevenZip();
|
|
const root = `/${createSevenZipTempId('build')}`;
|
|
sevenZip.FS.mkdir(root);
|
|
for (const [name, contents] of Object.entries(files)) {
|
|
const segments = name.split('/');
|
|
for (let depth = 1; depth < segments.length; depth++) {
|
|
try {
|
|
sevenZip.FS.mkdir(`${root}/${segments.slice(0, depth).join('/')}`);
|
|
} catch {
|
|
// already there
|
|
}
|
|
}
|
|
const fd = sevenZip.FS.open(`${root}/${name}`, 'w+');
|
|
const bytes = new TextEncoder().encode(contents);
|
|
sevenZip.FS.write(fd, bytes, 0, bytes.length);
|
|
sevenZip.FS.close(fd);
|
|
}
|
|
sevenZip.callMain(['a', `${root}/comic.7z`, `${root}/*`, '-y']);
|
|
return sevenZip.FS.readFile(`${root}/comic.7z`);
|
|
}
|
|
|
|
/** The page list the flat `e` extraction produced before folders were kept. */
|
|
async function flatPageNames(archive: Uint8Array): Promise<string[]> {
|
|
const sevenZip = await getSevenZip();
|
|
const root = `/${createSevenZipTempId('flat')}`;
|
|
sevenZip.FS.mkdir(root);
|
|
const fd = sevenZip.FS.open(`${root}.7z`, 'w+');
|
|
sevenZip.FS.write(fd, archive, 0, archive.length);
|
|
sevenZip.FS.close(fd);
|
|
sevenZip.callMain(['e', `${root}.7z`, `-o${root}`, '-y']);
|
|
return sevenZip.FS.readdir(root)
|
|
.filter((name) => name !== '.' && name !== '..' && /\.(jpe?g|png)$/i.test(name) && !name.startsWith('.'))
|
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
|
}
|
|
|
|
async function pageTexts(service: CbzService, fileId: number): Promise<string[]> {
|
|
const count = await service.getPageCount(fileId, user);
|
|
const texts: string[] = [];
|
|
for (let index = 0; index < count; index++) {
|
|
const result: ComicPageResult = await service.streamPage(fileId, index, user);
|
|
if (result.notModified) throw new Error('expected page bytes');
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of result.stream) chunks.push(Buffer.from(chunk));
|
|
texts.push(Buffer.concat(chunks).toString());
|
|
}
|
|
return texts;
|
|
}
|
|
|
|
it('keeps the flat page order for an archive whose page names are unique', async () => {
|
|
const files = {
|
|
'Chapter 10/c10.jpg': 'c10.jpg',
|
|
'Chapter 2/c2.jpg': 'c2.jpg',
|
|
'cover.jpg': 'cover.jpg',
|
|
'extras/.thumbs/z1.jpg': 'z1.jpg',
|
|
'extras/credits.png': 'credits.png',
|
|
'extras/._credits.png': 'hidden',
|
|
};
|
|
const archive = await buildSevenZip(files);
|
|
const path = join(workspace, 'unique.cb7');
|
|
await writeFile(path, archive);
|
|
const service = new CbzService({ verifyFileAccess: vi.fn().mockResolvedValue({ format: 'cb7', absolutePath: path }) } as any);
|
|
|
|
await expect(pageTexts(service, 31)).resolves.toEqual(await flatPageNames(archive));
|
|
});
|
|
|
|
it('serves pages that share a name in different folders, and rereads a rewritten file', async () => {
|
|
const path = join(workspace, 'nested.cb7');
|
|
await writeFile(path, await buildSevenZip({ 'ch2/001.jpg': 'ch2-1', 'ch1/001.jpg': 'ch1-1', 'ch1/002.jpg': 'ch1-2' }));
|
|
const service = new CbzService({ verifyFileAccess: vi.fn().mockResolvedValue({ format: 'cb7', absolutePath: path }) } as any);
|
|
|
|
await expect(pageTexts(service, 32)).resolves.toEqual(['ch1-1', 'ch2-1', 'ch1-2']);
|
|
|
|
await writeFile(path, await buildSevenZip({ 'ch1/001.jpg': 'rewritten' }));
|
|
await utimes(path, new Date(), new Date(Date.now() + 60_000));
|
|
|
|
await expect(pageTexts(service, 32)).resolves.toEqual(['rewritten']);
|
|
});
|
|
});
|