Notes tab: shared and private notes and checklists; icon-only menu rail on short windows
build-container / build (push) Successful in 6m21s

- 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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 11:28:13 -04:00
1 parent 41aa4e676d
commit cf292e3bc7
12 files changed
+236 -12

No files matched your search

+4
View File
@@ -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.
+1 -1
View File
@@ -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)
+61
View File
@@ -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 };
+1 -1
View File
@@ -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": [],
+8 -7
View File
@@ -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 ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
const tab = ([id, label]) => `<a href="#/${id}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
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]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
}
+1
View File
@@ -93,6 +93,7 @@
<script src="/file-actions.js"></script>
<script src="/signal-view.js"></script>
<script src="/chat-view.js"></script>
<script src="/notes-view.js"></script>
<script src="/mail.js"></script>
<script src="/share.js"></script>
<script src="/cast.js"></script>
+110
View File
@@ -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 = '<div class="wrap"><h1>Notes</h1><p class="empty">Loading…</p></div>';
try { await ntLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Notes</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/notes')) return;
const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']];
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Notes</h1><span class="pspace grow"></span><button class="btn" id="ntnew">${icon('plus')}New</button></div>
<input type="search" id="ntq" placeholder="Search notes and lists" value="${esc(nt.q)}" autocomplete="off" aria-label="Search notes" style="width:100%"><div class="seg" id="ntf">${filters.map(([k, l]) => `<a href="#" data-f="${k}" class="${nt.filter === k ? 'on' : ''}">${l}</a>`).join('')}</div><div id="ntgrid" class="ntgrid"></div></div>`;
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 => `<a class="ntcard" href="#/notes/${n.id}" style="--nc:${NT_COLORS[n.color] || 'var(--line)'}"><div class="nttop"><b>${esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note'))}</b>${n.pinned ? icon('bookmark') : ''}</div>
${n.type === 'list' ? `<div class="ntitems">${n.items.slice(0, 5).map(i => `<span class="${i.done ? 'done' : ''}">${i.done ? '☑' : '☐'} ${esc(i.text)}</span>`).join('')}${n.items.length > 5 ? `<small>+ ${n.items.length - 5} more</small>` : ''}</div><small>${ntDone(n)} of ${n.items.length} done</small>` : `<p>${esc(n.body.slice(0, 180))}</p>`}
<small class="ntmeta">${esc(ntWho(n))}${n.mine ? '' : ' · ' + esc(n.ownerName)} · ${esc(sgTime(n.updated))}</small></a>`).join('') || `<p class="empty">${nt.notes.length ? 'Nothing matches.' : 'No notes yet. Tap New to start a note, a checklist, or a ready-made list.'}</p>`;
};
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', '', `<div class="list"><button class="row" id="ntn">${icon('doc')}<span class="grow"><b>Note</b><small>Free writing</small></span></button><button class="row" id="ntl">${icon('check')}<span class="grow"><b>Checklist</b><small>Items to tick off</small></span></button></div>
<h2>Ready-made lists</h2><div class="list">${NT_TEMPLATES.map((t, i) => `<button class="row" data-t="${i}">${icon('list')}<span class="grow"><b>${esc(t[0])}</b><small>${esc(t[1].join(', '))}</small></span></button>`).join('')}</div>`);
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 = '<div class="wrap"><p class="empty">Loading…</p></div>';
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 = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a></div><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/notes/')) return;
let saveT, busy = false, dirty = false;
const render = () => {
const list = n.type === 'list';
v.innerHTML = `<div class="wrap ntedit" style="--nc:${NT_COLORS[n.color] || 'var(--line)'}"><div class="ptop"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a><span class="pspace grow"></span><button class="ibtn" id="ntpin" aria-label="${n.pinned ? 'Unpin' : 'Pin'}" title="${n.pinned ? 'Unpin' : 'Pin to the top'}" style="${n.pinned ? 'color:var(--accent)' : ''}">${icon('bookmark')}</button><button class="ibtn" id="ntmore" aria-label="More">${icon('more')}</button></div>
<input id="ntt" class="nttitle" type="text" value="${esc(n.title)}" placeholder="${list ? 'Checklist' : 'Title'}" maxlength="120" autocomplete="off">
<p class="sub" style="margin:4px 0 12px"><a href="#" id="ntsh" style="color:var(--accent)">${esc(ntWho(n))}</a>${n.mine ? '' : ' · by ' + esc(n.ownerName)}${n.updatedByName ? ' · last change by ' + esc(n.updatedByName) + ', ' + esc(sgTime(n.updated)) : ''}</p>
${list ? `<div class="ntlist">${n.items.map(i => `<div class="ntitem ${i.done ? 'done' : ''}" data-i="${i.id}"><input type="checkbox" ${i.done ? 'checked' : ''} aria-label="Done"><span class="nttext" contenteditable="plaintext-only" spellcheck="false">${esc(i.text)}</span>${i.done && i.byName ? `<small>${esc(i.byName)}</small>` : ''}<button class="ibtn" data-x aria-label="Remove">${icon('x')}</button></div>`).join('')}</div>
<form id="ntadd" class="bar" style="margin:8px 0;gap:8px"><input type="text" id="ntnewitem" placeholder="Add an item" maxlength="300" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
<div class="bar" style="justify-content:space-between"><small class="sub">${ntDone(n)} of ${n.items.length} done</small>${ntDone(n) ? '<button class="btn ghost" id="ntclr">Clear the done ones</button>' : ''}</div>`
: `<textarea id="ntb" class="ntbody" placeholder="Write here" maxlength="50000">${esc(n.body)}</textarea>`}</div>`;
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.', `<div class="list"><button class="row" data-s="me"><span class="grow"><b>Just me</b></span>${n.share === 'me' ? icon('check') : ''}</button><button class="row" data-s="family"><span class="grow"><b>The whole family</b><small>Everyone with Notes</small></span>${n.share === 'family' ? icon('check') : ''}</button></div>
${nt.people.length ? `<h2>Or choose people</h2><div class="list">${nt.people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${chosen.has(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div><div class="bar"><button class="btn" id="ntps" style="width:100%;justify-content:center">Share with these people</button></div>` : ''}`);
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', '', `<div class="ntsw">${Object.entries(NT_COLORS).map(([k, c]) => `<button class="ntsw1" data-c="${k}" style="background:${c}" aria-label="${k || 'none'}">${n.color === k ? '✓' : ''}</button>`).join('')}</div>`); 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);
}
+14
View File
@@ -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; } }
+1 -1
View File
@@ -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'];
+3
View File
@@ -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);
+30
View File
@@ -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);
});
+2 -2
View File
@@ -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; };