diff --git a/cal.js b/cal.js index 1fc63a2..a5e24cd 100644 --- a/cal.js +++ b/cal.js @@ -228,7 +228,8 @@ const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 }); const HEX = /^#[0-9a-f]{6}$/i; const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d']; -const dirFor = id => users.dirOf(id); +const FAM = 'family', FAMCAL = { id: 'local', name: 'Family', color: '#e0742b', shares: [] }; // the calendar the whole household shares +const dirFor = id => { if (id !== FAM) return users.dirOf(id); const d = path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family'); fs.mkdirSync(d, { recursive: true }); return d; }; const myId = () => users.current().id; /** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */ @@ -244,7 +245,7 @@ const colorOverrides = () => readJ(here(COLOR_FILE), {}); /** Calendars other people shared with the current person: [{ id: "owner~calId", owner, ownerName, cal, mode }] */ function sharedWithMe() { - const me = myId(), out = []; + const me = myId(), out = users.current() && users.can(users.current(), 'calendar') ? [{ id: FAM + '~local', owner: FAM, ownerName: 'the whole family', cal: FAMCAL, mode: 'edit' }] : []; for (const u of users.all()) { if (u.disabled || u.id === me) continue; for (const c of calsIn(dirFor(u.id))) { @@ -260,6 +261,7 @@ function resolveCal(id) { const me = myId(); if (typeof id === 'string' && id.includes('~')) { const [owner, cid] = id.split('~'); + if (owner === FAM) return cid === 'local' && users.can(users.current(), 'calendar') ? { owner: FAM, dir: dirFor(FAM), cal: FAMCAL, cid: 'local', mode: 'edit' } : null; const u = users.get(owner); if (!u || u.disabled) return null; const c = calsIn(dirFor(owner)).find(x => x.id === cid); diff --git a/notes.js b/notes.js index 93d74ca..4c6de5c 100644 --- a/notes.js +++ b/notes.js @@ -33,7 +33,7 @@ async function handle(req, res) { const b = JSON.parse(await readBody(req, MAX_BODY + 5e4) || '{}'); if (!b.id) { if (all().length >= MAX_NOTES) throw fail(400, 'That is plenty of notes'); - const type = b.type === 'list' ? 'list' : 'note', n = { id: newId(), type, title: str(b.title, 120).trim(), body: type === 'note' ? str(b.body, MAX_BODY) : '', items: type === 'list' ? (Array.isArray(b.items) ? b.items : []).map(t => str(t, 300).trim()).filter(Boolean).slice(0, MAX_ITEMS).map(text => ({ id: newId(), text, done: false })) : [], owner: me.id, share: cleanShare(b.share, me), color: COLORS.includes(b.color) ? b.color : '', pinned: false, archived: false, created: Date.now(), updated: Date.now(), updatedBy: me.id, rev: 1 }; + const type = b.type === 'list' ? 'list' : 'note', n = { id: newId(), type, title: str(b.title, 120).trim(), body: type === 'note' ? str(b.body, MAX_BODY) : '', items: type === 'list' ? (Array.isArray(b.items) ? b.items : []).map(t => str(t, 300).trim()).filter(Boolean).slice(0, MAX_ITEMS).map(text => ({ id: newId(), text, done: false })) : [], owner: me.id, share: cleanShare(b.share === undefined ? 'family' : b.share, me), color: COLORS.includes(b.color) ? b.color : '', pinned: false, archived: false, created: Date.now(), updated: Date.now(), updatedBy: me.id, rev: 1 }; all().push(n); save(); return json(res, 200, view(n, me)); } const n = find(String(b.id), me, b.share !== undefined); diff --git a/package.json b/package.json index 7bac900..c7c46a1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox; do node tools/$t-test.js || exit 1; done", + "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/chat-view.js b/public/chat-view.js index f48d8a8..b2e4dac 100644 --- a/public/chat-view.js +++ b/public/chat-view.js @@ -340,3 +340,15 @@ async function chAnnounceCard(el) { el.innerHTML = `
${icon('chat')}Family announcement ยท ${esc(a.by)}${esc(a.text)}
`; el.querySelector('#ancx').onclick = () => { try { localStorage.setItem('ch_ann_gone:' + (ME ? ME.id : ''), a.id); } catch {} el.innerHTML = ''; }; } + +/** On a phone the open chat has to end above the mini player and the bottom bar, or the message box lands off screen: size it from what is really at the bottom. */ +function chFit() { + const m = chMounted() && ch.root.querySelector('.sgm'); if (!m || m.offsetParent === null) return; + if (window.innerWidth >= 1024) { m.style.height = ''; return; } + let bottom = window.visualViewport ? window.visualViewport.height : window.innerHeight; + const top0 = m.getBoundingClientRect().top; // (look up from the screen's edge for the first spot that is page content, not the bottom bar or mini player) + for (let y = Math.floor(bottom) - 2; y > top0 + 130; y -= 4) { const hit = document.elementsFromPoint(innerWidth / 2, y).find(e => !e.closest('.sgm')); const covers = hit && !hit.closest('#view'); if (!covers) { bottom = y + 4; break; } } + const top = m.getBoundingClientRect().top; m.style.height = Math.max(260, Math.floor(bottom - top - 10)) + 'px'; +} +window.addEventListener('resize', chFit); if (window.visualViewport) window.visualViewport.addEventListener('resize', chFit); +setInterval(chFit, 700); diff --git a/public/style.css b/public/style.css index 85c401c..b4ec518 100644 --- a/public/style.css +++ b/public/style.css @@ -1056,3 +1056,4 @@ body.vizopen { overflow: hidden; } .inboxbell { position: relative; } .inboxbell .nbadge { position: absolute; top: -6px; right: -6px; } +.fmrow .grow { min-width: 0; } diff --git a/public/sw.js b/public/sw.js index 0d0d3eb..e9d2a0e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v70'; +const CACHE = 'hub-v72'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/public/tasks.js b/public/tasks.js index 7259448..22dba68 100644 --- a/public/tasks.js +++ b/public/tasks.js @@ -96,7 +96,7 @@ function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task } function newListSheet(lists, list) { - const mine = lists.filter(l => l.local && l.id !== 'local:inbox'); + const mine = lists.filter(l => l.local && l.id !== 'local:inbox' && !l.shared); const el = openSheet('Lists', '', `${mine.length ? `
${mine.map(l => `
${esc(l.name)}
`).join('')}
` : ''}
`); diff --git a/tasks.js b/tasks.js index 6f373b9..63a00ad 100644 --- a/tasks.js +++ b/tasks.js @@ -10,8 +10,18 @@ const caldav = require('./caldav'); const users = require('./users'); const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own -const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } }; -const save = d => atomicWrite(file(), JSON.stringify(d, null, 2), { mode: 0o600 }); +const famFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family', 'tasks.json'); // the Family list: one for the whole household +const FAMILY = 'family'; +const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; +/** Your own lists and tasks, plus the shared Family list (kept in its own file, so everyone sees and edits the same one). */ +const load = () => { + const mine = readJ(file(), { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }), fam = readJ(famFile(), { tasks: [] }); + return { ...mine, lists: (l => [...l.slice(0, 1), { id: FAMILY, name: 'Family', shared: true }, ...l.slice(1)])(mine.lists.filter(l => l.id !== FAMILY)), tasks: [...mine.tasks.filter(t => t.list !== FAMILY), ...(Array.isArray(fam.tasks) ? fam.tasks : [])] }; +}; +const save = d => { + atomicWrite(file(), JSON.stringify({ ...d, lists: d.lists.filter(l => l.id !== FAMILY), tasks: d.tasks.filter(t => t.list !== FAMILY) }, null, 2), { mode: 0o600 }); + fs.mkdirSync(path.dirname(famFile()), { recursive: true }); atomicWrite(famFile(), JSON.stringify({ tasks: d.tasks.filter(t => t.list === FAMILY) }, null, 2), { mode: 0o600 }); +}; const { readBody } = require('./body'); const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; const isLocal = id => String(id || '').startsWith('local:'); @@ -78,7 +88,7 @@ async function handle(req, res) { try { if (p === '/api/tasks/lists' && req.method === 'GET') { const d = load(), st = caldav.status(); - return json(res, 200, { lists: [...d.lists.map(l => ({ id: 'local:' + l.id, name: l.name, local: true, ...(l.mealie ? { mealie: true } : {}) })), ...caldav.todoLists().map(l => ({ ...l, icloud: true }))], + return json(res, 200, { lists: [...d.lists.map(l => ({ id: 'local:' + l.id, name: l.name, local: true, ...(l.mealie ? { mealie: true } : {}), ...(l.shared ? { shared: true } : {}) })), ...caldav.todoLists().map(l => ({ ...l, icloud: true }))], icloud: { connected: !!st.connected, reminderLists: st.reminderLists || 0 } }); } if (p === '/api/tasks/lists' && req.method === 'POST') { @@ -89,7 +99,7 @@ async function handle(req, res) { } if (p === '/api/tasks/lists/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req)), d = load(), raw = String(id).replace(/^local:/, ''); - if (raw === 'inbox') throw new Error('The main list cannot be deleted'); + if (raw === 'inbox' || raw === FAMILY) throw new Error('That list cannot be deleted'); d.lists = d.lists.filter(l => l.id !== raw); d.tasks = d.tasks.filter(t => t.list !== raw); save(d); return json(res, 200, { ok: true }); } diff --git a/tools/cal-test.js b/tools/cal-test.js index f9865a3..72f281e 100644 --- a/tools/cal-test.js +++ b/tools/cal-test.js @@ -24,7 +24,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor (async () => { let r = await call(ann, '/api/cal/list'); - ok('everyone starts with the one NE Hub calendar', r.json.length === 1 && r.json[0].id === 'local' && r.json[0].name === 'NE Hub' && r.json[0].color === '#4f7cff', r.json); + ok('everyone starts with the one NE Hub calendar', r.json.length === 2 && r.json[0].id === 'local' && r.json[0].name === 'NE Hub' && r.json[1].id === 'family~local' && r.json[0].color === '#4f7cff', r.json); // legacy events (no calendar field) belong to the first calendar fs.writeFileSync(path.join(users.dirOf(ann.id), 'events.json'), JSON.stringify([{ uid: 'old1', fields: F('Old event'), created: 1, updated: 1 }])); @@ -32,7 +32,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor // create, recolor, rename r = await call(ann, '/api/cal/ncal', { name: ' Family ', color: '#d9453a' }); const fam = r.json.id; - ok('a person can make another calendar', r.status === 200 && fam && (await call(ann, '/api/cal/list')).json.length === 2); + ok('a person can make another calendar', r.status === 200 && fam && (await call(ann, '/api/cal/list')).json.filter(c => c.id !== 'family~local').length === 2); r = await call(ann, '/api/cal/ncal', { name: ' ' }); ok('a calendar needs a name', r.status === 400, r); r = await call(ann, '/api/cal/ncal/update', { id: fam, color: 'red' }); ok('colors must be real hex colors', r.status === 400, r); await call(ann, '/api/cal/ncal/update', { id: fam, name: 'Family stuff', color: '#8a5cf0' }); @@ -48,7 +48,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor ok('the same uid is not found in a different calendar', (await call(ann, `/api/cal/event?cal=local&uid=${dn.uid}&tz=UTC`)).status === 400); // private by default - ok('other people see nothing of it by default', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1); + ok('other people see nothing of it by default', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.filter(c => c.id !== 'family~local').length === 1); r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Sneaky') }); ok('and cannot write to it', r.status === 400, r); r = await call(bob, `/api/cal/event?cal=${ann.id}~${fam}&uid=${dn.uid}&tz=UTC`); ok('or read it', r.status === 400, r); @@ -57,7 +57,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor r = await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: bob.id, mode: 'view' }, { user: cy.id, mode: 'edit' }, { user: ann.id, mode: 'edit' }, { user: 'nobody', mode: 'edit' }] }); ok('sharing saves, ignoring yourself and unknown people', r.status === 200 && (await call(ann, '/api/cal/list')).json.find(c => c.id === fam).shares.length === 2); ok('a view-only person sees the events', (await titles(bob)) === 'Dinner'); - const bl = (await call(bob, '/api/cal/list')).json.find(c => c.shared); + const bl = (await call(bob, '/api/cal/list')).json.find(c => c.shared && c.id !== 'family~local'); ok('and the calendar in their list, marked shared with the owner and their access', bl && bl.mode === 'view' && bl.owner === 'Ann' && bl.name === 'Family stuff', bl); const bev = (await call(bob, win)).json.events[0]; ok('shared events say they cannot be edited', bev.editable === false && bev.shared === true, bev); r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Bob adds') }); ok('view-only people cannot add', r.status === 400 && /view only/.test(r.json.error), r); @@ -76,7 +76,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor // stopping sharing await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: cy.id, mode: 'edit' }] }); - ok('removing someone hides it from them right away', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1); + ok('removing someone hides it from them right away', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.filter(c => c.id !== 'family~local').length === 1); // subscribe links const feedOf = async (u, id, reset) => { const x = await call(u, '/api/cal/ncal/feed', { id, reset }); return x.json.url; }; @@ -91,7 +91,7 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor ok('making a new link kills the old one', u4 !== u1 && old.code === 404 && (await get(u4)).code === 200); // delete - r = await call(ann, '/api/cal/ncal/delete', { id: fam }); ok('deleting a calendar works', r.status === 200 && (await call(ann, '/api/cal/list')).json.length === 1); + r = await call(ann, '/api/cal/ncal/delete', { id: fam }); ok('deleting a calendar works', r.status === 200 && (await call(ann, '/api/cal/list')).json.filter(c => c.id !== 'family~local').length === 1); ok('and removes its events and its share', !(await titles(ann)).includes('Cy adds') && (await titles(cy)) === ''); r = await call(ann, '/api/cal/ncal/delete', { id: 'local' }); ok('the last calendar cannot be deleted', r.status === 400, r); ok('older events survived all that', (await titles(ann)).includes('Old event')); diff --git a/tools/family-test.js b/tools/family-test.js new file mode 100644 index 0000000..86070c6 --- /dev/null +++ b/tools/family-test.js @@ -0,0 +1,23 @@ +// The shared Family calendar and Family task list: every person with the tab sees and edits the same ones. +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-fam-')); +const users = require('../users'), cal = require('../cal'), tasks = require('../tasks'); +const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['calendar', 'tasks'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['notes'], handle: 'Yw' }) }; +const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => req.url.startsWith('/api/tasks') ? tasks.handle(req, res) : cal.handle(req, res))).listen(0, async () => { + const B = 'http://127.0.0.1:' + hub.address().port, call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json(); + try { + const day = new Date().toISOString().slice(0, 10), ev = { summary: 'Dinner at Grandma\'s', start: day + 'T18:00', end: day + 'T19:00' }; + let r = await j(await post('/api/cal/event', { cal: 'family~local', fields: { title: 'Dinner at 6', start: day, end: day, allDay: true } }, 'a')); assert.ok(r.ok, JSON.stringify(r)); + const win = '/api/cal/events?' + new URLSearchParams({ from: Date.parse(day + 'T00:00:00Z') - 864e5, to: Date.parse(day + 'T00:00:00Z') + 2 * 864e5, fd: day, td: day }); + let e = await j(await call(win, {}, 'b')); assert.ok(e.events.some(x => x.title === 'Dinner at 6' && x.editable), 'Bob sees and can edit the family event'); + assert.ok(!(await j(await call(win, {}, 'c'))).events.some(x => x.title === 'Dinner at 6'), 'Cy has no calendar tab: nothing'); + assert.ok((await j(await call('/api/cal/list', {}, 'b'))).some(c => c.id === 'family~local' && c.name === 'Family')); + const lists = (await j(await call('/api/tasks/lists', {}, 'b'))).lists; assert.deepEqual(lists.map(l => l.name), ['Tasks', 'Family']); + await post('/api/tasks', { list: 'local:family', fields: { title: 'Book the sitter', due: day } }, 'a'); + let t = await j(await call('/api/tasks?list=local:family', {}, 'b')); assert.equal(t.length, 1, 'Bob sees the family task'); assert.equal((await j(await call('/api/tasks?list=local:inbox', {}, 'b'))).length, 0); + await post('/api/tasks/toggle', { id: t[0].id, list: 'local:family', done: true }, 'b'); t = await j(await call('/api/tasks?list=local:family', {}, 'a')); assert.equal(t[0].done, true, 'ticked by Bob, seen by Ann'); + const del = await post('/api/tasks/lists/delete', { id: 'local:family' }, 'a'); assert.equal(del.status, 400, 'the Family list stays'); + console.log('family ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); +}); diff --git a/tools/notes-test.js b/tools/notes-test.js index 262ce3f..26fd81d 100644 --- a/tools/notes-test.js +++ b/tools/notes-test.js @@ -6,7 +6,7 @@ const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: us const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => notes.handle(req, res))).listen(0, async () => { const B = 'http://127.0.0.1:' + hub.address().port + '/api/notes', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json(), list = async who => (await j(await call('/', {}, who))).notes; try { - const n1 = await j(await post('/', { type: 'note', title: ' Private thoughts ', body: 'secret', color: 'blue' }, 'b')); assert.equal(n1.title, 'Private thoughts'); assert.equal(n1.share, 'me'); assert.equal((await list('a')).length, 0, 'a private note is only for its owner (even an admin does not see it in the list)'); + const n1 = await j(await post('/', { type: 'note', title: ' Private thoughts ', body: 'secret', color: 'blue', share: 'me' }, 'b')); assert.equal(n1.title, 'Private thoughts'); assert.equal(n1.share, 'me'); assert.equal((await list('a')).length, 0, 'a private note is only for its owner (even an admin does not see it in the list)'); assert.equal((await post('/', { id: n1.id, title: 'Peek' }, 'a')).status, 404, 'and cannot be opened'); const gro = await j(await post('/', { type: 'list', title: 'Groceries', items: ['Milk', ' ', 'Eggs'], share: 'family' }, 'b')); assert.deepEqual(gro.items.map(x => x.text), ['Milk', 'Eggs'], 'blank items are dropped'); assert.equal((await list('c')).length, 1, 'a family note is seen by everyone with Notes'); assert.equal((await list('d')).length, 0, 'but not by someone without the Notes tab'); // checklist: items change one at a time, so two people do not overwrite each other