Books: EPUB, PDF and DOCX reader with synced position, bookmarks, notes and highlights
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
This commit is contained in:
17 files changed
+933
-8
No files matched your search
@@ -0,0 +1,43 @@
|
||||
// E-book library: uploads are checked by content, each person has their own shelf, and position and notes merge so the newest edit wins.
|
||||
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), cp = require('child_process');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-books-'));
|
||||
const books = require('../books'), users = require('../users');
|
||||
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-bookfx-'));
|
||||
cp.execFileSync('python3', [path.join(__dirname, 'mkbooks.py'), fx]);
|
||||
const epub = fs.readFileSync(path.join(fx, 'test.epub')), pdf = fs.readFileSync(path.join(fx, 'test.pdf')), docx = fs.readFileSync(path.join(__dirname, '..', 'sample.docx'));
|
||||
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['read'], handle: 'Yg' }) };
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => books.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/read', call = (p, o = {}) => fetch(base + p, o), up = (name, buf, h) => call('/upload?name=' + encodeURIComponent(name), { method: 'POST', body: buf, headers: h });
|
||||
try {
|
||||
let r = await up('Rabbit_Tales.epub', epub); assert.equal(r.status, 200); const e = await r.json(); assert.equal(e.ext, 'epub'); assert.equal(e.title, 'Rabbit Tales');
|
||||
r = await up('again.epub', epub); assert.equal((await r.json()).duplicate, true, 'the same file is not stored twice');
|
||||
r = await up('m.pdf', pdf); const p = await r.json(); assert.equal(p.ext, 'pdf');
|
||||
r = await up('d.docx', docx); assert.equal(r.status, 200);
|
||||
assert.equal((await up('fake.pdf', Buffer.from('<html>not a pdf</html>'))).status, 400, 'a file must look like what its name says');
|
||||
assert.equal((await up('fake.epub', pdf)).status, 400); assert.equal((await up('x.exe', epub)).status, 400); assert.equal((await up('plain.docx', epub)).status, 400, 'an EPUB is not a DOCX');
|
||||
assert.equal((await call('/list')).status, 200); assert.equal((await (await call('/list')).json()).length, 3);
|
||||
assert.equal((await (await call('/list', { headers: { 'x-user': 'b' } })).json()).length, 0, 'each person has their own shelf');
|
||||
assert.equal((await call('/book/' + e.id + '/file', { headers: { 'x-user': 'b' } })).status, 404, "nobody can open another person's book");
|
||||
r = await call('/book/' + p.id + '/file', { headers: { range: 'bytes=0-4' } }); assert.equal(r.status, 206); assert.equal(await r.text(), '%PDF-'); assert.equal(r.headers.get('content-range'), `bytes 0-4/${pdf.length}`);
|
||||
r = await call('/book/' + p.id + '/file', { headers: { range: 'bytes=-6' } }); assert.equal((await r.text()), '%%EOF\n');
|
||||
assert.equal((await call('/book/' + p.id + '/file', { headers: { range: 'bytes=99999-' } })).status, 416);
|
||||
r = await call('/book/' + e.id, { method: 'PATCH', body: JSON.stringify({ title: 'Better', author: 'Me' }) }); assert.equal((await r.json()).author, 'Me');
|
||||
assert.equal((await call('/book/' + e.id + '/cover', { method: 'PUT', body: 'x', headers: { 'content-type': 'text/html' } })).status, 400, 'covers must be images');
|
||||
r = await call('/book/' + e.id + '/cover', { method: 'PUT', body: Buffer.from('GIF89a'), headers: { 'content-type': 'image/gif' } }); assert.equal((await r.json()).cover, true);
|
||||
r = await call('/book/' + e.id + '/cover'); assert.equal(r.headers.get('content-type'), 'image/gif');
|
||||
const pos = (loc, t) => call('/book/' + e.id + '/pos', { method: 'POST', body: JSON.stringify({ loc, pct: 0.5, t }) }).then(x => x.json());
|
||||
await pos('new', 100); assert.equal((await pos('old', 50)).loc, 'new', 'the newest position wins');
|
||||
let l = await (await call('/list')).json(); assert.equal(l.find(b => b.id === e.id).loc, 'new'); assert.equal(l.find(b => b.id === e.id).pct, 0.5);
|
||||
const note = (arr) => call('/book/' + e.id + '/notes', { method: 'POST', body: JSON.stringify(arr) }).then(x => x.json());
|
||||
await note([{ id: 'n1', t: 10, type: 'hl', loc: 'epubcfi(/6/2)', text: 'hello', color: 'green' }]);
|
||||
let ns = await note([{ id: 'n2', t: 11, type: 'note', note: 'think', loc: 'x' }, { id: 'n1', t: 5, type: 'hl', text: 'stale edit', loc: 'y' }, { id: '../bad', t: 1 }]);
|
||||
assert.deepEqual(ns.map(n => n.id).sort(), ['n1', 'n2'], 'two devices add notes without losing each other'); assert.equal(ns.find(n => n.id === 'n1').text, 'hello', 'an older edit does not win');
|
||||
ns = await note([{ id: 'n1', t: 20, del: true, loc: 'y' }]); assert.deepEqual(ns.map(n => n.id), ['n2'], 'deleted notes stay deleted');
|
||||
assert.equal((await (await call('/book/' + e.id + '/notes')).json()).length, 1);
|
||||
assert.equal((await call('/book/' + e.id, { method: 'DELETE' })).status, 200); assert.equal((await (await call('/list')).json()).length, 2);
|
||||
assert.equal((await call('/book/' + e.id + '/file')).status, 404);
|
||||
assert.equal((await call('/nope')).status, 404);
|
||||
console.log('books test ok');
|
||||
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||
hub.close();
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
# Builds tiny EPUB and PDF files for tools/books-test.js and manual checks: python3 tools/mkbooks.py <outdir>
|
||||
import sys, zipfile, os
|
||||
out = sys.argv[1]; os.makedirs(out, exist_ok=True)
|
||||
with zipfile.ZipFile(os.path.join(out, 'test.epub'), 'w') as z:
|
||||
z.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
|
||||
z.writestr('META-INF/container.xml', '<?xml version="1.0"?><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>')
|
||||
z.writestr('OEBPS/content.opf', '<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">x1</dc:identifier><dc:title>Rabbit Tales</dc:title><dc:creator>A. Writer</dc:creator><dc:language>en</dc:language></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/><item id="c2" href="c2.xhtml" media-type="application/xhtml+xml"/><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/></manifest><spine><itemref idref="c1"/><itemref idref="c2"/></spine></package>')
|
||||
z.writestr('OEBPS/nav.xhtml', '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><body><nav epub:type="toc"><ol><li><a href="c1.xhtml">Chapter One</a></li><li><a href="c2.xhtml">Chapter Two</a></li></ol></nav></body></html>')
|
||||
para = ' '.join(['The quick brown rabbit jumps over the lazy dog and keeps hopping through the green meadow.'] * 40)
|
||||
for i, n in ((1, 'One'), (2, 'Two')):
|
||||
z.writestr(f'OEBPS/c{i}.xhtml', f'<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>{n}</title></head><body><h1>Chapter {n}</h1>' + ''.join(f'<p>{para} Marker{i}x{k}.</p>' for k in range(8)) + '</body></html>')
|
||||
# minimal 3-page PDF
|
||||
objs = []
|
||||
def add(s): objs.append(s); return len(objs)
|
||||
add('<< /Type /Catalog /Pages 2 0 R >>'); add('<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>')
|
||||
for i in range(3):
|
||||
pid = add(f'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {4+i*2} 0 R /Resources << /Font << /F1 9 0 R >> >> >>')
|
||||
text = f'BT /F1 24 Tf 72 700 Td (Page {i+1} of the rabbit manual) Tj 0 -40 Td (Carrots are great on page {i+1}.) Tj ET'
|
||||
add(f'<< /Length {len(text)} >>\nstream\n{text}\nendstream')
|
||||
add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')
|
||||
data = '%PDF-1.4\n'; offs = []
|
||||
for i, o in enumerate(objs): offs.append(len(data)); data += f'{i+1} 0 obj\n{o}\nendobj\n'
|
||||
x = len(data); data += f'xref\n0 {len(objs)+1}\n0000000000 65535 f \n' + ''.join(f'{o:010d} 00000 n \n' for o in offs) + f'trailer\n<< /Size {len(objs)+1} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n'
|
||||
open(os.path.join(out, 'test.pdf'), 'w').write(data)
|
||||
Reference in new issue
Block a user