diff --git a/README.md b/README.md index 9b81289..39befae 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ One **Store** tab (turn it on per person) with sections for **Books** (the free **My Store** is your own catalogue, for everyone who has the Store tab. The admin adds items with **Add**: upload files, or copy a file from their Seafile (**From Files**), with an optional cover picture. - **Books** (EPUB, PDF, MOBI, AZW3, DOCX): *Add to my library* puts a copy on that person's shelf, or download it. - **Add many books** (admin): pick a whole batch of book files at once. Each one is uploaded on its own and the server reads the **title, author, description and cover picture out of the EPUB itself** (`epubmeta.js`, no libraries); a book already in the store (same file) is skipped. PDF, MOBI, AZW3 and DOCX carry little usable metadata, so they are named after the file, with no author or cover. When it finishes it lists any book with no cover or no author so you can fix those with *Edit*. + - **Select and add to people** (admin): tap *Select* in My Store, tick books (or *Select all shown*), then *Add to people…* and tick the people. The books land on each person's own shelf; someone without the Books tab is skipped and a book they already have is left alone. - **Music** (MP3, M4A, AAC, FLAC, OGG, OPUS, WAV): an album can have many tracks; they play in the hub's player and can be downloaded. - **Apps**: any files to download (an APK, an installer...), with an optional link and version. The hub does not run them. - **Sites**: any website as a card. It opens in a new tab, or inside the hub if you tick *Open inside the hub* (some sites refuse to be shown that way). diff --git a/mystore.js b/mystore.js index 9642e80..a55d5e3 100644 --- a/mystore.js +++ b/mystore.js @@ -154,6 +154,23 @@ async function handle(req, res) { if (req.method === 'DELETE') { needAdmin(me); fs.rm(coverOn(it), { force: true }, () => {}); it.cover = false; saveItems(l); return json(res, 200, view(it)); } if (req.method === 'GET') { if (!it.cover) throw fail(404, 'No cover'); return send(req, res, coverOn(it), it.cover, 'cover', false); } } + if (p === '/people' && req.method === 'GET') { needAdmin(me); return json(res, 200, users.all().filter(u => !u.disabled).map(u => ({ id: u.id, name: u.name, books: users.can(u, 'read') }))); } + if (p === '/assign' && req.method === 'POST') { // put the chosen books on the chosen people's shelves (admin). A book they already have is left alone. + needAdmin(me); const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = loadItems(); + const its = [...new Set(Array.isArray(b.items) ? b.items : [])].map(id => l.find(x => x.id === id)).filter(x => x && x.kind === 'book' && x.files[0]); + const who = [...new Set(Array.isArray(b.users) ? b.users : [])].map(id => users.get(id)).filter(u => u && !u.disabled); + if (!its.length) throw fail(400, 'Choose at least one book'); if (!who.length) throw fail(400, 'Choose at least one person'); + const out = []; + for (const u of who) { + const r = { id: u.id, name: u.name, added: 0, had: 0, failed: 0, skipped: !users.can(u, 'read') }; + if (!r.skipped) await users.runAs(u, async () => { for (const it of its) { + try { const f = it.files[0], buf = fs.readFileSync(fileOn(it, f.id)); let cover; if (it.cover) { try { cover = { type: it.cover, data: fs.readFileSync(coverOn(it)) }; } catch {} } + books.addBook(buf, f.ext, { title: it.title, author: it.creator }, cover).duplicate ? r.had++ : r.added++; } catch { r.failed++; } + } }); + out.push(r); + } + return json(res, 200, { books: its.length, people: out }); + } if ((m = /^\/items\/([0-9a-f]{12})\/to-library$/.exec(p)) && req.method === 'POST') { // a book goes onto the person's own shelf if (!users.can(me, 'read')) throw fail(403, 'You do not have the Books tab'); const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'book' || !it.files[0]) throw fail(404, 'That is not a book'); diff --git a/public/store-view.js b/public/store-view.js index 2785b3f..d0dba5a 100644 --- a/public/store-view.js +++ b/public/store-view.js @@ -29,19 +29,26 @@ async function viewStore(args = []) { } // ---------------------------------------------------------------- My Store +let stSelecting = false; const stPicked = new Set(); async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stSeen = r.seen || {}; stAdmin = r.admin; return r; } async function stMine(el) { try { await stLoad(); } catch (e) { el.innerHTML = `

${esc(e.message)}

`; return; } const draw = () => { if (!$('#stbody')) return; const mine = stItems.filter(i => i.kind === 'extension' && stInstalled.includes(i.id)), shown = stItems.filter(i => stFilter === 'all' || i.kind === stFilter); - el.innerHTML = `

Books, music, apps and extensions added just for this hub.

${stAdmin ? `` : ''}
+ el.innerHTML = `

Books, music, apps and extensions added just for this hub.

${stAdmin ? `` : ''}
+ ${stSelecting ? `
${stPicked.size} selected
` : ''} ${mine.length ? `

My extensions

${mine.map(i => stCard(i, true)).join('')}
` : ''}
All${ST_KINDS.map(k => `${k[1]}`).join('')}
${shown.length ? `
${shown.map(i => stCard(i)).join('')}
` : `

${stItems.length ? 'Nothing of that kind yet.' : stAdmin ? 'Nothing here yet. Add your first book, album, app or extension.' : 'Nothing has been added yet.'}

`}`; el.querySelectorAll('#stkinds a').forEach(a => a.onclick = e => { e.preventDefault(); stFilter = a.dataset.k; draw(); }); - el.querySelectorAll('[data-it]').forEach(a => a.onclick = e => { e.preventDefault(); const it = stItems.find(x => x.id === a.dataset.it); if (a.dataset.run) location.hash = '#/store/ext/' + it.id; else stDetail(it, draw); }); + el.querySelectorAll('[data-it]').forEach(a => a.onclick = e => { e.preventDefault(); const it = stItems.find(x => x.id === a.dataset.it); + if (stSelecting) { if (it.kind !== 'book' || !it.files.length) return toast('Only books can be given to people'); stPicked.has(it.id) ? stPicked.delete(it.id) : stPicked.add(it.id); return draw(); } if (a.dataset.run) location.hash = '#/store/ext/' + it.id; else stDetail(it, draw); }); if ($('#stadd')) $('#stadd').onclick = () => stEdit(null, draw); + if ($('#stsel')) $('#stsel').onclick = () => { stSelecting = !stSelecting; stPicked.clear(); if (stSelecting) stFilter = 'book'; draw(); }; + el.querySelectorAll('[data-it]').forEach(a => a.classList.toggle('picked', stPicked.has(a.dataset.it))); + if ($('#stgive')) $('#stgive').onclick = () => stGive([...stPicked], () => { stSelecting = false; stPicked.clear(); draw(); }); + if ($('#stall')) $('#stall').onclick = () => { const bs = shown.filter(i => i.kind === 'book' && i.files.length); bs.every(i => stPicked.has(i.id)) ? stPicked.clear() : bs.forEach(i => stPicked.add(i.id)); draw(); }; if ($('#stbulk')) $('#stbulk').onchange = e => { const fs = [...e.target.files]; e.target.value = ''; if (fs.length) stBulk(fs, draw); }; }; draw(); @@ -268,3 +275,18 @@ async function stBulk(files, redraw) { q('#sbx').textContent = 'Done'; done = files.length; try { await stLoad(); } catch {} redraw(); } + +// Give the chosen books to people: they land on each person's shelf (a person without the Books tab is skipped, and a book they already have is left alone). +async function stGive(ids, after) { + let people; try { people = await stApi('/people'); } catch (e) { return toast(e.message); } + const el = openSheet('Add to people', ids.length + (ids.length === 1 ? ' book' : ' books'), `
${people.map(p => ``).join('')}
+
`); + el.querySelector('#sgx').onclick = closeSheet; + el.querySelector('#sgok').onclick = async e => { + const us = [...el.querySelectorAll('[data-u]:checked')].map(c => c.dataset.u), err = m => { el.querySelector('#sterr').textContent = m; }; if (!us.length) return err('Choose at least one person'); + e.currentTarget.disabled = true; e.currentTarget.textContent = 'Adding…'; + try { const r = await stApi('/assign', stOpt('POST', { items: ids, users: us })); closeSheet(); const n = r.people.reduce((a, p) => a + p.added, 0), had = r.people.reduce((a, p) => a + p.had, 0), bad = r.people.reduce((a, p) => a + p.failed, 0); + toast(`Added ${n} ${n === 1 ? 'book' : 'books'} to ${r.people.filter(p => p.added).length} ${r.people.filter(p => p.added).length === 1 ? 'person' : 'people'}` + (had ? `, ${had} already had it` : '') + (bad ? `, ${bad} failed` : '')); after(); + } catch (x) { err(x.message); e.currentTarget.disabled = false; e.currentTarget.textContent = 'Add'; } + }; +} diff --git a/public/style.css b/public/style.css index f823683..655242b 100644 --- a/public/style.css +++ b/public/style.css @@ -1197,3 +1197,6 @@ body.qdock #view { padding-right: 340px; } img, svg, a, button { -webkit-touch-callout: none; -webkit-user-drag: none; } input, textarea, select { font-size: max(16px, 1em); } /* (iPadOS zooms the page when a smaller field is focused) */ } + +/* My Store: books ticked for "Add to people" */ +.vcard.picked { outline: 3px solid var(--accent); outline-offset: 2px; border-radius: 12px; } diff --git a/tools/mystore-test.js b/tools/mystore-test.js index d75463f..7d5dc80 100644 --- a/tools/mystore-test.js +++ b/tools/mystore-test.js @@ -34,6 +34,13 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] == assert.equal((await (await imp('again.epub', withCover)).json()).duplicate, true, 'the same book is not added twice'); ir = await (await imp('Plain_Name.epub', epub)).json(); assert.equal(ir.item.title, 'Rabbit Tales'); assert.equal(ir.noCover, true); assert.equal(ir.item.creator, 'A. Writer'); ir = await (await imp('Some_Pdf_Book.pdf', Buffer.from('%PDF-1.4 hello'))).json(); assert.equal(ir.item.title, 'Some Pdf Book'); assert.equal(ir.noAuthor, true); + // the admin gives chosen books to chosen people + const impd = (await (await fetch(B + '/items')).json()).items.filter(x => x.id !== book.id && x.kind === 'book').map(x => x.id); + assert.equal((await fetch(B + '/people', { headers: bob })).status, 403); const ppl = await (await fetch(B + '/people')).json(); assert.ok(ppl.some(x => x.id === people.b.id && x.books)); + assert.equal((await post('/assign', { items: impd, users: [people.b.id] }, bob)).status, 403); assert.equal((await post('/assign', { items: [], users: [people.b.id] })).status, 400); assert.equal((await post('/assign', { items: impd, users: [] })).status, 400); + let as = await (await post('/assign', { items: impd, users: [people.b.id] })).json(); assert.equal(as.people[0].added + as.people[0].had, impd.length, 'one of them is the book Bob already has'); assert.ok(as.people[0].added >= 2); assert.equal(as.people[0].failed, 0); + as = await (await post('/assign', { items: impd, users: [people.b.id] })).json(); assert.equal(as.people[0].added, 0); assert.equal(as.people[0].had, impd.length, 'a repeat is left alone'); + assert.ok((await (await fetch(base + '/api/read/list', { headers: bob })).json()).some(x => x.title === 'Café & Cream'), 'the book is on Bob\'s shelf'); for (const it of (await (await fetch(B + '/items')).json()).items.filter(x => x.id !== book.id && x.kind === 'book')) await fetch(`${B}/items/${it.id}`, { method: 'DELETE' }); // music: several files, streamed with ranges let alb = await (await post('/items', { kind: 'music', title: 'Album', creator: 'Band' })).json(); const mp3 = Buffer.from('0123456789abcdefghij');