'use strict'; // ---- Store: everything you can get, in one place. Books (free sources), free audiobooks, radio stations, and My Store: content the admin adds themselves (books, // music, apps to download, and extensions that add features to the hub). The first three are the same screens as before, shown inside this one. const stApi = (p, opts) => jfetch('/api/store/my' + p, opts); const stOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); const ST_KINDS = [['book', 'Books', 'book'], ['music', 'Music', 'music'], ['app', 'Apps', 'files'], ['extension', 'Extensions', 'cog'], ['link', 'Sites', 'link']]; const stKind = k => ST_KINDS.find(x => x[0] === k) || ST_KINDS[2]; const stSize = b => fmtSize(+b || 0); const stCover = it => it.cover ? `/api/store/my/items/${it.id}/cover?v=${it.updated}` : ''; const stHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; }; let stItems = [], stInstalled = [], stSeen = {}, stAdmin = false, stFilter = 'all'; const stUpdated = it => it.kind === 'extension' && stInstalled.includes(it.id) && stSeen[it.id] !== undefined && (it.updated || 0) > stSeen[it.id]; // a new version of an extension you have (one installed before this was tracked is not flagged) /** The sections this person may open (each needs its own tab's permission, except My Store). */ const stSections = () => [['books', 'Books', allowed('read')], ['audiobooks', 'Audiobooks', allowed('audio')], ['radio', 'Radio', allowed('radio')], ['mine', 'My Store', true]].filter(s => s[2]); async function viewStore(args = []) { const secs = stSections(), want = args[0] === 'ext' || args[0] === 'site' ? 'mine' : args[0], sec = (secs.find(s => s[0] === want) || secs[0])[0]; if (!args[0] || (!secs.some(s => s[0] === args[0]) && args[0] !== 'ext' && args[0] !== 'site')) return location.replace('#/store/' + sec); // (a bare #/store opens the first section by its own address, so its page knows it is the one showing) const v = $('#view'); v.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 = ` ${mine.length ? `${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); }); if ($('#stadd')) $('#stadd').onclick = () => stEdit(null, draw); }; draw(); } const stCard = (it, run) => `${it.cover ? `${esc(it.description).replace(/\n/g, '
')}
No description.
'}No files.
'}${esc(it.url)}
` : ''}`); const err = t => { el.querySelector('#sterr').textContent = t; }; const tracks = () => it.files.map(f => ({ key: `my:${it.id}:${f.id}`, title: f.name.replace(/\.[^.]+$/, '').replace(/^\d+[\s._-]+/, ''), artist: it.creator || '', album: it.title, thumb: stCover(it), src: url(f), nocache: true })); const play = i => { playQueue(tracks(), i || 0); closeSheet(); }; if (el.querySelector('#stplay')) el.querySelector('#stplay').onclick = () => play(0); el.querySelectorAll('[data-play]').forEach(b => b.onclick = () => play(+b.dataset.play)); if (el.querySelector('#stlib')) el.querySelector('#stlib').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const r = await stApi(`/items/${it.id}/to-library`, stOpt('POST')); closeSheet(); toast(r.duplicate ? 'Already in your library' : 'Added to your library', { label: 'Open', run: () => { location.hash = '#/read/open/' + r.id; } }); } catch (x) { err(x.message); b.disabled = false; } }; if (el.querySelector('#stopen')) el.querySelector('#stopen').onclick = () => { closeSheet(); if (it.embed) location.hash = '#/store/site/' + it.id; else window.open(it.url, '_blank', 'noopener,noreferrer'); }; if (el.querySelector('#stinst')) el.querySelector('#stinst').onclick = async e => { const b = e.currentTarget; try { if (!isMine) { b.disabled = true; stInstalled = (await stApi(`/ext/${it.id}/install`, stOpt('POST'))).installed; } closeSheet(); location.hash = '#/store/ext/' + it.id; } catch (x) { err(x.message); b.disabled = false; } }; if (el.querySelector('#stun')) el.querySelector('#stun').onclick = () => { closeSheet(); confirmSheet('Remove this extension?', 'Its saved settings are forgotten too.', 'Remove', async () => { try { stInstalled = (await stApi(`/ext/${it.id}/uninstall`, stOpt('POST'))).installed; toast('Removed'); redraw(); } catch (x) { toast(x.message); } }); }; if (el.querySelector('#stedit')) el.querySelector('#stedit').onclick = () => { closeSheet(); stEdit(it, redraw); }; } // ---- adding and changing (admin) function stEdit(it, redraw) { const x = it || { kind: 'book', title: '', creator: '', description: '', version: '', url: '', icon: '', files: [], cover: false }; let created = false; const el = openSheet(it ? 'Edit item' : 'Add to My Store', '', ``); const $e = s => el.querySelector(s), err = t => { $e('#sterr').textContent = t; }; let kind = x.kind; const files = [], existing = [...x.files]; let cover = null; const kindUi = () => { kind = it ? x.kind : $e('#stk').value; $e('#stcl').textContent = kind === 'music' ? 'Artist' : kind === 'book' ? 'Author' : 'Made by'; $e('#stver').hidden = kind !== 'app' && kind !== 'extension'; $e('#stlnk').hidden = kind !== 'app' && kind !== 'link'; $e('#stul').textContent = kind === 'link' ? 'Address of the site' : 'Link (optional)'; $e('#stemb').hidden = kind !== 'link'; $e('#stext').hidden = kind !== 'extension' && kind !== 'link'; $e('#stfiles').hidden = kind === 'link'; $e('#stup').accept = kind === 'book' ? '.epub,.pdf,.mobi,.azw3,.azw,.docx' : kind === 'music' ? 'audio/*,.mp3,.m4a,.flac,.ogg,.opus,.wav' : kind === 'extension' ? '.html' : ''; $e('#stup').multiple = kind === 'music' || kind === 'app'; }; const drawFiles = () => { $e('#stfl').innerHTML = [...existing.map((f, i) => [f.name, stSize(f.size), 'e', i]), ...files.map((f, i) => [f.name || f.path.split('/').pop(), f.size != null ? stSize(f.size) : 'from Files', 'n', i])].map(([n, s, t, i]) => `No files yet.
'; el.querySelectorAll('#stfl [data-t]').forEach(b => b.onclick = () => { (b.dataset.t === 'e' ? existing : files).splice(+b.dataset.i, 1); drawFiles(); }); }; if (!it) $e('#stk').onchange = kindUi; kindUi(); drawFiles(); $e('#stup').onchange = e => { const single = kind !== 'music' && kind !== 'app'; if (single) { files.length = 0; existing.length = 0; } for (const f of e.target.files) files.push(f); e.target.value = ''; drawFiles(); }; if ($e('#stsf')) $e('#stsf').onclick = async () => { const p = await stPickFile(); if (!p) return; if (kind !== 'music' && kind !== 'app') { files.length = 0; existing.length = 0; } files.push({ path: p.path, repo: p.repo, name: p.path.split('/').pop() }); drawFiles(); }; $e('#stcv').onchange = e => { cover = e.target.files[0] || null; $e('#stcvn').textContent = cover ? cover.name : ''; }; $e('#stx').onclick = closeSheet; if ($e('#stdel')) $e('#stdel').onclick = () => { closeSheet(); confirmSheet('Delete this item?', it.title, 'Delete', async () => { try { await stApi('/items/' + it.id, { method: 'DELETE' }); toast('Deleted'); await stLoad(); redraw(); } catch (e) { toast(e.message); } }); }; $e('#stf').onsubmit = async e => { e.preventDefault(); const ok = $e('#stok'); ok.disabled = true; err(''); try { if (!files.length && !existing.length && kind !== 'app' && kind !== 'link') throw new Error('Add the file first'); let saved = await stApi('/items', stOpt('POST', { id: it && it.id, kind, title: $e('#stt').value, creator: $e('#stc').value, description: $e('#std').value, version: $e('#stv').value, url: $e('#stu').value, embed: !!$e('#ste').checked, icon: $e('#sti') ? $e('#sti').value : '' })); if (!it) created = true; for (const f of x.files) if (!existing.some(y => y.id === f.id)) saved = await stApi(`/items/${saved.id}/files/${f.id}`, { method: 'DELETE' }); let n = 0; for (const f of files) { ok.textContent = `Adding ${++n} of ${files.length}…`; saved = f instanceof File ? await stApi(`/items/${saved.id}/files?name=${encodeURIComponent(f.name)}`, { method: 'POST', body: f, headers: { 'content-type': 'application/octet-stream' } }) : await stApi(`/items/${saved.id}/files`, stOpt('POST', { repo: f.repo, path: f.path })); } if (cover) saved = await stApi(`/items/${saved.id}/cover`, { method: 'POST', body: cover, headers: { 'content-type': cover.type } }); closeSheet(); toast(it ? 'Saved' : 'Added'); await stLoad(); redraw(); } catch (x2) { err(x2.message + (created ? ' The item was created without it: open it to finish.' : '')); ok.disabled = false; ok.textContent = it ? 'Save' : 'Add'; } }; } /** Pick one file from your Seafile libraries. Resolves { repo, path } or null. */ function stPickFile() { return new Promise(resolve => { if (!sf.token()) { toast('Open Files and sign in first'); return resolve(null); } let repo = null, dir = '/', done = false; const el = openSheet('Choose a file from Files', '', ''); const finish = v => { done = true; closeSheet(); resolve(v); }; el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); }); const draw = async () => { const box = el.querySelector('#stpk'); box.innerHTML = 'Loading…
'; try { if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted).sort((a, b) => a.name.localeCompare(b.name)); box.innerHTML = `${esc(dir)}
${esc(e.message)}
`; } }; draw(); }); } // ---------------------------------------------------------------- running an extension // The extension is one HTML file in a sandboxed frame (no access to your sign-in or the hub's data). It talks to the hub only through the messages below. async function stRunExt(el, id) { try { await stLoad(); } catch (e) { el.innerHTML = `${esc(e.message)}
`; return; } const it = stItems.find(x => x.id === id && x.kind === 'extension'); if (!it) { el.innerHTML = 'That extension is not in the store any more.
'; return; } if (!stInstalled.includes(id)) { el.innerHTML = `Install “${esc(it.title)}” from My Store first.
`; return; } el.innerHTML = ` ${stUpdated(it) ? `${esc(e.message)}
`; }); const load = async () => data || (data = await stApi(`/ext/${id}/data`).catch(() => ({}))); const save = () => { clearTimeout(timer); timer = setTimeout(() => stApi(`/ext/${id}/data`, stOpt('POST', data)).catch(e => toast(e.message)), 400); }; // What an extension may ask the hub for. Each kind of access is asked of the person the first time and remembered until the extension is removed. const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar', 'notes.read': 'see your notes and lists', 'notes.write': 'add notes', 'recipes.read': 'search and read your recipes', 'music.read': 'see what music is playing' }; let grants = null; const refused = new Set(); const allowedTo = async perm => { if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants; if (grants.includes(perm)) return true; if (refused.has(perm) || !allowed(perm.split('.')[0] === 'calendar' ? 'calendar' : perm.split('.')[0])) return false; const yes = await new Promise(ok => { let done = false; const el2 = openSheet('Allow access?', it.title, `“${esc(it.title)}” would like to ${esc(WHY[perm])}.
`); const fin = v => { if (!done) { done = true; closeSheet(); ok(v); } }; el2.querySelector('#pyes').onclick = () => fin(true); el2.querySelector('#pno').onclick = () => fin(false); el2.querySelector('.scrim').addEventListener('click', () => fin(false)); }); if (!yes) { refused.add(perm); return false; } grants = [...grants, perm]; stApi(`/ext/${id}/grants`, stOpt('POST', { grants })).catch(() => {}); return true; }; // The permissions screen: everything this extension may reach, each one switched off or on (turning one off just means it is asked again next time it needs it) el.querySelector('#stperm').onclick = async () => { if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants; const sh = openSheet('Permissions', it.title, `What “${esc(it.title)}” may reach in the hub. Switch one off and it has to ask again.
${esc(e.message)}
`; return; } const it = stItems.find(x => x.id === id && x.kind === 'link'); if (!it) { el.innerHTML = 'That site is not in the store any more.
'; return; } el.innerHTML = `If the page stays blank, that site does not allow being shown here. Use New tab.
`; } /** On Home, under the app tiles: the extensions this person installed, and the Sites that were added to My Store, one tap away. */ async function stHomeExt(el) { if (!el) return; try { await stLoad(); } catch { return; } if (!el.isConnected) return; const mine = stItems.filter(i => (i.kind === 'extension' && stInstalled.includes(i.id)) || i.kind === 'link'); if (!mine.length) { el.innerHTML = ''; return; } const tileOf = i => { const href = i.kind === 'extension' ? '#/store/ext/' + i.id : i.embed ? '#/store/site/' + i.id : i.url, ext = i.kind === 'link' && !i.embed; return `${esc(i.icon || (i.title.match(/\p{L}|\p{N}/u) || ['?'])[0].toUpperCase())}${esc(i.title)}${i.kind === 'extension' ? 'Extension' : 'Site'}`; }; el.innerHTML = `