From cf292e3bc708390c104e306884a59127921ff052 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:28:13 -0400 Subject: [PATCH] Notes tab: shared and private notes and checklists; icon-only menu rail on short windows - notes.js and the Notes tab: private, family or chosen-people sharing; checklist items change one at a time (no overwrites), with who ticked them; open lists refresh live; stale text edits are refused - Pin, colour, archive, search, filters, ready-made lists, Share to Chat, send open items to Tasks - The narrow menu rail shows icons only (names on hover) when the window is too short for labels - Roadmap and README updated Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 ++ docs/v2-roadmap.md | 2 +- notes.js | 61 ++++++++++++++++++++++++ package.json | 2 +- public/app.js | 15 +++--- public/index.html | 1 + public/notes-view.js | 110 +++++++++++++++++++++++++++++++++++++++++++ public/style.css | 14 ++++++ public/sw.js | 2 +- server.js | 3 ++ tools/notes-test.js | 30 ++++++++++++ users.js | 4 +- 12 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 notes.js create mode 100644 public/notes-view.js create mode 100644 tools/notes-test.js diff --git a/README.md b/README.md index b3b89bf..d19262f 100644 --- a/README.md +++ b/README.md @@ -182,3 +182,7 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions **Chat** (turn it on per person in the admin screen): the family's own messenger, with the same look as the Signal tab. Direct chats and groups between people who have an account here, text with links and emoji, attachments (up to 100 MB each; pictures, audio and video show in the chat), replies, reactions, edit and delete for everyone, unread counts on the menu and the tab title, search (chats and messages), drafts that survive closing the app, mute and archive, live delivery while the app is open and a phone push when it is not (the same notification devices as reminders, and muted chats stay quiet). Messages and files are kept readable on this server in `data/chat`; end-to-end encryption is not built. Also: sent, delivered and read ticks (everyone, in a group), typing and who is online, disappearing messages (a timer per chat), @mentions (a mentioned person is alerted even in a muted group), pinned messages, forwarding, voice messages with a waveform, link previews (the server reads the page, so devices never contact the site), polls (single or multiple choice, with who voted), an emoji picker, turn a message into a task, a shopping item or a calendar event, *Share to Chat* from a recipe or a song, quiet hours for phone alerts, and a family announcement on Home that an admin posts from Chat settings. Not built: a GIF picker, sharing a location (the hub blocks location access), and a parent setting a child's quiet hours (see `docs/v2-roadmap.md`). +**Notes** (turn it on per person): notes and checklists that are private, shared with the whole family, or shared with chosen people. Anyone it is shared with can edit it. Checklist items change one at a time (tick, add, edit, remove), so two people ticking the same list never overwrite each other, ticked items say who ticked them, and an open list follows other people's changes every few seconds. Text edits are checked against a revision, so a stale edit is refused instead of overwriting someone else's. Pin, colour, archive, search and filters (lists, notes, mine, shared, archived), ready-made lists (grocery, packing, weekend to-do), *Share to Chat*, and *Send the open items to Tasks*. Only the person who made a note (or an admin) changes who it is shared with or deletes it. + +On a tablet or with the menu collapsed, the menu rail shows icons only (the name appears on hover) when the window is not tall enough for a label under every icon. + diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md index 0c1a57c..05d0cd9 100644 --- a/docs/v2-roadmap.md +++ b/docs/v2-roadmap.md @@ -43,7 +43,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup, ## 2. Family core - [ ] **Family Home (M):** one shared start page: who is around, today's family calendar, shared shopping list and chores, what everyone is listening to or reading -- [ ] **Shared lists and notes (M):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos) +- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos) - [ ] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own - [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family - [ ] **Meal planning (M):** a weekly plan from Recipes that builds the shopping list (and can flag what is already in the pantry) diff --git a/notes.js b/notes.js new file mode 100644 index 0000000..a25ba2e --- /dev/null +++ b/notes.js @@ -0,0 +1,61 @@ +// Notes and lists: notes and checklists that are private, shared with the whole family, or shared with chosen people. Anyone a note is shared with can edit it; +// checklist items are changed one at a time (tick, add, edit, remove), so two people ticking things at once never overwrite each other. Kept in DATA_DIR/notes.json. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const users = require('./users'); +const { readBody } = require('./body'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'notes.json'); +const COLORS = ['', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink']; +const MAX_NOTES = 3000, MAX_ITEMS = 300, MAX_BODY = 50000; +const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const fail = (code, message, extra) => Object.assign(new Error(message), { code, extra }); +const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n); +const newId = () => crypto.randomBytes(6).toString('hex'); +let cache = null; +const all = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j.notes) ? j.notes : []; } catch { return []; } })()); +const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify({ notes: all() }), { mode: 0o600 }); }; + +const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'notes'); }; +const sees = (n, me) => n.owner === me.id || (n.share === 'family' && users.can(me, 'notes')) || (Array.isArray(n.share) && n.share.includes(me.id)); +const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; }; +const view = (n, me) => ({ id: n.id, type: n.type, title: n.title, body: n.body, items: n.items, owner: n.owner, ownerName: nameOf(n.owner), mine: n.owner === me.id, share: n.share, sharedWith: Array.isArray(n.share) ? n.share.map(id => ({ id, name: nameOf(id) })) : [], color: n.color, pinned: n.pinned, archived: n.archived, created: n.created, updated: n.updated, updatedByName: n.updatedBy ? nameOf(n.updatedBy) : '', rev: n.rev }); +const cleanShare = (v, me) => v === 'family' ? 'family' : Array.isArray(v) ? [...new Set(v.map(String))].filter(id => id !== me.id && live(id)).slice(0, 30) : 'me'; +const touch = (n, me) => { n.updated = Date.now(); n.updatedBy = me.id; n.rev = (n.rev || 0) + 1; }; +const find = (id, me, ownerOnly) => { const n = all().find(x => x.id === id); if (!n || !sees(n, me)) throw fail(404, 'Note not found'); if (ownerOnly && n.owner !== me.id && me.role !== 'admin') throw fail(403, 'Only the person who made it, or an admin, can do that'); return n; }; + +async function handle(req, res) { + const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/notes'.length) || '/', me = users.current(); + try { + let m; + if (p === '/' && req.method === 'GET') return json(res, 200, { notes: all().filter(n => sees(n, me)).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).map(n => view(n, me)), people: users.all().filter(x => x.id !== me.id && live(x.id)).map(x => ({ id: x.id, name: x.name })) }); + if (p === '/' && req.method === 'POST') { // create, or change the note's own fields + 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 }; + all().push(n); save(); return json(res, 200, view(n, me)); + } + const n = find(String(b.id), me, b.share !== undefined); + if (b.rev !== undefined && +b.rev !== n.rev && (b.title !== undefined || b.body !== undefined)) throw fail(409, 'Someone else changed this note while you were editing', { note: view(n, me) }); + if (b.title !== undefined) n.title = str(b.title, 120).trim(); if (b.body !== undefined && n.type === 'note') n.body = str(b.body, MAX_BODY); + if (b.share !== undefined) n.share = cleanShare(b.share, me); if (b.color !== undefined && COLORS.includes(b.color)) n.color = b.color; + if (b.pinned !== undefined) n.pinned = !!b.pinned; if (b.archived !== undefined) n.archived = !!b.archived; + touch(n, me); save(); return json(res, 200, view(n, me)); + } + if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { const n = find(m[1], me, true); cache = all().filter(x => x !== n); save(); return json(res, 200, { ok: true }); } + if ((m = /^\/([0-9a-f]{12})\/items$/.exec(p)) && req.method === 'POST') { // one change to a checklist + const n = find(m[1], me); if (n.type !== 'list') throw fail(400, 'That is not a checklist'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), it = id => n.items.find(x => x.id === String(id)); + if (b.text !== undefined && !b.edit) { const t = str(b.text, 300).trim(); if (!t) throw fail(400, 'Write the item'); if (n.items.length >= MAX_ITEMS) throw fail(400, 'That is a long list'); n.items.push({ id: newId(), text: t, done: false }); } + else if (b.toggle) { const x = it(b.toggle); if (!x) throw fail(404, 'Item not found'); x.done = !!b.done; if (x.done) { x.by = me.id; x.byName = me.name; x.at = Date.now(); } else { delete x.by; delete x.byName; delete x.at; } } + else if (b.edit) { const x = it(b.edit), t = str(b.text, 300).trim(); if (!x || !t) throw fail(400, 'Write the item'); x.text = t; } + else if (b.remove) n.items = n.items.filter(x => x.id !== String(b.remove)); + else if (b.clearDone) n.items = n.items.filter(x => !x.done); + else throw fail(400, 'Nothing to do'); + touch(n, me); save(); return json(res, 200, view(n, me)); + } + return json(res, 404, { error: 'Not found' }); + } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message, ...(e.extra || {}) }); } +} + +module.exports = { handle }; diff --git a/package.json b/package.json index e08ddfe..f683939 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; 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; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/app.js b/public/app.js index 6a681e2..691980f 100644 --- a/public/app.js +++ b/public/app.js @@ -168,8 +168,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) + const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); // ---- Navigation -const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; -const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' }; +const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']]; +const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', notes: 'doc', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' }; /** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */ const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id)); const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1]; @@ -179,14 +179,14 @@ const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0])); const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1]; /** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */ -const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]]; +const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'notes', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]]; const navGroups = (tabs = navTabs()) => { const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) })); const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0]))); return out.filter(g => g.tabs.length); }; const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state -const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' }; +const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', notes: '#e5b800', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' }; // The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More". const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail']; const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); }; @@ -200,7 +200,7 @@ function renderNav(active = activeTab) { if (typeof sgDockBadge === 'function') sgDockBadge(); if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere(); const badge = n => n ? `${n > 99 ? '99+' : n}` : ''; - const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label}${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}`; + const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label}${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}`; const brand = `
NE Hub
`; const mine = navTabs(); if (phone.matches) { @@ -256,7 +256,7 @@ function drawSubnav(name) { const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name; let curRoute = ''; -const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); +const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit }); let editor; // Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device. const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } }; @@ -324,6 +324,7 @@ function homeApps() { office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'), audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'), podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'), + notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'), chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', ch.unread ? ch.unread + ' unread' : 'Family messages'), store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'), recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'), @@ -335,7 +336,7 @@ function homeApps() { mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'), }; T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible'); - const HOME_GROUPS = [['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]]; + const HOME_GROUPS = [['Work', ['planner', 'notes', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]]; return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `

${esc(label)}

${ids.map(id => T[id]()).join('')}
`).join(''); } diff --git a/public/index.html b/public/index.html index f148284..288adf2 100644 --- a/public/index.html +++ b/public/index.html @@ -93,6 +93,7 @@ + diff --git a/public/notes-view.js b/public/notes-view.js new file mode 100644 index 0000000..ce687e8 --- /dev/null +++ b/public/notes-view.js @@ -0,0 +1,110 @@ +'use strict'; +// ---- Notes: notes and checklists that are private, shared with the family, or shared with chosen people (see notes.js). Checklist items change one at a time, so two +// people ticking the same list never overwrite each other, and the open list refreshes itself every few seconds. +const nt = { notes: [], people: [], filter: 'all', q: '', timer: null }; +const ntApi = (p, o) => jfetch('/api/notes' + p, o); +const ntPost = (p, b) => ntApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }); +const NT_COLORS = { '': 'var(--panel)', red: '#e5484d', orange: '#f08a24', yellow: '#e5b800', green: '#30a46c', teal: '#12a594', blue: '#3e63dd', purple: '#8e4ec6', pink: '#d6409f' }; +const NT_TEMPLATES = [['Grocery list', ['Milk', 'Eggs', 'Bread', 'Fruit', 'Vegetables', 'Coffee']], ['Packing list', ['Clothes', 'Toiletries', 'Chargers', 'Documents', 'Snacks']], ['Weekend to-do', ['Laundry', 'Groceries', 'Clean the kitchen', 'Mow the lawn']]]; +const ntWho = n => n.share === 'family' ? 'Family' : n.share === 'me' || !n.share ? 'Just me' : 'Shared with ' + (n.sharedWith.length ? n.sharedWith.map(x => x.name).slice(0, 2).join(', ') + (n.sharedWith.length > 2 ? ' +' + (n.sharedWith.length - 2) : '') : 'no one'); +const ntDone = n => n.items.filter(i => i.done).length; + +function viewNotes(args = []) { + clearInterval(nt.timer); nt.timer = null; + return args[0] ? ntEditor(args[0]) : ntHome(); +} +async function ntLoad() { const r = await ntApi('/'); nt.notes = r.notes; nt.people = r.people; return r; } + +// ================================================================ the list of notes +async function ntHome() { + const v = $('#view'); v.innerHTML = '

Notes

Loading…

'; + try { await ntLoad(); } catch (e) { v.innerHTML = `

Notes

${esc(e.message)}

`; return; } + if (!location.hash.startsWith('#/notes')) return; + const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']]; + v.innerHTML = `

Notes

+
${filters.map(([k, l]) => `${l}`).join('')}
`; + const draw = () => { + const q = nt.q.toLowerCase(), f = nt.filter; + const rows = nt.notes.filter(n => (f === 'archived' ? n.archived : !n.archived) && (f === 'list' ? n.type === 'list' : f === 'note' ? n.type === 'note' : f === 'mine' ? n.mine : f === 'shared' ? n.share !== 'me' : true) + && (!q || (n.title + ' ' + n.body + ' ' + n.items.map(i => i.text).join(' ')).toLowerCase().includes(q))); + $('#ntgrid').innerHTML = rows.map(n => `
${esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note'))}${n.pinned ? icon('bookmark') : ''}
+ ${n.type === 'list' ? `
${n.items.slice(0, 5).map(i => `${i.done ? '☑' : '☐'} ${esc(i.text)}`).join('')}${n.items.length > 5 ? `+ ${n.items.length - 5} more` : ''}
${ntDone(n)} of ${n.items.length} done` : `

${esc(n.body.slice(0, 180))}

`} + ${esc(ntWho(n))}${n.mine ? '' : ' · ' + esc(n.ownerName)} · ${esc(sgTime(n.updated))}
`).join('') || `

${nt.notes.length ? 'Nothing matches.' : 'No notes yet. Tap New to start a note, a checklist, or a ready-made list.'}

`; + }; + draw(); let t; $('#ntq').oninput = e => { nt.q = e.target.value.trim(); clearTimeout(t); t = setTimeout(draw, 200); }; + $('#ntf').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); nt.filter = a.dataset.f; $('#ntf').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); draw(); }); + $('#ntnew').onclick = ntNewSheet; +} +function ntNewSheet() { + const mk = async b => { try { const n = await ntPost('/', b); closeSheet(); location.hash = '#/notes/' + n.id; } catch (e) { toast(e.message); } }; + const el = openSheet('New', '', `
+

Ready-made lists

${NT_TEMPLATES.map((t, i) => ``).join('')}
`); + el.querySelector('#ntn').onclick = () => mk({ type: 'note' }); el.querySelector('#ntl').onclick = () => mk({ type: 'list' }); + el.querySelectorAll('[data-t]').forEach(b => b.onclick = () => { const t = NT_TEMPLATES[+b.dataset.t]; mk({ type: 'list', title: t[0], items: t[1], share: 'family' }); }); +} + +// ================================================================ one note or list +async function ntEditor(id) { + const v = $('#view'); v.innerHTML = '

Loading…

'; + let n; try { await ntLoad(); n = nt.notes.find(x => x.id === id); if (!n) throw new Error('That note is not available (it may have been deleted, or is not shared with you).'); } catch (e) { v.innerHTML = `

${esc(e.message)}

`; return; } + if (!location.hash.startsWith('#/notes/')) return; + let saveT, busy = false, dirty = false; + const render = () => { + const list = n.type === 'list'; + v.innerHTML = `
${icon('back')}Notes
+ +

${esc(ntWho(n))}${n.mine ? '' : ' · by ' + esc(n.ownerName)}${n.updatedByName ? ' · last change by ' + esc(n.updatedByName) + ', ' + esc(sgTime(n.updated)) : ''}

+ ${list ? `
${n.items.map(i => `
${esc(i.text)}${i.done && i.byName ? `${esc(i.byName)}` : ''}
`).join('')}
+
+
${ntDone(n)} of ${n.items.length} done${ntDone(n) ? '' : ''}
` + : ``}
`; + wire(); + }; + const wire = () => { + const tt = $('#ntt'); tt.oninput = () => { dirty = true; queue(); }; + if (n.type === 'note') { const b = $('#ntb'); b.oninput = () => { dirty = true; queue(); }; } + $('#ntpin').onclick = () => patch({ pinned: !n.pinned }); $('#ntsh').onclick = e => { e.preventDefault(); ntShareSheet(n, patch); }; $('#ntmore').onclick = () => ntMore(n, patch); + if (n.type === 'list') { + const act = async b => { try { n = await ntPost('/' + n.id + '/items', b); render(); } catch (e) { toast(e.message); } }; + v.querySelectorAll('.ntitem').forEach(r => { const iid = r.dataset.i; + r.querySelector('input[type=checkbox]').onchange = e => act({ toggle: iid, done: e.target.checked }); r.querySelector('[data-x]').onclick = () => act({ remove: iid }); + const tx = r.querySelector('.nttext'), orig = n.items.find(i => i.id === iid).text; tx.onblur = () => { const t = tx.textContent.trim(); if (t && t !== orig) act({ edit: iid, text: t }); else if (!t) tx.textContent = orig; }; tx.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); tx.blur(); } }; }); + $('#ntadd').onsubmit = async e => { e.preventDefault(); const i = $('#ntnewitem'), t = i.value.trim(); if (!t) return; i.value = ''; try { n = await ntPost('/' + n.id + '/items', { text: t }); render(); $('#ntnewitem').focus(); } catch (x) { toast(x.message); i.value = t; } }; + if ($('#ntclr')) $('#ntclr').onclick = () => act({ clearDone: true }); + } + }; + const queue = () => { clearTimeout(saveT); saveT = setTimeout(save, 700); }; + async function save() { + if (busy) return queue(); busy = true; const body = { id: n.id, title: $('#ntt') ? $('#ntt').value : n.title, rev: n.rev }; if (n.type === 'note' && $('#ntb')) body.body = $('#ntb').value; + try { n = await ntPost('/', body); dirty = false; } + catch (e) { if (e.status === 409 && e.message) { toast('Someone else changed this just now. Showing their version.'); const j = await fetch('/api/notes').then(r => r.json()).catch(() => null); const fresh = j && j.notes.find(x => x.id === n.id); if (fresh) { n = fresh; dirty = false; render(); } } else toast(e.message); } + busy = false; + } + const patch = async fields => { try { n = await ntPost('/', { id: n.id, ...fields }); if (location.hash.startsWith('#/notes/')) render(); if (fields.archived) { toast('Archived'); location.hash = '#/notes'; } } catch (e) { toast(e.message); } }; + render(); + // the open note follows what other people do to it + nt.timer = setInterval(async () => { + if (!location.hash.startsWith('#/notes/') || document.hidden || busy || dirty) return; + const a = document.activeElement; if (a && (a.id === 'ntt' || a.id === 'ntb' || a.classList.contains('nttext') || (a.id === 'ntnewitem' && a.value))) return; // (never while someone is typing; an empty add box does not count) + const refocus = !!(a && a.id === 'ntnewitem'); + try { const r = await ntApi('/'); const fresh = r.notes.find(x => x.id === n.id); if (!fresh) { clearInterval(nt.timer); toast('This note was deleted'); location.hash = '#/notes'; return; } if (fresh.rev !== n.rev) { n = fresh; render(); if (refocus && $('#ntnewitem')) $('#ntnewitem').focus(); } } catch { /* try again */ } + }, 6000); + addEventListener('hashchange', () => { if (!location.hash.startsWith('#/notes/')) { clearInterval(nt.timer); nt.timer = null; } }, { once: true }); +} +function ntShareSheet(n, patch) { + if (!n.mine && ME && ME.role !== 'admin') return toast('Only ' + n.ownerName + ' can change who this is shared with'); + const chosen = new Set(Array.isArray(n.share) ? n.share : []); + const el = openSheet('Who can see this?', 'Anyone it is shared with can edit it.', `
+ ${nt.people.length ? `

Or choose people

${nt.people.map(p => ``).join('')}
` : ''}`); + el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { closeSheet(); patch({ share: b.dataset.s }); }); + const ps = el.querySelector('#ntps'); if (ps) ps.onclick = () => { const ids = [...el.querySelectorAll('[data-p]:checked')].map(c => c.dataset.p); closeSheet(); patch({ share: ids.length ? ids : 'me' }); }; +} +function ntMore(n, patch) { + const acts = [{ label: 'Colour', icon: 'cog', run: () => { const el = openSheet('Colour', '', `
${Object.entries(NT_COLORS).map(([k, c]) => ``).join('')}
`); el.querySelectorAll('[data-c]').forEach(b => b.onclick = () => { closeSheet(); patch({ color: b.dataset.c }); }); } }]; + if (allowed('chat')) acts.push({ label: 'Share to Chat', icon: 'send', run: () => shareToChat((n.title || 'Note') + (n.type === 'list' ? ': ' + n.items.filter(i => !i.done).map(i => i.text).slice(0, 8).join(', ') : '') + ' ' + location.origin + '/#/notes/' + n.id) }); + if (n.type === 'list' && allowed('tasks') && n.items.some(i => !i.done)) acts.push({ label: 'Send the open items to Tasks', icon: 'check', run: async () => { try { for (const i of n.items.filter(x => !x.done)) await jfetch('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ list: 'local:inbox', fields: { title: i.text, notes: 'From ' + (n.title || 'a checklist'), due: '', done: false } }) }); toast('Sent to Tasks'); } catch (e) { toast(e.message); } } }); + acts.push({ label: 'Archive', icon: 'files', run: () => patch({ archived: true }) }); + if (n.archived) acts[acts.length - 1] = { label: 'Move out of the archive', icon: 'files', run: () => patch({ archived: false }) }; + if (n.mine || (ME && ME.role === 'admin')) acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () => confirmSheet('Delete this ' + (n.type === 'list' ? 'checklist' : 'note') + '?', 'It is deleted for everyone it is shared with.', 'Delete', async () => { try { await jfetch('/api/notes/' + n.id, { method: 'DELETE' }); toast('Deleted'); location.hash = '#/notes'; } catch (e) { toast(e.message); } }) }); + actionSheet(n.title || 'Note', ntWho(n), acts); +} diff --git a/public/style.css b/public/style.css index 1ad25bf..351ce5d 100644 --- a/public/style.css +++ b/public/style.css @@ -1021,3 +1021,17 @@ body.vizopen { overflow: hidden; } .annc { display: flex; align-items: center; gap: 12px; margin: 0 0 14px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, var(--accent) 14%, var(--panel)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); } .annc .ic { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; color: var(--accent-t); flex: none; } .annc small { display: block; color: var(--muted); } .annc b { display: block; font-size: 17px; overflow-wrap: anywhere; } + +/* Notes */ +.ntgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-top: 4px; } +.ntcard { display: flex; flex-direction: column; gap: 6px; padding: 14px; border-radius: 14px; background: color-mix(in srgb, var(--nc, var(--line)) 12%, var(--panel)); border: 1px solid var(--line); border-top: 4px solid var(--nc, var(--line)); color: inherit; text-decoration: none; min-height: 110px; } +.ntcard .nttop { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; } .ntcard .nttop svg { width: 16px; height: 16px; color: var(--accent); flex: none; } .ntcard p { margin: 0; white-space: pre-wrap; overflow: hidden; max-height: 5.5em; color: var(--muted); } +.ntitems { display: flex; flex-direction: column; gap: 2px; } .ntitems span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ntitems span.done { text-decoration: line-through; color: var(--muted); } .ntmeta { margin-top: auto; color: var(--muted); } +.ntedit { border-top: 5px solid var(--nc, var(--line)); padding-top: 8px; } .nttitle { width: 100%; font-size: 24px; font-weight: 700; background: transparent; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 8px 2px; } +.ntbody { width: 100%; min-height: 55vh; font: inherit; line-height: 1.6; resize: vertical; } +.ntitem { display: flex; align-items: center; gap: 10px; padding: 6px 2px; border-bottom: 1px solid var(--line); } .ntitem input[type=checkbox] { width: 22px; height: 22px; flex: none; } .ntitem .nttext { flex: 1; outline: 0; padding: 4px 2px; overflow-wrap: anywhere; } .ntitem.done .nttext { text-decoration: line-through; color: var(--muted); } .ntitem small { color: var(--muted); } +.ntsw { display: flex; gap: 10px; flex-wrap: wrap; } .ntsw1 { width: 44px; height: 44px; border-radius: 50%; border: 2px solid var(--line); cursor: pointer; color: #fff; font-weight: 700; } + +/* The narrow menu rail (a tablet, or the menu collapsed) has no room for a label under every icon once there are many entries: below this height it shows the icons alone (the name is in the tooltip) */ +@media (min-width: 768px) and (max-width: 1023px) and (max-height: 1100px) { #nav a > span:not(.nbadge), #nav .ngrp span { display: none; } #nav a { min-height: 34px; justify-content: center; } #nav a svg { width: 22px; height: 22px; } #nav .ngrp { margin-top: 3px; padding-top: 3px; } #nav { gap: 1px; } } +@media (min-width: 1024px) and (max-height: 1100px) { body.nav-mini #nav a > span:not(.nbadge), body.nav-mini #nav .ngrp span { display: none; } body.nav-mini #nav a { min-height: 34px; justify-content: center; } body.nav-mini #nav a svg { width: 22px; height: 22px; } body.nav-mini #nav { gap: 1px; } } diff --git a/public/sw.js b/public/sw.js index 1ae275c..aa6b31a 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-v62'; +const CACHE = 'hub-v64'; 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/server.js b/server.js index 2008ae9..2ccb924 100644 --- a/server.js +++ b/server.js @@ -44,6 +44,7 @@ const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free const recipes = require('./recipes'); // recipes and the shopping list, from Mealie (MEALIE_URL, MEALIE_TOKEN) const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export) const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through +const notes = require('./notes'); // shared and private notes and checklists const chat = require('./chat'); // Family Chat: a messenger for the people with an account here const musicdata = require('./musicdata'); // what was played, favorites, smart and Family playlists for Music const mystore = require('./mystore'); // My Store: your own books, music, apps and extensions for everyone to use @@ -557,6 +558,7 @@ const ACCESS = [ [/^\/api\/bible\//, ['bible']], [/^\/api\/read\//, ['read']], [/^\/api\/radio\//, ['radio']], + [/^\/api\/notes(\/|$)/, ['notes']], [/^\/api\/chat(\/|$)/, ['chat']], [/^\/api\/music(\/|$)/, ['music']], [/^\/api\/store\/my(\/|$)/, ['store']], @@ -659,6 +661,7 @@ async function handleRequest(req, res) { if (pathname.startsWith('/api/read/')) return books.handle(req, res); if (pathname.startsWith('/api/radio/')) return radio.handle(req, res); if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user); + if (pathname === '/api/notes' || pathname.startsWith('/api/notes/')) return notes.handle(req, res); if (pathname.startsWith('/api/chat/')) return chat.handle(req, res); if (pathname.startsWith('/api/music/')) return musicdata.handle(req, res); if (pathname === '/api/store/my' || pathname.startsWith('/api/store/my/')) return mystore.handle(req, res); diff --git a/tools/notes-test.js b/tools/notes-test.js new file mode 100644 index 0000000..262ce3f --- /dev/null +++ b/tools/notes-test.js @@ -0,0 +1,30 @@ +// Notes and lists: private, family and chosen-people sharing, checklist items changed one at a time, conflict detection, and who may change what. +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-notes-')); +const users = require('../users'), notes = require('../notes'); +const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['notes'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['notes'], handle: 'Yw' }), d: users.create({ name: 'Di', role: 'user', tabs: ['music'], handle: 'ZA' }) }; +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)'); + 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 + let g = await j(await post('/' + gro.id + '/items', { text: 'Bread' }, 'c')); assert.equal(g.items.length, 3); const milk = g.items[0].id, eggs = g.items[1].id; + await post('/' + gro.id + '/items', { toggle: milk, done: true }, 'c'); g = await j(await post('/' + gro.id + '/items', { toggle: eggs, done: true }, 'a')); assert.deepEqual(g.items.map(x => [x.text, x.done, x.byName]), [['Milk', true, 'Cy'], ['Eggs', true, 'Ann'], ['Bread', false, undefined]], 'two people ticking at once both count, and it says who'); + g = await j(await post('/' + gro.id + '/items', { toggle: milk, done: false }, 'b')); assert.equal(g.items[0].done, false); assert.equal(g.items[0].byName, undefined, 'unticking forgets who ticked it'); + g = await j(await post('/' + gro.id + '/items', { edit: eggs, text: 'Free-range eggs' }, 'b')); assert.equal(g.items[1].text, 'Free-range eggs'); g = await j(await post('/' + gro.id + '/items', { clearDone: true }, 'b')); assert.deepEqual(g.items.map(x => x.text), ['Milk', 'Bread'], 'clear done removes the ticked ones'); g = await j(await post('/' + gro.id + '/items', { remove: milk }, 'c')); assert.deepEqual(g.items.map(x => x.text), ['Bread']); + assert.equal((await post('/' + gro.id + '/items', { text: '' }, 'c')).status, 400); assert.equal((await post('/' + n1.id + '/items', { text: 'x' }, 'b')).status, 400, 'a plain note has no items'); assert.equal((await post('/' + gro.id + '/items', { text: 'x' }, 'd')).status, 404); + // editing text: a stale edit is refused, not lost + const n2 = await j(await post('/', { type: 'note', title: 'Plans', body: 'v1', share: 'family' }, 'a')); const e1 = await j(await post('/', { id: n2.id, body: 'v2 by Bob', rev: n2.rev }, 'b')); assert.equal(e1.body, 'v2 by Bob'); assert.equal(e1.updatedByName, 'Bob'); + let r = await post('/', { id: n2.id, body: 'v2 by Ann', rev: n2.rev }, 'a'); assert.equal(r.status, 409); const conf = await j(r); assert.equal(conf.note.body, 'v2 by Bob', 'the other change comes back so nothing is lost'); assert.match(conf.error, /Someone else/); + // sharing: only the owner (or an admin) changes it; chosen people + assert.equal((await post('/', { id: n2.id, share: 'me' }, 'b')).status, 403); const sh = await j(await post('/', { id: n1.id, share: [P.c.id, P.d.id, P.b.id, 'nobody'] }, 'b')); assert.deepEqual(sh.share, [P.c.id], 'only other people with Notes, never yourself'); assert.equal((await list('c')).some(x => x.id === n1.id), true, 'a chosen person sees it'); assert.equal((await list('a')).some(x => x.id === n1.id), false); + assert.equal((await j(await post('/', { id: n1.id, pinned: true, color: 'red', archived: true }, 'c'))).color, 'red', 'a person it is shared with can pin, colour and archive'); assert.equal((await list('b'))[0].id, n1.id, 'pinned notes come first'); + // delete + assert.equal((await call('/' + n1.id, { method: 'DELETE' }, 'c')).status, 403, 'only the owner or an admin deletes'); assert.equal((await call('/' + n1.id, { method: 'DELETE' }, 'b')).status, 200); assert.equal((await call('/' + n2.id, { method: 'DELETE' }, 'a')).status, 200); assert.equal((await list('b')).length, 1); + assert.equal(JSON.parse(fs.readFileSync(path.join(process.env.DATA_DIR, 'notes.json'), 'utf8')).notes.length, 1, 'it is kept on disk'); + console.log('notes ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); +}); diff --git a/users.js b/users.js index d905439..526f858 100644 --- a/users.js +++ b/users.js @@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users'); fs.mkdirSync(DATA_DIR, { recursive: true }); // Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own). -const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links']; +const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', 'notes', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links']; const ALWAYS = ['home', 'settings']; -const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' }; +const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', notes: 'Notes', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' }; let cache = null; const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };