Extensions: notes, recipes and music in the bridge, a permissions screen, and an Updated notice
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
808274b4fe
commit
f80b718bd4
5 files changed
+38
-13
No files matched your search
+20
-6
@@ -8,7 +8,8 @@ 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 = [], stAdmin = false, stFilter = 'all';
|
||||
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]);
|
||||
@@ -28,7 +29,7 @@ async function viewStore(args = []) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- My Store
|
||||
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stAdmin = r.admin; return r; }
|
||||
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 = `<p class="err">${esc(e.message)}</p>`; return; }
|
||||
const draw = () => {
|
||||
@@ -154,15 +155,17 @@ async function stRunExt(el, id) {
|
||||
const it = stItems.find(x => x.id === id && x.kind === 'extension');
|
||||
if (!it) { el.innerHTML = '<p class="empty">That extension is not in the store any more.</p>'; return; }
|
||||
if (!stInstalled.includes(id)) { el.innerHTML = `<p class="empty">Install “${esc(it.title)}” from My Store first.</p>`; return; }
|
||||
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}</b></div>
|
||||
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}${it.version ? ` <small style="color:var(--muted)">v${esc(it.version)}</small>` : ''}</b><button class="btn ghost" id="stperm">Permissions</button></div>
|
||||
${stUpdated(it) ? `<div class="card" id="stupd" style="max-width:none;margin:0 0 8px"><b>Updated</b> ${esc(new Date(it.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }))}${it.version ? ' to version ' + esc(it.version) : ''}.${it.description ? ' ' + esc(it.description.split('\n')[0].slice(0, 160)) : ''}<div class="bar" style="margin:8px 0 0"><button class="btn ghost" id="stupdx">Got it</button></div></div>` : ''}
|
||||
<iframe id="stframe" title="${esc(it.title)}" sandbox="allow-scripts allow-forms allow-popups allow-modals" referrerpolicy="no-referrer" style="width:100%;height:calc(100dvh - 190px);min-height:360px;border:1px solid var(--line);border-radius:12px;background:#fff"></iframe>`;
|
||||
const frame = el.querySelector('#stframe'); let data = null, timer;
|
||||
if (stUpdated(it)) { stApi(`/ext/${id}/seen`, stOpt('POST', {})).then(r => { stSeen = r.seen || stSeen; }).catch(() => {}); const x = el.querySelector('#stupdx'); if (x) x.onclick = () => el.querySelector('#stupd').remove(); }
|
||||
// (the page is fetched by the hub and shown from here, so the frame needs no sign-in of its own; its sandbox keeps it from touching anything of the hub's)
|
||||
fetch(`/api/store/my/ext/${id}/page`).then(r => r.ok ? r.text() : Promise.reject(new Error('Could not load it'))).then(h => { frame.srcdoc = h; }).catch(e => { el.querySelector('#stframe').outerHTML = `<p class="err">${esc(e.message)}</p>`; });
|
||||
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' };
|
||||
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;
|
||||
@@ -172,10 +175,16 @@ async function stRunExt(el, id) {
|
||||
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, `<p class="sub" style="margin-top:0">What “${esc(it.title)}” may reach in the hub. Switch one off and it has to ask again.</p><div class="list">${Object.entries(WHY).map(([k, w]) => `<label class="row" style="cursor:pointer"><span class="grow"><b>${esc(w[0].toUpperCase() + w.slice(1))}</b><small>${esc(k)}</small></span><input type="checkbox" data-p="${esc(k)}"${grants.includes(k) ? ' checked' : ''}></label>`).join('')}</div>`);
|
||||
sh.querySelectorAll('[data-p]').forEach(c => c.onchange = async () => { const next = c.checked ? [...new Set([...grants, c.dataset.p])] : grants.filter(x => x !== c.dataset.p); try { await stApi(`/ext/${id}/grants`, stOpt('POST', { grants: next })); grants = next; refused.delete(c.dataset.p); } catch (e) { c.checked = !c.checked; toast(e.message); } });
|
||||
};
|
||||
const jf = (p, o) => jfetch(p, o), pj = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
|
||||
const txt = (v, n) => String(v == null ? '' : v).slice(0, n), ymd = v => /^\d{4}-\d{2}-\d{2}$/.test(String(v)) ? String(v) : '';
|
||||
const hubCall = async (method, a = {}) => {
|
||||
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write' }[method];
|
||||
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write', 'notes.list': 'notes.read', 'notes.add': 'notes.write', 'recipes.search': 'recipes.read', 'recipes.get': 'recipes.read', 'music.nowPlaying': 'music.read' }[method];
|
||||
if (!need) throw new Error('Unknown request'); if (!a || typeof a !== 'object') a = {};
|
||||
if (!(await allowedTo(need))) throw new Error('The person did not allow that');
|
||||
const localList = async name => { const l = (await jf('/api/tasks/lists')).lists.filter(x => x.local), n = txt(name, 60).toLowerCase(); return (n && l.find(x => x.name.toLowerCase() === n)) || l.find(x => x.id === 'local:inbox') || l[0]; };
|
||||
@@ -185,6 +194,11 @@ async function stRunExt(el, id) {
|
||||
if (method === 'tasks.complete') { const list = txt(a.list, 80), t = (await jf('/api/tasks?list=' + encodeURIComponent(list))).find(x => x.id === a.id); if (!t) throw new Error('Task not found'); await jf('/api/tasks', pj('POST', { list, id: t.id, fields: { title: t.title, notes: t.notes || '', due: t.due || '', done: true } })); return { ok: true }; }
|
||||
if (method === 'contacts.list') return (await ctApi('/')).map(c => ({ id: c.id, name: c.name, org: c.org, emails: c.emails.map(e => e.value), phones: c.phones.map(p => p.value), birthday: c.birthday }));
|
||||
if (method === 'contacts.add') { const c = await ctApi('/', pj('POST', { name: txt(a.name, 120), org: txt(a.org, 120), emails: a.email ? [{ label: '', value: txt(a.email, 120) }] : [], phones: a.phone ? [{ label: '', value: txt(a.phone, 60) }] : [], birthday: txt(a.birthday, 12), notes: txt(a.notes, 2000) })); ctList = null; return { id: c.id }; }
|
||||
if (method === 'notes.list') return (await jf('/api/notes/')).notes.slice(0, 200).map(n => ({ id: n.id, type: n.type, title: n.title, body: n.type === 'note' ? txt(n.body, 5000) : '', items: n.type === 'list' ? (n.items || []).map(i => ({ text: i.text, done: !!i.done })) : [] }));
|
||||
if (method === 'notes.add') { if (!txt(a.title, 120) && !txt(a.body, 10)) throw new Error('Give the note a title or some text'); await jf('/api/notes/', pj('POST', { type: 'note', title: txt(a.title, 120), body: txt(a.body, 20000), share: 'private' })); return { ok: true }; }
|
||||
if (method === 'recipes.search') return (await jf('/api/recipes/?page=1&q=' + encodeURIComponent(txt(a.q, 100)))).items.slice(0, 24).map(r => ({ slug: r.slug, name: r.name, minutes: r.minutes || 0 }));
|
||||
if (method === 'recipes.get') { const r = await jf('/api/recipes/r/' + encodeURIComponent(txt(a.slug, 200))); return { name: r.name, description: txt(r.description, 1000), servings: r.servings || '', ingredients: r.ingredients.map(i => i.text || i.title), steps: r.steps.map(x => x.text) }; }
|
||||
if (method === 'music.nowPlaying') { const t = typeof cur === 'function' && cur(); return t ? { title: t.title || '', artist: t.artist || t.sub || '', album: t.album || '', playing: !pl.a.paused } : null; }
|
||||
if (method === 'calendar.events') { const f = ymd(a.from), t = ymd(a.to); if (!f || !t) throw new Error('Give from and to as YYYY-MM-DD'); const q = new URLSearchParams({ from: new Date(f + 'T00:00:00').getTime(), to: new Date(t + 'T23:59:59').getTime(), fd: f, td: t });
|
||||
return (await jf('/api/cal/events?' + q)).events.slice(0, 500).map(e => ({ title: e.title, start: e.start, end: e.end, allDay: !!e.allDay, calendar: e.cal })); }
|
||||
if (method === 'calendar.add') { const cals = await jf('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('No calendar to add to'); const allDay = !!a.allDay, re = allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
||||
@@ -202,7 +216,7 @@ async function stRunExt(el, id) {
|
||||
else if (method === 'storage.remove') { delete (await load())[String(args[0])]; save(); result = true; }
|
||||
else if (method === 'toast') { toast(String(args[0]).slice(0, 200)); result = true; }
|
||||
else if (method === 'user') result = { name: ME ? ME.name : '' };
|
||||
else if (method.startsWith('tasks.') || method.startsWith('contacts.') || method.startsWith('calendar.')) result = await hubCall(method, args[0]);
|
||||
else if (/^(tasks|contacts|calendar|notes|recipes|music)\./.test(method)) result = await hubCall(method, args[0]);
|
||||
else if (method === 'open') { if (/^https?:\/\//i.test(String(args[0]))) window.open(String(args[0]), '_blank', 'noopener,noreferrer'); result = true; }
|
||||
else throw new Error('Unknown request');
|
||||
} catch (x) { error = x.message; }
|
||||
|
||||
Reference in new issue
Block a user