Open Library in the book store; podcast topic search and transcript search; Recipes (Mealie) with a shopping list synced to Tasks; Contacts linked to Mail and the Calendar
build-container / build (push) Successful in 7m21s
build-container / build (push) Successful in 7m21s
- Store: Open Library search, books fetched from the Internet Archive (free editions only) - Podcast: Podcast Index discovery by name and topic (optional key); search what is said across transcripts and play from that moment - Recipes: browse, import from a link, add ingredients to Mealie's shopping list, mirrored two ways with a Shopping list in Tasks - Contacts: per-person address book, vCard import/export, recipient suggestions in Mail, birthdays in the Calendar Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0617664d97
commit
176b1b8169
28 files changed
+799
-46
No files matched your search
@@ -0,0 +1,34 @@
|
||||
// Contacts: adding, editing, finding, vCard import/export (merging people already there), birthdays for the calendar, and each person's own book.
|
||||
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-ct-'));
|
||||
const contacts = require('../contacts'), users = require('../users');
|
||||
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', handle: 'Yg' }) };
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => contacts.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/contacts', post = (p, b, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(b), headers: h });
|
||||
try {
|
||||
let r = await post('/', { name: 'Zoe Zed', emails: [{ label: 'work', value: 'Zoe@Example.com' }, { value: 'not an email' }], phones: [{ value: '555 1234' }], birthday: '1990-04-15' }); let c = await r.json();
|
||||
assert.equal(r.status, 200); assert.deepEqual(c.emails, [{ label: 'work', value: 'zoe@example.com' }], 'addresses are lower-cased and bad ones dropped');
|
||||
assert.equal((await post('/', { name: '', emails: [] })).status, 400, 'a contact needs a name or an address');
|
||||
assert.equal((await (await post('/', { emails: [{ value: 'amy@x.org' }] })).json()).name, 'amy', 'with no name the address stands in');
|
||||
assert.equal((await (await post('/', { name: 'Bad Day', birthday: '1990-02-31' })).json()).birthday, '', 'a date that does not exist is dropped');
|
||||
c = await (await post('/', { id: c.id, name: 'Zoe Zed', org: 'Acme', emails: c.emails, birthday: '--04-15' })).json(); assert.equal(c.org, 'Acme'); assert.equal((await (await fetch(base + '/')).json()).length, 3, 'editing does not add another');
|
||||
assert.equal((await post('/', { id: 'nope', name: 'X' })).status, 404);
|
||||
// vCard in and out
|
||||
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zoe Zed\r\nEMAIL;TYPE=HOME:zoe@example.com\r\nEMAIL:zoe2@example.com\r\nTEL;TYPE=CELL:+1 555 0000\r\nEND:VCARD\r\nBEGIN:VCARD\r\nVERSION:4.0\r\nN:Smith;Jo;;;\r\nORG:Big\\, Co;Sales\r\nEMAIL;TYPE=work:jo@big.co\r\nBDAY:19801225\r\nNOTE:Line one\\nLine two\r\n continued\r\nADR;TYPE=home:;;1 Main St;Town;ST;12345;USA\r\nEND:VCARD\r\nBEGIN:VCARD\r\nEND:VCARD\r\n';
|
||||
j = await (await post('/import', { vcf })).json(); assert.deepEqual(j, { added: 1, merged: 1, skipped: 1 }, 'Zoe is filled in, Jo is new, the empty card is skipped');
|
||||
const all = await (await fetch(base + '/')).json(), zoe = all.find(x => x.name === 'Zoe Zed'), jo = all.find(x => x.name === 'Jo Smith');
|
||||
assert.deepEqual(zoe.emails.map(e => e.value), ['zoe@example.com', 'zoe2@example.com']); assert.equal(zoe.phones.length, 1); assert.equal(jo.org, 'Big, Co'); assert.equal(jo.birthday, '1980-12-25'); assert.equal(jo.notes, 'Line one\nLine twocontinued', 'folded lines are joined'); assert.match(jo.address, /1 Main St/);
|
||||
const out = await (await fetch(base + '/export')).text(); assert.match(out, /BEGIN:VCARD/); assert.match(out, /FN:Jo Smith/); assert.match(out, /BDAY:19801225/); assert.match(out, /BDAY:--0415/);
|
||||
const back = contacts.parseVcf(out); assert.equal(back.length, all.length, 'what is exported can be read back');
|
||||
// calendar birthdays
|
||||
const ev = contacts.birthdayEvents({ fd: '2026-04-01', td: '2026-04-30' }, people.a.id); assert.equal(ev.length, 1); assert.equal(ev[0].start, '2026-04-15'); assert.equal(ev[0].end, '2026-04-16'); assert.equal(ev[0].title, "Zoe Zed's birthday");
|
||||
const dec = contacts.birthdayEvents({ fd: '2026-12-01', td: '2027-01-10' }, people.a.id); assert.equal(dec[0].title, "Jo Smith's birthday (46)", 'the age is shown when the year is known');
|
||||
assert.equal(contacts.birthdayEvents({ fd: '2026-06-01', td: '2026-06-30' }, people.a.id).length, 0);
|
||||
// each person has their own book
|
||||
assert.deepEqual(await (await fetch(base + '/', { headers: { 'x-user': 'b' } })).json(), []);
|
||||
r = await post('/delete', { id: zoe.id }); assert.equal(r.status, 200); assert.equal((await (await fetch(base + '/')).json()).some(x => x.id === zoe.id), false);
|
||||
console.log('contacts ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
});
|
||||
var j;
|
||||
@@ -9,10 +9,12 @@ const fake = http.createServer((req, res) => {
|
||||
if (req.url === '/podcasts' && req.method === 'POST') { const u = JSON.parse(b).url; if (u === 'https://a.test/feed') { res.writeHead(409, { 'content-type': 'application/json' }); return res.end('{"message":"exists"}'); } res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"ID":"p2","Title":"New","URL":"' + u + '"}'); }
|
||||
if (req.url === '/podcastitems/e1/file') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); }
|
||||
if (req.url === '/podcastitems/e2/file') { res.writeHead(302, { location: 'https://cdn.example.com/a.mp3' }); return res.end(); }
|
||||
if (req.url.startsWith('/pi/')) { const d = Math.floor(Date.now() / 1000); const okAuth = req.headers['x-auth-key'] === 'k' && req.headers.authorization === require('crypto').createHash('sha1').update('k' + 's' + req.headers['x-auth-date']).digest('hex') && Math.abs(+req.headers['x-auth-date'] - d) < 60; res.writeHead(okAuth ? 200 : 401, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ feeds: [{ title: 'Bread Talk', url: 'https://b.test/feed', author: 'Baker', image: 'https://b.test/i.jpg' }, { title: 'Dead', url: 'https://d.test/feed', dead: 1 }, { title: 'Dup', url: 'https://a.test/feed' }] })); }
|
||||
if (req.url.startsWith('/apple?')) { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ results: [{ collectionName: 'Dup again', artistName: 'X', feedUrl: 'https://a.test/feed/' }, { collectionName: 'Apple Only', feedUrl: 'https://c.test/feed' }] })); }
|
||||
res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}');
|
||||
});
|
||||
}).listen(0, async () => {
|
||||
process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret';
|
||||
process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret'; Object.assign(process.env, { PODCASTINDEX_URL: 'http://127.0.0.1:' + fake.address().port + '/pi', APPLE_PODCASTS_URL: 'http://127.0.0.1:' + fake.address().port + '/apple' });
|
||||
const podcast = require('../podcast'), users = require('../users');
|
||||
const people = { admin: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), other: users.create({ name: 'Bob', role: 'user', tabs: ['podcast'], handle: 'Yg' }) };
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'other' ? people.other : people.admin, () => podcast.handle(req, res))).listen(0, async () => {
|
||||
@@ -22,6 +24,12 @@ const fake = http.createServer((req, res) => {
|
||||
assert.equal(seen[0].auth, 'Basic ' + Buffer.from('podgrab:secret').toString('base64'), 'password added by the server');
|
||||
r = await get('/podcastitems/e1/file', { headers: { range: 'bytes=0-3' } }); assert.equal(r.status, 206); assert.equal(await r.text(), 'abcd'); assert.equal(seen.at(-1).range, 'bytes=0-3');
|
||||
r = await get('/podcastitems/e2/file'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), 'https://cdn.example.com/a.mp3');
|
||||
assert.deepEqual(await (await get('/discover/topics')).json(), []); // no key yet: Apple only
|
||||
assert.deepEqual((await (await get('/discover?q=bread')).json()).map(x => x.title), ['Dup again', 'Apple Only']);
|
||||
Object.assign(process.env, { PODCASTINDEX_KEY: 'k', PODCASTINDEX_SECRET: 's' });
|
||||
assert.ok((await (await get('/discover/topics')).json()).includes('History'), 'topics are offered once Podcast Index is set up');
|
||||
let dj = await (await get('/discover?q=bread')).json(); assert.deepEqual(dj.map(x => x.title), ['Bread Talk', 'Dup', 'Apple Only'], 'both directories, a feed listed twice shown once, dead feeds dropped'); assert.equal(dj[0].author, 'Baker');
|
||||
dj = await (await get('/discover?topic=History')).json(); assert.equal(dj[0].title, 'Bread Talk', 'a topic lists that category\'s trending shows (request signed with the key)');
|
||||
const other = { 'x-user': 'other' };
|
||||
r = await get('/podcasts', { headers: other }); assert.deepEqual(await r.json(), [], 'a new person starts with their own empty list');
|
||||
r = await get('/podcasts/p1/items', { headers: other }); assert.equal(r.status, 404, 'a show not on your list is not reachable');
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Recipes: reading recipes and the shopping list from Mealie, importing a link, adding a recipe to the list, and keeping the list in step with Tasks.
|
||||
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-rcp-'));
|
||||
const seen = []; let items = [{ id: 'abcdef01-1', note: 'milk', display: 'milk', checked: false, position: 1 }], nextId = 1, newApi = true;
|
||||
const recipe = { id: '0123abcd-1', slug: 'bread', name: 'Bread', description: 'Simple', image: 'x', totalTime: 'PT1H30M', prepTime: '20 minutes', performTime: '1 hour', recipeServings: 4, orgURL: 'https://a.test/bread',
|
||||
recipeIngredient: [{ display: '3 cups flour' }, { title: 'Glaze' }, { quantity: 1, unit: { name: 'tbsp' }, food: { name: 'butter' } }], recipeInstructions: [{ text: 'Mix.' }, { title: 'Bake', text: 'Bake it.' }], recipeCategory: [{ name: 'Baking' }], tags: [] };
|
||||
const fake = http.createServer((req, res) => {
|
||||
let b = ''; req.on('data', c => b += c); req.on('end', () => {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname, J = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
||||
seen.push(req.method + ' ' + p + (u.search || '')); if (req.headers.authorization !== 'Bearer tok') return J({ detail: 'Unauthorized' }, 401);
|
||||
if (p === '/api/recipes' && req.method === 'GET') return J({ page: 1, total_pages: 2, items: [{ id: recipe.id, slug: 'bread', name: 'Bread', totalTime: '45 minutes', image: 'x', rating: 4 }] });
|
||||
if (p === '/api/recipes/bread') return J(recipe);
|
||||
if (p === '/api/recipes/create/url') { assert.equal(JSON.parse(b).url, 'https://a.test/new'); return J('new-one'); }
|
||||
if (p === '/api/recipes/new-one') return J({ ...recipe, id: '0123abcd-2', slug: 'new-one', name: 'New One' });
|
||||
if (p.startsWith('/api/media/recipes/0123abcd-1/images/')) { res.writeHead(200, { 'content-type': 'image/webp' }); return res.end('img'); }
|
||||
const sh = p.startsWith('/api/households/shopping') ? p.slice('/api/households/shopping'.length) : null;
|
||||
if (sh === null) return J({}, 404);
|
||||
if (sh === '/lists' && req.method === 'GET') return J({ items: [{ id: '1111aaaa-1', name: 'Shopping List' }] });
|
||||
if (sh === '/lists/1111aaaa-1' && req.method === 'GET') return J({ id: '1111aaaa-1', name: 'Shopping List', listItems: items });
|
||||
if (sh === '/lists/1111aaaa-1/recipe/0123abcd-1' && req.method === 'POST') { if (!newApi) return J({}, 404); items.push({ id: 'abcdef01-' + (++nextId), note: '3 cups flour', display: '3 cups flour', checked: false, position: 2 }); return J({}); }
|
||||
if (sh === '/lists/1111aaaa-1/recipe' && req.method === 'POST') { assert.equal(JSON.parse(b)[0].recipeId, '0123abcd-1'); items.push({ id: 'abcdef01-' + (++nextId), note: '3 cups flour', display: '3 cups flour', checked: false, position: 2 }); return J({}); }
|
||||
if (sh === '/items' && req.method === 'POST') { const j = JSON.parse(b), it = { id: 'abcdef01-' + (++nextId), note: j.note, display: j.note, checked: false, position: 9 }; items.push(it); return J({ createdItems: [it] }); }
|
||||
if (sh === '/items' && req.method === 'DELETE') { const ids = u.searchParams.getAll('ids'); items = items.filter(i => !ids.includes(i.id)); return J({}); }
|
||||
let m; if ((m = /^\/items\/([\w-]+)$/.exec(sh)) && req.method === 'PUT') { const j = JSON.parse(b), it = items.find(i => i.id === m[1]); Object.assign(it, j); return J(it); }
|
||||
J({}, 404);
|
||||
});
|
||||
}).listen(0, async () => {
|
||||
const port = fake.address().port; process.env.MEALIE_URL = 'http://127.0.0.1:' + port; process.env.MEALIE_TOKEN = 'tok';
|
||||
const recipes = require('../recipes'), users = require('../users'), me = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' });
|
||||
assert.equal(recipes.minutes('PT1H30M'), 90); assert.equal(recipes.minutes('1 hour 15 minutes'), 75); assert.equal(recipes.minutes('45 minutes'), 45); assert.equal(recipes.minutes(''), 0);
|
||||
const hub = http.createServer((req, res) => users.runAs(me, () => recipes.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/recipes', get = p => fetch(base + p), post = (p, b = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(b) });
|
||||
const tasks = () => JSON.parse(fs.readFileSync(path.join(users.dirOf(me.id), 'tasks.json'), 'utf8'));
|
||||
try {
|
||||
let j = await (await get('/?q=bre')).json(); assert.equal(j.items[0].name, 'Bread'); assert.equal(j.items[0].minutes, 45); assert.equal(j.more, true); assert.ok(seen.some(x => x.includes('search=bre')));
|
||||
j = await (await get('/r/bread')).json(); assert.equal(j.total, 90); assert.equal(j.prep, 20); assert.equal(j.cook, 60); assert.deepEqual(j.ingredients.map(i => i.text || i.title), ['3 cups flour', 'Glaze', '1 tbsp butter']); assert.equal(j.steps[1].title, 'Bake');
|
||||
assert.equal((await get('/r/..%2Fx')).status, 404, 'a recipe name cannot reach other Mealie pages');
|
||||
let r = await get('/image/0123abcd-1'); assert.equal(r.headers.get('content-type'), 'image/webp'); assert.equal((await get('/image/not%20ok')).status, 404);
|
||||
j = await (await post('/import', { url: 'https://a.test/new' })).json(); assert.equal(j.slug, 'new-one'); assert.equal((await post('/import', { url: 'javascript:1' })).status, 400);
|
||||
// the shopping list
|
||||
j = await (await get('/shopping')).json(); assert.deepEqual(j.items.map(i => i.text), ['milk']);
|
||||
j = await (await post('/shopping/add-recipe', { slug: 'bread' })).json(); assert.equal(j.ok, true); assert.equal(j.added, 2, 'the whole list came across to Tasks the first time');
|
||||
let t = tasks(), sl = t.lists.find(l => l.name === 'Shopping'); assert.ok(sl && sl.mealie); assert.deepEqual(t.tasks.filter(x => x.list === sl.id).map(x => x.title).sort(), ['3 cups flour', 'milk']);
|
||||
// ticked in Tasks -> ticked in Mealie
|
||||
t.tasks.find(x => x.title === 'milk').done = true; fs.writeFileSync(path.join(users.dirOf(me.id), 'tasks.json'), JSON.stringify(t));
|
||||
j = await (await post('/shopping/sync')).json(); assert.equal(j.toMealie, 1); assert.equal(items.find(i => i.note === 'milk').checked, true, 'finishing a task ticks the item in Mealie');
|
||||
// ticked in Mealie -> done in Tasks
|
||||
items.find(i => i.note === '3 cups flour').checked = true; j = await (await post('/shopping/sync')).json(); assert.equal(j.toTasks, 1); assert.equal(tasks().tasks.find(x => x.title === '3 cups flour').done, true);
|
||||
// typed into Tasks -> on the Mealie list; deleted in Tasks -> deleted in Mealie
|
||||
t = tasks(); t.tasks.push({ id: 'zz', list: sl.id, title: 'eggs', done: false }); t.tasks = t.tasks.filter(x => x.title !== 'milk'); fs.writeFileSync(path.join(users.dirOf(me.id), 'tasks.json'), JSON.stringify(t));
|
||||
j = await (await post('/shopping/sync')).json(); assert.equal(j.sent, 1); assert.ok(items.some(i => i.note === 'eggs')); assert.ok(!items.some(i => i.note === 'milk'), 'a task deleted in Tasks leaves Mealie too');
|
||||
// typed in the hub -> a task; ticked in the hub -> ticked; removing ticked items
|
||||
await post('/shopping/add', { text: 'butter' }); assert.ok(tasks().tasks.some(x => x.title === 'butter'));
|
||||
const bid = items.find(i => i.note === 'butter').id; await post('/shopping/check', { id: bid, checked: true }); assert.equal(items.find(i => i.id === bid).checked, true); assert.equal(tasks().tasks.find(x => x.title === 'butter').done, true);
|
||||
j = await (await post('/shopping/clear')).json(); assert.ok(j.removed >= 2); assert.ok(!items.some(i => i.checked)); assert.ok(!tasks().tasks.some(x => x.title === 'butter'), 'removed items leave Tasks too');
|
||||
// an older Mealie that only knows the list form of "add recipe"
|
||||
newApi = false; await post('/shopping/add-recipe', { slug: 'bread' }); assert.ok(seen.includes('POST /api/households/shopping/lists/1111aaaa-1/recipe'));
|
||||
// a wrong token is explained
|
||||
process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/);
|
||||
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
|
||||
console.log('recipes ok');
|
||||
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||
hub.close(); fake.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
|
||||
});
|
||||
});
|
||||
+8
-4
@@ -26,6 +26,8 @@ const fake = http.createServer((req, res) => {
|
||||
if (p === '/ia/metadata/moby1') return J({ metadata: {}, files: [{ name: 'moby1.pdf', format: 'Text PDF' }, { name: 'moby1.epub', format: 'EPUB' }] });
|
||||
if (p === '/ia/metadata/locked') return J({ metadata: { 'access-restricted-item': 'true' }, files: [{ name: 'x.epub', format: 'EPUB' }] });
|
||||
if (p === '/ia/download/moby1/moby1.epub') { res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(Buffer.concat([epub, Buffer.from('ia')])); }
|
||||
// Open Library (search only; the book is an Internet Archive item)
|
||||
if (p === '/ol/search.json') return J({ numFound: 2, docs: [{ title: 'Moby Dick', author_name: ['Melville'], cover_i: 7, ia: ['moby1'], ebook_access: 'public' }, { title: 'Borrowable', ia: ['locked'], ebook_access: 'borrowable' }] });
|
||||
// an OPDS catalogue that needs a sign-in (Calibre-Web style)
|
||||
if (p.startsWith('/cal/')) {
|
||||
if (req.headers.authorization !== 'Basic ' + Buffer.from('reader:pw').toString('base64')) { res.writeHead(401); return res.end(); }
|
||||
@@ -37,15 +39,15 @@ const fake = http.createServer((req, res) => {
|
||||
res.writeHead(404); res.end('no');
|
||||
}).listen(0, async () => {
|
||||
port = fake.address().port; const B = 'http://127.0.0.1:' + port;
|
||||
Object.assign(process.env, { GUTENBERG_URL: B, ARCHIVE_URL: B + '/ia', STANDARD_EBOOKS_URL: B });
|
||||
Object.assign(process.env, { GUTENBERG_URL: B, ARCHIVE_URL: B + '/ia', OPENLIBRARY_URL: B + '/ol', STANDARD_EBOOKS_URL: B });
|
||||
const books = require('../books'), users = require('../users');
|
||||
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/store', call = (p, o = {}) => fetch(base + p, o), post = (p, b, h = {}) => call(p, { method: 'POST', body: JSON.stringify(b), headers: h });
|
||||
try {
|
||||
let r = await call('/sources'); assert.deepEqual((await r.json()).map(s => s.id), ['gutenberg', 'standardebooks', 'archive']);
|
||||
let r = await call('/sources'); assert.deepEqual((await r.json()).map(s => s.id), ['gutenberg', 'standardebooks', 'archive', 'openlibrary']);
|
||||
r = await call('/search?q=alice'); let j = await r.json();
|
||||
assert.deepEqual(j.items.map(i => i.source), ['gutenberg', 'standardebooks', 'archive', 'gutenberg', 'standardebooks'].slice(0, j.items.length), 'one result from each catalogue in turn'); assert.deepEqual(j.errors, {});
|
||||
assert.deepEqual(j.items.map(i => i.source), ['gutenberg', 'standardebooks', 'archive', 'openlibrary', 'gutenberg', 'standardebooks'].slice(0, j.items.length), 'one result from each catalogue in turn'); assert.deepEqual(j.errors, {});
|
||||
const g = j.items.filter(i => i.source === 'gutenberg'); assert.deepEqual(g.map(i => i.ref), ['11', '345'], 'catalogue entries that are not books, and repeats, are left out'); assert.equal(g[0].author, 'Lewis Carroll'); assert.equal(g[1].title, 'Dracula & Co'); assert.match(g[0].cover, /^\/api\/read\/store\/cover\?s=gutenberg/); assert.equal(j.more, true);
|
||||
const se = j.items.filter(i => i.source === 'standardebooks'); assert.equal(se[0].title, 'Dracula alice'); assert.equal(se[0].author, 'Bram Stoker'); assert.equal(se[1].title, 'The Pilgrim’s Progress'); assert.equal(se[0].ref, '/ebooks/bram-stoker/dracula');
|
||||
assert.ok(!j.items.some(i => i.ref === '../bad'), 'odd identifiers are dropped'); assert.ok(!seen.some(x => x.startsWith('/feeds/opds')), 'the members-only feed is never asked for');
|
||||
@@ -63,12 +65,14 @@ const fake = http.createServer((req, res) => {
|
||||
r = await post('/add', { source: 'standardebooks', ref: '/ebooks/bram-stoker/dracula', title: 'Dracula', author: 'Bram Stoker' }); assert.equal(r.status, 200, 'the real EPUB is fetched, not the "download has started" page'); assert.ok(seen.some(x => x.includes('bram-stoker_dracula.epub?source=download')) && !seen.some(x => x.includes('kepub') && x.includes('source')), 'the plain EPUB (not the Kobo or advanced one) is chosen');
|
||||
r = await post('/add', { source: 'standardebooks', ref: '/ebooks/../etc/passwd' }); assert.equal(r.status, 400);
|
||||
r = await post('/add', { source: 'archive', ref: 'moby1', title: 'Moby', author: 'Melville' }); assert.equal((await r.json()).title, 'Moby'); assert.ok(seen.includes('/ia/download/moby1/moby1.epub'), 'the EPUB is chosen over the PDF');
|
||||
const ol = (await (await call('/search?q=moby&source=openlibrary')).json()).items; assert.deepEqual(ol.map(i => i.ref), ['moby1'], 'only freely readable editions are listed'); assert.equal(ol[0].author, 'Melville'); assert.match(decodeURIComponent(ol[0].coverUrl), /covers\.openlibrary\.org\/b\/id\/7-M\.jpg/);
|
||||
r = await post('/add', { source: 'openlibrary', ref: 'moby1', title: 'Moby Dick' }); assert.equal(r.status, 200, 'an Open Library book is fetched from the Internet Archive');
|
||||
r = await post('/add', { source: 'archive', ref: 'locked' }); assert.equal(r.status, 403); assert.match((await r.json()).error, /DRM/);
|
||||
r = await post('/add', { source: 'gutenberg', ref: '../x' }); assert.equal(r.status, 400);
|
||||
// custom catalogue
|
||||
assert.equal((await post('/sources', { name: 'Mine', url: B + '/cal/opds' }, { 'x-user': 'b' })).status, 403, 'only an admin adds catalogues');
|
||||
assert.equal((await post('/sources', { name: 'Bad', url: B + '/cal/opds', user: 'reader', pass: 'wrong' })).status, 502, 'a catalogue that refuses the sign-in is not added');
|
||||
assert.equal((await call('/sources').then(x => x.json())).length, 3);
|
||||
assert.equal((await call('/sources').then(x => x.json())).length, 4);
|
||||
r = await post('/sources', { name: 'Mine', url: B + '/cal/opds', user: 'reader', pass: 'pw' }); const mine = await r.json(); assert.equal(r.status, 200);
|
||||
assert.ok(!JSON.stringify(await (await call('/sources')).json()).includes('pw'), 'sign-ins are never sent to the browser'); assert.ok(!fs.readFileSync(path.join(process.env.DATA_DIR, 'store-sources.json'), 'utf8').includes('"pw"'), 'and are encrypted on disk');
|
||||
j = await (await call('/search?q=hello%20world&source=' + mine.id)).json(); assert.equal(j.items[0].title, 'Own Book hello world', 'search found through the OpenSearch description'); assert.equal(j.items[0].format, 'pdf');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Transcripts: reading SRT/VTT/JSON, finding an episode's transcript link in a feed, refusing private addresses, and speech-to-text through a fake Whisper server.
|
||||
const http = require('http'), fs = require('fs'), os = require('os'), path = require('path');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'trt-u-'));
|
||||
const tr = require('../transcripts');
|
||||
let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m); if (!c) bad++; };
|
||||
|
||||
@@ -44,6 +45,16 @@ let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m);
|
||||
const r = await tr.whisper(audio, 'audio'); ok(r && r.found && r.lines.length === 2 && r.lines[0].text === 'Welcome to the show.' && r.lines[1].text === 'Today we talk about bread.' && r.source === 'Transcribed on your server', 'speech-to-text segments become timed lines (blank ones dropped, tags removed)');
|
||||
ok(calls.includes('POST /v1/models/Systran/faster-whisper-small') && calls.includes('POST /v1/models') && installed, 'a model that is not installed is downloaded first (trying the older form, then the newer)');
|
||||
ok(r.lines[1].t === 2500 && r.lines[1].e === 2500 + 6000, 'times are in milliseconds');
|
||||
// searching what was said
|
||||
const users = require('../users'), me = users.create({ name: 'Zed', role: 'user', handle: 'Wg' });
|
||||
const put = (name, ep, lines) => fs.writeFileSync(path.join(dir, 'transcripts', name + '.json'), JSON.stringify({ found: true, lines, ep }));
|
||||
put('a1', { key: 'ab:book1', src: '/api/ab/books/x', title: 'Bread Book', artist: 'Baker' }, [{ t: 1000, text: 'Today we talk about sourdough bread.' }, { t: 65000, text: 'Knead the dough well' }, { t: 90000, text: 'Let it rest, then bake the bread.' }]);
|
||||
put('a2', { key: 'pod:77', src: '/api/podcast/podcastitems/77/file', title: 'Not Mine', artist: 'Other' }, [{ t: 0, text: 'sourdough bread everywhere' }]);
|
||||
put('a3', null, [{ t: 0, text: 'sourdough bread with no name' }]);
|
||||
const found = await users.runAs(me, () => tr.search(me, 'Sourdough bread'));
|
||||
ok(found.length === 1 && found[0].title === 'Bread Book' && found[0].hits[0].t === 1000 && found[0].exact, 'a search finds the moment, ignoring capitals; other people\'s podcasts and unnamed files are left out');
|
||||
const two = await users.runAs(me, () => tr.search(me, 'knead well')); ok(two.length === 1 && two[0].hits[0].t === 65000 && !two[0].exact, 'all the words on a line match, not only the exact phrase');
|
||||
ok((await users.runAs(me, () => tr.search(me, 'x'))).length === 0 && (await users.runAs(me, () => tr.search(me, 'zebra'))).length === 0, 'too short, or not said: nothing');
|
||||
srv.close(); fs.rmSync(dir, { recursive: true, force: true });
|
||||
console.log(bad ? '\n' + bad + ' check(s) failed' : '\nAll transcript checks passed'); process.exit(bad ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
Reference in new issue
Block a user