18 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 41aa4e676d Family Chat phases 2 and 3, family extras; genre search on the roadmap
build-container / build (push) In progress
- Ticks (sent, delivered, read), typing, presence, disappearing messages, @mentions, pins, forwarding
- Voice messages with a waveform, link previews, polls, emoji picker
- Message to task, shopping item or calendar event; Share to Chat from recipes and songs; quiet hours; family announcement on Home
- Roadmap: chat items ticked off, and Browse and search by genre added

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:22:51 -04:00
nmemmertandClaude Sonnet 5.5 8283d10b41 Family Chat (2.0): a built-in messenger for the family, phase 1
build-container / build (push) Successful in 7m9s
- Chat tab: direct chats and groups, text, attachments, replies, reactions, edit and delete, unread counts, search, drafts, mute and archive
- Live delivery over server-sent events; phone push for people who are not in the app (muted chats stay quiet)
- chat.js keeps conversations and files in data/chat; push.notify sends one person a notification
- Roadmap updated: phase 1 done, combined inbox with Signal still to do

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:14:05 -04:00
nmemmertandClaude Sonnet 5.5 74592c25a5 iPad and Screen Time helper; v2.0 roadmap
build-container / build (push) Successful in 5m57s
- Admin screen: the simple Screen Time setup, the web addresses the app needs, addresses devices were seen reaching, and a Check this device test
- Report-only Content-Security-Policy so devices report any other address a page loads (nothing is blocked)
- Pictures from other sites pass through the hub (/api/img): podcast search covers, Plex Home pictures, a VBV cover, search answers
- docs/v2-roadmap.md: the family-first 2.0 plan, with Family Chat (text only) and no photos or calls

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:07:05 -04:00
nmemmertandClaude Sonnet 5.5 2d5aaad41a Music: Mixes, Favorites and Stats; smart and Family playlists; library radio; offline albums; more
build-container / build (push) Successful in 6m21s
- Mixes tab: daily mix, most played, forgotten favorites, moods, on this day, decades, genres, smart playlists (rules), library radio
- Favorites (hearts, kept by the hub) and Stats (plays, time, top artists/songs/albums, streak, recently played)
- Family playlists shared by everyone with Music; queue reorder; More like this; album/artist About notes
- Save albums offline; player takes colors from the cover; Share a lyric line as a picture; verse of the day on Featured
- musicdata.js keeps plays, favorites, smart rules and Family playlists, with tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 10:33:14 -04:00
nmemmertandClaude Sonnet 5.5 46e686ce22 Music: Featured tab with a random artist and album, from the whole library or chosen playlists
build-container / build (push) Successful in 7m35s
- Featured is the first Music tab; picks stay for the day on a device, Pick again rolls new ones
- Based on: choose the playlists they come from (synced across devices)
- Play mix shuffles the artist's songs; Play plays the album

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 10:21:19 -04:00
nmemmertandClaude Sonnet 5.5 dc8265a850 Store: Smashwords as a family-filtered book source; free audiobooks stream through the hub; extensions and Sites on Home
build-container / build (push) Failing after 1m30s
- Smashwords: free EPUBs from its shelf feed, filtered (adult/erotica flags, LGBTQ+, keywords, unlabelled romance); admin saves a Smashwords sign-in (encrypted) used to download
- LibriVox chapter audio is fetched and streamed by the hub (with seeking) instead of redirecting the browser to archive.org
- Home: a My apps section with installed extensions and Sites, also in the command palette

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 10:14:13 -04:00
nmemmertandClaude Sonnet 5.5 3c42d1ecd5 Sidebar never scrolls: entries shrink to fit a short window, with tighter spacing under 700px tall
build-container / build (push) Successful in 7m11s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:44:50 -04:00
nmemmertandClaude Sonnet 5.5 a804f9a8db Radio starts empty and fills from the Store (Add to Radio, including The Journey FM); iCloud connect falls back to the calendar home beside the principal
build-container / build (push) Successful in 6m9s
- Store > Radio is the station finder with Add to Radio / Added; Radio under Listen lists only what was added
- Fix the Radio tab going blank (the router passed its arguments as the Store host)
- iCloud: when the account reports no calendar home, try /<id>/calendars/, and say what iCloud answered if that fails

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:37:04 -04:00
nmemmertandClaude Sonnet 5.5 a31fdcdabd Recipes: tick off ingredients and steps while cooking (remembered per device), ingredients beside steps on wide screens
build-container / build (push) Successful in 6m11s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:30:33 -04:00
nmemmertandClaude Sonnet 5.5 efd17f4263 Extensions: hub bridge for tasks, contacts and calendar (asked of the person once), Sites in My Store, and four example extensions
- hub.tasks / hub.contacts / hub.calendar in the extension sandbox; each kind of access is approved by the person and remembered per extension
- My Store: Sites kind (any web link, optionally shown inside the hub)
- Examples: notes and checklists, habit tracker, kitchen converter and scaler, weather and verse of the day

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:25:39 -04:00
nmemmertandClaude Sonnet 5.5 f292294ad7 Store: one tab combining books, free audiobooks and radio, plus My Store for your own books, music, apps and sandboxed extensions
build-container / build (push) Successful in 7m39s
- Store tab with sections; the book store, LibriVox and radio finder are shown inside it (old entry points redirect)
- My Store: admin uploads files or copies from Seafile; books go to a person's shelf, music plays in the player, apps download
- Extensions: one HTML file run in a sandboxed frame with a small storage/toast/user bridge; example in docs/extensions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:08:41 -04:00
nmemmertandClaude Sonnet 5.5 176b1b8169 Open Library in the book store; podcast topic search and transcript search; Recipes (Mealie) with a shopping list synced to Tasks; Contacts linked to Mail and the Calendar
build-container / build (push) Successful in 7m21s
- Store: Open Library search, books fetched from the Internet Archive (free editions only)
- Podcast: Podcast Index discovery by name and topic (optional key); search what is said across transcripts and play from that moment
- Recipes: browse, import from a link, add ingredients to Mealie's shopping list, mirrored two ways with a Shopping list in Tasks
- Contacts: per-person address book, vCard import/export, recipient suggestions in Mail, birthdays in the Calendar

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:55:32 -04:00
Claude 0617664d97 Bump the app cache after merging
build-container / build (push) Successful in 6m34s
2026-10-26 12:17:43 +00:00
Claude ce76f4fa8d Merge suggestions 2026-10-26 12:16:47 +00:00
Claude 5fc741ab02 Merge mailscroll 2026-10-26 12:16:47 +00:00
Claude dc72665610 Merge hardening 2026-10-26 12:16:46 +00:00
Claude 1d345e072e Mail: on a phone the message is as tall as its text, so there is one scroll instead of a scrolling box inside a scrolling page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-26 11:46:38 +00:00
Claude d606e11de4 Limit request sizes, answer 413, stop cleanly on SIGTERM
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-26 11:40:31 +00:00
71 changed files with 3337 additions and 149 deletions

No files matched your search

+39
View File
@@ -143,3 +143,42 @@ CPU only: about a quarter of the running time or more. One job at a time; result
In the background the server also keeps transcripts up to date by itself: every ten minutes it looks at the newest `TRANSCRIBE_KEEP` (default 5) episodes of every show in Podgrab, uses the show's
published transcript when there is one, and otherwise transcribes the episode (one at a time, at most two new ones per round, a failing one retried a few times). `TRANSCRIBE_AUTO=0` switches this off;
playing an episode or pressing the button still works. Settings -> Health shows how many are saved and what it is working on.
## Podcast search, transcript search, Recipes and Contacts
- **Podcast discovery**: *Add podcast* searches Apple's free directory and, when `PODCASTINDEX_KEY` and `PODCASTINDEX_SECRET` are set (a free key from podcastindex.org), Podcast Index too, with topic buttons (News, History, Science...) that list trending shows. A feed in both appears once.
- **Search what's said** (Podcast screen): finds a word or phrase in every transcript on the server and plays that episode from that moment. Only podcasts on your own list (and audiobooks) are searched; older transcripts are matched to their episode the first time.
- **Recipes** (needs `MEALIE_TOKEN`; `MEALIE_URL` defaults to `https://mealie.necloud.us`): browse and search your Mealie recipes, read one (ingredients, steps, times), import a recipe from a web link, and add its ingredients to the Mealie shopping list (Mealie adds up repeats). The shopping list is also kept in **Tasks** as a "Shopping" list: ticking, adding or deleting an item in either place is carried over when either screen opens or an item changes. In a recipe, tick ingredients (and tap steps) as you go; this is remembered on the device, with *Uncheck all* to start over. On wide screens the ingredients sit beside the steps. Turn the tab on per person in the admin screen.
- **Contacts**: an address book per person (name, company, emails, phones, address, birthday, notes). Mail suggests contacts while you type a recipient, a message you are reading can be saved as a contact (or opens the one you have), birthdays appear in the Calendar, and .vcf files from iCloud, Google or a phone can be imported and exported.
## Store
One **Store** tab (turn it on per person) with sections for **Books** (the free book sources, Open Library and any OPDS catalogue), **Audiobooks** (LibriVox) and **Radio** (the station finder), each shown only to people who have that tab, plus **My Store**. The old Book store and Free audiobooks buttons now open the Store. **Radio** under Listen starts empty: find stations in the Store's Radio section (The Journey FM is listed there too) and tap *Add to Radio*; only what you add appears under Listen, and you can remove it again. Someone without the Store tab keeps the station finder in Radio itself.
**My Store** is your own catalogue, for everyone who has the Store tab. The admin adds items with **Add**: upload files, or copy a file from their Seafile (**From Files**), with an optional cover picture.
- **Books** (EPUB, PDF, MOBI, AZW3, DOCX): *Add to my library* puts a copy on that person's shelf, or download it.
- **Music** (MP3, M4A, AAC, FLAC, OGG, OPUS, WAV): an album can have many tracks; they play in the hub's player and can be downloaded.
- **Apps**: any files to download (an APK, an installer...), with an optional link and version. The hub does not run them.
- **Sites**: any website as a card. It opens in a new tab, or inside the hub if you tick *Open inside the hub* (some sites refuse to be shown that way).
- **Extensions**: one self-contained `.html` file that adds a feature to the hub. A person installs it from My Store and it opens inside the Store. It runs in a sandbox with no access to the hub's sign-in or data (and `connect-src https:` only), and talks to the hub through a small `hub` object: `hub.storage.get/set/all/remove` (its own settings for that person, up to 256 KB), `hub.user()` (the person's name), `hub.toast(text)` and `hub.open(url)`. It can also ask for hub data, which the person approves the first time (and which is forgotten when the extension is removed): `hub.tasks.lists() / list(listId) / add({ title, notes, due, list }) / complete(listId, taskId)`, `hub.contacts.list() / add({ name, email, phone, org })` and `hub.calendar.events(from, to) / add({ title, start, end, allDay })` (dates as `YYYY-MM-DD`, times as `YYYY-MM-DDTHH:MM`). A refused request rejects with an error. Ready-made examples are in `docs/extensions/`: **notes.html** (notes and checklists, can send items to Tasks), **tracker.html** (habit and Bible-reading streaks), **kitchen.html** (unit converter and recipe scaler that can add to your Shopping list), **weather.html** (forecast plus a verse of the day) and **notepad.html** (the smallest one).
Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions up to 2 MB). Only an admin can add, change or delete items.
**Smashwords** is a book source in the Store (Books). It lists Smashwords' most downloaded free EPUBs through its own shelf feed, filtered for a family: anything Smashwords flags as adult or erotica, anything in LGBTQ+, anything whose categories, tags or description say so, and any romance that does not say it is clean (sweet, Christian, inspirational...). The filter is automatic and cannot be perfect, so a book can occasionally slip through. Smashwords has no public search, so searching looks through a remembered set of about 400 of its popular free books. Downloading needs a free Smashwords account: an admin enters it once under Books, *Catalogues* (kept encrypted on the server, used only to sign in at smashwords.com). `SMASHWORDS_LANG` (default `1`, English) picks the language.
**My apps on Home:** the extensions a person installed from My Store, and any Sites in My Store, appear under *My apps* on Home and in the command palette.
**Music, Featured:** the first tab of Music shows a featured artist and a featured album, picked at random. They stay the same for the day on that device; *Pick again* rolls new ones. *Based on* chooses which playlists they come from (nothing chosen means the whole library), and that choice follows you between devices. *Play mix* shuffles the artist's songs; *Play* plays the album in order.
**Music, more** (Plex music; what is about a person is kept by the hub in `music-*.json` in their folder, and `music-family.json` for Family playlists):
- **Mixes tab:** a Daily mix (what you play, your favorites and a few surprises), Most played this month, Forgotten favorites, Moods (calm, upbeat, focus, worship, built from your genres), On this day (albums released today in past years), Decades, Genres (each with a featured album and a shuffle), *Library radio* (an endless shuffle that keeps adding songs; it uses the playlists chosen on Featured), and **Smart playlists**: your own rules (genres, years, added recently, not played in a while, played at least N times, favorites only, order, size) that pick songs each time you open them.
- **Favorites:** a heart on songs (in lists, the full player and the song menu); the Favorites page plays them. Kept by the hub, so they follow you between devices.
- **Stats tab:** songs played, time listened, artists, day streak, minutes per day or month, top artists, songs and albums, and recently played; for a week, month, year or all time. A song counts when it has been played for half its length. *Clear my history* starts over.
- **Family playlists** (Playlists tab, and *Add to playlist* on any song): shared by everyone who has Music; anyone can add or remove songs, only the owner or an admin renames or deletes one.
- **Also:** reorder the queue with the arrows; *More like this* on an artist or song; an About note on albums and artists (Plex's own summary); **Save offline** on an album (plays with no signal); the player takes its colors from the album cover; **Share a line** in the lyrics makes a picture of the lyric over the cover; Featured shows a verse of the day when you have the Bible tab.
- Not included: crossfade or gapless (the player already keeps the next songs downloaded for quick changes), searching lyrics by a line, and a speaker picker.
**iPad and Screen Time:** Settings, Admin, *iPad and Screen Time* explains the simple setup (Screen Time, Content Restrictions, Web Content: **Limit Adult Websites**, and add only the hub's address under *Always Allow*), lists the web addresses the app needs (the hub, plus the document editor for the Office tab) for anyone using **Allowed Websites Only**, shows other addresses devices were actually seen reaching, and has a *Check this device* button to run on the iPad. The hub sends a report-only Content-Security-Policy (nothing is blocked) so devices report any other address a page loads, and pictures from other sites (podcast search covers, Plex Home pictures, a Verse By Verse cover, search answers) are passed through the hub (`/api/img`) so the browser only talks to the hub for them. Extensions and Sites you add may still reach their own addresses. If the document editor is put behind the same address as the hub (a reverse proxy path), even that one drops off the list.
**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`).
+1 -1
View File
@@ -4,7 +4,7 @@ const users = require('./users');
const auth = require('./auth');
const audit = require('./audit');
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const { readBody } = require('./body');
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const row = u => ({ ...users.publicView(u), tabsChosen: u.tabs || [], passkeys: auth.passkeyCount(u.id) });
+1 -1
View File
@@ -144,7 +144,7 @@ const rp = req => {
};
const equalStr = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); };
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const { readBody } = require('./body');
const send = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', ...(res.getHeader('Set-Cookie') ? { 'Set-Cookie': res.getHeader('Set-Cookie') } : {}) }); res.end(JSON.stringify(obj)); };
const credsOf = uid => store.creds.filter(c => c.uid === uid);
+11
View File
@@ -0,0 +1,11 @@
'use strict';
// Reads a request body as text, and refuses one that is too big, so a huge upload cannot use up the server's memory.
function readBody(req, max = 2e6) {
return new Promise((ok, no) => {
const c = []; let n = 0, done = false;
req.on('data', d => { if (done) return; n += d.length; if (n > max) { done = true; c.length = 0; const e = new Error('That is too large'); e.status = 413; no(e); return; } c.push(d); });
req.on('end', () => { if (!done) ok(Buffer.concat(c).toString()); });
req.on('error', e => { if (!done) no(e); });
});
}
module.exports = { readBody };
+2 -1
View File
@@ -306,7 +306,7 @@ function feed(req, res) {
}
// ---- HTTP API (called by server.js, behind the passkey gate)
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const readBody = req => require('./body').readBody(req, 8e6);
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
/** Every event in the window from all calendars (ICS links, iCloud, NE Hub, shared): { events, errors } */
@@ -329,6 +329,7 @@ async function collectEvents(win) {
for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true });
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName });
} catch (e) { errors.push('NE Hub: ' + e.message); }
try { all.push(...require('./contacts').birthdayEvents(win)); } catch {} // birthdays from Contacts
return { events: all, errors };
}
+6 -5
View File
@@ -87,11 +87,12 @@ async function connect(appleId, password) {
const principalHref = hrefOf(tag(a.text, 'current-user-principal') || '');
if (!principalHref) throw new Error('iCloud did not return an account (is calendar turned on for this Apple ID?)');
const b = await dav('PROPFIND', abs(principalHref, a.url), { ...o, depth: 0, body: PROPFIND_HOME });
const homeHref = hrefOf(tag(b.text, 'calendar-home-set') || '');
if (!homeHref) throw new Error('Could not find the calendar home for this account');
const home = abs(homeHref, b.url);
const c = await dav('PROPFIND', home, { ...o, depth: 1, body: PROPFIND_CALS });
if (c.status >= 400) throw new Error(`iCloud answered ${c.status} when listing the calendars`);
let homeHref = hrefOf(tag(b.text, 'calendar-home-set') || '');
const guess = abs(principalHref, a.url).replace(/\/principal\/?$/, '/calendars/');
const home0 = homeHref ? abs(homeHref, b.url) : guess.endsWith('/calendars/') ? guess : null; // (some accounts do not report the home at the principal; Apple keeps it beside it, as /<id>/calendars/)
if (!home0) throw new Error(`Could not find the calendar home for this account (iCloud answered ${b.status}: ${b.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160) || 'nothing'})`);
const c = await dav('PROPFIND', home0, { ...o, depth: 1, body: PROPFIND_CALS });
if (c.status >= 400) throw new Error(homeHref ? `iCloud answered ${c.status} when listing the calendars` : `Could not find the calendar home for this account (the principal gave none, and ${new URL(home0).pathname} answered ${c.status})`);
const calendars = parseCollections(c.text, c.url);
if (!calendars.found.length) throw new Error(`Signed in, but no calendars were found (iCloud listed ${calendars.seen.length} item${calendars.seen.length === 1 ? '' : 's'}: ${calendars.seen.slice(0, 8).join(', ') || 'none'}). Make sure Calendar is turned on for this Apple ID in iCloud settings.`);
save({ user: o.user, pass: o.pass, calendars: calendars.found });
+225
View File
@@ -0,0 +1,225 @@
// Family Chat: a messenger built into the hub, for the people who have an account here. Direct chats and groups, text, attachments, replies, reactions, edit and delete,
// unread counts, search, mute and archive, live delivery (server-sent events) and phone push when someone is not looking. Everything is kept on this server, in
// DATA_DIR/chat: conversations and messages as JSON, attachments as files. (Messages are stored readable on the server; end-to-end encryption is a later decision.)
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const { pipeline } = require('stream/promises');
const atomicWrite = require('./atomic');
const users = require('./users');
const push = require('./push');
const cal = require('./cal'); // (its public-address check, for link previews)
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), ROOT = () => path.join(DATA_DIR, 'chat');
const MAX_TEXT = 8000, MAX_FILE = 100 * 1024 * 1024, MAX_FILES = 10, MAX_MEMBERS = 40, MAX_MSGS = 50000, PAGE = 60;
const EMOJI = /^[\p{Extended_Pictographic}‍️⃣\p{Emoji_Modifier}]{1,12}$/u;
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) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
const newId = () => crypto.randomBytes(8).toString('hex');
const idOk = s => /^[0-9a-f]{16}$/.test(s || '') || /^d:[0-9a-z]+:[0-9a-z]+$/.test(s || '');
// ---- storage (small enough to hold in memory and write whole)
const cvFile = () => path.join(ROOT(), 'convs.json'), msFile = id => path.join(ROOT(), 'msgs', id.replace(/:/g, '_') + '.json'), flFile = () => path.join(ROOT(), 'files.json'), blob = id => path.join(ROOT(), 'files', id);
const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const write = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 }); atomicWrite(f, JSON.stringify(v), { mode: 0o600 }); };
let convsC = null, filesC = null; const msgsC = new Map();
const convs = () => convsC || (convsC = read(cvFile(), []));
const saveConvs = () => write(cvFile(), convs());
const files = () => filesC || (filesC = read(flFile(), {}));
const saveFiles = () => write(flFile(), files());
const msgs = id => { let m = msgsC.get(id); if (!m) { m = read(msFile(id), []); msgsC.set(id, m); if (msgsC.size > 60) msgsC.delete(msgsC.keys().next().value); } return m; };
const saveMsgs = id => write(msFile(id), msgs(id));
// each person's own marks: how far they have read, and which chats they muted or archived
const stFile = uid => path.join(users.dirOf(uid), 'chat-state.json');
const stateOf = uid => { const s = read(stFile(uid), {}); return { read: s.read || {}, delivered: s.delivered || {}, muted: s.muted || [], archived: s.archived || [], quiet: s.quiet || { on: false, from: '21:00', to: '07:00' } }; };
const saveState = (uid, s) => write(stFile(uid), s);
/** Is it quiet time for this person now (in their own time zone, from their reminder settings)? Phone alerts wait; the app still shows messages. */
function quietNow(uid) {
const q = stateOf(uid).quiet; if (!q.on) return false; let tz = 'UTC'; try { tz = JSON.parse(fs.readFileSync(path.join(users.dirOf(uid), 'push.json'), 'utf8')).tz || 'UTC'; } catch { /* the default */ }
const hm = push.localNow(tz).hm; return q.from <= q.to ? hm >= q.from && hm < q.to : hm >= q.from || hm < q.to;
}
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chat'); };
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chat')).map(u => ({ id: u.id, name: u.name }));
const convOf = (id, me) => { const c = convs().find(x => x.id === id); if (!c || !c.members.includes(me.id)) throw fail(404, 'Conversation not found'); return c; };
const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted && !m.sys).length; };
const totalUnread = uid => { const st = stateOf(uid); return convs().filter(c => c.members.includes(uid) && !st.muted.includes(c.id) && !st.archived.includes(c.id)).reduce((n, c) => n + unreadIn(c, uid, st), 0); };
const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : '';
// ---- live updates (server-sent events), one stream per open app
const annFile = () => path.join(ROOT(), 'announcement.json');
const lastSeen = new Map(); // user id -> when they last did anything (kept in memory)
const clients = new Map(); // user id -> Set of responses
const online = uid => (clients.get(uid) || new Set()).size > 0;
function emit(uids, ev, per) { for (const uid of uids) for (const res of clients.get(uid) || []) { try { res.write('data: ' + JSON.stringify(per ? per(uid, ev) : ev) + '\n\n'); } catch { /* closed */ } } }
const view = (c, uid, st) => { const last = msgs(c.id).slice(-1)[0], others = c.members.filter(x => x !== uid), o = c.type === 'direct' ? others[0] : null;
return { ttl: c.ttl || 0, ...(o ? { presence: { online: online(o), seen: lastSeen.get(o) || 0 } } : {}), id: c.id, type: c.type, name: c.type === 'group' ? c.name : nameOf(others[0] || uid), members: c.members.map(x => ({ id: x, name: nameOf(x) })), by: c.by, created: c.created, ts: last ? last.ts : c.created, preview: last ? (last.sys ? '' : c.type === 'group' && last.from !== uid ? nameOf(last.from) + ': ' : last.from === uid ? 'You: ' : '') + preview(last) : '', unread: unreadIn(c, uid, st), muted: st.muted.includes(c.id), archived: st.archived.includes(c.id) }; };
/** sent, delivered or read: for what you sent, from what the others have read and received (everyone, in a group). */
const statusOf = (m, c, uid, sts) => { if (m.from !== uid || m.sys) return undefined; const o = c.members.filter(x => x !== uid); if (!o.length) return 'sent'; if (o.every(x => (sts[x].read[c.id] || 0) >= m.ts)) return 'read'; if (o.every(x => (sts[x].delivered[c.id] || 0) >= m.ts)) return 'delivered'; return 'sent'; };
const stsOf = c => Object.fromEntries(c.members.map(x => [x, stateOf(x)]));
const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) });
/** Someone has received or read the chat up to now: the senders' ticks change. */
function markDelivered(uid, c, also) { const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; if (!last) return; let ch = false; if ((st.delivered[c.id] || 0) < last.ts) { st.delivered[c.id] = last.ts; ch = true; } if (also && (st.read[c.id] || 0) < last.ts) { st.read[c.id] = last.ts; ch = true; } if (ch) { saveState(uid, st); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); } }
const TTLS = [0, 3600, 86400, 604800], ttlWords = s => s === 3600 ? '1 hour' : s === 86400 ? '1 day' : s === 604800 ? '1 week' : 'off';
/** Disappearing messages that are due go, with their files. */
function purge() {
const now = Date.now(); let filesChanged = false;
for (const c of convs()) { const list = msgs(c.id), due = list.filter(m => m.exp && m.exp <= now); if (!due.length) continue;
for (const m of due) for (const a of m.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; filesChanged = true; }
const keep = list.filter(m => !(m.exp && m.exp <= now)); list.length = 0; list.push(...keep); c.pins = (c.pins || []).filter(id => keep.some(m => m.id === id)); saveMsgs(c.id); saveConvs(); emit(c.members, { t: 'upd', conv: c.id }); }
if (filesChanged) saveFiles();
}
setInterval(() => { try { purge(); } catch (e) { console.warn('[chat]', e.message); } }, 60e3).unref();
const mentionsIn = (text, c) => c.type !== 'group' ? [] : c.members.filter(uid => { const n = nameOf(uid); return n && new RegExp('(^|\\s)@' + n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w])', 'i').test(text); });
function send(me, b) {
const c = convOf(String(b.conv || ''), me), text = str(b.text, MAX_TEXT).trim(), ids = (Array.isArray(b.files) ? b.files : []).map(String).slice(0, MAX_FILES), att = [];
for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size, ...(f.voice ? { voice: true, wave: f.wave, dur: f.dur } : {}) }); }
if (!text && !att.length) throw fail(400, 'Write something or attach a file');
let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
return addMsg(me, c, { text, att, quote });
}
/** Put a message in a chat and tell everyone: the live stream, and a phone notification for those who are away. */
function addMsg(me, c, { text, att = [], quote, fwd, sys, poll }) {
const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined };
list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { if (files()[a.id]) files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles();
const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : text ? str(text, 140) : att[0] && att[0].voice ? '🎤 Voice message' : '📎 ' + att[0].name), title = c.type === 'group' ? c.name : me.name;
emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, sys: !!sys, mention: !!(m.mentions && m.mentions.includes(uid)), name: title, preview: body, unread: totalUnread(uid) }));
for (const uid of c.members) if (online(uid) && (sys || uid !== me.id)) markDelivered(uid, c); // (what an open app has received counts as delivered)
if (!sys) for (const uid of c.members) if (uid !== me.id && !online(uid) && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid))) && !quietNow(uid)) push.notify(uid, { title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id }).catch(() => {});
if (!sys) { const url = /https:\/\/[^\s<>"']+/.exec(text || ''); if (url) previewOf(url[0].replace(/[.,;:!?)\]]+$/, '')).then(p => { if (p && msgs(c.id).includes(m) && !m.deleted) { m.link = p; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: m.id }); } }).catch(() => {}); }
return msgView(m, me.id, c, stsOf(c));
}
// ---- link previews: the server reads the page's own title, description and picture (public https addresses only), so nobody's device contacts the site
const metaOf = (html, names) => { for (const n of names) { const m = new RegExp('<meta[^>]+(?:property|name)=["\']' + n + '["\'][^>]*>', 'i').exec(html), c = m && /content=["']([^"']*)["']/i.exec(m[0]) || (m && /content=([^\s>]+)/i.exec(m[0])); if (c && c[1].trim()) return c[1]; } return ''; };
const unent = t => String(t).replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|#39);/gi, (x, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'" }[e.toLowerCase()] || (e[1] === 'x' || e[1] === 'X' ? String.fromCodePoint(parseInt(e.slice(2), 16)) : e[0] === '#' ? String.fromCodePoint(+e.slice(1)) : x))).replace(/\s+/g, ' ').trim();
/** The preview a page describes about itself: { url, site, title, desc, image } or null. */
function parsePreview(html, base) {
const title = unent(metaOf(html, ['og:title', 'twitter:title']) || (/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) || [])[1] || ''), desc = unent(metaOf(html, ['og:description', 'twitter:description', 'description'])), site = unent(metaOf(html, ['og:site_name']));
let image = metaOf(html, ['og:image', 'og:image:url', 'twitter:image']); try { image = image ? new URL(unent(image), base).href : ''; if (!/^https:/.test(image)) image = ''; } catch { image = ''; }
if (!title && !desc) return null; return { url: base, site: site.slice(0, 80), title: title.slice(0, 200), desc: desc.slice(0, 300), image: image.slice(0, 500) };
}
const previews = new Map();
async function previewOf(raw) {
const hit = previews.get(raw); if (hit && Date.now() - hit.t < 3600e3) return hit.p;
let url; try { url = new URL(raw); } catch { return null; } let r;
for (let hop = 0; hop < 4; hop++) { await cal.assertPublic(url); r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (compatible; NE-Hub/1.0; link preview)', accept: 'text/html' }, redirect: 'manual', signal: AbortSignal.timeout(6000) }); if ([301, 302, 303, 307, 308].includes(r.status) && r.headers.get('location')) { url = new URL(r.headers.get('location'), url); try { await r.body?.cancel(); } catch {} continue; } break; }
let p = null; if (r.ok && /html/i.test(r.headers.get('content-type') || '')) { const chunks = []; let n = 0; for await (const c of r.body) { n += c.length; chunks.push(c); if (n > 400000) break; } p = parsePreview(Buffer.concat(chunks).toString('utf8'), url.href); }
previews.set(raw, { t: Date.now(), p }); if (previews.size > 200) previews.delete(previews.keys().next().value); return p;
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = (() => { try { return decodeURIComponent(u.pathname.slice('/api/chat'.length)) || '/'; } catch { return '/'; } })(), me = users.current(), q = u.searchParams; // (a chat id has colons, which a browser writes as %3A)
try {
let m; lastSeen.set(me.id, Date.now()); purge();
if (p === '/status') return json(res, 200, { ok: true, me: { id: me.id, name: me.name }, unread: totalUnread(me.id) });
if (p === '/people') return json(res, 200, people().filter(x => x.id !== me.id));
if (p === '/events') {
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' }); res.write(': hello\n\n');
const set = clients.get(me.id) || new Set(); set.add(res); clients.set(me.id, set);
const ka = setInterval(() => { try { res.write(': \n\n'); } catch { /* closed */ } }, 25000);
res.on('close', () => { clearInterval(ka); set.delete(res); if (!set.size) clients.delete(me.id); lastSeen.set(me.id, Date.now()); }); return;
}
if (p === '/convs' && req.method === 'GET') { const st = stateOf(me.id); return json(res, 200, { convs: convs().filter(c => c.members.includes(me.id)).map(c => view(c, me.id, st)).sort((a, b) => b.ts - a.ts), unread: totalUnread(me.id) }); }
if (p === '/new' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'); let c;
if (b.type === 'group') {
const name = str(b.name, 60).trim(); if (!name) throw fail(400, 'Give the group a name'); const mem = [...new Set([me.id, ...(Array.isArray(b.members) ? b.members : [])].map(String))].filter(live); if (mem.length < 2) throw fail(400, 'Add at least one other person'); if (mem.length > MAX_MEMBERS) throw fail(400, 'That is a lot of people for one group');
c = { id: newId(), type: 'group', name, members: mem, by: me.id, created: Date.now() }; convs().push(c);
} else {
const other = String(b.with || ''); if (other === me.id || !live(other)) throw fail(400, 'Choose someone from the family'); const id = 'd:' + [me.id, other].sort().join(':'); c = convs().find(x => x.id === id);
if (!c) { c = { id, type: 'direct', members: [me.id, other].sort(), by: me.id, created: Date.now() }; convs().push(c); }
}
saveConvs(); emit(c.members, { t: 'conv', conv: c.id }); return json(res, 200, { conv: view(c, me.id, stateOf(me.id)) });
}
if (p === '/conv/update' && req.method === 'POST') { // rename, add or remove people, leave (groups)
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me); if (c.type !== 'group') throw fail(400, 'Only groups can be changed');
const isOwner = c.by === me.id || me.role === 'admin'; const before = [...c.members];
if (b.name !== undefined) { const n = str(b.name, 60).trim(); if (!n) throw fail(400, 'Give the group a name'); c.name = n; }
for (const id of Array.isArray(b.add) ? b.add : []) if (live(String(id)) && !c.members.includes(String(id)) && c.members.length < MAX_MEMBERS) c.members.push(String(id));
for (const id of Array.isArray(b.remove) ? b.remove : []) { if (!isOwner && String(id) !== me.id) throw fail(403, 'Only the person who made the group, or an admin, can remove others'); c.members = c.members.filter(x => x !== String(id)); }
if (b.leave) c.members = c.members.filter(x => x !== me.id);
if (!c.members.length) convsC = convs().filter(x => x !== c); else if (c.by && !c.members.includes(c.by)) c.by = c.members[0];
saveConvs(); emit([...new Set([...before, ...c.members])], { t: 'conv', conv: c.id }); return json(res, 200, c.members.includes(me.id) ? { conv: view(c, me.id, stateOf(me.id)) } : { left: true });
}
if ((m = /^\/conv\/(\w[\w:]*)$/.exec(p)) && req.method === 'GET') {
const c = convOf(m[1], me), all = msgs(c.id), before = +q.get('before') || Infinity, eligible = all.filter(x => x.ts < before), page = eligible.slice(-PAGE), sts = stsOf(c); markDelivered(me.id, c);
return json(res, 200, { conv: view(c, me.id, stateOf(me.id)), pinned: (c.pins || []).map(id => all.find(x => x.id === id)).filter(x => x && !x.deleted).map(x => msgView(x, me.id, c, sts)), msgs: page.map(x => msgView(x, me.id, c, sts)), more: eligible.length > page.length, names: Object.fromEntries(c.members.map(x => [x, nameOf(x)])) });
}
if (p === '/send' && req.method === 'POST') return json(res, 200, { msg: send(me, JSON.parse(await readBody(req, 5e4) || '{}')) });
if (p === '/upload' && req.method === 'POST') { // the body is the file; ?conv= says which chat it is for
const c = convOf(String(q.get('conv') || ''), me); if ((+req.headers['content-length'] || 0) > MAX_FILE) throw fail(413, 'That file is too large (100 MB at most)');
const id = newId(), type = /^[\w.+-]+\/[\w.+-]+$/.test(String(req.headers['content-type'] || '').split(';')[0].trim()) ? String(req.headers['content-type']).split(';')[0].trim().toLowerCase() : 'application/octet-stream', name = path.basename(str(q.get('name'), 160)).replace(/[\\/:*?"<>|]+/g, '_') || 'file';
fs.mkdirSync(path.dirname(blob(id)), { recursive: true, mode: 0o700 }); let n = 0; req.on('data', d => { n += d.length; if (n > MAX_FILE) req.destroy(fail(413, 'That file is too large')); });
try { await pipeline(req, fs.createWriteStream(blob(id) + '.part', { mode: 0o600 })); } catch (e) { fs.rm(blob(id) + '.part', { force: true }, () => {}); throw e.code === 413 ? e : fail(400, 'The upload did not finish'); }
fs.renameSync(blob(id) + '.part', blob(id)); const voice = q.get('voice') === '1' && /^audio\//.test(type), wave = voice ? String(q.get('wave') || '').split(',').map(Number).filter(v => Number.isFinite(v)).slice(0, 64).map(v => Math.max(0, Math.min(100, Math.round(v)))) : undefined, dur = voice ? Math.max(0, Math.min(900, Math.round(+q.get('dur') || 0))) : undefined;
files()[id] = { conv: c.id, by: me.id, name, type, size: n, ts: Date.now(), ...(voice ? { voice: true, wave, dur } : {}) }; saveFiles(); return json(res, 200, { id, name, type, size: n, voice: voice || undefined });
}
if ((m = /^\/file\/([0-9a-f]{16})$/.exec(p)) && req.method === 'GET') {
const f = files()[m[1]]; if (!f) throw fail(404, 'Not found'); convOf(f.conv, me); let st; try { st = fs.statSync(blob(m[1])); } catch { throw fail(404, 'Not found'); }
const inline = /^(image\/(png|jpeg|gif|webp|heic|heif|avif)|audio\/|video\/|application\/pdf$)/.test(f.type), h = { 'content-type': inline ? f.type : 'application/octet-stream', 'x-content-type-options': 'nosniff', 'cache-control': 'private, max-age=3600', 'accept-ranges': 'bytes', 'content-security-policy': "default-src 'none'; sandbox" };
if (!inline || q.get('dl') === '1') h['content-disposition'] = `attachment; filename="${f.name.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '')}"; filename*=UTF-8''${encodeURIComponent(f.name)}`;
const rg = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
if (rg && (rg[1] || rg[2])) { const a = rg[1] ? +rg[1] : Math.max(0, st.size - +rg[2]), b = rg[1] && rg[2] ? Math.min(+rg[2], st.size - 1) : st.size - 1; if (a > b || a >= st.size) { res.writeHead(416, { 'content-range': 'bytes */' + st.size }); return res.end(); } res.writeHead(206, { ...h, 'content-range': `bytes ${a}-${b}/${st.size}`, 'content-length': b - a + 1 }); return fs.createReadStream(blob(m[1]), { start: a, end: b }).pipe(res); }
res.writeHead(200, { ...h, 'content-length': st.size }); return fs.createReadStream(blob(m[1])).pipe(res);
}
if (p === '/react' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || x.deleted) throw fail(404, 'Message not found');
const e = String(b.emoji || ''); if (e && !EMOJI.test(e)) throw fail(400, 'That is not an emoji'); x.react = x.react || {}; if (e) x.react[me.id] = e; else delete x.react[me.id]; saveMsgs(c.id);
emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
}
if ((p === '/edit' || p === '/delete') && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 2e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x) throw fail(404, 'Message not found'); if (x.from !== me.id) throw fail(403, 'You can only change your own messages'); if (x.deleted) throw fail(400, 'That message was deleted');
if (p === '/edit') { const t = str(b.text, MAX_TEXT).trim(); if (!t) throw fail(400, 'Write something'); if (Date.now() - x.ts > 48 * 3600e3) throw fail(400, 'Messages can be edited for two days'); x.text = t; x.edited = true; }
else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.react = {}; saveFiles(); }
saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
}
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); st.delivered[c.id] = Math.max(st.delivered[c.id] || 0, st.read[c.id]); saveState(me.id, st); emit([me.id], { t: 'read', conv: c.id, unread: totalUnread(me.id) }); emit(c.members.filter(x => x !== me.id), { t: 'receipt', conv: c.id }); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); }
if ((p === '/conv/mute' || p === '/conv/archive') && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), k = p.endsWith('mute') ? 'muted' : 'archived'; st[k] = st[k].filter(x => x !== c.id); if (b.on) st[k].push(c.id); saveState(me.id, st); emit([me.id], { t: 'conv', conv: c.id, unread: totalUnread(me.id) }); return json(res, 200, { ok: true }); }
if (p === '/announcement' && req.method === 'GET') return json(res, 200, read(annFile(), null));
if (p === '/announcement' && req.method === 'POST') { // the family board on Home: an admin posts or clears it
if (me.role !== 'admin') throw fail(403, 'Only an admin can change the announcement'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = str(b.text, 300).trim();
if (!text) { fs.rmSync(annFile(), { force: true }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, null); }
const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a);
}
if (p === '/prefs' && req.method === 'GET') return json(res, 200, { quiet: stateOf(me.id).quiet });
if (p === '/prefs' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), st = stateOf(me.id), hm = /^([01]\d|2[0-3]):[0-5]\d$/; if (b.quiet) st.quiet = { on: !!b.quiet.on, from: hm.test(b.quiet.from) ? b.quiet.from : st.quiet.from, to: hm.test(b.quiet.to) ? b.quiet.to : st.quiet.to }; saveState(me.id, st); return json(res, 200, { quiet: st.quiet }); }
if (p === '/poll' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), q = str(b.question, 200).trim(), opts = (Array.isArray(b.options) ? b.options : []).map(o => str(o, 100).trim()).filter(Boolean).slice(0, 8);
if (!q) throw fail(400, 'Ask a question'); if (opts.length < 2) throw fail(400, 'Give at least two choices');
const msg = addMsg(me, c, { text: '', poll: { q, options: opts.map((t, i) => ({ id: String(i), text: t })), multi: !!b.multi } }); return json(res, 200, { msg });
}
if (p === '/vote' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || !x.poll || x.deleted) throw fail(404, 'Poll not found'); if (x.poll.closed) throw fail(400, 'This poll is closed');
const ids = [...new Set((Array.isArray(b.opts) ? b.opts : []).map(String))].filter(id => x.poll.options.some(o => o.id === id)); if (!x.poll.multi) ids.splice(1);
x.votes = x.votes || {}; if (ids.length) x.votes[me.id] = ids; else delete x.votes[me.id]; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
}
if (p === '/poll/close' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || !x.poll) throw fail(404, 'Poll not found'); if (x.from !== me.id) throw fail(403, 'Only the person who asked can close it'); x.poll.closed = true; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true }); }
if (p === '/typing' && req.method === 'POST') { const c = convOf(String(JSON.parse(await readBody(req, 1e4) || '{}').conv || ''), me); emit(c.members.filter(x => x !== me.id), { t: 'typing', conv: c.id, from: me.id, name: me.name }); return json(res, 200, { ok: true }); }
if (p === '/conv/ttl' && req.method === 'POST') { // disappearing messages: from now on, new messages go after this long
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), ttl = +b.ttl || 0; if (!TTLS.includes(ttl)) throw fail(400, 'Choose off, 1 hour, 1 day or 1 week');
if ((c.ttl || 0) !== ttl) { c.ttl = ttl; saveConvs(); addMsg(me, c, { sys: true, text: ttl ? `${me.name} set messages to disappear after ${ttlWords(ttl)}` : `${me.name} turned off disappearing messages` }); }
emit(c.members, { t: 'conv', conv: c.id }); return json(res, 200, { ok: true, ttl });
}
if (p === '/pin' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
c.pins = (c.pins || []).filter(id => id !== x.id); if (b.on) { c.pins.push(x.id); if (c.pins.length > 5) c.pins.shift(); } saveConvs(); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true, pins: c.pins });
}
if (p === '/forward' && req.method === 'POST') { // a copy of a message (and its files) into another chat
const b = JSON.parse(await readBody(req, 1e4) || '{}'), from = convOf(String(b.from || ''), me), to = convOf(String(b.to || ''), me), x = msgs(from.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
const att = (x.att || []).map(a => { const id = newId(); try { fs.copyFileSync(blob(a.id), blob(id)); } catch { return null; } files()[id] = { conv: to.id, by: me.id, name: a.name, type: a.type, size: a.size, ts: Date.now(), attached: true }; return { id, name: a.name, type: a.type, size: a.size, ...(a.voice ? { voice: true, wave: a.wave, dur: a.dur } : {}) }; }).filter(Boolean);
if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, fwd: { name: nameOf(x.from) } }) });
}
if (p === '/search' && req.method === 'GET') {
const needle = str(q.get('q'), 100).trim().toLowerCase(); if (needle.length < 2) return json(res, 200, { results: [] }); const out = [], st = stateOf(me.id);
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
return json(res, 200, { results: out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 40) });
}
return json(res, 404, { error: 'Not found' });
} catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, totalUnread, purge, parsePreview };
+116
View File
@@ -0,0 +1,116 @@
// Contacts: each person's address book, kept in NE Hub. Mail uses it to suggest recipients, the calendar shows birthdays from it, and vCards (.vcf, the
// format iCloud, Google and phones export) can be imported and exported.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const MAX_CONTACTS = 5000, MAX_IMPORT = 8e6;
const file = uid => path.join(uid ? users.dirOf(uid) : users.dir(), 'contacts.json');
const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } };
const save = (l, uid) => atomicWrite(file(uid), JSON.stringify(l), { mode: 0o600 });
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) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const EMAIL = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
/** A birthday as "YYYY-MM-DD" (year known) or "--MM-DD" (year not known), or '' when it is not a real date. */
function cleanBirthday(v) {
const s = String(v || '').trim(); let m;
if ((m = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(s))) { const [, y, mo, d] = m; return real(+y, +mo, +d) ? `${y}-${mo}-${d}` : ''; }
if ((m = /^(?:--)?(\d{2})-?(\d{2})$/.exec(s))) { const [, mo, d] = m; return real(2000, +mo, +d) ? `--${mo}-${d}` : ''; }
return '';
}
const real = (y, mo, d) => { const t = new Date(Date.UTC(y, mo - 1, d)); return t.getUTCFullYear() === y && t.getUTCMonth() === mo - 1 && t.getUTCDate() === d; };
const list = (v, n, ok) => (Array.isArray(v) ? v : []).map(x => ({ label: str(x && x.label, 20) || 'other', value: str(x && x.value, 120) })).filter(x => x.value && (!ok || ok(x.value))).slice(0, n);
/** Whatever the browser or an import sent, reduced to the fields a contact has. */
function clean(b, old = {}) {
const c = {
id: old.id || crypto.randomBytes(6).toString('hex'), name: str(b.name, 120), org: str(b.org, 120),
emails: list(b.emails, 10, v => EMAIL.test(v)), phones: list(b.phones, 10), address: str(b.address, 300),
birthday: cleanBirthday(b.birthday), notes: str(b.notes, 2000), updated: Date.now(),
};
c.emails.forEach(e => { e.value = e.value.toLowerCase(); });
if (!c.name) c.name = c.emails[0] ? c.emails[0].value.split('@')[0] : c.org;
if (!c.name) throw fail(400, 'Give the contact a name or an email address');
return c;
}
// ---- vCards
const unesc = s => String(s).replace(/\\([nN,;\\])/g, (m, c) => c === 'n' || c === 'N' ? '\n' : c);
const esc = s => String(s || '').replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;');
/** The contacts in a vCard file (versions 2.1, 3.0 and 4.0; photos and other extras are left out). */
function parseVcf(text) {
const lines = []; for (const raw of String(text).replace(/\r\n?/g, '\n').split('\n')) { if (/^[ \t]/.test(raw) && lines.length) lines[lines.length - 1] += raw.slice(1); else lines.push(raw); }
const out = []; let cur = null;
for (const line of lines) {
if (/^BEGIN:VCARD/i.test(line)) { cur = { emails: [], phones: [] }; continue; }
if (/^END:VCARD/i.test(line)) { if (cur) out.push(cur); cur = null; continue; }
if (!cur) continue;
const i = line.indexOf(':'); if (i < 1) continue;
const head = line.slice(0, i).split(';'), name = head[0].replace(/^.*\./, '').toUpperCase(), params = head.slice(1).join(';'), val = line.slice(i + 1);
const label = ((/TYPE=([^;:]+)/i.exec(params) || /(?:^|;)(home|work|cell|mobile|fax|pref)(?:;|$)/i.exec(params) || [])[1] || 'other').split(',').filter(x => !/^(pref|internet|voice)$/i.test(x))[0] || 'other';
if (name === 'FN') cur.name = unesc(val);
else if (name === 'N' && !cur.n) { const p = val.split(';').map(unesc); cur.n = [p[1], p[0]].filter(Boolean).join(' '); }
else if (name === 'ORG') cur.org = unesc(val.split(';')[0]);
else if (name === 'EMAIL') cur.emails.push({ label: label.toLowerCase(), value: unesc(val) });
else if (name === 'TEL') cur.phones.push({ label: label.toLowerCase(), value: unesc(val).replace(/^tel:/i, '') });
else if (name === 'ADR') cur.address = val.split(';').map(unesc).filter(Boolean).join(', ');
else if (name === 'BDAY') cur.birthday = val.replace(/T.*$/, '');
else if (name === 'NOTE') cur.notes = unesc(val);
}
return out.map(c => ({ ...c, name: c.name || c.n || '' }));
}
function toVcf(l) {
return l.map(c => ['BEGIN:VCARD', 'VERSION:3.0', 'FN:' + esc(c.name), 'N:;' + esc(c.name) + ';;;', c.org && 'ORG:' + esc(c.org),
...c.emails.map(e => `EMAIL;TYPE=${/^(home|work)$/.test(e.label) ? e.label.toUpperCase() : 'INTERNET'}:${esc(e.value)}`),
...c.phones.map(p => `TEL;TYPE=${/^(home|work|cell|fax)$/.test(p.label) ? p.label.toUpperCase() : 'VOICE'}:${esc(p.value)}`),
c.address && 'ADR:;;' + esc(c.address) + ';;;;', c.birthday && 'BDAY:' + (c.birthday.startsWith('--') ? c.birthday.replace(/-/g, '').replace(/^/, '--') : c.birthday.replace(/-/g, '')),
c.notes && 'NOTE:' + esc(c.notes), 'END:VCARD'].filter(Boolean).join('\r\n')).join('\r\n') + (l.length ? '\r\n' : '');
}
/** Birthdays as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Repeats every year. */
function birthdayEvents(win, uid) {
const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4);
for (const c of load(uid)) {
if (!c.birthday) continue;
const [mo, d] = c.birthday.startsWith('--') ? [c.birthday.slice(2, 4), c.birthday.slice(5, 7)] : [c.birthday.slice(5, 7), c.birthday.slice(8, 10)], born = c.birthday.startsWith('--') ? 0 : +c.birthday.slice(0, 4);
for (let y = y0; y <= y1; y++) {
const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue;
const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10);
out.push({ title: `${c.name}'s birthday${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: 'birthday:' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color: '#d9578a', editable: false, contactId: c.id });
}
}
return out;
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/contacts'.length) || '/';
try {
if (p === '/' && req.method === 'GET') return json(res, 200, load().sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })));
if (p === '/' && req.method === 'POST') { // add or change one
const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = load(), old = b.id ? l.find(x => x.id === b.id) : null;
if (b.id && !old) throw fail(404, 'Contact not found');
if (!old && l.length >= MAX_CONTACTS) throw fail(400, 'That is plenty of contacts');
const c = clean(b, old || {}); if (old) l[l.indexOf(old)] = c; else l.push(c);
save(l); return json(res, 200, c);
}
if (p === '/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); save(load().filter(x => x.id !== id)); return json(res, 200, { ok: true }); }
if (p === '/import' && req.method === 'POST') { // a .vcf file's text: new people are added, and ones with the same email address are filled in rather than doubled
const b = JSON.parse(await readBody(req, MAX_IMPORT) || '{}'), l = load(); let added = 0, merged = 0, skipped = 0;
for (const raw of parseVcf(b.vcf || '')) {
let c; try { c = clean(raw); } catch { skipped++; continue; }
const have = l.find(x => x.emails.some(e => c.emails.some(f => f.value === e.value))) || (!c.emails.length && l.find(x => x.name.toLowerCase() === c.name.toLowerCase()));
if (have) { for (const k of ['org', 'address', 'birthday', 'notes']) if (!have[k] && c[k]) have[k] = c[k]; for (const k of ['emails', 'phones']) for (const x of c[k]) if (!have[k].some(y => y.value.toLowerCase() === x.value.toLowerCase())) have[k].push(x); merged++; }
else if (l.length < MAX_CONTACTS) { l.push(c); added++; } else skipped++;
}
save(l); return json(res, 200, { added, merged, skipped });
}
if (p === '/export' && req.method === 'GET') {
const body = toVcf(load()); res.writeHead(200, { 'content-type': 'text/vcard; charset=utf-8', 'content-disposition': 'attachment; filename="contacts.vcf"', 'cache-control': 'no-store' }); return res.end(body);
}
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status || 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, load, parseVcf, toVcf, birthdayEvents, clean, cleanBirthday };
+4
View File
@@ -27,6 +27,10 @@ services:
OPENAUDIBLE_URL: ${OPENAUDIBLE_URL:-} # audiobooks straight from OpenAudible (e.g. http://172.16.16.106:8000); the username and password in Settings -> Audiobooks sign-in are OpenAudible's. Leave empty to go through the audiobook player instead
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
PODGRAB_PASSWORD: ${PODGRAB_PASSWORD:-} # Podgrab's PASSWORD setting, if it has one (the sign-in name is always podgrab); stays on the server
PODCASTINDEX_KEY: ${PODCASTINDEX_KEY:-} # optional: a free key from https://api.podcastindex.org (with the secret below) adds Podcast Index to podcast search and browsing by topic
PODCASTINDEX_SECRET: ${PODCASTINDEX_SECRET:-}
MEALIE_URL: ${MEALIE_URL:-https://mealie.necloud.us} # Recipes tab: your Mealie server, and an API token (Mealie: your profile -> API tokens); the token stays on the server
MEALIE_TOKEN: ${MEALIE_TOKEN:-}
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
+32
View File
@@ -0,0 +1,32 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Kitchen</title>
<style>
:root{--bg:#fff;--fg:#1b1e24;--mut:#6b7280;--line:#e0e3e9;--acc:#e0742b;--card:#f4f5f8}@media(prefers-color-scheme:dark){:root{--bg:#181b21;--fg:#eceef2;--mut:#8b93a3;--line:#2a2f38;--acc:#f0883e;--card:#20242c}}
*{box-sizing:border-box}body{margin:0;padding:14px;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg);max-width:720px}h2{margin:18px 0 8px}h2:first-child{margin-top:0}
input,select,textarea,button{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px}button{cursor:pointer}button.p{background:var(--acc);color:#fff;border-color:var(--acc)}
.r{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:6px 0}textarea{width:100%;height:150px}pre{background:var(--card);border-radius:8px;padding:10px;white-space:pre-wrap;margin:8px 0}.m{color:var(--mut);font-size:13px}
</style></head><body>
<h2>Convert</h2>
<div class="r"><input id="v" type="number" step="any" value="1" style="width:110px"><select id="from"></select><span>to</span><select id="to"></select></div><div id="out" style="font-size:22px;font-weight:600"></div>
<h2>Scale a recipe</h2>
<div class="r"><span>From</span><input id="a" type="number" min="1" value="4" style="width:70px"><span>servings to</span><input id="b" type="number" min="1" value="6" style="width:70px"></div>
<textarea id="t" placeholder="Paste ingredients, one per line&#10;2 cups flour&#10;1 1/2 tsp salt&#10;3/4 cup sugar"></textarea>
<div class="r"><button class="p" id="go">Scale</button><button id="sl" disabled>Add to my Shopping list</button></div><pre id="res" hidden></pre><div class="m" id="msg"></div>
<script>
var U={cup:['Volume',236.588],tbsp:['Volume',14.787],tsp:['Volume',4.929],ml:['Volume',1],l:['Volume',1000],'fl oz':['Volume',29.574],pint:['Volume',473.176],quart:['Volume',946.353],
g:['Weight',1],kg:['Weight',1000],oz:['Weight',28.3495],lb:['Weight',453.592]};
var opts=Object.keys(U).map(function(k){return '<option>'+k+'</option>'}).join('')+'<option>F</option><option>C</option>';
from.innerHTML=opts;to.innerHTML=opts;from.value='cup';to.value='ml';
function conv(){var x=parseFloat(v.value),f=from.value,t=to.value,o;if(isNaN(x)){out.textContent='';return}
if((f==='F'||f==='C')&&(t==='F'||t==='C'))o=f===t?x:f==='F'?(x-32)*5/9:x*9/5+32;
else if(U[f]&&U[t]&&U[f][0]===U[t][0])o=x*U[f][1]/U[t][1];else{out.textContent='Cannot convert '+(U[f]?U[f][0]:'temperature')+' to '+(U[t]?U[t][0]:'temperature').toLowerCase();return}
out.textContent=x+' '+f+' = '+(Math.round(o*100)/100)+' '+t}
[v,from,to].forEach(function(e){e.oninput=conv});conv();
var FR={'½':.5,'¼':.25,'¾':.75,'⅓':1/3,'⅔':2/3,'⅛':.125};
function num(s){s=s.trim();var m;if((m=/^(\d+)\s+(\d+)\/(\d+)$/.exec(s)))return +m[1]+m[2]/m[3];if((m=/^(\d+)\/(\d+)$/.exec(s)))return m[1]/m[2];if((m=/^(\d*)\s*([½¼¾⅓⅔⅛])$/.exec(s)))return (+m[1]||0)+FR[m[2]];return /^\d*\.?\d+$/.test(s)?parseFloat(s):null}
function show(n){var w=Math.floor(n+1e-9),f=n-w,names=[[0,''],[.125,'1/8'],[.25,'1/4'],[1/3,'1/3'],[.5,'1/2'],[2/3,'2/3'],[.75,'3/4'],[1,'']],best=names[0];names.forEach(function(c){if(Math.abs(c[0]-f)<Math.abs(best[0]-f))best=c});
if(Math.abs(best[0]-f)>0.04)return String(Math.round(n*100)/100);if(best[0]===1){w++;best=names[0]}return (w?w+(best[1]?' ':''):'')+best[1]||'0'}
var lines=[];
go.onclick=function(){var k=(+b.value)/(+a.value);if(!(k>0))return;lines=t.value.split('\n').map(function(l){return l.trim()}).filter(Boolean).map(function(l){var m=/^((?:\d+\s+)?\d+\/\d+|\d*\s*[½¼¾⅓⅔⅛]|\d*\.?\d+)(.*)$/.exec(l);if(!m)return l;var n=num(m[1]);return n==null?l:show(n*k)+m[2]});
res.hidden=false;res.textContent=lines.join('\n');sl.disabled=!lines.length;msg.textContent='Scaled by '+(Math.round(k*100)/100)+'x'};
sl.onclick=function(){sl.disabled=true;(function next(i){if(i>=lines.length){hub.toast('Added '+lines.length+' items to Shopping');sl.disabled=false;return}hub.tasks.add({list:'Shopping',title:lines[i]}).then(function(){next(i+1)},function(e){hub.toast(e.message);sl.disabled=false})})(0)};
</script></body></html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html><html><head><meta charset="utf-8"><title>Notepad</title><style>body{font-family:sans-serif;margin:12px}textarea{width:100%;height:200px}</style></head><body><h3>Notepad</h3><textarea id="t"></textarea><p id="who"></p><script>
hub.user().then(function(u){document.getElementById('who').textContent='Hello '+u.name});
hub.storage.get('note').then(function(v){document.getElementById('t').value=v||''});
document.getElementById('t').oninput=function(e){hub.storage.set('note',e.target.value)};
</script></body></html>
+42
View File
@@ -0,0 +1,42 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Notes</title>
<style>
:root{--bg:#fff;--fg:#1b1e24;--mut:#6b7280;--line:#e0e3e9;--acc:#3d6bf0;--card:#f4f5f8}@media(prefers-color-scheme:dark){:root{--bg:#181b21;--fg:#eceef2;--mut:#8b93a3;--line:#2a2f38;--acc:#6a8cff;--card:#20242c}}
*{box-sizing:border-box}body{margin:0;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg);display:flex;height:100vh}
#side{width:240px;border-right:1px solid var(--line);display:flex;flex-direction:column}#side header{padding:10px;display:flex;gap:6px}
input,textarea,button{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px}button{cursor:pointer}button.p{background:var(--acc);color:#fff;border-color:var(--acc)}
#list{overflow:auto;flex:1}.n{padding:10px 12px;border-bottom:1px solid var(--line);cursor:pointer}.n.on{background:var(--card)}.n b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.n small{color:var(--mut)}
#main{flex:1;padding:12px;overflow:auto;min-width:0}#t{width:100%;font-size:20px;font-weight:600;margin-bottom:8px}#b{width:100%;height:60vh}
.it{display:flex;gap:8px;align-items:center;margin:4px 0}.it input[type=text]{flex:1}.it.d input[type=text]{text-decoration:line-through;color:var(--mut)}.bar{display:flex;gap:6px;margin:8px 0;flex-wrap:wrap}
@media(max-width:560px){body{flex-direction:column}#side{width:100%;height:38vh;border-right:0;border-bottom:1px solid var(--line)}}
</style></head><body>
<div id="side"><header><input id="q" placeholder="Search" style="flex:1;min-width:0"><button class="p" id="nn" title="New note">+ Note</button><button id="nl" title="New checklist">+ List</button></header><div id="list"></div></div>
<div id="main"><p style="color:var(--mut)">Pick a note, or make a new one.</p></div>
<script>
var notes=[],cur=null,q='',timer;
function id(){return Math.random().toString(36).slice(2,10)}
function save(){clearTimeout(timer);timer=setTimeout(function(){hub.storage.set('notes',notes)},400)}
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function drawList(){var l=notes.filter(function(n){var t=(n.title+' '+(n.body||'')+' '+(n.items||[]).map(function(i){return i.t}).join(' ')).toLowerCase();return !q||t.indexOf(q)>=0}).sort(function(a,b){return b.updated-a.updated});
document.getElementById('list').innerHTML=l.map(function(n){var sub=n.type==='list'?(n.items.filter(function(i){return i.done}).length+' of '+n.items.length+' done'):(n.body||'').slice(0,40);return '<div class="n'+(cur&&cur.id===n.id?' on':'')+'" data-i="'+n.id+'"><b>'+esc(n.title||'Untitled')+'</b><small>'+esc(sub)+'</small></div>'}).join('')||'<p style="padding:12px;color:var(--mut)">No notes.</p>';
[].forEach.call(document.querySelectorAll('.n'),function(e){e.onclick=function(){cur=notes.filter(function(n){return n.id===e.dataset.i})[0];drawList();drawMain()}})}
function touch(){cur.updated=Date.now();save()}
function drawMain(){var m=document.getElementById('main');if(!cur){m.innerHTML='<p style="color:var(--mut)">Pick a note, or make a new one.</p>';return}
var h='<input id="t" placeholder="Title" value="'+esc(cur.title)+'">';
if(cur.type==='list'){h+='<div id="items">'+cur.items.map(function(it,i){return '<div class="it'+(it.done?' d':'')+'"><input type="checkbox" data-c="'+i+'"'+(it.done?' checked':'')+'><input type="text" data-t="'+i+'" value="'+esc(it.t)+'"><button data-x="'+i+'">x</button></div>'}).join('')+'</div><div class="bar"><button id="ai">+ Item</button><button id="cl">Clear done</button><button id="ts" title="Add the open items to your Tasks">Send open items to Tasks</button></div>'}
else h+='<textarea id="b" placeholder="Write here">'+esc(cur.body||'')+'</textarea><div class="bar"><button id="tt" title="Add this note to your Tasks as a task">Make a task from the title</button></div>';
h+='<div class="bar"><button id="dl">Delete</button></div>';m.innerHTML=h;
document.getElementById('t').oninput=function(e){cur.title=e.target.value;touch();drawList()};
var b=document.getElementById('b');if(b)b.oninput=function(e){cur.body=e.target.value;touch()};
[].forEach.call(m.querySelectorAll('[data-c]'),function(e){e.onchange=function(){cur.items[+e.dataset.c].done=e.checked;touch();drawMain();drawList()}});
[].forEach.call(m.querySelectorAll('[data-t]'),function(e){e.oninput=function(){cur.items[+e.dataset.t].t=e.value;touch()};e.onkeydown=function(k){if(k.key==='Enter'){cur.items.splice(+e.dataset.t+1,0,{t:'',done:false});touch();drawMain();var x=m.querySelector('[data-t="'+(+e.dataset.t+1)+'"]');if(x)x.focus()}}});
[].forEach.call(m.querySelectorAll('[data-x]'),function(e){e.onclick=function(){cur.items.splice(+e.dataset.x,1);touch();drawMain();drawList()}});
var ai=document.getElementById('ai');if(ai)ai.onclick=function(){cur.items.push({t:'',done:false});touch();drawMain();var a=m.querySelectorAll('[data-t]');a[a.length-1].focus()};
var cl=document.getElementById('cl');if(cl)cl.onclick=function(){cur.items=cur.items.filter(function(i){return !i.done});touch();drawMain();drawList()};
var ts=document.getElementById('ts');if(ts)ts.onclick=function(){var open=cur.items.filter(function(i){return !i.done&&i.t.trim()});ts.disabled=true;(function next(k){if(k>=open.length){hub.toast('Sent '+open.length+' to Tasks');ts.disabled=false;return}hub.tasks.add({title:open[k].t}).then(function(){next(k+1)},function(e){hub.toast(e.message);ts.disabled=false})})(0)};
var tt=document.getElementById('tt');if(tt)tt.onclick=function(){hub.tasks.add({title:cur.title||'Untitled',notes:cur.body||''}).then(function(){hub.toast('Added to Tasks')},function(e){hub.toast(e.message)})};
document.getElementById('dl').onclick=function(){if(confirm('Delete this?')){notes=notes.filter(function(n){return n!==cur});cur=null;save();drawList();drawMain()}}}
function add(type){var n={id:id(),type:type,title:'',body:'',items:type==='list'?[{t:'',done:false}]:[],updated:Date.now()};notes.push(n);cur=n;save();drawList();drawMain();document.getElementById('t').focus()}
document.getElementById('nn').onclick=function(){add('note')};document.getElementById('nl').onclick=function(){add('list')};
document.getElementById('q').oninput=function(e){q=e.target.value.toLowerCase();drawList()};
hub.storage.get('notes').then(function(v){notes=Array.isArray(v)?v:[];drawList()});
</script></body></html>
+23
View File
@@ -0,0 +1,23 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Habits</title>
<style>
:root{--bg:#fff;--fg:#1b1e24;--mut:#6b7280;--line:#e0e3e9;--acc:#2f9e6e;--card:#f4f5f8}@media(prefers-color-scheme:dark){:root{--bg:#181b21;--fg:#eceef2;--mut:#8b93a3;--line:#2a2f38;--acc:#3dbb85;--card:#20242c}}
*{box-sizing:border-box}body{margin:0;padding:14px;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg)}h2{margin:0 0 10px}
input,button{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px}button{cursor:pointer}button.p{background:var(--acc);color:#fff;border-color:var(--acc)}
.h{background:var(--card);border-radius:12px;padding:12px;margin:0 0 12px}.top{display:flex;gap:8px;align-items:center}.top b{flex:1}.streak{color:var(--mut);font-size:13px}
.g{display:grid;grid-template-columns:repeat(14,1fr);gap:3px;margin-top:10px}.g i{aspect-ratio:1;border-radius:3px;background:var(--line)}.g i.y{background:var(--acc)}.g i.t{outline:2px solid var(--fg)}
</style></head><body><h2>Habits</h2><div id="hs"></div>
<form id="f" style="display:flex;gap:6px"><input id="nh" placeholder="New habit (for example Read the Bible)" style="flex:1" required><button class="p">Add</button></form>
<script>
var habits=[],timer;
function save(){clearTimeout(timer);timer=setTimeout(function(){hub.storage.set('habits',habits)},300)}
function key(d){var m=d.getMonth()+1,x=d.getDate();return d.getFullYear()+'-'+(m<10?'0':'')+m+'-'+(x<10?'0':'')+x}
function back(n){var d=new Date();d.setDate(d.getDate()-n);return key(d)}
function streak(h){var s=0,i=h.days[back(0)]?0:1;while(h.days[back(i)]){s++;i++}return s}
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function draw(){document.getElementById('hs').innerHTML=habits.map(function(h,i){var cells='';for(var k=27;k>=0;k--){var d=back(k);cells+='<i class="'+(h.days[d]?'y':'')+(k===0?' t':'')+'" title="'+d+'"></i>'}
var done=h.days[back(0)];return '<div class="h"><div class="top"><b>'+esc(h.name)+'</b><span class="streak">'+streak(h)+' day streak</span><button class="'+(done?'':'p')+'" data-t="'+i+'">'+(done?'Done today':'Check in')+'</button><button data-x="'+i+'" title="Delete">x</button></div><div class="g">'+cells+'</div></div>'}).join('')||'<p style="color:var(--mut)">Add a habit to start a streak.</p>';
[].forEach.call(document.querySelectorAll('[data-t]'),function(b){b.onclick=function(){var h=habits[+b.dataset.t],t=back(0);if(h.days[t])delete h.days[t];else h.days[t]=1;save();draw()}});
[].forEach.call(document.querySelectorAll('[data-x]'),function(b){b.onclick=function(){if(confirm('Delete this habit and its history?')){habits.splice(+b.dataset.x,1);save();draw()}}})}
document.getElementById('f').onsubmit=function(e){e.preventDefault();var v=document.getElementById('nh').value.trim();if(v){habits.push({name:v,days:{}});document.getElementById('nh').value='';save();draw()}};
hub.storage.get('habits').then(function(v){habits=Array.isArray(v)?v:[{name:'Read the Bible',days:{}}];draw()});
</script></body></html>
+24
View File
@@ -0,0 +1,24 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Today</title>
<style>
:root{--bg:#fff;--fg:#1b1e24;--mut:#6b7280;--line:#e0e3e9;--acc:#3d6bf0;--card:#f4f5f8}@media(prefers-color-scheme:dark){:root{--bg:#181b21;--fg:#eceef2;--mut:#8b93a3;--line:#2a2f38;--acc:#6a8cff;--card:#20242c}}
*{box-sizing:border-box}body{margin:0;padding:14px;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg);max-width:720px}
input,button{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px}button{cursor:pointer}
.c{background:var(--card);border-radius:12px;padding:14px;margin:0 0 12px}.big{font-size:40px;font-weight:600}.d{display:flex;gap:8px;overflow:auto}.d div{min-width:78px;text-align:center;background:var(--bg);border-radius:10px;padding:8px}.m{color:var(--mut);font-size:13px}blockquote{margin:0;font-size:17px;line-height:1.5}
</style></head><body>
<div class="c"><form id="f" style="display:flex;gap:6px"><input id="city" placeholder="City (for example Lynchburg)" style="flex:1"><button>Set</button></form><div id="w" class="m" style="margin-top:10px">Pick a city for the weather.</div></div>
<div class="c"><div class="m">Verse of the day</div><blockquote id="verse">Loading…</blockquote><div class="m" id="ref" style="margin-top:6px"></div></div>
<script>
var CODES={0:'Clear',1:'Mostly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Fog',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',80:'Showers',81:'Showers',82:'Heavy showers',95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm'};
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function today(){var d=new Date();return d.getFullYear()+'-'+(d.getMonth()+1)+'-'+d.getDate()}
function weather(p){document.getElementById('w').textContent='Loading…';
fetch('https://api.open-meteo.com/v1/forecast?latitude='+p.lat+'&longitude='+p.lon+'&current=temperature_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto&forecast_days=6').then(function(r){return r.json()}).then(function(j){
var c=j.current,d=j.daily;document.getElementById('w').innerHTML='<div class="m">'+esc(p.name)+'</div><div class="big">'+Math.round(c.temperature_2m)+'&deg;F</div><div>'+(CODES[c.weather_code]||'')+', wind '+Math.round(c.wind_speed_10m)+' mph</div><div class="d" style="margin-top:10px">'+d.time.map(function(t,i){return '<div><div class="m">'+(i?new Date(t+'T12:00').toLocaleDateString([], {weekday:'short'}):'Today')+'</div><div>'+Math.round(d.temperature_2m_max[i])+'&deg;</div><div class="m">'+Math.round(d.temperature_2m_min[i])+'&deg;</div></div>'}).join('')+'</div>'}).catch(function(){document.getElementById('w').textContent='Could not load the weather.'})}
document.getElementById('f').onsubmit=function(e){e.preventDefault();var q=document.getElementById('city').value.trim();if(!q)return;
fetch('https://geocoding-api.open-meteo.com/v1/search?count=1&name='+encodeURIComponent(q)).then(function(r){return r.json()}).then(function(j){var g=j.results&&j.results[0];if(!g){document.getElementById('w').textContent='City not found.';return}
var p={name:g.name+(g.admin1?', '+g.admin1:''),lat:g.latitude,lon:g.longitude};hub.storage.set('place',p);weather(p)})};
hub.storage.get('place').then(function(p){if(p)weather(p)});
hub.storage.get('verse').then(function(v){if(v&&v.day===today()){showV(v);return}
fetch('https://bible-api.com/?random=verse').then(function(r){return r.json()}).then(function(j){var n={day:today(),text:String(j.text||'').trim(),ref:j.reference||''};hub.storage.set('verse',n);showV(n)}).catch(function(){document.getElementById('verse').textContent='Could not load a verse today.'})});
function showV(v){document.getElementById('verse').textContent=v.text;document.getElementById('ref').textContent=v.ref}
</script></body></html>
+92
View File
@@ -0,0 +1,92 @@
# NE Hub 2.0 roadmap
A family-first hub. Status: `[ ]` planned, `[~]` in progress, `[x]` done. Size: S (a day or less), M (a few days), L (a week or more).
Decided: no photos tab, no voice or video calling (text messaging only).
## 1. Family Chat (a built-in messenger; text only)
A messenger inside the hub for the family, so family chats do not need Signal. Every hub account is a contact, and a "Family" group exists from the start. Family chats and Signal chats are meant to show in one inbox (labelled by source; not built yet: for now Chat and Signal are separate tabs); people outside the family still use Signal. Messages are kept on the family's own server.
**Phase 1: the basics (L): done (chat.js, public/chat-view.js; the Chat tab)**
- [x] Direct chats and group chats (names, members, add and remove people)
- [x] Text with links, emoji and line breaks
- [x] Attachments: photos, video, files (stored on the server)
- [x] Replies (quote), reactions
- [x] Unread counts, search, drafts that survive closing the app
- [x] Live delivery in the open app, push notifications in the background
- [x] Mute and archive per chat
**Phase 2: feels like Signal (M): done**
- [x] Edit and delete for everyone
- [x] Sent, delivered and read ticks
- [x] Typing indicators and online presence
- [x] Disappearing messages (a timer per chat)
- [x] @mentions in groups, pinned messages, forward a message
**Phase 3: richer messages (M): done except the GIF picker and shared places**
- [x] Voice notes (record and play, with a waveform)
- [x] Link previews (fetched safely by the server)
- [x] Polls
- [ ] GIF or sticker picker (optional)
- [ ] Share a place as a link
**Family extras (M): mostly done**
- [x] Turn a message into a task, an event or a shopping item in one tap
- [x] Share a song, recipe, book or podcast episode from the hub into a chat
- [x] Pinned family announcement on Home ("dinner at 6")
- [~] Kid accounts can only message family (already true: only hub accounts can chat); quiet hours are set by each person for themselves (a parent setting them for a child is not built); sharing so far covers recipes and songs, not books or podcast episodes
**Privacy (decide first)**
- [ ] Open question: server-side storage on the family's own machine to start (TLS + encrypted disk), or end-to-end encryption from day one? Starting without E2EE and adding it later is much harder than choosing now.
- [~] Per-person notification settings (mute, quiet hours; the push kind can be turned off in the reminders settings)
Not planned: voice and video calls, stories, usernames and phone-number lookup, sealed sender.
## 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)
- [ ] **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)
- [ ] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list
## 3. Kids and safety
- [ ] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits
- [ ] **Approvals (S):** a child asks to add a book, app or extension and a parent approves
- [ ] **Simple mode (S):** a big-button layout with few tabs for young kids or grandparents
## 4. Everyday convenience
- [ ] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses
- [ ] **Continue row (S):** the last audiobook, episode, book, movie and song on Home
- [ ] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person
- [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views
- [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files
- [ ] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas
## 5. Media
- [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote
- [ ] **Podcast and audiobook stats (S):** listening history like Music has
- [ ] **Reading goals (S):** a family reading challenge with streaks; export notes and highlights
- [ ] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list.
- [ ] **Casting (M):** pick AirPlay or Cast targets for audio
## 6. Reliability (the admin's side)
- [ ] **Browser smoke tests in CI (M):** many bugs this year showed only in the interface
- [ ] **Admin health page (S):** all good or something is down: Seafile, Plex, Mealie, mail, storage
- [ ] **Backup and restore screen (S):** one-click restore, per-person export
- [ ] **Offline mode (L):** tasks, lists, recipes, saved books and music keep working with no signal (worth it if the family travels)
- [ ] **Performance pass (S):** lazy-load the big pieces (the Bible text, the reader libraries)
- [ ] **Accessibility (M):** larger text and high-contrast settings, keyboard navigation everywhere, reduced motion
## 7. Platform (later)
- [ ] **Extensions 2.0 (L):** more hub data in the bridge (notes, music, recipes), a permissions screen, extension updates
- [ ] **Automations (L):** "when X then Y" rules (a new episode adds a task, a low pantry item adds to Shopping)
- [ ] **Themes (S):** accent colours and an AMOLED dark mode per person
## Suggested order
1. Family Chat phase 1, Family Home, Shared lists and notes, Chores
2. Kid profiles, Search everywhere, Notifications center, Family Chat phase 2
3. Meal planning, Family calendar, Family Chat phase 3 and family extras
4. Reliability items alongside everything, so each step stays safe to ship
## Done already (for reference, 1.x)
Store (books, audiobooks, radio, My Store, extensions), Recipes with a shared shopping list, Contacts, Music (Featured, Mixes, Favorites, Stats, smart and Family playlists), podcast discovery and transcript search, Smashwords with a family filter, and the sidebar and Radio fixes.
+15 -2
View File
@@ -45,9 +45,22 @@ async function handle(req, res) {
return json(res, 200, { items: items.map(b => ({ ...b, cover: b.cover ? `/api/ab/free/cover/${b.id}?u=${encodeURIComponent(b.cover)}` : '' })), more: lists.flat().length >= 20 });
}
if ((m = /^\/book\/(\d+)$/.exec(p)) && req.method === 'GET') return json(res, 200, publicBook(await book(m[1])));
if ((m = /^\/audio\/(\d+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording itself is on the Internet Archive; the browser is sent straight there (it supports seeking)
if ((m = /^\/audio\/(\d+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording is on the Internet Archive; the hub fetches it (redirects and all) and passes it on, with seeking
const b = await book(m[1]), c = b.chapters[+m[2]]; if (!c) throw fail(404, 'No such chapter');
res.writeHead(302, { location: c.url, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
const ctl = new AbortController(); res.on('close', () => ctl.abort());
let url = c.url, r;
for (let i = 0; i < 6; i++) {
if (!allowed(url)) throw fail(502, 'That audio address is not allowed');
r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0', 'accept-encoding': 'identity', ...(req.headers.range ? { range: req.headers.range } : {}) }, redirect: 'manual', signal: ctl.signal }).catch(e => { throw fail(502, e.name === 'AbortError' ? 'Cancelled' : 'The Internet Archive did not answer'); });
if ([301, 302, 303, 307, 308].includes(r.status) && r.headers.get('location')) { url = new URL(r.headers.get('location'), url).href; try { await r.body?.cancel(); } catch {} continue; }
break;
}
if (r.status === 416) { res.writeHead(416, { 'content-range': r.headers.get('content-range') || 'bytes */0' }); return res.end(); }
if (!r.ok && r.status !== 206) throw fail(502, 'The Internet Archive answered ' + r.status + ' for that chapter');
const out = { 'content-type': /^audio\//.test(r.headers.get('content-type') || '') ? r.headers.get('content-type') : 'audio/mpeg', 'accept-ranges': 'bytes', 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' };
for (const k of ['content-length', 'content-range']) if (r.headers.get(k)) out[k] = r.headers.get(k);
res.writeHead(r.status, out); if (!r.body) return res.end();
return require('stream').Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res);
}
if ((m = /^\/cover\/(\d+)$/.exec(p)) && req.method === 'GET') {
let cu = u.searchParams.get('u'); if (!cu || !allowed(cu)) { const b = await book(m[1]); cu = b.cover; } if (!cu || !allowed(cu)) throw fail(404, 'No cover');
+107
View File
@@ -0,0 +1,107 @@
// Music data kept by the hub (Plex only lets the hub read the library, so what is about a person lives here): what they played (for history, stats and mixes), their
// favorites, their smart playlists (rules that pick songs from the Plex library), and Family playlists that everyone with Music can add to. Songs are remembered
// by their Plex key plus enough to show them; the player fetches the song itself from Plex when it is played.
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'), FAMILY = () => path.join(DATA_DIR, 'music-family.json');
const mine = n => path.join(users.dir(), n);
const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const write = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true }); atomicWrite(f, JSON.stringify(v), { mode: 0o600 }); };
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) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const keyOk = k => /^\d{1,12}$/.test(String(k || ''));
const newId = () => crypto.randomBytes(6).toString('hex');
const DAY = 864e5, MAX_PLAYS = 20000, MAX_FAVS = 5000, MAX_FAMILY_TRACKS = 2000;
/** A song as it is kept: its Plex key and what is needed to list it. */
function song(b) {
if (!b || !keyOk(b.key)) throw fail(400, 'That is not a song from your music library');
return { key: String(b.key), title: str(b.title, 200) || 'Song', artist: str(b.artist, 200), album: str(b.album, 200), thumb: str(b.thumb, 300), artistKey: keyOk(b.artistKey) ? String(b.artistKey) : '', albumKey: keyOk(b.albumKey) ? String(b.albumKey) : '', secs: Math.max(0, Math.min(36000, Math.round(+b.secs || (+b.dur || 0) / 1000 || 0))) };
}
// ---- what was played
const plays = () => read(mine('music-history.json'), []);
function recordPlay(b) {
const s = song(b), l = plays(), last = l[l.length - 1], now = Date.now();
if (last && last.key === s.key && now - last.t < 20000) return { ok: true, duplicate: true }; // (the same song counted twice by a reload)
l.push({ ...s, t: now }); write(mine('music-history.json'), l.slice(-MAX_PLAYS)); return { ok: true };
}
const since = (l, days) => days ? l.filter(p => p.t >= Date.now() - days * DAY) : l;
const top = (l, kf, mk, n) => { const m = new Map(); for (const p of l) { const k = kf(p); if (!k) continue; const e = m.get(k) || { ...mk(p), plays: 0, secs: 0 }; e.plays++; e.secs += p.secs || 0; m.set(k, e); } return [...m.values()].sort((a, b) => b.plays - a.plays || b.secs - a.secs).slice(0, n).map(e => ({ ...e, minutes: Math.round(e.secs / 60) })); };
const dayKey = t => { const d = new Date(t); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
/** Summary numbers for a stretch of time: totals, top artists, songs and albums, minutes per day (or per month for a long stretch), and the listening streak. */
function stats(range) {
const days = { week: 7, month: 30, year: 365, all: 0 }[range] ?? 30, all = plays(), l = since(all, days);
const perDay = new Map(); for (const p of l) perDay.set(dayKey(p.t), (perDay.get(dayKey(p.t)) || 0) + (p.secs || 0) / 60);
const series = []; if (days && days <= 31) for (let i = days - 1; i >= 0; i--) { const k = dayKey(Date.now() - i * DAY); series.push({ d: k, minutes: Math.round(perDay.get(k) || 0) }); }
else { const m = new Map(); for (const [k, v] of perDay) m.set(k.slice(0, 7), (m.get(k.slice(0, 7)) || 0) + v); const keys = [...m.keys()].sort(); for (const k of (days ? keys.slice(-12) : keys.slice(-24))) series.push({ d: k, minutes: Math.round(m.get(k)) }); }
const played = new Set(all.map(p => dayKey(p.t))); let streak = 0; for (let i = played.has(dayKey(Date.now())) ? 0 : 1; played.has(dayKey(Date.now() - i * DAY)); i++) streak++;
return { range, plays: l.length, minutes: Math.round(l.reduce((n, p) => n + (p.secs || 0), 0) / 60), artistsHeard: new Set(l.map(p => p.artist).filter(Boolean)).size, streak,
artists: top(l, p => p.artist, p => ({ name: p.artist, key: p.artistKey, thumb: p.thumb }), 10), songs: top(l, p => p.key, p => ({ key: p.key, title: p.title, artist: p.artist, thumb: p.thumb }), 10),
albums: top(l, p => p.albumKey || p.album, p => ({ key: p.albumKey, title: p.album, artist: p.artist, thumb: p.thumb }), 8), series };
}
// ---- favorites
const favs = () => read(mine('music-favs.json'), []);
const saveFavs = l => write(mine('music-favs.json'), l.slice(-MAX_FAVS));
// ---- smart playlists: rules the player turns into a question for the Plex library
const GENRE_OK = /^[\w .&'/+-]{1,60}$/;
function rules(r = {}) {
const n = (v, lo, hi) => { v = Math.round(+v); return Number.isFinite(v) && v >= lo && v <= hi ? v : 0; };
return { genres: (Array.isArray(r.genres) ? r.genres : []).map(String).filter(g => GENRE_OK.test(g)).slice(0, 10), yearFrom: n(r.yearFrom, 1000, 2100), yearTo: n(r.yearTo, 1000, 2100), addedDays: n(r.addedDays, 1, 3650), notPlayedDays: n(r.notPlayedDays, 1, 3650), minPlays: n(r.minPlays, 1, 1000),
onlyFavorites: !!r.onlyFavorites, order: ['random', 'newest', 'mostPlayed', 'leastRecent'].includes(r.order) ? r.order : 'random', limit: n(r.limit, 5, 500) || 50 };
}
const smart = () => read(mine('music-smart.json'), []);
// ---- Family playlists
const family = () => read(FAMILY(), []);
const saveFamily = l => write(FAMILY(), l);
const view = p => ({ id: p.id, name: p.name, owner: p.owner, ownerName: p.ownerName, created: p.created, count: p.tracks.length, tracks: p.tracks });
const canChange = (p, me) => me && (me.role === 'admin' || p.owner === me.id);
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/music'.length) || '/', me = users.current(), q = u.searchParams;
try {
let m;
if (p === '/played' && req.method === 'POST') return json(res, 200, recordPlay(JSON.parse(await readBody(req, 2e4) || '{}')));
if (p === '/history' && req.method === 'GET') { const n = Math.min(200, Math.max(1, +q.get('limit') || 40)); return json(res, 200, plays().slice(-n).reverse()); }
if (p === '/stats' && req.method === 'GET') return json(res, 200, stats(q.get('range') || 'month'));
if (p === '/top' && req.method === 'GET') { const days = Math.min(3650, Math.max(0, +q.get('days') || 30)); return json(res, 200, top(since(plays(), days), x => x.key, x => ({ key: x.key, title: x.title, artist: x.artist }), Math.min(200, +q.get('limit') || 50))); }
if (p === '/history' && req.method === 'DELETE') { write(mine('music-history.json'), []); return json(res, 200, { ok: true }); }
// favorites
if (p === '/favs' && req.method === 'GET') return json(res, 200, favs().reverse());
if (p === '/favs' && req.method === 'POST') { const s = song(JSON.parse(await readBody(req, 2e4) || '{}')), l = favs().filter(x => x.key !== s.key); l.push({ ...s, t: Date.now() }); saveFavs(l); return json(res, 200, { ok: true }); }
if ((m = /^\/favs\/(\d{1,12})$/.exec(p)) && req.method === 'DELETE') { saveFavs(favs().filter(x => x.key !== m[1])); return json(res, 200, { ok: true }); }
// smart playlists
if (p === '/smart' && req.method === 'GET') return json(res, 200, smart());
if (p === '/smart' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), name = str(b.name, 60); if (!name) throw fail(400, 'Give the playlist a name');
const l = smart(), old = b.id ? l.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Playlist not found'); if (!old && l.length >= 50) throw fail(400, 'That is plenty of smart playlists');
const e = old || { id: newId() }; Object.assign(e, { name, rules: rules(b.rules) }); if (!old) l.push(e); write(mine('music-smart.json'), l); return json(res, 200, e);
}
if ((m = /^\/smart\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { write(mine('music-smart.json'), smart().filter(x => x.id !== m[1])); return json(res, 200, { ok: true }); }
// family playlists
if (p === '/family' && req.method === 'GET') return json(res, 200, family().map(view));
if (p === '/family' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 2e5) || '{}'), name = str(b.name, 60); if (!name) throw fail(400, 'Give the playlist a name'); const l = family(); if (l.length >= 100) throw fail(400, 'That is plenty of family playlists');
const e = { id: newId(), name, owner: me.id, ownerName: me.name, created: Date.now(), tracks: (Array.isArray(b.tracks) ? b.tracks : []).slice(0, 500).map(t => ({ ...song(t), by: me.name })) }; l.push(e); saveFamily(l); return json(res, 200, view(e));
}
if ((m = /^\/family\/([0-9a-f]{12})(?:\/(add|tracks\/(\d{1,12})))?$/.exec(p))) {
const l = family(), e = l.find(x => x.id === m[1]); if (!e) throw fail(404, 'Playlist not found');
if (!m[2] && req.method === 'GET') return json(res, 200, view(e));
if (!m[2] && req.method === 'POST') { if (!canChange(e, me)) throw fail(403, 'Only the person who made it, or an admin, can rename it'); const name = str(JSON.parse(await readBody(req, 1e4) || '{}').name, 60); if (!name) throw fail(400, 'Give the playlist a name'); e.name = name; saveFamily(l); return json(res, 200, view(e)); }
if (!m[2] && req.method === 'DELETE') { if (!canChange(e, me)) throw fail(403, 'Only the person who made it, or an admin, can delete it'); saveFamily(l.filter(x => x !== e)); return json(res, 200, { ok: true }); }
if (m[2] === 'add' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e5) || '{}'), add = (Array.isArray(b.tracks) ? b.tracks : []).slice(0, 500).map(t => ({ ...song(t), by: me.name })); let n = 0;
for (const t of add) if (!e.tracks.some(x => x.key === t.key) && e.tracks.length < MAX_FAMILY_TRACKS) { e.tracks.push(t); n++; } saveFamily(l); return json(res, 200, { ...view(e), added: n }); }
if (m[3] && req.method === 'DELETE') { e.tracks = e.tracks.filter(x => x.key !== m[3]); saveFamily(l); return json(res, 200, view(e)); } // (anyone in the family may take a song off)
}
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 }); }
}
module.exports = { handle, stats, rules, song };
+167
View File
@@ -0,0 +1,167 @@
// My Store: a catalogue of your own content that everyone with the Store tab can use. The admin adds books, music, apps (files to download) and extensions
// (small web apps that add features to the hub), by uploading files or copying them from their Seafile. Books go onto a person's shelf in one tap, music plays
// in the hub's player, apps download, and extensions run in a sandbox of their own (an iframe with no access to the hub's sign-in or data; it gets a small
// message bridge for its own saved settings, below). Items and files live in the data folder, shared by everyone.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const { pipeline } = require('stream/promises');
const atomicWrite = require('./atomic');
const users = require('./users'), settings = require('./settings'), books = require('./books');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const ROOT = () => path.join(DATA_DIR, 'mystore'), ITEMS = () => path.join(ROOT(), 'items.json'), FILES = () => path.join(ROOT(), 'files');
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const MAX_FILE = 1024 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_EXT = 2 * 1024 * 1024, MAX_STATE = 256 * 1024, MAX_FILES_PER_ITEM = 200;
const KINDS = ['book', 'music', 'app', 'extension', 'link'];
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write']; // what an extension may ask the person for
const BOOK_EXT = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'docx'];
const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw3: 'application/vnd.amazon.mobi8-ebook', azw: 'application/vnd.amazon.ebook', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', html: 'text/html; charset=utf-8', apk: 'application/vnd.android.package-archive', zip: 'application/zip', dmg: 'application/x-apple-diskimage', exe: 'application/vnd.microsoft.portable-executable', pkg: 'application/octet-stream', txt: 'text/plain; charset=utf-8' };
const IMAGE = { jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };
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) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const idOk = s => /^[0-9a-f]{12}$/.test(s || '');
const newId = () => crypto.randomBytes(6).toString('hex');
const extOf = n => (/\.([A-Za-z0-9]{1,8})$/.exec(n) || [])[1] ? (/\.([A-Za-z0-9]{1,8})$/.exec(n))[1].toLowerCase() : '';
const safeName = n => str(path.basename(String(n || 'file')), 160).replace(/[\\/:*?"<>|]+/g, '_') || 'file';
const needAdmin = me => { if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can change the store'); };
const loadItems = () => { try { const j = JSON.parse(fs.readFileSync(ITEMS(), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } };
const saveItems = l => { fs.mkdirSync(FILES(), { recursive: true, mode: 0o700 }); atomicWrite(ITEMS(), JSON.stringify(l, null, 1), { mode: 0o600 }); };
const fileOn = (item, fid) => path.join(FILES(), item.id + '-' + fid);
const coverOn = item => path.join(FILES(), item.id + '-cover');
const view = it => ({ id: it.id, kind: it.kind, title: it.title, creator: it.creator, description: it.description, version: it.version, url: it.url, cover: !!it.cover, added: it.added, updated: it.updated,
files: it.files.map(f => ({ id: f.id, name: f.name, size: f.size, ext: f.ext })), ...(it.kind === 'extension' || it.kind === 'link' ? { icon: it.icon || '' } : {}), ...(it.kind === 'link' ? { embed: !!it.embed } : {}) });
// ---- the per-person bits: which extensions are installed, and each extension's saved settings
const stateFile = () => path.join(users.dir(), 'mystore.json');
const stateOf = () => { try { const j = JSON.parse(fs.readFileSync(stateFile(), 'utf8')); return { installed: Array.isArray(j.installed) ? j.installed : [], data: j.data && typeof j.data === 'object' ? j.data : {}, grants: j.grants && typeof j.grants === 'object' ? j.grants : {} }; } catch { return { installed: [], data: {}, grants: {} }; } };
const saveState = s => atomicWrite(stateFile(), JSON.stringify(s), { mode: 0o600 });
/** Stream a stored file, with Range support so audio can be skipped through. */
function send(req, res, file, type, name, download) {
let st; try { st = fs.statSync(file); } catch { return json(res, 404, { error: 'Not found' }); }
const h = { 'content-type': type, 'accept-ranges': 'bytes', 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff' };
if (download) h['content-disposition'] = `attachment; filename="${name.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '')}"; filename*=UTF-8''${encodeURIComponent(name)}`;
const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
if (m && (m[1] || m[2])) {
let a = m[1] ? +m[1] : Math.max(0, st.size - +m[2]), b = m[1] && m[2] ? Math.min(+m[2], st.size - 1) : st.size - 1;
if (a > b || a >= st.size) { res.writeHead(416, { 'content-range': 'bytes */' + st.size }); return res.end(); }
res.writeHead(206, { ...h, 'content-range': `bytes ${a}-${b}/${st.size}`, 'content-length': b - a + 1 }); return fs.createReadStream(file, { start: a, end: b }).pipe(res);
}
res.writeHead(200, { ...h, 'content-length': st.size }); fs.createReadStream(file).pipe(res);
}
/** Save a request body to a file, refusing more than `max` bytes. */
async function receive(req, file, max) {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
if ((+req.headers['content-length'] || 0) > max) throw fail(413, 'That file is too large');
const tmp = file + '.part'; let n = 0;
req.on('data', c => { n += c.length; if (n > max) req.destroy(fail(413, 'That file is too large')); });
try { await pipeline(req, fs.createWriteStream(tmp, { mode: 0o600 })); } catch (e) { fs.rm(tmp, { force: true }, () => {}); throw e.code === 413 ? e : fail(e.code === 413 ? 413 : 400, e.message || 'The upload did not finish'); }
fs.renameSync(tmp, file); return n;
}
// ---- copying a file from the admin's Seafile (the Seafile sign-in the hub already holds for them)
async function fromSeafile(repo, p) {
const token = settings.get('sf_token'); if (!token) throw fail(401, 'Open Files and sign in first, then try again');
if (!/^[\w-]{8,64}$/.test(repo) || !p || !p.startsWith('/') || p.split('/').includes('..')) throw fail(400, 'Choose a file');
const call = async url => fetch(url, { headers: { Authorization: 'Token ' + token }, signal: AbortSignal.timeout(30000) });
const lr = await call(`${SEAFILE()}/api2/repos/${repo}/file/?p=${encodeURIComponent(p)}&reuse=1`);
if (lr.status === 401) throw fail(401, 'Your Files sign-in has expired. Open Files and sign in again.'); if (!lr.ok) throw fail(lr.status === 404 ? 404 : 502, lr.status === 404 ? 'That file was not found in Files' : 'Files answered ' + lr.status);
const link = new URL(JSON.parse(await lr.text()), SEAFILE());
const r = await fetch(link, { signal: AbortSignal.timeout(30 * 60e3) }); if (!r.ok || !r.body) throw fail(502, 'Files would not send that file (' + r.status + ')');
if ((+r.headers.get('content-length') || 0) > MAX_FILE) throw fail(413, 'That file is too large');
return { stream: require('stream').Readable.fromWeb(r.body), name: safeName(p.split('/').pop()) };
}
// ---- extensions: one self-contained HTML file, run in a sandboxed frame with this Content-Security-Policy (no hub cookies, no hub address, no other frames)
const EXT_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob: https:; media-src data: blob: https:; font-src data: https:; connect-src https:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'";
const EXT_META = '<meta http-equiv="Content-Security-Policy" content="' + EXT_CSP.replace(/^sandbox [^;]*; /, '').replace(/; frame-ancestors 'self'$/, '') + '">'; // (the same rules, for when the page is shown from the hub's own copy of it)
const BRIDGE = EXT_META + `<script>(function(){var n=0,w={};window.addEventListener('message',function(e){var m=e.data;if(m&&m.hub&&w[m.id]){var f=w[m.id];delete w[m.id];m.error?f[1](new Error(m.error)):f[0](m.result)}});
function call(method,args){return new Promise(function(ok,no){var id=++n;w[id]=[ok,no];parent.postMessage({hub:1,id:id,method:method,args:args||[]},'*')})}
window.hub={storage:{get:function(k){return call('storage.get',[k])},set:function(k,v){return call('storage.set',[k,v])},all:function(){return call('storage.all')},remove:function(k){return call('storage.remove',[k])}},toast:function(t){return call('toast',[String(t)])},user:function(){return call('user')},open:function(u){return call('open',[String(u)])},
tasks:{lists:function(){return call('tasks.lists')},list:function(l){return call('tasks.list',[{list:l}])},add:function(o){return call('tasks.add',[o])},complete:function(l,id){return call('tasks.complete',[{list:l,id:id}])}},
contacts:{list:function(){return call('contacts.list')},add:function(o){return call('contacts.add',[o])}},
calendar:{events:function(from,to){return call('calendar.events',[{from:from,to:to}])},add:function(o){return call('calendar.add',[o])}}}})()</script>`;
const withBridge = html => /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, m => m + BRIDGE) : BRIDGE + html;
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/store/my'.length) || '/', me = users.current();
try {
let m;
if (p === '/items' && req.method === 'GET') return json(res, 200, { items: loadItems().sort((a, b) => b.added - a.added).map(view), installed: stateOf().installed, admin: !!me && me.role === 'admin' });
if (p === '/items' && req.method === 'POST') { // add or change an item's details (admin)
needAdmin(me); const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = loadItems(), old = b.id ? l.find(x => x.id === b.id) : null;
if (b.id && !old) throw fail(404, 'Item not found'); if (!old && !KINDS.includes(b.kind)) throw fail(400, 'Choose what kind of thing this is');
const title = str(b.title, 120); if (!title) throw fail(400, 'Give it a title');
const url = str(b.url, 300); if (url && !/^https?:\/\/\S+$/i.test(url)) throw fail(400, 'The link must start with http');
if ((old ? old.kind : b.kind) === 'link' && !url) throw fail(400, 'Paste the address of the site');
const it = old || { id: newId(), kind: b.kind, files: [], cover: false, added: Date.now() };
Object.assign(it, { title, creator: str(b.creator, 120), description: str(b.description, 2000), version: str(b.version, 30), url, updated: Date.now() });
if (it.kind === 'extension' || it.kind === 'link') it.icon = str(b.icon, 4);
if (it.kind === 'link') it.embed = !!b.embed;
if (!old) { if (l.length >= 2000) throw fail(400, 'That is plenty of items'); l.push(it); } saveItems(l); return json(res, 200, view(it));
}
if ((m = /^\/items\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') {
needAdmin(me); const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Item not found');
for (const f of it.files) fs.rmSync(fileOn(it, f.id), { force: true }); fs.rmSync(coverOn(it), { force: true }); saveItems(l.filter(x => x !== it)); return json(res, 200, { ok: true });
}
if ((m = /^\/items\/([0-9a-f]{12})\/files$/.exec(p)) && req.method === 'POST') { // upload one file (the body is the file; its name is in ?name=) or copy one from Seafile (JSON { repo, path })
needAdmin(me); const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Item not found');
if (it.files.length >= MAX_FILES_PER_ITEM) throw fail(400, 'That is plenty of files for one item');
if (it.kind === 'link') throw fail(400, 'A site has no files'); if (it.kind !== 'music' && it.kind !== 'app' && it.files.length >= 1) throw fail(400, 'This kind of item holds one file. Remove the old one first.');
const fid = newId(); let name, size;
if (/json/.test(req.headers['content-type'] || '')) { const b = JSON.parse(await readBody(req, 1e4) || '{}'), src = await fromSeafile(String(b.repo || ''), String(b.path || '')); name = src.name; const tmp = fileOn(it, fid) + '.part'; fs.mkdirSync(FILES(), { recursive: true, mode: 0o700 }); let n = 0; src.stream.on('data', c => { n += c.length; if (n > MAX_FILE) src.stream.destroy(fail(413, 'That file is too large')); }); await pipeline(src.stream, fs.createWriteStream(tmp, { mode: 0o600 })).catch(e => { fs.rm(tmp, { force: true }, () => {}); throw e.code ? e : fail(502, 'The copy did not finish'); }); fs.renameSync(tmp, fileOn(it, fid)); size = n; }
else { name = safeName(u.searchParams.get('name')); size = await receive(req, fileOn(it, fid), it.kind === 'extension' ? MAX_EXT : MAX_FILE); }
const ext = extOf(name);
const bad = it.kind === 'book' && !BOOK_EXT.includes(ext) ? 'A book must be an EPUB, PDF, MOBI, AZW3 or DOCX file' : it.kind === 'music' && !/^(mp3|m4a|aac|flac|ogg|opus|wav)$/.test(ext) ? 'Music must be an MP3, M4A, AAC, FLAC, OGG, OPUS or WAV file' : it.kind === 'extension' && ext !== 'html' ? 'An extension is one .html file' : '';
if (bad) { fs.rm(fileOn(it, fid), { force: true }, () => {}); throw fail(400, bad); }
it.files.push({ id: fid, name, size, ext }); it.updated = Date.now(); saveItems(l); return json(res, 200, view(it));
}
if ((m = /^\/items\/([0-9a-f]{12})\/files\/([0-9a-f]{12})$/.exec(p))) {
const l = loadItems(), it = l.find(x => x.id === m[1]), f = it && it.files.find(x => x.id === m[2]); if (!f) throw fail(404, 'Not found');
if (req.method === 'DELETE') { needAdmin(me); fs.rmSync(fileOn(it, f.id), { force: true }); it.files = it.files.filter(x => x !== f); it.updated = Date.now(); saveItems(l); return json(res, 200, view(it)); }
if (req.method === 'GET') return send(req, res, fileOn(it, f.id), it.kind === 'extension' ? 'application/octet-stream' : TYPES[f.ext] || 'application/octet-stream', f.name, u.searchParams.get('dl') === '1' || !/^(audio|application\/pdf)/.test(TYPES[f.ext] || ''));
}
if ((m = /^\/items\/([0-9a-f]{12})\/cover$/.exec(p))) {
const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Not found');
if (req.method === 'POST') { // the picture is the body; its type must be a picture
needAdmin(me); const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase(); if (!Object.values(IMAGE).includes(type)) throw fail(400, 'The cover must be a JPEG, PNG, GIF or WebP picture');
await receive(req, coverOn(it), MAX_COVER); it.cover = type; it.updated = Date.now(); saveItems(l); return json(res, 200, view(it));
}
if (req.method === 'DELETE') { needAdmin(me); fs.rm(coverOn(it), { force: true }, () => {}); it.cover = false; saveItems(l); return json(res, 200, view(it)); }
if (req.method === 'GET') { if (!it.cover) throw fail(404, 'No cover'); return send(req, res, coverOn(it), it.cover, 'cover', false); }
}
if ((m = /^\/items\/([0-9a-f]{12})\/to-library$/.exec(p)) && req.method === 'POST') { // a book goes onto the person's own shelf
if (!users.can(me, 'read')) throw fail(403, 'You do not have the Books tab');
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'book' || !it.files[0]) throw fail(404, 'That is not a book');
const f = it.files[0], buf = fs.readFileSync(fileOn(it, f.id)); let cover; if (it.cover) { try { cover = { type: it.cover, data: fs.readFileSync(coverOn(it)) }; } catch {} }
try { return json(res, 200, books.addBook(buf, f.ext, { title: it.title, author: it.creator }, cover)); } catch (e) { throw fail(400, e.message); }
}
// ---- extensions
if ((m = /^\/ext\/([0-9a-f]{12})\/page$/.exec(p)) && req.method === 'GET') { // the page itself, framed by the hub
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension' || !it.files[0]) throw fail(404, 'Not found');
const html = withBridge(fs.readFileSync(fileOn(it, it.files[0].id), 'utf8'));
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': EXT_CSP, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' }); return res.end(html);
}
if ((m = /^\/ext\/([0-9a-f]{12})\/(install|uninstall)$/.exec(p)) && req.method === 'POST') {
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found');
const s = stateOf(); s.installed = s.installed.filter(x => x !== it.id); if (m[2] === 'install') s.installed.push(it.id); if (m[2] === 'uninstall') { delete s.data[it.id]; delete s.grants[it.id]; } saveState(s); return json(res, 200, { installed: s.installed });
}
if ((m = /^\/ext\/([0-9a-f]{12})\/grants$/.exec(p))) { // what the person has allowed this extension to reach in the hub
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf();
if (req.method === 'GET') return json(res, 200, { grants: s.grants[it.id] || [] });
if (req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'); s.grants[it.id] = [...new Set((Array.isArray(b.grants) ? b.grants : []).filter(x => PERMS.includes(x)))]; saveState(s); return json(res, 200, { grants: s.grants[it.id] }); }
}
if ((m = /^\/ext\/([0-9a-f]{12})\/data$/.exec(p))) { // the extension's own saved settings for this person
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf();
if (req.method === 'GET') return json(res, 200, s.data[it.id] || {});
if (req.method === 'POST') { const b = JSON.parse(await readBody(req, MAX_STATE + 1000) || '{}'); if (!b || typeof b !== 'object' || Array.isArray(b)) throw fail(400, 'Bad data'); if (JSON.stringify(b).length > MAX_STATE) throw fail(413, 'That is more than an extension may keep (256 KB)'); s.data[it.id] = b; saveState(s); return json(res, 200, { ok: true }); }
}
return json(res, 404, { error: 'Not found' });
} catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, loadItems, EXT_CSP };
+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; 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; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
+41 -10
View File
@@ -20,6 +20,25 @@ const ALLOWED = [
const subsFile = uid => path.join(users.dirOf(uid), 'podcast-subs.json');
const subsRead = uid => { try { const j = JSON.parse(fs.readFileSync(subsFile(uid), 'utf8')); return Array.isArray(j) ? j : null; } catch { return null; } };
const subsWrite = (uid, ids) => { const f = subsFile(uid), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify([...new Set(ids)]), { mode: 0o600 }); fs.renameSync(tmp, f); };
// ---- Podcast Index (https://podcastindex.org): a free, open directory searched by topic as well as name. Needs a free key and secret (PODCASTINDEX_KEY, PODCASTINDEX_SECRET).
const PI_URL = (process.env.PODCASTINDEX_URL || 'https://api.podcastindex.org/api/1.0').replace(/\/$/, '');
const PI_TOPICS = ['News', 'Technology', 'Business', 'Science', 'History', 'Health', 'Religion', 'Education', 'Comedy', 'Sports', 'Music', 'Society'];
const piOn = () => !!(process.env.PODCASTINDEX_KEY && process.env.PODCASTINDEX_SECRET);
async function piGet(path) {
const date = String(Math.floor(Date.now() / 1000)), sig = require('crypto').createHash('sha1').update(process.env.PODCASTINDEX_KEY + process.env.PODCASTINDEX_SECRET + date).digest('hex');
const r = await fetch(PI_URL + path, { headers: { 'user-agent': 'NE-Hub/1.0', 'x-auth-key': process.env.PODCASTINDEX_KEY, 'x-auth-date': date, authorization: sig }, signal: AbortSignal.timeout(10000) });
if (!r.ok) throw new Error('status ' + r.status); return r.json();
}
const piShape = f => ({ title: f.title, author: f.author || f.ownerName || '', feedUrl: f.url, image: f.image || f.artwork || '' });
const piKeep = f => f && /^https?:\/\//i.test(f.url || '') && !f.dead;
const piSearch = async q => ((await piGet('/search/byterm?max=20&q=' + encodeURIComponent(q))).feeds || []).filter(piKeep).map(piShape);
const piTrending = async cat => ((await piGet('/podcasts/trending?max=20&lang=en&cat=' + encodeURIComponent(cat))).feeds || []).filter(piKeep).map(piShape);
async function appleSearch(q) {
const r = await fetch((process.env.APPLE_PODCASTS_URL || 'https://itunes.apple.com/search') + '?' + new URLSearchParams({ media: 'podcast', limit: '15', term: q }), { signal: AbortSignal.timeout(10000) });
if (!r.ok) throw new Error('status ' + r.status);
return ((await r.json()).results || []).filter(x => /^https?:\/\//i.test(x.feedUrl || '')).map(x => ({ title: x.collectionName, author: x.artistName, feedUrl: x.feedUrl, image: x.artworkUrl100 }));
}
const idOf = o => o && (o.ID !== undefined ? o.ID : o.id);
async function podgrab(p, init = {}) {
const r = await fetch(BASE + p, { ...init, headers: { accept: 'application/json', ...(AUTH ? { authorization: AUTH } : {}), ...init.headers }, redirect: 'manual', signal: AbortSignal.timeout(20000) });
@@ -36,16 +55,17 @@ const enabled = () => !!BASE;
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/podcast'.length) || '/';
if (p === '/discover') { // find new shows in Apple's free podcast directory (the search words go to Apple; nothing else does)
const q = (u.searchParams.get('q') || '').trim().slice(0, 100);
if (q.length < 2) return json(res, 200, []);
try {
const r = await fetch('https://itunes.apple.com/search?' + new URLSearchParams({ media: 'podcast', limit: '15', term: q }), { signal: AbortSignal.timeout(10000) });
if (!r.ok) throw new Error('status ' + r.status);
const out = ((await r.json()).results || []).filter(x => /^https?:\/\//i.test(x.feedUrl || '')).map(x => ({ title: x.collectionName, author: x.artistName, feedUrl: x.feedUrl, image: x.artworkUrl100 }));
return json(res, 200, out);
} catch { return json(res, 502, { error: 'The podcast directory did not answer' }); }
if (p === '/discover') { // find new shows: Podcast Index (free, with a key) and Apple's free directory (the search words go to them; nothing else does)
const q = (u.searchParams.get('q') || '').trim().slice(0, 100), topic = (u.searchParams.get('topic') || '').trim().slice(0, 40);
if (topic && !piOn()) return json(res, 200, []);
if (!topic && q.length < 2) return json(res, 200, []);
const [pi, ap] = await Promise.allSettled([piOn() ? (topic ? piTrending(topic) : piSearch(q)) : Promise.resolve([]), topic ? Promise.resolve([]) : appleSearch(q)]);
if (pi.status === 'rejected' && ap.status === 'rejected') return json(res, 502, { error: 'The podcast directory did not answer' });
const seen = new Set(), out = [];
for (const x of [...(pi.value || []), ...(ap.value || [])]) { const k = x.feedUrl.replace(/\/+$/, '').toLowerCase(); if (!seen.has(k)) { seen.add(k); out.push(x); } }
return json(res, 200, out.slice(0, 30));
}
if (p === '/discover/topics') return json(res, 200, piOn() ? PI_TOPICS : []);
if (p === '/progress') {
if (req.method === 'GET') return json(res, 200, prog.load());
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
@@ -142,4 +162,15 @@ async function episodeAudio(itemId) {
if (r.status >= 300 && r.status < 400 && loc && /^https?:\/\//i.test(loc)) return fetch(loc, { signal: AbortSignal.timeout(2 * 3600e3) }); // (Podgrab has no copy of its own: the original)
return r;
}
module.exports = { handle, enabled, feedInfo, recentEpisodes, episodeAudio };
/** Every episode of the shows on this person's list: [{ id, title, show }], remembered for a few minutes (the transcript search uses it to know what they may see). */
const epsCache = new Map();
async function myEpisodes(me) {
const c = epsCache.get(me.id); if (c && Date.now() - c.t < 300000) return c.v;
const f = (o, k) => { const hit = Object.keys(o || {}).find(x => x.toLowerCase() === k.toLowerCase()); return hit === undefined ? undefined : o[hit]; }, v = [];
if (BASE) for (const sid of await mySubs(me)) {
const sh = await (await podgrab('/podcasts/' + sid)).json().catch(() => null), items = await (await podgrab('/podcasts/' + sid + '/items')).json().catch(() => []);
for (const it of Array.isArray(items) ? items : []) v.push({ id: String(idOf(it)), title: f(it, 'Title') || '', show: (sh && f(sh, 'Title')) || '', thumb: '/api/podcast/podcasts/' + sid + '/image' });
}
epsCache.set(me.id, { t: Date.now(), v }); return v;
}
module.exports = { handle, myEpisodes, enabled, feedInfo, recentEpisodes, episodeAudio };
+25 -10
View File
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups']);
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups', 'music_feat_pls']);
const outbox = new Map(); let outTimer = null, warned = false;
function flushSettings() {
outTimer = null;
@@ -93,6 +93,14 @@ const ICONS = {
shuffle: '<path d="M3 7h3c4 0 5 10 9 10h6M3 17h3c1.5 0 2.5-1.5 3.5-3M15 7h6M18 4l3 3-3 3M18 14l3 3-3 3"/>',
more: '<circle cx="5" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="19" cy="12" r="1.6" fill="currentColor"/>',
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
store: '<path d="M4 9l1.5-5h13L20 9M4 9v11h16V9M4 9c0 1.7 1.3 3 3 3s3-1.3 3-3c0 1.7 1.3 3 3 3s3-1.3 3-3 1.3 3 3 3"/><path d="M10 20v-5h4v5"/>',
download: '<path d="M12 4v11M7 11l5 5 5-5M5 20h14"/>',
arrup: '<path d="M6 14l6-6 6 6"/>',
arrdn: '<path d="M6 10l6 6 6-6"/>',
bell: '<path d="M6 17V11a6 6 0 0112 0v6l1.5 2h-15zM10 21h4"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
chef: '<path d="M7 14a4 4 0 11.8-7.9A4 4 0 0116 6.5 4 4 0 0117 14v6H7z"/><path d="M7 17h10"/>',
people: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0113 0M16 4.5a3.5 3.5 0 010 7M18 14a6 6 0 013.5 6"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
@@ -160,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'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['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', 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'], ['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' };
/** 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];
@@ -171,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']], ['Communicate', ['mail', 'signal']], ['Media', ['listen', 'reading', 'video']], ['', ['settings']]];
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', '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', 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', 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); };
@@ -192,13 +200,13 @@ 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 === 'mail' ? badge(mailUnread) : ''}</a>`;
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 brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
const mine = navTabs();
if (phone.matches) {
const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active);
$('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') +
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : rest.some(t => t[0] === 'chat') ? badge(ch.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); };
} else {
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
@@ -248,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' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, 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), 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 {}; } };
@@ -316,6 +324,10 @@ function homeApps() {
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
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'),
contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'),
radio: () => tile('#/radio', '#7b4fd6', 'radio', 'Radio', 'The Journey FM & more'),
read: () => tile('#/read', '#2b8a5e', 'book', 'Books', 'EPUB, PDF, DOCX'),
signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'),
@@ -323,7 +335,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']], ['Communicate', ['mail', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video']]];
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']]];
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('');
}
@@ -337,6 +349,7 @@ function viewHome() {
$('#view').innerHTML = `<div class="wrap home">
<header class="homehead"><div><h1>${greet}</h1><p class="sub">${today}</p></div>
<div class="homeact"><button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div></header>
<div id="annc"></div>
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
${allowed('music') ? '<div id="npslot"></div>' : ''}
${evening ? '<div id="resume"></div>' : ''}
@@ -346,7 +359,9 @@ function viewHome() {
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}</div>
</div>
${evening ? '' : '<div id="resume"></div>'}
<div class="homeapps">${homeApps()}</div></div>`;
<div class="homeapps">${homeApps()}</div><div id="homeext"></div></div>`;
if (typeof stHomeExt === 'function' && allowed('store')) stHomeExt($('#homeext'));
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
if (typeof homeResume === 'function') homeResume($('#resume'));
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
+1 -1
View File
@@ -18,7 +18,7 @@
setInterval(checkForUpdate, 10 * 60e3);
await loadMe();
await loadSettings();
sgStart();
sgStart(); if (typeof chStart === 'function') chStart();
addEventListener('hashchange', route);
restorePlayer();
route();
+5
View File
@@ -124,6 +124,11 @@ function openEvent(e) {
if (sh) mo.observe(document.body, { childList: true });
return;
}
if (e.contactId) { // a birthday from Contacts
const el = openSheet(e.title, 'Birthdays', `<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button>${allowed('contacts') ? '<a class="btn" id="bco" style="flex:1;justify-content:center">Open contact</a>' : ''}</div>`);
el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; };
return;
}
if (e.editable) return editEvent(e);
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
const el = openSheet(e.title, e.cal, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div>
+342
View File
@@ -0,0 +1,342 @@
'use strict';
// ---- Chat: the family messenger (see chat.js on the server). It looks like the Signal screen on purpose and shares its styles (sg*) and helpers (sgTime, sgLink...).
const ch = { convs: [], cur: null, curConv: null, root: null, es: null, unread: 0, reply: null, tray: [], names: {}, people: null, showArchived: false, sendQ: null, found: null, typing: null, typedAt: 0, pins: [] };
const chq = q => (ch.root || document).querySelector(q);
const chApi = (p, o) => jfetch('/api/chat' + p, o);
const chPost = (p, body) => chApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const chOnView = () => location.hash.startsWith('#/chat');
const chMounted = () => !!ch.root && document.contains(ch.root);
const chDraftKey = id => 'ch_draft:' + (ME ? ME.id : '') + ':' + id;
const chGetDraft = id => { try { return localStorage.getItem(chDraftKey(id)) || ''; } catch { return ''; } };
const chSetDraft = (id, t) => { try { t ? localStorage.setItem(chDraftKey(id), t) : localStorage.removeItem(chDraftKey(id)); } catch {} };
// ---- live updates, kept running whatever screen is open (so the unread count on the menu is always right)
async function chStart() {
if (!allowed('chat') || ch.es) return;
try { const st = await chApi('/status'); ch.unread = st.unread || 0; renderNav(activeTab); } catch { return; }
ch.es = new EventSource('/api/chat/events');
ch.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } chEvent(ev); };
}
let chEvT = null;
function chEvent(ev) {
if (typeof ev.unread === 'number' && ev.unread !== ch.unread) { ch.unread = ev.unread; renderNav(activeTab); }
if (ev.t === 'msg' && !ev.out) {
const looking = chMounted() && ch.cur === ev.conv && document.visibilityState === 'visible' && document.hasFocus();
if (!looking) { toast((ev.name || 'Chat') + ': ' + (ev.preview || '')); if (typeof sgAlerts === 'function' && sgAlerts().sound && typeof sgChime === 'function') sgChime(); }
}
if (ev.t === 'announce' && typeof chAnnounceCard === 'function') chAnnounceCard(document.getElementById('annc'));
if (ev.t === 'typing') { if (chMounted() && ch.cur === ev.conv) { ch.typing = { conv: ev.conv, name: ev.name, until: Date.now() + 4500 }; chPaintSub(); setTimeout(chPaintSub, 4600); } return; }
if (!chMounted()) return;
clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150);
}
function viewChat(args = []) { ch.cur = args[0] ? decodeURIComponent(args[0]) : null; return chMount($('#view')); }
async function chMount(root) {
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty">Loading…</p></div>';
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
await chRefresh(true);
}
async function chRefresh(first) {
try {
const r = await chApi('/convs'); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab);
if (!chq('#chlist')) return; chDrawList();
if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '<p class="empty">Choose a chat, or start a new one.</p>';
} catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
}
async function chSearchMessages() {
const q = (chq('#chq') || {}).value ? chq('#chq').value.trim() : ''; if (q.length < 2) { ch.found = null; return chDrawList(); }
try { const r = await chApi('/search?q=' + encodeURIComponent(q)); if ((chq('#chq') || {}).value.trim() === q) { ch.found = r.results; chDrawList(); } } catch { /* the list still works */ }
}
function chRow(c) {
return `<a class="row sgrow ${c.id === ch.cur ? 'on' : ''}" data-id="${esc(c.id)}" href="#/chat/${encodeURIComponent(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span>
<span class="grow"><b>${esc(c.name)}${c.muted ? ' 🔕' : ''}</b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.preview || c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static${c.muted ? ';opacity:.5' : ''}">${c.unread}</i>` : ''}</span></a>`;
}
function chDrawList() {
const q = (chq('#chq') || {}).value ? chq('#chq').value.toLowerCase().trim() : '', box = chq('#chlist'); if (!box) return;
const match = c => !q || c.name.toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q), live = ch.convs.filter(c => !c.archived && match(c)), arch = ch.convs.filter(c => c.archived && match(c));
const found = q.length >= 2 && ch.found ? ch.found : [];
box.innerHTML = live.map(chRow).join('') + (arch.length ? `<button class="row" id="charch"><span class="grow"><small>${ch.showArchived || q ? '▾' : '▸'} Archived (${arch.length})</small></span></button>${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '')
+ (found.length ? `<p class="sub" style="margin:12px 12px 4px">In messages</p>${found.map(f => `<a class="row sgrow" href="#/chat/${encodeURIComponent(f.conv)}"><span class="grow"><b>${esc(f.convName)}</b><small>${esc(f.msg.name)}: ${esc(f.msg.text)}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|| '';
if (!box.innerHTML) box.innerHTML = `<p class="empty">${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}</p>`;
const a = box.querySelector('#charch'); if (a) a.onclick = () => { ch.showArchived = !ch.showArchived; chDrawList(); };
box.oncontextmenu = e => { const r = e.target.closest('[data-id]'); if (r) { e.preventDefault(); chChatMenu(r.dataset.id); } };
}
/** @Name in a message stands out (and means that person gets an alert even if they muted the group). */
const chMention = html => { const names = Object.values(ch.names || {}).filter(Boolean).sort((a, b) => b.length - a.length); let out = html; for (const n of names) out = out.replace(new RegExp('(^|\\s)@' + esc(n).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w])', 'gi'), (x, pre) => pre + '<b class="mention">@' + esc(n) + '</b>'); return out; };
const chDur = s => { s = Math.round(+s || 0); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function chPoll(m) {
const P = m.poll, votes = P.votes || {}, count = id => Object.values(votes).filter(v => v.includes(id)).length, total = Object.keys(votes).length, mine = (ME && votes[ME.id]) || [];
return `<div class="sgpoll"><b class="sgpq" title="Who voted">📊 ${esc(P.q)}</b><small>${P.multi ? 'Choose any' : 'Choose one'}${P.closed ? ' · closed' : ''}</small>
${P.options.map(o => { const n = count(o.id); return `<button type="button" class="sgopt ${mine.includes(o.id) ? 'on' : ''}" data-o="${esc(o.id)}" ${P.closed ? 'disabled' : ''}><i style="width:${total ? Math.round(n / total * 100) : 0}%"></i><span>${mine.includes(o.id) ? '✓ ' : ''}${esc(o.text)}</span><small>${n}</small></button>`; }).join('')}
<small>${total} ${total === 1 ? 'person has' : 'people have'} voted</small></div>`;
}
function chBubble(m, c, prev) {
const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id;
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
: a.voice ? `<div class="sgvoice" data-src="${u}" data-dur="${a.dur || 0}"><button type="button" class="ibtn sgvp" aria-label="Play">${icon('play')}</button><span class="sgbars">${(a.wave && a.wave.length ? a.wave : Array(28).fill(30)).map(v => `<i style="height:${Math.max(12, v)}%"></i>`).join('')}</span><small class="sgvt">${chDur(a.dur)}</small></div>`
: /^audio\//.test(a.type || '') ? `<audio controls preload="none" src="${u}"></audio>` : /^video\//.test(a.type || '') ? `<video controls preload="metadata" playsinline src="${u}"></video>`
: `<a class="sgfile" href="${u}?dl=1" target="_blank" rel="noopener">${icon('clip')}<span>${esc(a.name || 'file')}${a.size ? ' · ' + sgSize(a.size) : ''}</span></a>`; }).join('');
const reacts = {}; Object.values(m.react || {}).forEach(e => { reacts[e] = (reacts[e] || 0) + 1; });
const day = !prev || new Date(prev.ts).toDateString() !== new Date(m.ts).toDateString() ? `<div class="sgday">${new Date(m.ts).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}</div>` : '';
if (m.sys) return `${day}<div class="sgday sgsys">${esc(m.text)}</div>`;
const tick = m.out && m.status ? `<span class="${m.status === 'read' ? 'rd' : ''}">${m.status === 'sent' ? '✓' : '✓✓'}</span>` : '';
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-id="${esc(m.id)}">${m.fwd ? `<small class="sgfwd">↪ Forwarded from ${esc(m.fwd.name)}</small>` : ''}${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name)}</small>` : ''}
${m.poll ? chPoll(m) : ''}${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${chMention(sgLink(esc(m.text)).replace(/\n/g, '<br>'))}</div>` : ''}
${m.link && !m.deleted ? `<a class="sgprev" href="${esc(m.link.url)}" target="_blank" rel="noopener noreferrer">${m.link.image ? `<img alt="" loading="lazy" src="${esc(stOk(m.link.image))}" onerror="this.remove()">` : ''}<span><small>${esc(m.link.site || new URL(m.link.url).hostname)}</small><b>${esc(m.link.title)}</b>${m.link.desc ? `<em>${esc(m.link.desc)}</em>` : ''}</span></a>` : ''}
<span class="sgmeta">${m.exp ? '⏱ ' : ''}${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ' ' + tick : ''}</span>
${Object.keys(reacts).length ? `<span class="sgre">${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')}</span>` : ''}</div>`;
}
async function chDrawThread(first) {
const box = chq('#chm'); if (!box) return;
const id = ch.cur; let r;
try { r = await chApi('/conv/' + encodeURIComponent(id)); } catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
if (ch.cur !== id || !chq('#chm')) return; ch.names = r.names || {};
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
if (!box.querySelector('.sgbar')) {
box.innerHTML = `<div class="sgbar"><a class="ibtn sgback" href="#/chat" aria-label="Back to chats">${icon('back')}</a><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b id="chtitle"></b><small id="chsub"></small></span><button class="ibtn" id="chmore" aria-label="Chat options">${icon('more')}</button></div>
<button type="button" class="sgpin" id="chpin" hidden></button><div class="sgmsgs" id="chmsgs"></div><div id="chrep" class="sgrep" hidden></div><div id="chtray" class="sgtray" hidden></div>
<form class="sgc" id="chform"><button type="button" class="ibtn" id="chat" aria-label="Attach">${icon('clip')}</button><input type="file" id="chfile" multiple hidden>
<textarea id="chtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button type="button" class="ibtn" id="chem" aria-label="Emoji">😊</button><button type="button" class="ibtn" id="chmic" aria-label="Record a voice message">${icon('mic')}</button><button class="ibtn sgsend" id="chsend" aria-label="Send">${icon('send')}</button></form>`;
chWireComposer(); chq('#chmore').onclick = () => chChatMenu(ch.cur); const ta = chq('#chtext'); ta.value = chGetDraft(id); ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
}
ch.curConv = c; ch.names = Object.fromEntries(c.members.map(x => [x.id, x.name])); ch.pins = r.pinned || []; chq('#chtitle').textContent = c.name; chPaintSub(); chDrawPin();
const list = r.msgs, el = chq('#chmsgs');
el.innerHTML = (r.more ? '<p class="sub" style="text-align:center">Older messages are not shown here</p>' : '') + list.map((m, i) => chBubble(m, c, list[i - 1])).join('') || '<p class="empty">No messages yet. Say hello.</p>';
if (first || stick) el.scrollTop = el.scrollHeight;
el.onclick = e => {
const b = e.target.closest('.sgb'), m0 = b && list.find(m => m.id === b.dataset.id), opt = e.target.closest('.sgopt'), vc = e.target.closest('.sgvoice'), pq = e.target.closest('.sgpq');
if (opt && m0) return chVote(m0, opt.dataset.o); if (vc) return chPlayVoice(vc); if (pq && m0) return chPollWho(m0);
if (!b || e.target.closest('a, audio, video')) return; chMessageMenu(m0, c);
};
if (c.unread) chPost('/read', { id }).then(() => { const x = ch.convs.find(q => q.id === id); if (x) x.unread = 0; chDrawList(); }).catch(() => {});
}
function chMessageMenu(m, c) {
if (!m || m.deleted) return;
const mine = m.out, el = openSheet('', '', `<div class="sgemo">${['👍', '❤️', '😂', '😮', '😢', '🙏'].map(e => `<button class="ibtn" data-e="${e}" aria-label="React ${e}">${e}</button>`).join('')}</div>
<div class="list" style="background:var(--panel2);border:0;margin-top:8px"><button class="row" id="chr">${icon('back')}<span class="grow"><b>Reply</b></span></button>${m.text ? `<button class="row" id="chcp">${icon('copy')}<span class="grow"><b>Copy text</b></span></button>` : ''}
${m.text && allowed('tasks') ? `<button class="row" id="chtk">${icon('check')}<span class="grow"><b>Make a task</b></span></button>` : ''}${m.text && allowed('recipes') ? `<button class="row" id="chsh">${icon('chef')}<span class="grow"><b>Add to the shopping list</b></span></button>` : ''}${m.text && allowed('calendar') ? `<button class="row" id="chev">${icon('cal')}<span class="grow"><b>Add to the calendar</b></span></button>` : ''}
<button class="row" id="chfw">${icon('send')}<span class="grow"><b>Forward</b></span></button><button class="row" id="chpn">${icon('bookmark')}<span class="grow"><b>${ch.pins.some(p => p.id === m.id) ? 'Unpin' : 'Pin'}</b></span></button>
${mine && m.text ? `<button class="row" id="ched">${icon('edit')}<span class="grow"><b>Edit</b></span></button>` : ''}${mine ? `<button class="row" id="chdl">${icon('trash')}<span class="grow"><b style="color:var(--danger)">Delete for everyone</b></span></button>` : ''}</div>`);
el.querySelectorAll('[data-e]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/react', { conv: c.id, msg: m.id, emoji: (m.react && ME && m.react[ME.id]) === b.dataset.e ? '' : b.dataset.e }); } catch (e) { toast(e.message); } });
el.querySelector('#chr').onclick = () => { closeSheet(); ch.reply = m; chDrawReply(); chq('#chtext').focus(); };
const tk = el.querySelector('#chtk'); if (tk) tk.onclick = async () => { closeSheet(); try { await jfetch('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ list: 'local:inbox', fields: { title: m.text.slice(0, 200), notes: 'From Chat (' + m.name + ')', due: '', done: false } }) }); toast('Added to your tasks', { label: 'Open', run: () => { location.hash = '#/tasks'; } }); } catch (e) { toast(e.message); } };
const sh = el.querySelector('#chsh'); if (sh) sh.onclick = async () => { closeSheet(); try { await jfetch('/api/recipes/shopping/add', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: m.text.slice(0, 200) }) }); toast('Added to the shopping list', { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (e) { toast(e.message); } };
const ev = el.querySelector('#chev'); if (ev) ev.onclick = () => { closeSheet(); chMakeEvent(m); };
el.querySelector('#chfw').onclick = () => { closeSheet(); chForward(m, c.id); };
el.querySelector('#chpn').onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: c.id, msg: m.id, on: !ch.pins.some(p => p.id === m.id) }); } catch (e) { toast(e.message); } };
const cp = el.querySelector('#chcp'); if (cp) cp.onclick = async () => { closeSheet(); try { await navigator.clipboard.writeText(m.text); toast('Copied'); } catch { toast('Could not copy'); } };
const ed = el.querySelector('#ched'); if (ed) ed.onclick = () => { closeSheet(); textSheet('Edit message', 'Message', 'Save', async t => { try { await chPost('/edit', { conv: c.id, msg: m.id, text: t }); } catch (e) { toast(e.message); } }, m.text); };
const dl = el.querySelector('#chdl'); if (dl) dl.onclick = () => { closeSheet(); confirmSheet('Delete this message?', 'It disappears for everyone in the chat.', 'Delete', async () => { try { await chPost('/delete', { conv: c.id, msg: m.id }); } catch (e) { toast(e.message); } }); };
}
function chDrawReply() {
const b = chq('#chrep'); if (!b) return; if (!ch.reply) { b.hidden = true; return; }
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
chq('#chrx').onclick = () => { ch.reply = null; chDrawReply(); };
}
function chDrawTray() {
const t = chq('#chtray'); if (!t) return; t.hidden = !ch.tray.length;
t.innerHTML = ch.tray.map((f, i) => `<span class="sgchip">${/^image\//.test(f.type) ? `<img alt="" src="${URL.createObjectURL(f)}">` : icon('clip')}<small>${esc(f.name)}</small><button type="button" class="ibtn" data-x="${i}" aria-label="Remove">${icon('x')}</button></span>`).join('');
t.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { ch.tray.splice(+b.dataset.x, 1); chDrawTray(); });
}
function chWireComposer() {
const ta = chq('#chtext'), grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px'; };
ta.oninput = () => { grow(); chSetDraft(ch.cur, ta.value); if (ta.value.trim() && Date.now() - ch.typedAt > 3000) { ch.typedAt = Date.now(); chPost('/typing', { conv: ch.cur }).catch(() => {}); } };
chMentionBox(ta);
ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); chq('#chform').requestSubmit(); } };
chq('#chat').onclick = () => actionSheet('Attach', '', [{ label: 'Photo or file', icon: 'clip', run: () => chq('#chfile').click() }, { label: 'Poll', icon: 'check', run: chNewPoll }]);
chq('#chem').onclick = chEmojiPanel; chq('#chmic').onclick = chRecStart;
const sw = () => { const has = !!ta.value.trim() || ch.tray.length; chq('#chmic').hidden = has; chq('#chsend').hidden = !has; }; ta.addEventListener('input', sw); sw(); ch.swSend = sw;
chq('#chfile').onchange = e => { ch.tray.push(...e.target.files); e.target.value = ''; chDrawTray(); ch.swSend(); };
ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { ch.tray.push(...fs); chDrawTray(); } });
// The message appears at once, dimmed as "Sending…"; if it fails, the words and files go back into the box so nothing is lost.
chq('#chform').onsubmit = e => {
e.preventDefault(); const text = ta.value, files = [...ch.tray], reply = ch.reply, to = ch.cur; if (!text.trim() && !files.length) return;
ta.value = ''; grow(); chSetDraft(to, ''); ch.tray = []; ch.reply = null; chDrawTray(); chDrawReply(); ta.focus(); ch.swSend();
const list = chq('#chmsgs'), tmp = document.createElement('div'); tmp.className = 'sgb out'; tmp.style.opacity = '.65'; tmp.innerHTML = `${esc(text) || 'Attachment'}<div class="sgmeta"><small>Sending…</small></div>`;
if (list) { list.appendChild(tmp); list.scrollTop = list.scrollHeight; }
ch.sendQ = (ch.sendQ || Promise.resolve()).then(async () => { // one at a time, so quick messages arrive in the order you wrote them
try {
const ids = []; for (const f of files) { const up = await chApi('/upload?conv=' + encodeURIComponent(to) + '&name=' + encodeURIComponent(f.name), { method: 'POST', body: f, headers: { 'content-type': f.type || 'application/octet-stream' } }); ids.push(up.id); }
await chPost('/send', { conv: to, text, files: ids, quote: reply ? reply.id : undefined }); tmp.remove(); await chRefresh(); const el = chq('#chmsgs'); if (el) el.scrollTop = el.scrollHeight;
} catch (err) { tmp.remove(); toast(err.message || 'Could not send'); if (ch.cur === to && !ta.value) { ta.value = text; grow(); ch.tray = files; ch.reply = reply; chDrawTray(); chDrawReply(); } }
});
};
}
// ---- starting chats, groups and managing them
async function chPeople() { if (!ch.people) ch.people = await chApi('/people'); return ch.people; }
async function chNewChat() {
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
const el = openSheet('New chat', 'Pick someone in the family', `<div class="list">${people.map(p => `<button class="row" data-p="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span></button>`).join('') || '<p class="empty">There is nobody else with Chat yet. An admin can turn it on for people in Settings.</p>'}</div>
${people.length > 1 ? '<div class="bar"><button class="btn ghost" id="chg" style="width:100%;justify-content:center">New group…</button></div>' : ''}`);
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { try { const r = await chPost('/new', { type: 'direct', with: b.dataset.p }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (e) { toast(e.message); } });
const g = el.querySelector('#chg'); if (g) g.onclick = () => { closeSheet(); chNewGroup(people); };
}
function chNewGroup(people) {
const el = openSheet('New group', '', `<form id="chgf"><label for="chgn">Group name</label><input type="text" id="chgn" maxlength="60" required autocomplete="off" placeholder="Family"><label>Who is in it?</label>
<div class="list">${people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}" checked><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div><div class="err" id="chge"></div>
<div class="bar"><button class="btn ghost" type="button" id="chgx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div></form>`);
el.querySelector('#chgx').onclick = closeSheet; setTimeout(() => el.querySelector('#chgn').focus(), 60);
el.querySelector('#chgf').onsubmit = async e => { e.preventDefault(); try { const r = await chPost('/new', { type: 'group', name: el.querySelector('#chgn').value, members: [...el.querySelectorAll('[data-p]:checked')].map(c => c.dataset.p) }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (x) { el.querySelector('#chge').textContent = x.message; } };
}
function chChatMenu(id) {
const c = ch.convs.find(x => x.id === id); if (!c) return; const acts = [];
acts.push({ label: c.muted ? 'Unmute' : 'Mute', icon: 'bell', run: async () => { try { await chPost('/conv/mute', { id, on: !c.muted }); toast(c.muted ? 'Unmuted' : 'Muted. No alerts from this chat.'); chRefresh(); } catch (e) { toast(e.message); } } });
acts.push({ label: c.archived ? 'Move out of the archive' : 'Archive', icon: 'files', run: async () => { try { await chPost('/conv/archive', { id, on: !c.archived }); if (!c.archived && ch.cur === id) location.hash = '#/chat'; chRefresh(); } catch (e) { toast(e.message); } } });
acts.push({ label: 'Disappearing messages' + (c.ttl ? ' · on' : ''), icon: 'clock', run: () => chTtlSheet(c) });
if (c.type === 'group') {
acts.push({ label: 'Rename the group', icon: 'edit', run: () => textSheet('Rename group', 'Group name', 'Rename', async n => { try { await chPost('/conv/update', { id, name: n }); chRefresh(); } catch (e) { toast(e.message); } }, c.name) });
acts.push({ label: 'People in the group', icon: 'people', run: () => chMembers(c) });
acts.push({ label: 'Leave the group', icon: 'trash', danger: true, run: () => confirmSheet('Leave this group?', c.name, 'Leave', async () => { try { await chPost('/conv/update', { id, leave: true }); location.hash = '#/chat'; chRefresh(); } catch (e) { toast(e.message); } }) });
}
actionSheet(c.name, c.type === 'group' ? c.members.length + ' people' : 'Chat', acts);
}
async function chMembers(c) {
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
const owner = ME && (ME.role === 'admin' || c.by === ME.id), others = people.filter(p => !c.members.some(m => m.id === p.id));
const el = openSheet(c.name, 'People in the group', `<div class="list">${c.members.map(m => `<div class="row"><span class="sgav" style="background:${sgColor(m.id)}">${esc(sgInitial(m.name))}</span><span class="grow"><b>${esc(m.name)}${ME && m.id === ME.id ? ' (you)' : ''}${m.id === c.by ? ' · made it' : ''}</b></span>${owner && !(ME && m.id === ME.id) ? `<button class="btn danger" data-r="${esc(m.id)}" style="min-height:34px">Remove</button>` : ''}</div>`).join('')}</div>
${others.length ? `<h2>Add someone</h2><div class="list">${others.map(p => `<button class="row" data-a="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span>${icon('plus')}</button>`).join('')}</div>` : ''}`);
const act = async body => { try { await chPost('/conv/update', { id: c.id, ...body }); closeSheet(); chRefresh(); } catch (e) { toast(e.message); } };
el.querySelectorAll('[data-r]').forEach(b => b.onclick = () => act({ remove: [b.dataset.r] })); el.querySelectorAll('[data-a]').forEach(b => b.onclick = () => act({ add: [b.dataset.a] }));
}
/** The line under the chat's name: who is typing, or who is here, or (for a group) who is in it, and the disappearing-messages timer. */
function chPaintSub() {
const el = chq('#chsub'), c = ch.curConv; if (!el || !c) return;
const ty = ch.typing && ch.typing.conv === c.id && ch.typing.until > Date.now() ? ch.typing.name + ' is typing…' : '';
const ago = t => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' }); };
const base = c.type === 'group' ? c.members.map(x => x.name).join(', ') : c.presence ? (c.presence.online ? '● Online' : c.presence.seen ? 'Active ' + ago(c.presence.seen) : '') : '';
el.textContent = ty || (base + (c.ttl ? (base ? ' · ' : '') + '⏱ ' + (c.ttl === 3600 ? '1 hour' : c.ttl === 86400 ? '1 day' : '1 week') : ''));
el.style.color = ty ? 'var(--accent)' : '';
}
function chDrawPin() {
const b = chq('#chpin'); if (!b) return; if (!ch.pins.length) { b.hidden = true; return; }
const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `<span>📌</span><span class="grow"><small>${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}</small><b>${esc(last.text || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}</b></span>`;
b.onclick = () => { const el = openSheet('Pinned', '', `<div class="list">${ch.pins.slice().reverse().map(m => `<div class="row"><button class="rmain" data-go="${esc(m.id)}"><span class="grow"><b>${esc(m.name)}</b><small style="white-space:normal">${esc(m.text || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`);
el.querySelectorAll('[data-go]').forEach(x => x.onclick = () => { closeSheet(); const t = chq(`.sgb[data-id="${CSS.escape(x.dataset.go)}"]`); if (t) { t.scrollIntoView({ block: 'center' }); t.classList.add('flash'); setTimeout(() => t.classList.remove('flash'), 1400); } });
el.querySelectorAll('[data-un]').forEach(x => x.onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: ch.cur, msg: x.dataset.un, on: false }); } catch (e) { toast(e.message); } }); };
}
function chTtlSheet(c) {
actionSheet('Disappearing messages', 'New messages in this chat go after the time you pick', [[0, 'Off'], [3600, '1 hour'], [86400, '1 day'], [604800, '1 week']].map(([v, l]) => ({ label: l + ((c.ttl || 0) === v ? ' ✓' : ''), icon: 'clock', run: async () => { try { await chPost('/conv/ttl', { id: c.id, ttl: v }); } catch (e) { toast(e.message); } } })));
}
function chForward(m, from) {
const el = openSheet('Forward to…', '', `<div class="list" style="max-height:50dvh;overflow:auto">${ch.convs.map(c => `<button class="row" data-t="${esc(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b></span></button>`).join('') || '<p class="empty">No chats yet.</p>'}</div>`);
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/forward', { from, to: b.dataset.t, msg: m.id }); toast('Forwarded'); } catch (e) { toast(e.message); } });
}
/** Type @ in a group and the people in it are offered. */
function chMentionBox(ta) {
let box; const close = () => { if (box) { box.remove(); box = null; } };
const show = () => {
const c = ch.curConv; if (!c || c.type !== 'group') return close(); const before = ta.value.slice(0, ta.selectionStart), m = /(^|\s)@(\w*)$/.exec(before); if (!m) return close();
const rows = c.members.filter(x => ME && x.id !== ME.id && x.name.toLowerCase().startsWith(m[2].toLowerCase())).slice(0, 6); if (!rows.length) return close();
if (!box) { box = document.createElement('div'); box.className = 'ctsug'; box.style.cssText += ';bottom:100%;top:auto'; ta.closest('form').style.position = 'relative'; ta.closest('form').appendChild(box); }
box.innerHTML = rows.map((r, i) => `<button type="button" data-i="${i}"><b>@${esc(r.name)}</b></button>`).join('');
box.querySelectorAll('button').forEach(b => b.onmousedown = e => { e.preventDefault(); const r = rows[+b.dataset.i], pos = ta.selectionStart - m[2].length; ta.value = ta.value.slice(0, pos) + r.name + ' ' + ta.value.slice(ta.selectionStart); ta.selectionStart = ta.selectionEnd = pos + r.name.length + 1; close(); ta.dispatchEvent(new Event('input')); });
};
ta.addEventListener('input', show); ta.addEventListener('blur', () => setTimeout(close, 120));
}
async function chVote(m, opt) {
const P = m.poll, mine = (ME && (P.votes || {})[ME.id]) || []; let opts;
if (P.multi) opts = mine.includes(opt) ? mine.filter(x => x !== opt) : [...mine, opt]; else opts = mine.includes(opt) ? [] : [opt];
try { await chPost('/vote', { conv: ch.cur, msg: m.id, opts }); } catch (e) { toast(e.message); }
}
function chPollWho(m) {
const P = m.poll, votes = P.votes || {};
const el = openSheet(P.q, P.multi ? 'Choose any' : 'Choose one', `<div class="list">${P.options.map(o => { const who = Object.entries(votes).filter(([, v]) => v.includes(o.id)).map(([u]) => ch.names[u] || 'Someone'); return `<div class="row"><span class="grow"><b>${esc(o.text)}</b><small style="white-space:normal">${who.length ? esc(who.join(', ')) : 'No votes yet'}</small></span></div>`; }).join('')}</div>
${m.out && !P.closed ? '<div class="bar"><button class="btn ghost" id="chpc" style="width:100%;justify-content:center">Close the poll</button></div>' : ''}`);
const c = el.querySelector('#chpc'); if (c) c.onclick = async () => { closeSheet(); try { await chPost('/poll/close', { conv: ch.cur, msg: m.id }); } catch (e) { toast(e.message); } };
}
function chNewPoll() {
const el = openSheet('New poll', 'Ask the chat to vote', `<form id="chpf"><label for="chpq">Question</label><input type="text" id="chpq" maxlength="200" required autocomplete="off" placeholder="Pizza or tacos?">
<label>Choices</label><div id="chpo">${[1, 2].map(i => `<input type="text" class="chpopt" maxlength="100" placeholder="Choice ${i}" autocomplete="off" style="margin-top:6px">`).join('')}</div>
<div class="bar" style="justify-content:flex-start"><button type="button" class="btn ghost" id="chpadd">${icon('plus')}Add a choice</button></div>
<label class="row" style="cursor:pointer"><span class="grow"><b>Allow several answers</b></span><input type="checkbox" id="chpm"></label><div class="err" id="chpe"></div>
<div class="bar"><button class="btn ghost" type="button" id="chpx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Send</button></div></form>`);
el.querySelector('#chpx').onclick = closeSheet; setTimeout(() => el.querySelector('#chpq').focus(), 60);
el.querySelector('#chpadd').onclick = () => { const box = el.querySelector('#chpo'); if (box.children.length >= 8) return; const i = document.createElement('input'); i.type = 'text'; i.className = 'chpopt'; i.maxLength = 100; i.placeholder = 'Choice ' + (box.children.length + 1); i.style.marginTop = '6px'; box.appendChild(i); i.focus(); };
el.querySelector('#chpf').onsubmit = async e => { e.preventDefault(); try { await chPost('/poll', { conv: ch.cur, question: el.querySelector('#chpq').value, options: [...el.querySelectorAll('.chpopt')].map(x => x.value), multi: el.querySelector('#chpm').checked }); closeSheet(); await chRefresh(); const l = chq('#chmsgs'); if (l) l.scrollTop = l.scrollHeight; } catch (x) { el.querySelector('#chpe').textContent = x.message; } };
}
// ---- voice messages: play (one at a time) ...
function chPlayVoice(box) {
const a = ch.audio || (ch.audio = new Audio()), same = ch.playing === box;
if (ch.playing && ch.playing !== box) chStopVoice();
if (same && !a.paused) { a.pause(); box.querySelector('.sgvp').innerHTML = icon('play'); return; }
if (!same) { a.src = box.dataset.src; ch.playing = box; a.onended = chStopVoice; a.ontimeupdate = () => { const d = a.duration || +box.dataset.dur || 1, bars = box.querySelectorAll('.sgbars i'), k = Math.round(a.currentTime / d * bars.length); bars.forEach((b, i) => b.classList.toggle('on', i < k)); box.querySelector('.sgvt').textContent = chDur(a.currentTime); }; }
a.play().then(() => { box.querySelector('.sgvp').innerHTML = icon('pause'); }).catch(() => toast('Could not play that'));
}
function chStopVoice() { const a = ch.audio, b = ch.playing; if (a) a.pause(); if (b) { b.querySelector('.sgvp').innerHTML = icon('play'); b.querySelectorAll('.sgbars i').forEach(x => x.classList.remove('on')); b.querySelector('.sgvt').textContent = chDur(b.dataset.dur); } ch.playing = null; }
// ... and record
async function chRecStart() {
if (ch.rec) return; if (!navigator.mediaDevices || !window.MediaRecorder) return toast('This browser cannot record audio');
let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { return toast('Allow the microphone to record a voice message'); }
const type = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'].find(t => MediaRecorder.isTypeSupported(t)) || '', mr = new MediaRecorder(stream, type ? { mimeType: type } : undefined), chunks = [], wave = [];
let ac, an, tick; try { ac = new (window.AudioContext || window.webkitAudioContext)(); an = ac.createAnalyser(); an.fftSize = 256; ac.createMediaStreamSource(stream).connect(an); const buf = new Uint8Array(an.fftSize); tick = setInterval(() => { an.getByteTimeDomainData(buf); let mx = 0; for (const v of buf) mx = Math.max(mx, Math.abs(v - 128)); wave.push(Math.min(100, Math.round(mx / 128 * 140))); }, 200); } catch { /* no waveform: the message still works */ }
mr.ondataavailable = e => { if (e.data.size) chunks.push(e.data); }; mr.start(); ch.rec = { mr, chunks, wave, stream, ac, tick, t0: Date.now(), type: mr.mimeType || type || 'audio/webm' };
chDrawRec(); ch.rec.timer = setInterval(chDrawRec, 500);
}
function chRecEnd() { const r = ch.rec; if (!r) return; clearInterval(r.timer); clearInterval(r.tick); try { r.mr.state !== 'inactive' && r.mr.stop(); } catch {} r.stream.getTracks().forEach(t => t.stop()); try { r.ac && r.ac.close(); } catch {} ch.rec = null; const f = chq('#chform'); if (f) f.classList.remove('rec'); const b = chq('#chrecbar'); if (b) b.remove(); }
function chDrawRec() {
const r = ch.rec, f = chq('#chform'); if (!r || !f) return; f.classList.add('rec'); let b = chq('#chrecbar');
if (!b) { b = document.createElement('div'); b.id = 'chrecbar'; b.className = 'chrecbar'; b.innerHTML = `<button type="button" class="ibtn" id="chrx2" aria-label="Cancel recording">${icon('trash')}</button><span class="recdot"></span><span id="chrt">0:00</span><span class="grow"></span><button type="button" class="ibtn sgsend" id="chrs" aria-label="Send voice message">${icon('send')}</button>`; f.appendChild(b);
b.querySelector('#chrx2').onclick = chRecEnd; b.querySelector('#chrs').onclick = chRecSend; }
b.querySelector('#chrt').textContent = chDur((Date.now() - r.t0) / 1000);
}
async function chRecSend() {
const r = ch.rec; if (!r) return; const to = ch.cur, secs = Math.round((Date.now() - r.t0) / 1000); if (secs < 1) return chRecEnd();
const done = new Promise(ok => { r.mr.onstop = ok; }); try { r.mr.stop(); } catch {} await done; const blob = new Blob(r.chunks, { type: r.type }), wave = r.wave.slice(); chRecEnd();
const bars = []; const n = 32; for (let i = 0; i < n; i++) { const seg = wave.slice(Math.floor(i * wave.length / n), Math.max(Math.floor(i * wave.length / n) + 1, Math.floor((i + 1) * wave.length / n))); bars.push(seg.length ? Math.max(...seg) : 20); }
const ext = /mp4/.test(r.type) ? 'm4a' : /ogg/.test(r.type) ? 'ogg' : 'webm';
try { const up = await chApi(`/upload?conv=${encodeURIComponent(to)}&name=${encodeURIComponent('Voice message.' + ext)}&voice=1&dur=${secs}&wave=${bars.join(',')}`, { method: 'POST', body: blob, headers: { 'content-type': r.type.split(';')[0] } }); await chPost('/send', { conv: to, files: [up.id] }); await chRefresh(); const l = chq('#chmsgs'); if (l) l.scrollTop = l.scrollHeight; }
catch (e) { toast(e.message || 'Could not send the voice message'); }
}
// ---- emoji
const CH_EMOJI = '😀 😃 😄 😁 😆 😅 😂 🤣 😊 🙂 😉 😍 🥰 😘 😋 😎 🤗 🤔 😐 😴 😢 😭 😮 😱 😡 🥳 😇 🙏 👍 👎 👏 🙌 💪 👋 ❤️ 🧡 💛 💚 💙 💜 🔥 ⭐ ✨ 🎉 🎂 🎁 🍕 🍔 🍎 ☕ 🍪 🏠 🚗 ✈️ ☀️ 🌧️ ❄️ 🌈 🐶 🐱 🌸 ✅ ❌ ❓ 💯'.split(' ');
function chEmojiPanel() {
const f = chq('#chform'), old = chq('#chemo'); if (old) return old.remove(); const box = document.createElement('div'); box.id = 'chemo'; box.className = 'chemo'; box.innerHTML = CH_EMOJI.map(e => `<button type="button" data-e="${e}">${e}</button>`).join(''); f.appendChild(box);
box.onclick = e => { const b = e.target.closest('[data-e]'); if (!b) return; const ta = chq('#chtext'), pos = ta.selectionStart || ta.value.length; ta.value = ta.value.slice(0, pos) + b.dataset.e + ta.value.slice(ta.selectionEnd || pos); ta.selectionStart = ta.selectionEnd = pos + b.dataset.e.length; ta.dispatchEvent(new Event('input')); ta.focus(); };
}
// ---- from a message into the rest of the hub
function chMakeEvent(m) {
const today = new Date(), d = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0');
const el = openSheet('Add to the calendar', 'From a message in Chat', `<form id="chef"><label for="chet">What</label><input type="text" id="chet" value="${esc(m.text.slice(0, 120))}" maxlength="200" required><label for="ched2">Day</label><input type="date" id="ched2" value="${d}" required>
<label for="chetm">Time (leave empty for all day)</label><input type="time" id="chetm"><div class="err" id="chee"></div><div class="bar"><button class="btn ghost" type="button" id="chex" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Add</button></div></form>`);
el.querySelector('#chex').onclick = closeSheet;
el.querySelector('#chef').onsubmit = async e => { e.preventDefault(); try {
const cals = await jfetch('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('There is no calendar to add to'); const day = el.querySelector('#ched2').value, tm = el.querySelector('#chetm').value, allDay = !tm, end = tm ? new Date(new Date(day + 'T' + tm).getTime() + 3600e3) : null;
const endStr = allDay ? day : day + 'T' + String(end.getHours()).padStart(2, '0') + ':' + String(end.getMinutes()).padStart(2, '0');
await jfetch('/api/cal/event', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cal: c.id, fields: { title: el.querySelector('#chet').value, location: '', notes: 'From Chat (' + m.name + ')', allDay, start: allDay ? day : day + 'T' + tm, end: endStr, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, repeat: 'none' } }) });
closeSheet(); toast('Added to the calendar', { label: 'Open', run: () => { location.hash = '#/calendar'; } }); } catch (x) { el.querySelector('#chee').textContent = x.message; } };
}
/** From anywhere in the hub: "Share to Chat". Picks a chat and sends the text (with a link back to the thing). */
async function shareToChat(text) {
if (!allowed('chat')) return toast('Chat is not turned on for you');
let convs = []; try { convs = (await chApi('/convs')).convs.filter(c => !c.archived); } catch (e) { return toast(e.message); }
const el = openSheet('Share to Chat', text.length > 90 ? text.slice(0, 90) + '…' : text, `<div class="list" style="max-height:50dvh;overflow:auto">${convs.map(c => `<button class="row" data-t="${esc(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b></span></button>`).join('') || '<p class="empty">Start a chat first.</p>'}</div>`);
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/send', { conv: b.dataset.t, text }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(b.dataset.t); } }); } catch (e) { toast(e.message); } });
}
// ---- settings: quiet hours, and the family announcement (admin)
async function chSettings() {
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
const q = pr.quiet, admin = ME && ME.role === 'admin';
const el = openSheet('Chat settings', '', `<form id="chsf"><label class="row" style="cursor:pointer"><span class="grow"><b>Quiet hours</b><small style="white-space:normal">No phone alerts from Chat in these hours. Messages still show up in the app.</small></span><input type="checkbox" id="chqo"${q.on ? ' checked' : ''}></label>
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="chqf">From</label><input type="time" id="chqf" value="${esc(q.from)}"></div><div style="flex:1"><label for="chqt">To</label><input type="time" id="chqt" value="${esc(q.to)}"></div></div>
${admin ? `<h2>Family announcement</h2><p class="sub" style="white-space:normal">Shown at the top of everyone's Home. Leave it empty and save to take it down.</p><textarea id="chan" rows="2" maxlength="300" placeholder="Dinner at 6">${esc(an ? an.text : '')}</textarea>` : ''}<div class="err" id="chse"></div>
<div class="bar"><button class="btn ghost" type="button" id="chsx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#chsx').onclick = closeSheet;
el.querySelector('#chsf').onsubmit = async e => { e.preventDefault(); try { await chPost('/prefs', { quiet: { on: el.querySelector('#chqo').checked, from: el.querySelector('#chqf').value, to: el.querySelector('#chqt').value } }); if (admin) await chPost('/announcement', { text: el.querySelector('#chan').value }); closeSheet(); toast('Saved'); } catch (x) { el.querySelector('#chse').textContent = x.message; } };
}
/** Home: the family announcement, if there is one (each person can dismiss it). */
async function chAnnounceCard(el) {
if (!el || !allowed('chat')) return; let a = null; try { a = await chApi('/announcement'); } catch { return; }
let gone = ''; try { gone = localStorage.getItem('ch_ann_gone:' + (ME ? ME.id : '')) || ''; } catch {}
if (!a || a.id === gone) { el.innerHTML = ''; return; }
el.innerHTML = `<div class="annc"><span class="ic" style="background:var(--accent)">${icon('chat')}</span><span class="grow"><small>Family announcement · ${esc(a.by)}</small><b>${esc(a.text)}</b></span><button class="ibtn" id="ancx" aria-label="Dismiss">${icon('x')}</button></div>`;
el.querySelector('#ancx').onclick = () => { try { localStorage.setItem('ch_ann_gone:' + (ME ? ME.id : ''), a.id); } catch {} el.innerHTML = ''; };
}
+121
View File
@@ -0,0 +1,121 @@
'use strict';
// ---- Contacts: your address book. Mail suggests these as you type a recipient, a message can be saved as a contact, birthdays show up in the calendar,
// and a .vcf file (iCloud, Google or a phone export) can be imported or exported.
const ctApi = (p, opts) => jfetch('/api/contacts' + p, opts);
const ctOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
let ctList = null, ctAt = 0, ctQuery = '';
const ctLoad = async force => { if (!force && ctList && Date.now() - ctAt < 60000) return ctList; ctList = await ctApi('/'); ctAt = Date.now(); return ctList; };
const ctInit = c => (c.name.match(/\p{L}/gu) || ['?']).slice(0, 2).join('').toUpperCase();
const ctHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
const ctAv = c => `<span class="ctav" style="background:hsl(${ctHue(c.id)} 45% 45%)">${esc(ctInit(c))}</span>`;
const ctBday = b => { if (!b) return ''; const noYear = b.startsWith('--'), [y, m, d] = noYear ? [0, b.slice(2, 4), b.slice(5, 7)] : [b.slice(0, 4), b.slice(5, 7), b.slice(8, 10)]; return new Date(2000, +m - 1, +d).toLocaleDateString([], { month: 'long', day: 'numeric' }) + (noYear ? '' : ', ' + y); };
const ctFind = (l, address) => { const a = String(address || '').toLowerCase(); return a ? l.find(c => c.emails.some(e => e.value === a)) : null; };
async function viewContacts(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Contacts</h1><p class="empty">Loading…</p></div>';
try {
const l = await ctLoad(true); if (!location.hash.startsWith('#/contacts')) return;
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Contacts</h1><span class="pspace grow"></span><button class="btn ghost" id="ctmore" aria-label="More">${icon('more')}</button><button class="btn" id="ctnew">${icon('plus')}New</button></div>
<input type="search" id="ctq" placeholder="Search contacts" value="${esc(ctQuery)}" autocomplete="off" aria-label="Search contacts" style="width:100%;margin-bottom:8px"><div id="ctl"></div></div>`;
const draw = () => {
const q = ctQuery.toLowerCase(), rows = l.filter(c => !q || [c.name, c.org, c.notes, ...c.emails.map(e => e.value), ...c.phones.map(p => p.value)].some(x => String(x).toLowerCase().includes(q)));
let last = '';
$('#ctl').innerHTML = rows.length ? rows.map(c => { const g = (ctInit(c)[0] || '#'), head = g !== last ? `<div class="ctgrp">${esc(last = g)}</div>` : '';
return head + `<a class="row" href="#/contacts/c/${esc(c.id)}">${ctAv(c)}<span class="grow"><b>${esc(c.name)}</b><small>${esc(c.org || (c.emails[0] || {}).value || (c.phones[0] || {}).value || '')}</small></span></a>`; }).join('')
: `<p class="empty">${l.length ? 'No one matches.' : 'No contacts yet. Add one, or import a .vcf file from iCloud, Google or your phone.'}</p>`;
};
draw(); $('#ctq').oninput = e => { ctQuery = e.target.value.trim(); draw(); };
$('#ctnew').onclick = () => ctEdit(null);
$('#ctmore').onclick = ctMore;
if (args[0] === 'c' && args[1]) { const c = l.find(x => x.id === args[1]); if (c) ctShow(c); }
} catch (e) {
if (!location.hash.startsWith('#/contacts')) return;
v.innerHTML = `<div class="wrap"><h1>Contacts</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="ctretry">Try again</button></div></div>`; $('#ctretry').onclick = () => route();
}
}
function ctMore() {
const el = openSheet('Contacts', '', `<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Import a .vcf file</b><small>From iCloud, Google or your phone. People already here are filled in, not doubled.</small></span><input type="file" id="ctfile" accept=".vcf,text/vcard,text/x-vcard" hidden></label>
<a class="row" href="/api/contacts/export" download="contacts.vcf"><span class="grow"><b>Export all as .vcf</b><small>A backup you can open in any contacts app</small></span></a></div>`);
el.querySelector('#ctfile').onchange = async e => {
const f = e.target.files[0]; if (!f) return;
try { if (f.size > 7e6) throw new Error('That file is too large'); const r = await ctApi('/import', ctOpt('POST', { vcf: await f.text() })); closeSheet(); toast(`Added ${r.added}, updated ${r.merged}${r.skipped ? ', skipped ' + r.skipped : ''}`); route(); } catch (err) { toast(err.message); }
};
}
function ctShow(c) {
const mailOn = allowed('mail') && cfg.mail;
const line = (label, html) => html ? `<div class="row"><span class="grow"><small>${esc(label)}</small><span style="display:block;white-space:normal">${html}</span></span></div>` : '';
const el = openSheet(c.name, c.org || '', `<div class="list">
${c.emails.map(e => line(e.label, mailOn ? `<a href="#" data-mail="${esc(e.value)}" style="color:var(--accent)">${esc(e.value)}</a>` : esc(e.value))).join('')}
${c.phones.map(p => line(p.label, `<a href="tel:${esc(p.value.replace(/[^\d+]/g, ''))}" style="color:var(--accent)">${esc(p.value)}</a>`)).join('')}
${line('address', c.address ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">${esc(c.address)}</a>` : '')}
${line('birthday', esc(ctBday(c.birthday)))}${line('notes', esc(c.notes).replace(/\n/g, '<br>'))}</div>
<div class="bar">${mailOn && c.emails[0] ? `<button class="btn" id="ctw">${icon('mail')}Email</button><button class="btn ghost" id="ctm">Mail from them</button>` : ''}<button class="btn ghost" id="cte">Edit</button><button class="btn danger" id="ctd">Delete</button></div>`);
el.querySelectorAll('[data-mail]').forEach(a => a.onclick = e => { e.preventDefault(); closeSheet(); ctWrite(a.dataset.mail); });
if (el.querySelector('#ctw')) el.querySelector('#ctw').onclick = () => { closeSheet(); ctWrite(c.emails[0].value); };
if (el.querySelector('#ctm')) el.querySelector('#ctm').onclick = () => { closeSheet(); mail.q = c.emails[0].value; location.hash = '#/mail'; if (location.hash === '#/mail' && typeof mailLoadList === 'function') mailLoadList(true); };
el.querySelector('#cte').onclick = () => { closeSheet(); ctEdit(c); };
el.querySelector('#ctd').onclick = () => { closeSheet(); confirmSheet('Delete this contact?', c.name, 'Delete', async () => { try { await ctApi('/delete', ctOpt('POST', { id: c.id })); toast('Deleted'); ctList = null; route(); } catch (e) { toast(e.message); } }); };
}
/** A new message to this address. */
function ctWrite(address) { if (typeof mailCompose === 'function') mailCompose({ to: address }); }
function ctEdit(c, seed) {
const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', notes: '' };
const rows = (kind, items, ph) => `<div class="ctrows" data-k="${kind}">${(items.length ? items : [{ label: '', value: '' }]).map(i => `<div class="bar" style="margin:4px 0;gap:6px;flex-wrap:nowrap"><input type="text" data-l placeholder="label" value="${esc(i.label === 'other' ? '' : i.label)}" style="width:80px;flex:none"><input type="text" data-v placeholder="${ph}" value="${esc(i.value)}" style="flex:1" autocapitalize="off"></div>`).join('')}</div><a href="#" data-more="${kind}" style="color:var(--accent);font-size:14px">Add another</a>`;
const el = openSheet(c ? 'Edit contact' : 'New contact', '', `<form id="ctf"><label for="ctn">Name</label><input type="text" id="ctn" value="${esc(x.name)}" autocomplete="off"><label for="cto">Company</label><input type="text" id="cto" value="${esc(x.org)}" autocomplete="off">
<label>Email</label>${rows('emails', x.emails, 'name@example.com')}<label>Phone</label>${rows('phones', x.phones, '555 123 4567')}
<label for="cta">Address</label><input type="text" id="cta" value="${esc(x.address)}" autocomplete="off"><label for="ctb">Birthday</label><input type="date" id="ctb" value="${esc(x.birthday && !x.birthday.startsWith('--') ? x.birthday : '')}">
<label for="ctt">Notes</label><textarea id="ctt" rows="3">${esc(x.notes)}</textarea><div class="err" id="cter"></div>
<div class="bar"><button class="btn ghost" type="button" id="ctx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#ctx').onclick = closeSheet;
el.querySelectorAll('[data-more]').forEach(a => a.onclick = e => { e.preventDefault(); const box = el.querySelector(`.ctrows[data-k="${a.dataset.more}"]`), first = box.firstElementChild; const n = first.cloneNode(true); n.querySelectorAll('input').forEach(i => { i.value = ''; }); box.appendChild(n); n.querySelector('[data-v]').focus(); });
const grab = k => [...el.querySelectorAll(`.ctrows[data-k="${k}"] .bar`)].map(r => ({ label: r.querySelector('[data-l]').value.trim().toLowerCase(), value: r.querySelector('[data-v]').value.trim() })).filter(i => i.value);
el.querySelector('#ctf').onsubmit = async e => {
e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = '';
const keepYearless = x.birthday && x.birthday.startsWith('--') && !el.querySelector('#ctb').value;
const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, notes: el.querySelector('#ctt').value };
try { const saved = await ctApi('/', ctOpt('POST', body)); ctList = null; closeSheet(); toast('Saved'); if (location.hash.startsWith('#/contacts')) route(); return saved; } catch (er) { err.textContent = er.message; }
};
el.querySelector('#ctn').focus();
}
// ---- Mail: suggestions while typing a recipient, and "Save contact" on a message you are reading
/** Suggest matching contacts under a recipient box (a comma-separated list: the part being typed is what is matched). */
function ctSuggest(input) {
if (!input || typeof allowed !== 'function' || !allowed('contacts')) return;
let box, sel = -1, rows = [];
const part = () => { const v = input.value, i = v.lastIndexOf(','); return { head: v.slice(0, i + 1), word: v.slice(i + 1).trim() }; };
const close = () => { if (box) { box.remove(); box = null; } sel = -1; };
const pick = r => { const { head } = part(); input.value = (head ? head.replace(/\s*$/, ' ') : '') + r.email + ', '; close(); input.focus(); };
const show = async () => {
const { word } = part(), w = word.toLowerCase(); if (w.length < 1) return close();
let l; try { l = await ctLoad(); } catch { return; }
rows = []; for (const c of l) for (const e of c.emails) if (c.name.toLowerCase().includes(w) || e.value.includes(w) || (c.org || '').toLowerCase().includes(w)) rows.push({ name: c.name, email: e.value });
rows = rows.slice(0, 6); if (!rows.length) return close();
if (!box) { box = document.createElement('div'); box.className = 'ctsug'; input.parentNode.style.position = input.parentNode.style.position || 'relative'; input.insertAdjacentElement('afterend', box); }
box.innerHTML = rows.map((r, i) => `<button type="button" data-i="${i}"><b>${esc(r.name)}</b> <small>${esc(r.email)}</small></button>`).join('');
box.querySelectorAll('button').forEach(b => b.onmousedown = e => { e.preventDefault(); pick(rows[b.dataset.i]); });
};
input.addEventListener('input', show); input.addEventListener('blur', () => setTimeout(close, 120));
input.addEventListener('keydown', e => {
if (!box) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); sel = (sel + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length; box.querySelectorAll('button').forEach((b, i) => b.classList.toggle('on', i === sel)); }
else if (e.key === 'Enter' && sel >= 0) { e.preventDefault(); pick(rows[sel]); }
else if (e.key === 'Escape') { e.stopPropagation(); close(); }
});
}
/** On a message being read: save the sender, or open them if they are already a contact. */
async function ctMailHook(rd, m) {
if (typeof allowed !== 'function' || !allowed('contacts') || !m.from || !m.from.address) return;
const p = rd.querySelector('.mfrom'); if (!p) return;
try {
const l = await ctLoad(), have = ctFind(l, m.from.address);
if (!rd.contains(p)) return;
const a = document.createElement('a'); a.href = '#'; a.style.cssText = 'color:var(--accent);font-size:13px;margin-left:8px'; a.textContent = have ? 'In contacts' : 'Save contact';
a.onclick = e => { e.preventDefault(); if (have) location.hash = '#/contacts/c/' + have.id; else ctEdit(null, { name: m.from.name || '', org: '', emails: [{ label: '', value: m.from.address }], phones: [], address: '', birthday: '', notes: '' }); };
p.appendChild(a);
} catch {}
}
+6
View File
@@ -59,6 +59,8 @@
<script src="/reminders.js"></script>
<script src="/file-history.js"></script>
<script src="/music-extras.js"></script>
<script src="/music-more.js"></script>
<script src="/screentime-view.js"></script>
<script src="/calendar-views.js"></script>
<script src="/app.js"></script>
<script src="/search-view.js"></script>
@@ -76,6 +78,9 @@
<script src="/audiobooks-view.js"></script>
<script src="/librivox-view.js"></script>
<script src="/radio-view.js"></script>
<script src="/store-view.js"></script>
<script src="/recipes-view.js"></script>
<script src="/contacts-view.js"></script>
<script src="/bible-view.js"></script>
<script src="/save-to-files.js"></script>
<script src="/vbv-view.js"></script>
@@ -87,6 +92,7 @@
<script src="/sheets.js"></script>
<script src="/file-actions.js"></script>
<script src="/signal-view.js"></script>
<script src="/chat-view.js"></script>
<script src="/mail.js"></script>
<script src="/share.js"></script>
<script src="/cast.js"></script>
+4 -4
View File
@@ -46,13 +46,13 @@ function lvPlay(b, at) {
lvApi('/shelf', lvOpt('POST', { id: b.id })).catch(() => {}); // a book you start is kept on your shelf
}
async function viewFreeBooks() {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty">Loading…</p></div>';
async function viewFreeBooks(host) { // host: { el, hash } when shown inside the Store tab
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty">Loading…</p></div>';
try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/audio/free')) return;
if (!here()) return;
const S = { q: '', page: 1, items: [], more: false, busy: false, seq: 0 };
const card = b => lvCard(b);
v.innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><h1 style="margin:0">Free audiobooks</h1></div>
v.innerHTML = `<div class="wrap">${host ? '' : `<div class="ptop"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><h1 style="margin:0">Free audiobooks</h1></div>`}
<p class="sub">Public-domain books read aloud by volunteers (LibriVox). Free, with no sign-in. A book you start is kept on your shelf.</p>
<input type="search" id="lvq" placeholder="Search by title or author" autocomplete="off" autocapitalize="off" style="width:100%"><div id="lvnote"></div><div id="lvbody"></div><div id="lvmore" class="bar" style="justify-content:center"></div></div>`;
const known = new Map(lvShelf.map(b => [b.id, b]));
+35 -5
View File
@@ -48,7 +48,7 @@ async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc
}
/** A transcript: the show's own if it publishes one, else (on request) one made on this server. Polls while that is being made. */
async function lyrSpoken(t, start) {
const q = new URLSearchParams({ key: t.key, src: t.src || '' });
const q = new URLSearchParams({ key: t.key, src: t.src || '', title: t.title || '', artist: t.artist || '', thumb: t.thumb || '' });
for (let n = 0; ; n++) { // (n restarts at -1 after an automatic start, so the POST goes out once)
if (!lyr.el || lyr.key !== t.key) return null;
let r; try { r = await jfetch('/api/transcript?' + q, start && n <= 0 ? { method: 'POST' } : undefined); } catch (e) { return { found: false, error: e.message }; }
@@ -99,16 +99,16 @@ function lyrShell() {
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><span class="grow"></span>
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><button class="btn ghost" id="lyrsh">Share a line</button><span class="grow"></span>
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
const $l = q => lyr.el.querySelector(q);
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; $l('#lyrsh').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
$l('#lyrs').onclick = lyrSearchAgain;
$l('#lyrs').onclick = lyrSearchAgain; $l('#lyrsh').onclick = lyrShare;
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
$l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
syncUI();
@@ -168,7 +168,37 @@ pl.a.addEventListener('playing', async () => {
if (!t || !String(t.key).startsWith('pod:') || !t.src || lyrAsked.has(t.key) || !cfg.transcribe || lyrPref().noAuto) return;
lyrAsked.add(t.key);
try {
const q = new URLSearchParams({ key: t.key, src: t.src }), r = await jfetch('/api/transcript?' + q);
const q = new URLSearchParams({ key: t.key, src: t.src, title: t.title || '', artist: t.artist || '', thumb: t.thumb || '' }), r = await jfetch('/api/transcript?' + q);
if (r.state === 'none' && r.canTranscribe) await jfetch('/api/transcript?' + q, { method: 'POST' });
} catch { lyrAsked.delete(t.key); }
});
// ---- Share a line: the lyric over the album cover, as a picture (shared through the phone's share sheet, or downloaded)
function lyrShare() {
const t = cur(), d = lyr.data; if (!t || !d || !d.found || d.instrumental || !d.lines) return toast('There are no lyrics to share yet');
const text = l => l.words ? l.words.map(w => w.text).join(' ') : l.text, rows = d.lines.map((l, i) => ({ i, s: (text(l) || '').trim() })).filter(x => x.s).slice(0, 250);
const now = d.sync !== 'none' && lyr.idx >= 0 ? lyr.idx : -1, el = openSheet('Share a line', 'Tick up to four lines for the picture.', `<div class="list" style="max-height:48dvh;overflow:auto">${rows.map(x => `<label class="row" style="cursor:pointer"><input type="checkbox" data-l="${x.i}"${x.i === now ? ' checked' : ''}><span class="grow" style="white-space:normal">${esc(x.s)}</span></label>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="shx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="shgo" style="flex:1;justify-content:center">Make the picture</button></div>`);
el.querySelector('#shx').onclick = closeSheet;
el.querySelectorAll('input[data-l]').forEach(c => c.onchange = () => { if (el.querySelectorAll('input[data-l]:checked').length > 4) c.checked = false; });
el.querySelector('#shgo').onclick = async () => {
const picked = [...el.querySelectorAll('input[data-l]:checked')].map(c => rows.find(x => x.i === +c.dataset.l).s); if (!picked.length) return toast('Tick at least one line');
closeSheet();
try { const blob = await lyrCard(t, picked), file = new File([blob], (t.title || 'lyric').replace(/[^\w -]+/g, '') + '.png', { type: 'image/png' });
if (navigator.canShare && navigator.canShare({ files: [file] })) await navigator.share({ files: [file], title: t.title, text: t.title + ' — ' + t.artist }).catch(e => { if (e.name !== 'AbortError') throw e; });
else { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); toast('Saved the picture'); } } catch (e) { toast(e.message || 'Could not make the picture'); }
};
}
async function lyrCard(t, lines) {
const W = 1080, H = 1350, cv = document.createElement('canvas'); cv.width = W; cv.height = H; const x = cv.getContext('2d');
const img = await new Promise(ok => { const im = new Image(); im.onload = () => ok(im); im.onerror = () => ok(null); im.src = px.art(t.thumb, 800); });
x.fillStyle = '#14161b'; x.fillRect(0, 0, W, H);
if (img) { x.save(); x.filter = 'blur(40px) saturate(1.3)'; const k = Math.max(W / img.width, H / img.height) * 1.2; x.drawImage(img, (W - img.width * k) / 2, (H - img.height * k) / 2, img.width * k, img.height * k); x.restore(); }
const g = x.createLinearGradient(0, 0, 0, H); g.addColorStop(0, 'rgba(0,0,0,.35)'); g.addColorStop(1, 'rgba(0,0,0,.75)'); x.fillStyle = g; x.fillRect(0, 0, W, H);
const text = lines.join('\n'), size = text.length > 160 ? 52 : text.length > 90 ? 62 : 74; x.fillStyle = '#fff'; x.font = `700 ${size}px system-ui, -apple-system, Segoe UI, sans-serif`; x.textAlign = 'center'; x.textBaseline = 'middle';
const wrapped = []; for (const ln of lines) { let cur1 = ''; for (const w of ln.split(/\s+/)) { const tryl = cur1 ? cur1 + ' ' + w : w; if (x.measureText(tryl).width > W - 160 && cur1) { wrapped.push(cur1); cur1 = w; } else cur1 = tryl; } wrapped.push(cur1); }
const lh = size * 1.3, y0 = H / 2 - ((wrapped.length - 1) * lh) / 2 - 40; wrapped.forEach((ln, i) => x.fillText(ln, W / 2, y0 + i * lh));
if (img) { const s = 150, ax = 100, ay = H - 250; x.save(); x.beginPath(); x.roundRect ? x.roundRect(ax, ay, s, s, 18) : x.rect(ax, ay, s, s); x.clip(); x.drawImage(img, ax, ay, s, s); x.restore(); }
x.textAlign = 'left'; x.font = '700 40px system-ui, sans-serif'; x.fillText(t.title.slice(0, 34), 280, H - 200); x.font = '400 34px system-ui, sans-serif'; x.fillStyle = 'rgba(255,255,255,.75)'; x.fillText((t.artist || '').slice(0, 40), 280, H - 150);
return new Promise((ok, no) => cv.toBlob(b => b ? ok(b) : no(new Error('Could not make the picture')), 'image/png'));
}
+13 -1
View File
@@ -33,6 +33,14 @@ function mailDoc(html, images) {
return `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><base target="_blank"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{margin:14px;font:15px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;color:#111;background:#fff;overflow-wrap:anywhere}img{max-width:100%;height:auto}table{max-width:100%}a{color:#1a5fd1}blockquote{margin:0 0 0 6px;padding-left:10px;border-left:3px solid #ccc;color:#555}</style>${html}`;
}
/** On a phone the message is as tall as its text and the page scrolls, so there is one scroll, not a box inside a page. (The box keeps its own scroll on a wide screen.) No scripts run in it: only same-origin so its height can be read. */
function mailFit(fr) {
if (!fr) return;
const fit = () => { if (!fr.isConnected) return; if (innerWidth >= 1024) { fr.style.height = ''; return; } try { const d = fr.contentDocument; d.documentElement.style.overflowY = 'hidden'; fr.style.height = Math.max(160, Math.ceil(d.documentElement.scrollHeight) + 2) + 'px'; } catch {} };
fr.addEventListener('load', () => { fit(); try { fr.contentDocument.addEventListener('load', fit, true); } catch {} setTimeout(fit, 300); setTimeout(fit, 1200); });
if (!mailFit.on) { mailFit.on = true; addEventListener('resize', () => { const f = document.querySelector('.mbody'); if (f) f.dispatchEvent(new Event('fit')); }); }
fr.addEventListener('fit', fit);
}
const mailHasRemote = html => /<img[^>]+src=["']?https?:|url\(\s*["']?https?:/i.test(html || '');
// ---- unread badge and Home card
@@ -462,8 +470,10 @@ function mailDrawReader() {
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="unread">Mark unread</button><button class="btn ghost" data-a="flag">${m.flagged ? 'Unflag' : 'Flag'}</button><button class="btn danger" data-a="delete">Delete</button></div>
${m.attachments.length ? `<div class="matt">${m.attachments.map(a => `<a class="btn ghost" href="/api/mail/attachment?id=${encodeURIComponent(m.id)}&aid=${encodeURIComponent(a.id)}" download>${icon('clip')}${esc(a.name)} <small>${esc(fmtSize(a.size || 0))}</small></a>`).join('')}</div>` : ''}
${remote ? '<div class="mimg">Pictures are hidden to protect your privacy. <button class="btn ghost" id="mshow" style="min-height:32px">Show pictures</button></div>' : ''}</div>
<iframe class="mbody" title="Message" sandbox="allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
<iframe class="mbody" title="Message" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
if ($('#mshow')) $('#mshow').onclick = () => { mail.images = true; mailDrawReader(); };
mailFit(rd.querySelector('.mbody'));
if (typeof ctMailHook === 'function') ctMailHook(rd, m);
rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a));
}
@@ -504,6 +514,8 @@ function mailCompose(o = {}) {
${reply ? '' : `<div class="bar" style="margin:8px 0 0;justify-content:flex-start"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('clip')}Attach<input type="file" id="mfile" multiple hidden></label><small id="mfl" style="color:var(--muted)"></small></div>`}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="msend" style="flex:1;justify-content:center">Send</button></div><div class="err" id="mer"></div></form>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
if (o.to && el.querySelector('#mto')) el.querySelector('#mto').value = o.to;
if (typeof ctSuggest === 'function') for (const id of ['#mto', '#mcc2', '#mbcc']) ctSuggest(el.querySelector(id));
const cc = el.querySelector('#mcc'); if (cc) cc.onclick = e => { e.preventDefault(); el.querySelector('#mcb').hidden = false; el.querySelector('#mccw').hidden = true; };
const fi = el.querySelector('#mfile');
const total = () => files.reduce((n, f) => n + f.size, 0);
+2 -1
View File
@@ -29,6 +29,7 @@ async function addToPlaylist(tracks, suggest = '') {
}, suggest);
actionSheet('Add to playlist', keys.length + (keys.length === 1 ? ' track' : ' tracks'), [
{ label: 'New playlist…', icon: 'plus', run: newOne },
...(typeof addToFamily === 'function' ? [{ label: 'A Family playlist…', icon: 'music', run: () => addToFamily(tracks) }] : []),
...lists.map(p => ({ label: p.title, icon: 'music', run: async () => { try { await plx.add(p.ratingKey, keys); toast('Added to ' + p.title); if (location.hash === '#/music/playlist/' + p.ratingKey) route(); } catch (e) { toast(e.message); } } })),
]);
}
@@ -81,7 +82,7 @@ async function plexHomeBadge(st) {
}
function plexSwitchSheet(users) {
const el = openSheet('Who is listening?', 'People in your Plex Home. Playlists, ratings and what you have played follow the person you pick.',
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(u.thumb)}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(stOk(u.thumb))}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
+319
View File
@@ -0,0 +1,319 @@
'use strict';
// ---- Music, more: history and stats, favorites, mixes (daily, most played, forgotten, moods, on this day, decades, genres), smart playlists, Family playlists, library radio,
// cover-art colors, offline albums, and a verse for the day. Uses the globals from music.js / player.js (px, pl, mapTrack, playQueue, listing helpers) and the hub's /api/music.
const mmApi = (p, o) => jfetch('/api/music' + p, o);
const mmOpt = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: b === undefined ? undefined : JSON.stringify(b) });
const mmPlex = k => /^\d{1,12}$/.test(String(k || ''));
const mmMeta = t => ({ key: t.key, title: t.title, artist: t.artist, album: t.album, thumb: t.thumb, artistKey: t.artistKey, albumKey: t.albumKey, dur: t.dur });
const mmPick = (a, n) => shuffled(a).slice(0, n);
const mmHours = m => m >= 90 ? (m / 60).toFixed(m >= 600 ? 0 : 1) + ' h' : m + ' min';
const mmNow = () => Math.floor(Date.now() / 1000);
// ---- what was played: a song that has been played for half its length (or four minutes) is counted, the same moment Plex is told
function musicPlayed(t, secs) {
if (!t || !mmPlex(t.key) || !allowed('music')) return;
fetch('/api/music/played', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...mmMeta(t), dur: secs ? secs * 1000 : t.dur }), keepalive: true }).catch(() => {});
}
// ---- favorites (kept by the hub, so they follow you between devices)
const mf = { keys: new Set(), list: null };
async function mfLoad(force) { if (mf.list && !force) return mf.list; try { mf.list = await mmApi('/favs'); mf.keys = new Set(mf.list.map(x => x.key)); } catch { mf.list = mf.list || []; } return mf.list; }
const mfIs = t => !!t && mf.keys.has(String(t.key));
async function mfToggle(t) {
if (!mmPlex(t.key)) return toast('Only songs from your music library can be favorites');
try {
if (mfIs(t)) { await mmApi('/favs/' + t.key, { method: 'DELETE' }); mf.keys.delete(String(t.key)); toast('Removed from Favorites'); }
else { await mmApi('/favs', mmOpt('POST', mmMeta(t))); mf.keys.add(String(t.key)); toast('Added to Favorites'); }
mf.list = null; mfPaint();
} catch (e) { toast(e.message); }
}
/** Hearts on screen (the Now Playing sheet and the track rows) follow what is a favorite. */
function mfPaint() {
const t = typeof cur === 'function' ? cur() : null;
document.querySelectorAll('#npfav, #cnfav').forEach(b => { const on = mfIs(t); b.classList.toggle('on', on); b.style.color = on ? 'var(--danger)' : ''; b.setAttribute('aria-pressed', on); });
document.querySelectorAll('[data-fv]').forEach(b => { const on = mf.keys.has(b.dataset.fv); b.style.color = on ? 'var(--danger)' : ''; b.setAttribute('aria-pressed', on); });
}
// ---- songs by their Plex keys (the hub remembers keys; the song itself, with its file, comes from Plex)
async function mmTracksByKeys(keys) {
keys = [...new Set(keys.map(String))].filter(mmPlex); const out = new Map();
for (let i = 0; i < keys.length; i += 40) {
try { const mc = await px.get('/library/metadata/' + keys.slice(i, i + 40).join(',')); for (const t of mc.Metadata || []) { const m = mapTrack(t, mc); if (m) out.set(String(t.ratingKey), m); } } catch { /* a missing song is simply left out */ }
}
return keys.map(k => out.get(k)).filter(Boolean);
}
const mmTracksOf = async albumKeys => (await Promise.all(albumKeys.map(async k => { try { const mc = await px.get(`/library/metadata/${k}/children`); return (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); } catch { return []; } }))).flat();
/** Random songs from the library, or only from the chosen playlists. */
async function mmRandomTracks(sec, n, pls = []) {
if (pls.length) { const lists = await Promise.all(pls.map(id => px.get(`/playlists/${id}/items`).catch(() => ({ Metadata: [] })))); return mmPick(lists.flatMap(l => (l.Metadata || []).map(t => mapTrack(t, l)).filter(Boolean)), n); }
try { return ((await px.get(`/library/sections/${sec}/all?type=10&sort=random&X-Plex-Container-Start=0&X-Plex-Container-Size=${n}`)).Metadata || []).map(t => mapTrack(t, {})).filter(Boolean); } catch { return []; }
}
// ---- a page of songs (a mix, a genre, favorites...): Play, Shuffle, Save as playlist, and the list
function mmPage(v, { title, sub, thumb, tracks, back, about, extra = '', onChange }) {
const hrefBack = back ? `<div class="bar"><a class="btn ghost" href="${back[0]}">${icon('back')}${esc(back[1])}</a></div>` : '';
v.innerHTML = `<div class="wrap">${hrefBack}<div class="mhead">${thumb ? `<img class="art" alt="" src="${px.art(thumb, 400)}">` : `<div class="art mixart" style="background:linear-gradient(135deg,hsl(${mmHue(title)} 55% 42%),hsl(${mmHue(title) + 40} 55% 26%))"></div>`}<div><h1>${esc(title)}</h1><p class="sub">${esc(sub || tracks.length + ' songs')}</p></div></div>
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}
<div class="bar"><button class="btn" id="mpa">${icon('play')}Play</button><button class="btn ghost" id="mps">${icon('shuffle')}Shuffle</button><button class="btn ghost" id="mpl">${icon('plus')}Save as playlist</button>${extra}</div>
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span><small>${fmtDur(t.dur)}</small></button>
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Song actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable songs</p>'}</div></div>`;
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => trackSheet(tracks[+b.dataset.m]));
v.querySelectorAll('button[data-fi]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.fi]; mfToggle(t).then(() => { if (onChange) onChange(); }); });
v.querySelector('#mpa').onclick = () => tracks.length && playQueue(tracks, 0);
v.querySelector('#mps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
v.querySelector('#mpl').onclick = () => tracks.length && addToPlaylist(tracks, title);
mfLoad().then(mfPaint); markPlaying();
}
const mmHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
const mmLoading = (v, title) => { v.innerHTML = `<div class="wrap"><h1>${esc(title)}</h1><p class="empty">Putting it together…</p></div>`; };
// ================================================================ the Mixes tab
const MOODS = [
['calm', 'Calm', /ambient|classical|acoustic|folk|instrumental|new age|piano|soft|easy|lullaby|worship|hymn/i],
['upbeat', 'Upbeat', /pop|dance|rock|gospel|praise|funk|disco|swing|country|r&b|soul|reggae|latin/i],
['focus', 'Focus', /instrumental|classical|ambient|jazz|soundtrack|score|piano|lo-?fi/i],
['worship', 'Worship', /worship|christian|gospel|hymn|praise|ccm|inspirational/i],
];
let mmGenres = null, mmAlbumsCache = null;
async function mmGenreList(sec) { if (mmGenres && mmGenres.sec === sec) return mmGenres.list; try { const l = ((await px.get(`/library/sections/${sec}/genre`)).Directory || []).map(g => ({ key: g.key, title: g.title })); mmGenres = { sec, list: l }; return l; } catch { return []; } }
async function mmAllAlbums(sec) { if (mmAlbumsCache && mmAlbumsCache.sec === sec && Date.now() - mmAlbumsCache.t < 600000) return mmAlbumsCache.list; const mc = await px.get(`/library/sections/${sec}/all?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=3000`); mmAlbumsCache = { sec, t: Date.now(), list: mc.Metadata || [] }; return mmAlbumsCache.list; }
/** Build the songs of a mix: { title, sub, tracks }. */
async function mmBuildMix(sec, id, arg) {
if (id === 'daily') {
const [top, favs, rnd] = await Promise.all([mmApi('/top?days=60&limit=40').catch(() => []), mfLoad(true), mmRandomTracks(sec, 24)]);
const keys = [...mmPick(top, 14).map(x => x.key), ...mmPick(favs, 12).map(x => x.key)], known = await mmTracksByKeys(keys), seen = new Set(known.map(t => t.key));
const tracks = shuffled([...known, ...rnd.filter(t => !seen.has(t.key))]);
return { title: 'Daily mix', sub: new Date().toLocaleDateString([], { weekday: 'long' }) + ' · ' + tracks.length + ' songs from what you play, your favorites and a few surprises', tracks };
}
if (id === 'mostplayed') { const top = await mmApi('/top?days=30&limit=50'); return { title: 'Most played this month', tracks: await mmTracksByKeys(top.map(x => x.key)), sub: top.length ? '' : 'Play a few songs and this fills in' }; }
if (id === 'forgotten') {
const cutoff = mmNow() - 90 * 86400; let l = [];
try { l = ((await px.get(`/library/sections/${sec}/all?type=10&viewCount>>=2&lastViewedAt<<=${cutoff}&sort=viewCount:desc&X-Plex-Container-Start=0&X-Plex-Container-Size=200`)).Metadata || []).map(t => mapTrack(t, {})).filter(Boolean); } catch { /* none */ }
const favs = await mfLoad(true), old = new Set(l.map(t => t.key)); const fav = (await mmTracksByKeys(mmPick(favs, 30).map(x => x.key))).filter(t => !old.has(t.key));
return { title: 'Forgotten favorites', sub: 'Songs you loved and have not played in a while', tracks: shuffled([...mmPick(l, 40), ...fav.slice(0, 15)]) };
}
if (id === 'mood') {
const mood = MOODS.find(m => m[0] === arg); if (!mood) throw new Error('Unknown mix');
const keys = (await mmGenreList(sec)).filter(g => mood[2].test(g.title)).map(g => g.key); if (!keys.length) throw new Error('No ' + mood[1].toLowerCase() + ' music was found in your genres');
const albums = ((await px.get(`/library/sections/${sec}/all?type=9&genre=${keys.join(',')}&sort=random&X-Plex-Container-Start=0&X-Plex-Container-Size=14`)).Metadata || []);
return { title: mood[1] + ' mix', sub: 'From your ' + mood[1].toLowerCase() + ' genres', tracks: mmPick(await mmTracksOf(albums.map(a => a.ratingKey)), 60) };
}
if (id === 'similar') { // more like an artist: the artists Plex says are similar, else the same genre
let artists = []; try { artists = ((await px.get(`/library/metadata/${arg}/similar`)).Metadata || []).slice(0, 8); } catch { /* none */ }
const me = ((await px.get(`/library/metadata/${arg}`)).Metadata || [])[0] || {};
if (!artists.length && me.Genre && me.Genre.length) { const g = ((await px.get(`/library/sections/${sec}/genre`)).Directory || []).find(d => d.title === me.Genre[0].tag); if (g) artists = ((await px.get(`/library/sections/${sec}/all?type=8&genre=${g.key}&sort=random&X-Plex-Container-Start=0&X-Plex-Container-Size=8`)).Metadata || []).filter(a => a.ratingKey !== arg); }
if (!artists.length) throw new Error('Plex has no similar artists for this one yet');
const albums = (await Promise.all(artists.slice(0, 6).map(async a => { try { return ((await px.get(`/library/metadata/${a.ratingKey}/children`)).Metadata || []).slice(0, 2); } catch { return []; } }))).flat();
return { title: 'More like ' + (me.title || 'this artist'), sub: artists.map(a => a.title).slice(0, 4).join(', '), tracks: mmPick(await mmTracksOf(albums.map(a => a.ratingKey)), 60) };
}
throw new Error('Unknown mix');
}
async function musicMixes({ sec, listing, v }) {
const [genres, smart] = await Promise.all([mmGenreList(sec), mmApi('/smart').catch(() => [])]);
const tile = (href, title, sub) => `<a class="acard mixtile" href="${href}"><span class="mixart" style="background:linear-gradient(135deg,hsl(${mmHue(title)} 55% 42%),hsl(${mmHue(title) + 40} 55% 26%))"><b>${esc(title)}</b></span><small>${esc(sub)}</small></a>`;
const moods = MOODS.filter(m => genres.some(g => m[2].test(g.title)));
const decades = [1960, 1970, 1980, 1990, 2000, 2010, 2020];
let today = [];
try { const d = new Date(), md = String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); today = (await mmAllAlbums(sec)).filter(a => (a.originallyAvailableAt || '').slice(5) === md && +(a.originallyAvailableAt || '0').slice(0, 4) < d.getFullYear()).sort((a, b) => (a.originallyAvailableAt < b.originallyAvailableAt ? -1 : 1)).slice(0, 12); } catch { /* skip it */ }
const card = (a, sub) => `<a class="acard" href="#/music/album/${a.ratingKey}"><img class="art" loading="lazy" alt="" src="${px.art(a.thumb)}"><b>${esc(a.title)}</b><small>${esc(sub)}</small></a>`;
listing('raw', `<div class="bar" style="justify-content:flex-start;flex-wrap:wrap;margin-top:0"><button class="btn" id="mxr">${icon('shuffle')}Library radio</button><small class="sub">An endless shuffle of your music. It uses the playlists chosen on Featured, if you chose any.</small></div>
<h2>Mixes for you</h2><div class="agrid">${tile('#/music/mix/daily', 'Daily mix', 'Your music, your favorites, a few surprises')}${tile('#/music/mix/mostplayed', 'Most played', 'This month')}${tile('#/music/mix/forgotten', 'Forgotten favorites', 'Not heard in a while')}${tile('#/music/favorites', 'Favorites', 'The songs you hearted')}</div>
${moods.length ? `<h2>Moods</h2><div class="agrid">${moods.map(m => tile('#/music/mix/mood/' + m[0], m[1], 'A mix of ' + m[1].toLowerCase() + ' music')).join('')}</div>` : ''}
${today.length ? `<h2>On this day</h2><p class="sub">Albums released on ${new Date().toLocaleDateString([], { month: 'long', day: 'numeric' })} in past years</p><div class="agrid">${today.map(a => card(a, (a.originallyAvailableAt || '').slice(0, 4) + ' · ' + (a.parentTitle || ''))).join('')}</div>` : ''}
<h2>Decades</h2><div class="seg">${decades.map(d => `<a href="#/music/decade/${d}">${d}s</a>`).join('')}</div>
${genres.length ? `<h2>Genres</h2><div class="seg" style="flex-wrap:wrap">${genres.slice(0, 40).map(g => `<a href="#/music/genre/${esc(g.key)}">${esc(g.title)}</a>`).join('')}</div>` : ''}
<h2>Smart playlists</h2><p class="sub">Rules that pick songs for you (for example: gospel from the 80s you have not played in a year).</p>
<div class="agrid">${smart.map(s => tile('#/music/smart/' + s.id, s.name, 'Smart playlist')).join('')}</div><div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="mxs">${icon('plus')}New smart playlist</button></div>`);
$('#mxr').onclick = () => mmRadioStart(sec);
$('#mxs').onclick = () => mmSmartEditor(sec, null, genres);
}
// ---- mix, genre, decade and smart playlist pages
async function musicMixPage({ sec, v }, id, arg, arg2) {
if (id === 'mood' || id === 'similar') { arg = arg; }
mmLoading(v, 'Mix');
try {
const m = await mmBuildMix(sec, id, id === 'mood' || id === 'similar' ? arg : arg);
if (!location.hash.startsWith('#/music/mix')) return;
mmPage(v, { title: m.title, sub: m.sub, tracks: m.tracks, back: ['#/music/mixes', 'Mixes'], extra: `<button class="btn ghost" id="mpr">${icon('repeat')}New ${esc(id === 'daily' ? 'mix' : 'pick')}</button>` });
v.querySelector('#mpr').onclick = () => musicMixPage({ sec, v }, id, arg);
} catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><h1>Mix</h1><p class="err">${esc(e.message)}</p></div>`; }
}
async function musicGenrePage({ sec, v, card }, key) {
mmLoading(v, 'Genre');
const genres = await mmGenreList(sec), g = genres.find(x => String(x.key) === String(key)); if (!g) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><p class="err">That genre is not in your library.</p></div>`; return; }
const albums = ((await px.get(`/library/sections/${sec}/all?type=9&genre=${encodeURIComponent(key)}&sort=titleSort&X-Plex-Container-Start=0&X-Plex-Container-Size=600`)).Metadata || []);
if (!location.hash.startsWith('#/music/genre')) return; const feat = albums.length ? albums[Math.floor(Math.random() * albums.length)] : null;
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><h1>${esc(g.title)}</h1><p class="sub">${albums.length} albums</p>
<div class="bar"><button class="btn" id="mgs">${icon('shuffle')}Shuffle ${esc(g.title)}</button></div>
${feat ? `<h2>Featured album</h2><div class="agrid"><a class="acard" href="#/music/album/${feat.ratingKey}"><img class="art" alt="" src="${px.art(feat.thumb)}"><b>${esc(feat.title)}</b><small>${esc(feat.parentTitle || '')}</small></a></div>` : ''}
<h2>Albums</h2><div class="agrid">${albums.map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')}</div></div>`;
$('#mgs').onclick = async e => { e.currentTarget.disabled = true; const t = mmPick(await mmTracksOf(mmPick(albums, 10).map(a => a.ratingKey)), 80); e.currentTarget.disabled = false; if (t.length) playQueue(shuffled(t), 0, true); else toast('No songs found'); };
}
async function musicDecadePage({ sec, v, card }, start) {
start = +start; if (!(start >= 1900 && start <= 2090)) return (location.hash = '#/music/mixes');
mmLoading(v, start + 's');
const albums = ((await px.get(`/library/sections/${sec}/all?type=9&year>>=${start}&year<<=${start + 9}&sort=year,titleSort&X-Plex-Container-Start=0&X-Plex-Container-Size=600`)).Metadata || []);
if (!location.hash.startsWith('#/music/decade')) return;
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><h1>${start}s</h1><p class="sub">${albums.length} albums</p>
<div class="bar"><button class="btn" id="mgs">${icon('shuffle')}Shuffle the ${start}s</button></div><div class="agrid">${albums.map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, (a.year || '') + ' · ' + (a.parentTitle || ''))).join('') || '<p class="empty">Nothing from this decade.</p>'}</div></div>`;
$('#mgs').onclick = async e => { e.currentTarget.disabled = true; const t = mmPick(await mmTracksOf(mmPick(albums, 10).map(a => a.ratingKey)), 80); e.currentTarget.disabled = false; if (t.length) playQueue(shuffled(t), 0, true); else toast('No songs found'); };
}
// ---- smart playlists
const SMART_ORDER = { random: 'random', newest: 'addedAt:desc', mostPlayed: 'viewCount:desc', leastRecent: 'lastViewedAt:asc' };
async function mmSmartTracks(sec, r) {
if (r.onlyFavorites) { const f = await mfLoad(true), keys = (r.order === 'random' ? shuffled(f) : f).slice(0, r.limit).map(x => x.key); return mmTracksByKeys(keys); }
const genres = await mmGenreList(sec), ids = r.genres.map(n => (genres.find(g => g.title === n) || {}).key).filter(Boolean);
const q = [`type=10`, `sort=${SMART_ORDER[r.order]}`, 'X-Plex-Container-Start=0', `X-Plex-Container-Size=${r.limit}`];
if (r.yearFrom) q.push(`year>>=${r.yearFrom}`); if (r.yearTo) q.push(`year<<=${r.yearTo}`); if (r.addedDays) q.push(`addedAt>>=${mmNow() - r.addedDays * 86400}`);
if (r.notPlayedDays) q.push(`lastViewedAt<<=${mmNow() - r.notPlayedDays * 86400}`); if (r.minPlays) q.push(`viewCount>>=${r.minPlays}`);
const run = async extra => ((await px.get(`/library/sections/${sec}/all?${[...q, ...extra].join('&')}`)).Metadata || []).map(t => mapTrack(t, {})).filter(Boolean);
if (!ids.length) return run([]);
let l = await run(['album.genre=' + ids.join(',')]); if (!l.length) l = await run(['artist.genre=' + ids.join(',')]); return l;
}
async function musicSmartPage({ sec, v }, id) {
mmLoading(v, 'Smart playlist');
const pl0 = (await mmApi('/smart')).find(s => s.id === id); if (!pl0) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><p class="err">That smart playlist is gone.</p></div>`; return; }
const tracks = await mmSmartTracks(sec, pl0.rules); if (!location.hash.startsWith('#/music/smart')) return;
mmPage(v, { title: pl0.name, sub: tracks.length + ' songs · a smart playlist', tracks, back: ['#/music/mixes', 'Mixes'], extra: `<button class="btn ghost" id="mse">Edit rules</button><button class="btn ghost" id="msd">Delete</button>` });
v.querySelector('#mse').onclick = async () => mmSmartEditor(sec, pl0, await mmGenreList(sec));
v.querySelector('#msd').onclick = () => confirmSheet('Delete this smart playlist?', pl0.name, 'Delete', async () => { try { await mmApi('/smart/' + id, { method: 'DELETE' }); toast('Deleted'); location.hash = '#/music/mixes'; } catch (e) { toast(e.message); } });
}
function mmSmartEditor(sec, sp, genres) {
const r = sp ? sp.rules : { genres: [], yearFrom: 0, yearTo: 0, addedDays: 0, notPlayedDays: 0, minPlays: 0, onlyFavorites: false, order: 'random', limit: 50 }, num = (id, label, val, ph) => `<label for="${id}">${label}</label><input type="number" id="${id}" min="0" value="${val || ''}" placeholder="${ph || 'any'}">`;
const el = openSheet(sp ? 'Edit smart playlist' : 'New smart playlist', 'Songs are picked by these rules each time you open it.', `<form id="msf"><label for="msn">Name</label><input type="text" id="msn" value="${esc(sp ? sp.name : '')}" maxlength="60" required autocomplete="off">
<label class="row" style="cursor:pointer"><span class="grow"><b>Only my favorites</b><small>Ignores the other rules except the order and the number of songs</small></span><input type="checkbox" id="msfv"${r.onlyFavorites ? ' checked' : ''}></label>
${genres.length ? `<label>Genres</label><div class="seg" id="msg" style="flex-wrap:wrap">${genres.slice(0, 40).map(g => `<a href="#" data-g="${esc(g.title)}" class="${r.genres.includes(g.title) ? 'on' : ''}">${esc(g.title)}</a>`).join('')}</div>` : ''}
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1">${num('msy1', 'From year', r.yearFrom, '1980')}</div><div style="flex:1">${num('msy2', 'To year', r.yearTo, '1989')}</div></div>
${num('msa', 'Added in the last (days)', r.addedDays)}${num('msnp', 'Not played in (days)', r.notPlayedDays)}${num('msmp', 'Played at least (times)', r.minPlays)}
<label for="mso">Order</label><select id="mso">${[['random', 'Random'], ['newest', 'Newest added first'], ['mostPlayed', 'Most played first'], ['leastRecent', 'Not played for longest first']].map(([k, l]) => `<option value="${k}"${r.order === k ? ' selected' : ''}>${l}</option>`).join('')}</select>
${num('msl', 'Number of songs', r.limit, '50')}<div class="err" id="mse2"></div>
<div class="bar"><button class="btn ghost" type="button" id="msx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#msx').onclick = closeSheet;
el.querySelectorAll('#msg [data-g]').forEach(a => a.onclick = e => { e.preventDefault(); a.classList.toggle('on'); });
el.querySelector('#msf').onsubmit = async e => {
e.preventDefault(); const g = id => +el.querySelector(id).value || 0;
const rules = { genres: [...el.querySelectorAll('#msg .on')].map(a => a.dataset.g), yearFrom: g('#msy1'), yearTo: g('#msy2'), addedDays: g('#msa'), notPlayedDays: g('#msnp'), minPlays: g('#msmp'), onlyFavorites: el.querySelector('#msfv').checked, order: el.querySelector('#mso').value, limit: g('#msl') || 50 };
try { const saved = await mmApi('/smart', mmOpt('POST', { id: sp && sp.id, name: el.querySelector('#msn').value, rules })); closeSheet(); toast('Saved'); location.hash = '#/music/smart/' + saved.id; if (sp) route(); } catch (x) { el.querySelector('#mse2').textContent = x.message; }
};
}
// ================================================================ Favorites, History and stats
async function musicFavorites({ v }) {
mmLoading(v, 'Favorites');
const list = await mfLoad(true), tracks = await mmTracksByKeys(list.map(x => x.key)); if (!location.hash.startsWith('#/music/favorites')) return;
mmPage(v, { title: 'Favorites', sub: tracks.length + ' songs you hearted', tracks, back: ['#/music/mixes', 'Mixes'], onChange: () => route() });
}
async function musicStats({ v, st }, range = 'month') {
mmLoading(v, 'Your listening');
let s, recent; try { [s, recent] = await Promise.all([mmApi('/stats?range=' + range), mmApi('/history?limit=30')]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Your listening</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/music/stats')) return;
const ranges = [['week', 'Week'], ['month', 'Month'], ['year', 'Year'], ['all', 'All time']], max = Math.max(1, ...s.series.map(x => x.minutes)), top = s.artists[0] ? s.artists[0].plays : 1;
v.innerHTML = `<div class="wrap"><h1>Your listening</h1><div class="seg">${ranges.map(([k, l]) => `<a href="#" data-r="${k}" class="${k === range ? 'on' : ''}">${l}</a>`).join('')}</div>
<div class="statgrid"><div class="stat"><b>${s.plays}</b><small>songs played</small></div><div class="stat"><b>${mmHours(s.minutes)}</b><small>listened</small></div><div class="stat"><b>${s.artistsHeard}</b><small>artists</small></div><div class="stat"><b>${s.streak}</b><small>day streak</small></div></div>
${s.plays ? `<h2>Minutes ${range === 'week' || range === 'month' ? 'per day' : 'per month'}</h2><div class="chart">${s.series.map(x => `<span title="${esc(x.d)}: ${x.minutes} min"><i style="height:${Math.max(2, Math.round(x.minutes / max * 100))}%"></i></span>`).join('')}</div>
<h2>Top artists</h2><div class="list">${s.artists.map(a => `<div class="row"><span class="grow"><b>${esc(a.name || 'Unknown')}</b><div class="statbar"><i style="width:${Math.round(a.plays / top * 100)}%"></i></div></span><small>${a.plays} plays · ${mmHours(a.minutes)}</small></div>`).join('')}</div>
<h2>Top songs</h2><div class="list">${s.songs.map((t, i) => `<button class="row" data-k="${esc(t.key)}"><span class="num">${i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span><small>${t.plays}×</small></button>`).join('')}</div>
${s.albums.length ? `<h2>Top albums</h2><div class="agrid">${s.albums.filter(a => a.key).map(a => `<a class="acard" href="#/music/album/${esc(a.key)}"><img class="art" loading="lazy" alt="" src="${px.art(a.thumb)}"><b>${esc(a.title)}</b><small>${esc(a.artist)} · ${a.plays} plays</small></a>`).join('')}</div>` : ''}` : '<p class="empty">Nothing yet. Songs you play from now on are counted here.</p>'}
<h2>Recently played</h2><div class="list">${recent.map((t, i) => `<button class="row" data-h="${i}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)} · ${esc(new Date(t.t).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }))}</small></span>${icon('play')}</button>`).join('') || '<p class="empty">Nothing played yet.</p>'}</div>
${s.plays || recent.length ? '<div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="mhc">Clear my history</button></div>' : ''}</div>`;
v.querySelectorAll('[data-r]').forEach(a => a.onclick = e => { e.preventDefault(); musicStats({ v, st }, a.dataset.r); });
const play = async keys => { const tr = await mmTracksByKeys(keys); if (tr.length) playQueue(tr, 0); else toast('That song is not in your library any more'); };
v.querySelectorAll('[data-k]').forEach(b => b.onclick = () => play([b.dataset.k]));
v.querySelectorAll('[data-h]').forEach(b => b.onclick = () => play([recent[+b.dataset.h].key]));
if ($('#mhc')) $('#mhc').onclick = () => confirmSheet('Clear your listening history?', 'The stats and the mixes that use it start over.', 'Clear', async () => { try { await mmApi('/history', { method: 'DELETE' }); toast('Cleared'); musicStats({ v, st }, range); } catch (e) { toast(e.message); } });
}
// ================================================================ Family playlists
const famCard = p => `<a class="acard" href="#/music/family/${p.id}"><span class="mixart" style="background:linear-gradient(135deg,hsl(${mmHue(p.name)} 55% 42%),hsl(${mmHue(p.name) + 40} 55% 26%))"><b>${esc(p.name)}</b></span><small>${p.count} songs · ${esc(p.ownerName)}</small></a>`;
async function musicFamilyPage({ v }, id) {
mmLoading(v, 'Family playlist');
let p; try { p = await mmApi('/family/' + id); } catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/playlists">${icon('back')}Playlists</a></div><p class="err">${esc(e.message)}</p></div>`; return; }
const tracks = await mmTracksByKeys(p.tracks.map(t => t.key)); if (!location.hash.startsWith('#/music/family')) return;
const mineOrAdmin = ME && (ME.role === 'admin' || ME.id === p.owner);
mmPage(v, { title: p.name, sub: `${tracks.length} songs · a Family playlist by ${p.ownerName}. Everyone can add and remove songs.`, tracks, back: ['#/music/playlists', 'Playlists'], extra: `${mineOrAdmin ? '<button class="btn ghost" id="mfr">Rename</button><button class="btn danger" id="mfd">Delete</button>' : ''}`, onChange: () => {} });
v.querySelectorAll('.row.it').forEach(r => { const t = tracks[+r.dataset.i], b = document.createElement('button'); b.className = 'ibtn'; b.setAttribute('aria-label', 'Remove from this playlist'); b.innerHTML = icon('trash'); b.onclick = async () => { try { await mmApi(`/family/${id}/tracks/${t.key}`, { method: 'DELETE' }); toast('Removed'); route(); } catch (e) { toast(e.message); } }; r.appendChild(b); });
if ($('#mfr')) $('#mfr').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await mmApi('/family/' + id, mmOpt('POST', { name: n })); route(); } catch (e) { toast(e.message); } }, p.name);
if ($('#mfd')) $('#mfd').onclick = () => confirmSheet('Delete this Family playlist?', 'It disappears for everyone.', 'Delete', async () => { try { await mmApi('/family/' + id, { method: 'DELETE' }); toast('Deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
}
/** "Add to a Family playlist…" from the song menus. */
async function addToFamily(tracks) {
let list = []; try { list = await mmApi('/family'); } catch (e) { return toast(e.message); }
const meta = tracks.map(mmMeta).filter(t => mmPlex(t.key));
const make = () => textSheet('New Family playlist', 'Everyone with Music can add to it', 'Create', async n => { try { await mmApi('/family', mmOpt('POST', { name: n, tracks: meta })); toast('Family playlist created'); if (location.hash.startsWith('#/music/playlists')) route(); } catch (e) { toast(e.message); } });
actionSheet('Add to a Family playlist', meta.length + (meta.length === 1 ? ' song' : ' songs'), [{ label: 'New Family playlist…', icon: 'plus', run: make }, ...list.map(p => ({ label: p.name + ' (' + p.count + ')', icon: 'music', run: async () => { try { const r = await mmApi(`/family/${p.id}/add`, mmOpt('POST', { tracks: meta })); toast(r.added ? 'Added to ' + p.name : 'Already in ' + p.name); } catch (e) { toast(e.message); } } }))]);
}
// ================================================================ Library radio: an endless shuffle that keeps adding songs as it plays
async function mmRadioStart(sec) {
const pls = store.get('music_feat_pls', []); toast('Starting library radio…');
const first = await mmRandomTracks(sec, 30, pls); if (!first.length) return toast('No songs found to play');
playQueue(first, 0); pl.radio = { sec, pls, seen: new Set(first.map(t => t.key)), busy: false };
toast('Library radio is on. It keeps going until you stop it.');
}
/** Called as each song starts: when the queue is nearly used up, add more. */
async function mmRadioTopUp() {
const r = pl.radio; if (!r || r.busy || pl.q.length - pl.i > 4) return; r.busy = true;
try {
let more = (await mmRandomTracks(r.sec, 30, r.pls)).filter(t => !r.seen.has(t.key));
if (!more.length) { r.seen = new Set(pl.q.slice(-6).map(t => t.key)); more = (await mmRandomTracks(r.sec, 30, r.pls)).filter(t => !r.seen.has(t.key)); } // (a small set of playlists has been played through: go round again)
more.forEach(t => r.seen.add(t.key)); if (pl.radio === r) { pl.q.push(...more); if (pl.q.length > 300 && pl.i > 100) { pl.q.splice(0, pl.i - 20); pl.i = 20; } npQueueCount(); saveState(); }
} catch { /* try again at the next song */ } finally { r.busy = false; }
}
// ================================================================ The player takes its colors from the album cover
const mmColors = new Map();
async function mmCoverTheme(t) {
const root = document.documentElement; if (!t || !t.thumb) return root.style.removeProperty('--np-a');
const src = px.art(t.thumb, 100); let c = mmColors.get(src);
if (c === undefined) {
c = await new Promise(ok => { const im = new Image(); im.onerror = () => ok(null);
im.onload = () => { try { const cv = document.createElement('canvas'); cv.width = cv.height = 16; const x = cv.getContext('2d', { willReadFrequently: true }); x.drawImage(im, 0, 0, 16, 16); const d = x.getImageData(0, 0, 16, 16).data; let r = 0, g = 0, b = 0, w = 0;
for (let i = 0; i < d.length; i += 4) { const mx = Math.max(d[i], d[i + 1], d[i + 2]), mn = Math.min(d[i], d[i + 1], d[i + 2]), k = (mx - mn) + 8; r += d[i] * k; g += d[i + 1] * k; b += d[i + 2] * k; w += k; }
r /= w; g /= w; b /= w; const mx = Math.max(r, g, b) / 255, mn = Math.min(r, g, b) / 255, l = (mx + mn) / 2, dl = mx - mn; let h = 0; const s = dl === 0 ? 0 : dl / (1 - Math.abs(2 * l - 1));
if (dl) { if (mx * 255 === r) h = ((g - b) / 255 / dl) % 6; else if (mx * 255 === g) h = (b - r) / 255 / dl + 2; else h = (r - g) / 255 / dl + 4; } h = Math.round((h * 60 + 360) % 360); ok(`hsl(${h} ${Math.round(Math.max(35, Math.min(75, s * 100)))}% 45%)`); } catch { ok(null); } };
im.src = src; });
mmColors.set(src, c); if (mmColors.size > 80) mmColors.delete(mmColors.keys().next().value);
}
if (typeof cur === 'function' && cur() !== t) return;
if (c) root.style.setProperty('--np-a', c); else root.style.removeProperty('--np-a');
}
// ================================================================ Albums saved on this device (they play with no signal)
const mmOff = {
async count(tracks) { try { const c = await caches.open(OFFLINE); let n = 0; for (const t of tracks) if (await c.match(t.src)) n++; return n; } catch { return 0; } },
async save(tracks, progress) {
if (!('caches' in window)) throw new Error('This browser cannot save for offline'); const c = await caches.open(OFFLINE); let n = 0;
for (const t of tracks) { if (!(await c.match(t.src))) { const r = await fetch(t.src, { credentials: 'same-origin' }); if (!r.ok) throw new Error('Could not download "' + t.title + '" (' + r.status + ')'); await c.put(t.src, r); } progress(++n); }
if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {});
},
async remove(tracks) { try { const c = await caches.open(OFFLINE); for (const t of tracks) await c.delete(t.src); } catch { /* nothing saved */ } },
};
/** The "Save offline" button on an album: saves every song, or takes them off the device again. */
async function mmOfflineButton(btn, tracks) {
if (!btn || !tracks.length) return; let busy = false;
const paint = async () => { const n = await mmOff.count(tracks); btn.dataset.n = n; btn.textContent = n === tracks.length ? 'Saved offline ✓' : n ? `Saved ${n} of ${tracks.length}` : 'Save offline'; btn.style.color = n === tracks.length ? 'var(--accent)' : ''; };
await paint();
btn.onclick = async () => {
if (busy) return; busy = true;
try {
if (+btn.dataset.n === tracks.length) { await mmOff.remove(tracks); toast('Removed from this device'); }
else { await mmOff.save(tracks, n => { btn.textContent = `Saving ${n} of ${tracks.length}…`; }); toast('Saved on this device'); }
} catch (e) { toast(e.name === 'QuotaExceededError' ? 'There is not enough room on this device' : e.message); }
busy = false; paint();
};
}
// ================================================================ A verse for the day, under Featured
const VERSES = [['JHN', 3, 16], ['PSA', 23, 1], ['PHP', 4, 13], ['ROM', 8, 28], ['ISA', 41, 10], ['PRO', 3, 5], ['JER', 29, 11], ['MAT', 11, 28], ['PSA', 46, 1], ['ROM', 12, 2], ['JOS', 1, 9], ['2TI', 1, 7], ['PSA', 119, 105], ['HEB', 11, 1], ['1CO', 13, 4], ['EPH', 2, 8], ['PSA', 27, 1], ['MAT', 6, 33], ['GAL', 5, 22], ['1JN', 4, 19], ['PSA', 121, 1], ['ISA', 40, 31], ['LAM', 3, 22], ['PHP', 4, 6], ['COL', 3, 23], ['PSA', 34, 8], ['JAS', 1, 5], ['1PE', 5, 7], ['ZEP', 3, 17], ['PSA', 100, 1]];
async function mmVerse(el) {
if (!el || !allowed('bible') || typeof bbLoad !== 'function') return;
try {
await bbLoad(); const [b, c, v] = VERSES[Math.floor(Date.now() / 864e5) % VERSES.length], text = bbVerseText(b, c, v); if (!text || !el.isConnected) return;
el.innerHTML = `<small class="sub">Verse of the day</small><blockquote class="mmverse">${esc(text)}</blockquote><small>${esc(bbRef(b, c, v))} · <a href="#/bible" style="color:var(--accent)">Open the Bible</a></small>`;
} catch { /* no Bible text: no verse */ }
}
+85 -13
View File
@@ -13,7 +13,7 @@ const px = {
if (!r.ok) throw new Error('Plex error ' + r.status);
return (await r.json()).MediaContainer || {};
},
art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio|vbv)\//.test(thumb) ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio|vbv|store)\//.test(thumb) ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
// All music libraries on the server; the chosen one is remembered per server.
async sections() {
if (px.secs) return px.secs;
@@ -32,7 +32,7 @@ const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor
const mapTrack = (t, mc) => {
const part = t.Media?.[0]?.Part?.[0];
return part && { key: t.ratingKey, title: t.title, artist: t.grandparentTitle || t.originalTitle || mc.title1 || '',
album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0 };
album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0, artistKey: t.grandparentRatingKey, albumKey: t.parentRatingKey };
};
async function viewMusic(args) {
@@ -110,36 +110,52 @@ async function plexPickServer() {
} catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p></div>`; }
}
async function musicRoute([kind = 'albums', key], st) {
async function musicRoute([kind = 'featured', key, key2], st) {
px.serverKey = st.server;
const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0];
const tabs = ['albums', 'artists', 'playlists', 'recent'];
const seg = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
const tabs = ['featured', 'mixes', 'albums', 'artists', 'playlists', 'favorites', 'stats', 'recent'], TAB_NAME = { recent: 'New', stats: 'Stats' };
const seg = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${TAB_NAME[t] || t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
const card = (href, thumb, title, sub, round) => `<a class="acard ${round ? 'round' : ''}" href="${href}" data-q="${esc((title + ' ' + sub).toLowerCase())}">
<img class="art" loading="lazy" alt="" src="${px.art(thumb)}"><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
const listing = (items, html) => {
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}<span id="phu"></span></p>${seg}<input type="search" id="mq" placeholder="Filter" autocomplete="off">
<div class="agrid" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}<span id="phu"></span></p>${seg}${items === 'raw' ? '' : '<input type="search" id="mq" placeholder="Filter" autocomplete="off">'}
<div class="${items === 'raw' ? '' : 'agrid'}" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
plexHomeBadge(st);
const pl2 = $('#plib');
if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music',
run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); };
$('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); };
if ($('#mq')) $('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); };
};
const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
const ctx = { sec, card, listing, st, v };
if (kind === 'featured') return musicFeatured({ sec, card, listing, st });
if (kind === 'mixes') return musicMixes(ctx);
if (kind === 'mix') return musicMixPage(ctx, key, key2, arguments[0][3]);
if (kind === 'genre') return musicGenrePage(ctx, key);
if (kind === 'decade') return musicDecadePage(ctx, key);
if (kind === 'smart') return musicSmartPage(ctx, key);
if (kind === 'family') return musicFamilyPage(ctx, key);
if (kind === 'favorites') return musicFavorites(ctx);
if (kind === 'stats') return musicStats(ctx);
if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
if (kind === 'recent') { const mc = await px.get(`/library/sections/${sec}/recentlyAdded?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=60`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
if (kind === 'artists') { const mc = await px.get(`/library/sections/${sec}/all?type=8${big}`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/artist/' + a.ratingKey, a.thumb, a.title, '', true)).join('')); }
if (kind === 'playlists') { const mc = await px.get('/playlists?playlistType=audio');
return listing(0, (mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('')); }
if (kind === 'playlists') { const [mc, fam] = await Promise.all([px.get('/playlists?playlistType=audio'), mmApi('/family').catch(() => [])]);
listing('raw', `<h2 style="margin-top:0">Your playlists</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('') || '<p class="empty">No playlists yet.</p>'}</div>
<h2>Family playlists</h2><p class="sub">Shared with everyone who has Music. Anyone can add and remove songs.</p><div class="agrid">${fam.map(famCard).join('')}</div>
<div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="fnew">${icon('plus')}New Family playlist</button></div>`);
$('#fnew').onclick = () => textSheet('New Family playlist', 'Everyone with Music can add to it', 'Create', async n => { try { const r = await mmApi('/family', mmOpt('POST', { name: n })); location.hash = '#/music/family/' + r.id; } catch (e) { toast(e.message); } });
return; }
if (kind === 'artist') {
const mc = await px.get(`/library/metadata/${key}/children`);
const [mc, am] = await Promise.all([px.get(`/library/metadata/${key}/children`), px.get(`/library/metadata/${key}`).catch(() => ({}))]), about = ((am.Metadata || [])[0] || {}).summary || '';
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/music/mix/similar/${esc(key)}">${icon('shuffle')}More like this</a></div>
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
return;
}
@@ -154,10 +170,12 @@ async function musicRoute([kind = 'albums', key], st) {
const thumb = info.thumb || info.composite || mc.thumb;
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
${info.summary ? `<details class="mmabout"><summary>About</summary><p>${esc(info.summary).replace(/\n/g, '<br>')}</p></details>` : ''}
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
<button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
v.querySelectorAll('button[data-fi]').forEach(b => b.onclick = () => mfToggle(tracks[+b.dataset.fi])); mfLoad().then(mfPaint); if (album) mmOfflineButton($('#pof'), tracks);
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); });
if (mine) {
@@ -171,3 +189,57 @@ async function musicRoute([kind = 'albums', key], st) {
}
location.hash = '#/music/albums';
}
// ---- Featured: an artist and an album picked at random, either from the whole library or only from the playlists you choose. The pick stays for the day on this
// device (so Music does not change every time you open it); "Pick again" rolls new ones, and "Based on" chooses the playlists.
const featKey = () => 'music_feat:' + px.serverKey, featPls = () => store.get('music_feat_pls', []);
const pick = a => a[Math.floor(Math.random() * a.length)];
/** Two random choices: an artist { id, title, thumb } and an album { id, title, artist, thumb }. */
async function featuredPick(sec, pls) {
if (pls.length) { // only what is in the chosen playlists
const lists = await Promise.all(pls.map(id => px.get(`/playlists/${id}/items`).catch(() => ({ Metadata: [] }))));
const artists = new Map(), albums = new Map();
for (const t of lists.flatMap(l => l.Metadata || [])) {
if (t.grandparentRatingKey) artists.set(t.grandparentRatingKey, { id: t.grandparentRatingKey, title: t.grandparentTitle, thumb: t.grandparentThumb });
if (t.parentRatingKey) albums.set(t.parentRatingKey, { id: t.parentRatingKey, title: t.parentTitle, artist: t.grandparentTitle || '', thumb: t.parentThumb || t.thumb });
}
if (!artists.size && !albums.size) throw new Error('The playlists you chose have no songs in them yet');
return { artist: artists.size ? pick([...artists.values()]) : null, album: albums.size ? pick([...albums.values()]) : null };
}
const one = async type => { // a random item of a type: ask how many there are, then fetch one at a random place
const n = (await px.get(`/library/sections/${sec}/all?type=${type}&X-Plex-Container-Start=0&X-Plex-Container-Size=0`)).totalSize || 0; if (!n) return null;
const m = ((await px.get(`/library/sections/${sec}/all?type=${type}&X-Plex-Container-Start=${Math.floor(Math.random() * n)}&X-Plex-Container-Size=1`)).Metadata || [])[0];
return m ? { id: m.ratingKey, title: m.title, artist: m.parentTitle || '', thumb: m.thumb } : null;
};
const [artist, album] = await Promise.all([one(8), one(9)]); return { artist, album };
}
async function musicFeatured({ sec, card, listing, st }) {
const day = new Date().toDateString(), pls = featPls(); let saved = store.get(featKey(), null);
const fresh = !saved || saved.day !== day || saved.sec !== sec || JSON.stringify(saved.pls) !== JSON.stringify(pls);
let p = fresh ? null : saved.p, err = '';
if (!p) { try { p = await featuredPick(sec, pls); store.set(featKey(), { day, sec, pls, p }); } catch (e) { err = e.message; p = { artist: null, album: null }; } }
const names = pls.length ? (await px.get('/playlists?playlistType=audio').then(m => (m.Metadata || []).filter(x => pls.includes(x.ratingKey)).map(x => x.title)).catch(() => [])) : [];
const big = (kind, o, round) => `<div class="featcard"><a href="#/music/${kind}/${esc(o.id)}"><img class="art ${round ? 'round' : ''}" alt="" src="${px.art(o.thumb, 400)}"></a><div class="featinfo"><small class="sub">Featured ${kind}</small><h2>${esc(o.title)}</h2>${o.artist ? `<p class="sub">${esc(o.artist)}</p>` : ''}
<div class="bar" style="justify-content:flex-start"><button class="btn" data-fp="${kind}">${icon('play')}${kind === 'artist' ? 'Play mix' : 'Play'}</button><a class="btn ghost" href="#/music/${kind}/${esc(o.id)}">Open</a></div></div></div>`;
listing('raw', `<div class="bar" style="justify-content:flex-start;margin:0 0 10px;flex-wrap:wrap"><button class="btn ghost" id="fre">${icon('shuffle')}Pick again</button><button class="btn ghost" id="fpl">Based on: ${pls.length ? esc(names.slice(0, 2).join(', ') || pls.length + ' playlist' + (pls.length > 1 ? 's' : '')) + (names.length > 2 ? ' +' + (names.length - 2) : '') : 'Whole library'} ▾</button></div>
${err ? `<p class="err">${esc(err)}</p>` : ''}<div class="featgrid">${p.artist ? big('artist', p.artist, true) : ''}${p.album ? big('album', p.album) : ''}</div><div id="mverse" class="mmv"></div>`);
const v = $('#view'); mmVerse(document.getElementById('mverse'));
v.querySelectorAll('[data-fp]').forEach(b => b.onclick = async () => {
b.disabled = true;
try {
if (b.dataset.fp === 'album') { const mc = await px.get(`/library/metadata/${p.album.id}/children`), tr = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); if (tr.length) playQueue(tr, 0); }
else { const mc = await px.get(`/library/metadata/${p.artist.id}/allLeaves`), tr = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); if (tr.length) playQueue(shuffled(tr), 0, true); else toast('No songs found for this artist'); }
} catch (e) { toast(e.message); }
b.disabled = false;
});
$('#fre').onclick = () => { store.del(featKey()); route(); };
$('#fpl').onclick = async () => {
let all = []; try { all = ((await px.get('/playlists?playlistType=audio')).Metadata || []).filter(x => !x.smart || true); } catch (e) { return toast(e.message); }
if (!all.length) return toast('Make a playlist first, then choose it here');
const chosen = new Set(pls), el = openSheet('Based on', 'Pick the playlists the featured artist and album come from. None chosen means your whole library.', `<div class="list" style="max-height:50dvh;overflow:auto">${all.map(x => `<label class="row" style="cursor:pointer"><input type="checkbox" data-pl="${esc(x.ratingKey)}"${chosen.has(x.ratingKey) ? ' checked' : ''}><span class="grow"><b>${esc(x.title)}</b><small>${x.leafCount || 0} songs</small></span></label>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="fall" style="flex:1;justify-content:center">Whole library</button><button class="btn" id="fok" style="flex:1;justify-content:center">Use these</button></div>`);
const save = ids => { store.set('music_feat_pls', ids); store.del(featKey()); closeSheet(); route(); };
el.querySelector('#fok').onclick = () => save([...el.querySelectorAll('[data-pl]:checked')].map(c => c.dataset.pl));
el.querySelector('#fall').onclick = () => save([]);
};
}
+1
View File
@@ -54,6 +54,7 @@ function openPalette() {
const out = navTabs().flatMap(t => MERGED[t[0]] ? MERGED[t[0]][1].filter(allowed).map(id => [id, tabLabel(id)]) : [t]).map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === (leafOf(id) || id))) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
if (phone.matches) out.push({ label: 'Edit the bottom bar', hint: 'View', ic: 'menu', run: () => openPinsSheet(), kw: 'menu tabs pins more' });
if (allowed('tasks') && allowed('calendar')) out.push({ label: 'Go to Tasks', hint: 'Planner', ic: 'check', run: go('#/tasks'), kw: 'todo' }, { label: 'Go to Calendar', hint: 'Planner', ic: 'cal', run: go('#/calendar'), kw: 'agenda events' });
if (typeof stItems !== 'undefined' && allowed('store')) for (const i of stItems) { if (i.kind === 'extension' && stInstalled.includes(i.id)) out.push({ label: 'Open ' + i.title, hint: 'Extension', ic: 'cog', run: go('#/store/ext/' + i.id), kw: 'app extension ' + i.title.toLowerCase() }); else if (i.kind === 'link') out.push({ label: 'Open ' + i.title, hint: 'Site', ic: 'link', run: i.embed ? go('#/store/site/' + i.id) : () => window.open(i.url, '_blank', 'noopener,noreferrer'), kw: 'app site ' + i.title.toLowerCase() }); }
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' });
if (allowed('mail') && cfg.mail) out.push({ label: 'New email', hint: 'Create', ic: 'mail', run: () => { location.hash = '#/mail'; setTimeout(() => mailCompose(), 300); }, kw: 'compose write message' });
+18 -8
View File
@@ -3,7 +3,7 @@
const shuffled = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; };
const pl = { q: [], i: -1, a: new Audio(), shuffle: false, repeat: 'off', orig: null, alt: new Set() };
const cur = () => pl.q[pl.i];
function playQueue(q, i = 0, shuffleOn = false) { pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); }
function playQueue(q, i = 0, shuffleOn = false) { pl.radio = null; pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); }
// Shuffle mixes only what is still to come, and turning it off puts the queue back in its original order.
function setShuffle(on) {
if (on === pl.shuffle || !pl.q.length) return;
@@ -63,6 +63,7 @@ function loadCur(delay = 220) {
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
if (lyr.open) { lyrShell(); lyrReload(); }
if (typeof vizTrackChanged === 'function') vizTrackChanged();
if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint();
const go = () => {
if (tok !== loadTok) return;
const hit = tc.m.get(t.src);
@@ -107,6 +108,12 @@ function trackSheet(t, extra) {
{ label: 'Add to queue', icon: 'files', run: () => queueTrack(t) },
{ label: 'Play now', icon: 'play', run: () => playQueue([t], 0) },
{ label: 'Add to playlist…', icon: 'plus', run: () => addToPlaylist([t]) },
...(typeof mmPlex === 'function' && mmPlex(t.key) ? [
{ label: mfIs(t) ? 'Remove from Favorites' : 'Add to Favorites', icon: 'heart', run: () => mfToggle(t) },
{ label: 'Add to a Family playlist…', icon: 'plus', run: () => addToFamily([t]) },
...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareToChat('♪ ' + t.title + ' — ' + t.artist + ' ' + location.origin + '/#/music/album/' + t.albumKey) }] : []),
...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []),
] : []),
...(extra || []),
]);
}
@@ -120,9 +127,9 @@ function restorePlayer() {
}
function openQueue() {
const upcoming = pl.q.map((t, i) => ({ t, i }));
const el = openSheet('Up next', pl.q.length + ' tracks', `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
const el = openSheet('Up next', pl.q.length + ' tracks' + (pl.radio ? ' · library radio is on' : ''), `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
${i > 0 ? `<button class="ibtn" data-u="${i}" aria-label="Move up" title="Move up">${icon('arrup')}</button>` : ''}${i < pl.q.length - 1 ? `<button class="ibtn" data-dn="${i}" aria-label="Move down" title="Move down">${icon('arrdn')}</button>` : ''}${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="qc" style="flex:1;justify-content:center">Clear up next</button><button class="btn ghost" id="qsp" style="flex:1;justify-content:center">Save as playlist</button></div>
<div class="bar" style="margin-top:0"><button class="btn ghost" id="qsl" style="flex:1;justify-content:center">${esc(sleepLabel())} timer</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
@@ -130,6 +137,8 @@ function openQueue() {
el.querySelector('#qsl').onclick = () => { closeSheet(); sleepSheet(); };
el.querySelector('#qc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); openQueue(); };
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); closeSheet(); });
const move = (k, d) => { const j = k + d; if (j < 0 || j >= pl.q.length) return; [pl.q[k], pl.q[j]] = [pl.q[j], pl.q[k]]; if (pl.i === k) pl.i = j; else if (pl.i === j) pl.i = k; pl.orig = null; saveState(); tcKick(); updateNow(); openQueue(); };
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => move(+b.dataset.u, -1)); el.querySelectorAll('[data-dn]').forEach(b => b.onclick = () => move(+b.dataset.dn, 1));
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); });
}
function advance(auto) {
@@ -159,7 +168,7 @@ pl.a.addEventListener('timeupdate', () => {
if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); }
const t = cur(), d = pl.a.duration;
if (t && String(t.key).startsWith('pod:')) { if (!pl.scrobbled && d && pl.a.currentTime > d * 0.9) { pl.scrobbled = true; fetch('/api/podcast/podcastitems/' + encodeURIComponent(t.key.slice(4)) + '/markPlayed').catch(() => {}); } } // a podcast episode counts as heard near its end
else if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex
else if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); if (typeof musicPlayed === 'function') musicPlayed(t, d); } // counts as played in Plex
if (Date.now() - lastPos > 1000 && 'mediaSession' in navigator && pl.a.duration) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: pl.a.duration, position: pl.a.currentTime, playbackRate: pl.a.playbackRate }); } catch {} }
});
addEventListener('pagehide', saveState);
@@ -205,7 +214,7 @@ function renderMini() {
let miniSwiped = false;
function stopPlayer() {
tlStop();
pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {}
pl.radio = null; pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {}
if ('mediaSession' in navigator) { navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = 'none'; }
tcKick(); saveState(); renderMini(); renderNpCard(); closeSheet(); markPlaying(); lyrClose();
}
@@ -248,7 +257,7 @@ function renderNpCard() {
el.innerHTML = `<button class="npc-art" id="cnart" aria-label="Open the full player"><img alt="" src="${px.art(t.thumb, 300)}"></button>
<div class="npc-text"><small class="npc-lbl">NOW PLAYING</small><b id="cnti">${esc(t.title)}</b><small id="cnsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
<button class="ibtn big" id="cnt" aria-label="Play or pause"></button><button class="ibtn" id="cnn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="cnr" aria-label="Repeat">${icon('repeat')}</button>
<button class="btn ghost npc-l" id="cnl">${lyrLabel()}</button><button class="btn ghost npc-l" id="cnv">Visuals</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
const sk = el.querySelector('#cnk');
@@ -256,7 +265,7 @@ function renderNpCard() {
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen; el.querySelector('#cnv').onclick = vizOpen;
el.querySelector('#cnp').onclick = prev; el.querySelector('#cnn').onclick = next; el.querySelector('#cnt').onclick = toggle;
el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
modeUI(); syncUI();
}
// The Now Playing sheet stays open while tracks change, so refresh what it shows.
@@ -274,12 +283,13 @@ function openNow() {
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button><button class="btn ghost" id="npv">Visuals</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button><button class="btn ghost" id="npv">Visuals</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
const sk = el.querySelector('#npk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint);
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
updateNow(); syncUI();
+36 -6
View File
@@ -48,36 +48,66 @@ async function viewPodcast(args = []) {
async function pdHome() {
const list = await pdApi('/podcasts?sort=lastepisode&order=desc'), shows = Array.isArray(list) ? list : (list.podcasts || []);
if (!location.hash.startsWith('#/podcast')) return;
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Podcast</h1><span class="pspace grow"></span><a class="btn ghost" href="#/podcast/vbv">Verse By Verse</a><button class="btn" id="pdadd">${icon('plus')}Add podcast</button></div>
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Podcast</h1><span class="pspace grow"></span><a class="btn ghost" href="#/podcast/vbv">Verse By Verse</a><button class="btn ghost" id="pdtr">${icon('search')}Search what's said</button><button class="btn" id="pdadd">${icon('plus')}Add podcast</button></div>
${shows.length ? `<div class="vgrid">${shows.map(s => `<a class="vcard" href="#/podcast/show/${encodeURIComponent(pdId(s))}">
<span class="vimg" style="aspect-ratio:1/1"><img loading="lazy" alt="" src="${pdImg('podcasts', s)}" onerror="this.remove()"></span>
<b>${esc(pdF(s, 'title'))}</b><small>${esc([pdF(s, 'allEpisodesCount') ? pdF(s, 'allEpisodesCount') + ' episodes' : '', pdF(s, 'isPaused') ? 'paused' : ''].filter(Boolean).join(' · '))}</small></a>`).join('')}</div>`
: '<p class="empty">No podcasts on your list yet. Search for one, or paste its feed address.</p>'}</div>`;
$('#pdtr').onclick = pdTranscriptSearch;
$('#pdadd').onclick = () => {
const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', `<input type="search" id="pdq" placeholder="Search podcasts, or paste https://…/feed.xml" autocomplete="off" autocapitalize="off">
<div class="err" id="pde"></div><div id="pdres" class="list" style="margin-top:8px;max-height:50vh;overflow:auto"></div>
<p class="sub" style="margin:8px 0 0">Searching uses Apple's public podcast directory, so the words you type are sent to Apple.</p>`);
<div id="pdtopics" class="bar" style="flex-wrap:wrap;gap:6px;margin:8px 0 0"></div><div class="err" id="pde"></div><div id="pdres" class="list" style="margin-top:8px;max-height:50vh;overflow:auto"></div>
<p class="sub" style="margin:8px 0 0">Searching uses public podcast directories (Apple's, and Podcast Index when the server has a key), so the words you type are sent to them.</p>`);
const q = el.querySelector('#pdq'), res = el.querySelector('#pdres'), err = el.querySelector('#pde'); let timer, seq = 0, found = [];
const add = async (url, btn) => {
err.textContent = ''; if (btn) btn.disabled = true;
try { await pdApi('/podcasts', pdOpt('POST', { url })); closeSheet(); toast('Added; episodes will appear in a moment'); route(); }
catch (e) { err.textContent = e.status === 502 || e.status === 409 || e.status === 400 ? 'Podgrab could not add that (it may already be there)' : e.message; if (btn) btn.disabled = false; }
};
const show = list => {
found = list; res.innerHTML = found.length ? found.map((f, i) => `<div class="row"><img class="art" alt="" style="width:48px;height:48px;border-radius:8px;flex:none" src="${esc(stOk(f.image))}" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(f.title)}</b><small>${esc(f.author || '')}</small></span><button class="btn" data-i="${i}" style="min-height:36px">Add</button></div>`).join('') : '<p class="empty">Nothing found.</p>';
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b));
};
pdApi('/discover/topics').then(ts => { const box = el.querySelector('#pdtopics'); if (!box || !ts.length) return; box.innerHTML = ts.map(t => `<button class="btn ghost" data-t="${esc(t)}" style="min-height:32px;padding:0 12px">${esc(t)}</button>`).join('');
box.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { const my = ++seq; q.value = ''; err.textContent = ''; res.innerHTML = '<p class="empty">Looking…</p>'; try { const l = await pdApi('/discover?topic=' + encodeURIComponent(b.dataset.t)); if (my === seq) show(l); } catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; } }); }).catch(() => {});
const run = async () => {
const term = q.value.trim(), my = ++seq; err.textContent = '';
if (/^https?:\/\/\S+$/i.test(term)) { res.innerHTML = `<button class="row" id="pdfeed"><span class="grow"><b>Add this feed</b><small style="white-space:normal">${esc(term)}</small></span></button>`; res.querySelector('#pdfeed').onclick = e => add(term, e.currentTarget); return; }
if (term.length < 2) { res.innerHTML = ''; return; }
res.innerHTML = '<p class="empty">Searching…</p>';
try {
found = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return;
res.innerHTML = found.length ? found.map((f, i) => `<div class="row"><img class="art" alt="" style="width:48px;height:48px;border-radius:8px;flex:none" src="${esc(f.image || '')}" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(f.title)}</b><small>${esc(f.author || '')}</small></span><button class="btn" data-i="${i}" style="min-height:36px">Add</button></div>`).join('') : '<p class="empty">Nothing found.</p>';
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b));
const l = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return; show(l);
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus();
};
}
// Search what has been said in every transcript you have: tap a result to play that episode from that moment.
const pdClock = ms => { const s = Math.floor(ms / 1000), h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); };
function pdTranscriptSearch() {
const el = openSheet("Search what's said", 'Finds a word or phrase in every transcript you have', `<input type="search" id="trq" placeholder="A word or phrase" autocomplete="off"><div class="err" id="tre"></div><div id="trres" class="list" style="margin-top:8px;max-height:60vh;overflow:auto"></div>`);
const q = el.querySelector('#trq'), res = el.querySelector('#trres'); let timer, seq = 0, found = [];
const hl = (text, w) => { const re = new RegExp('(' + w.split(/\s+/).filter(x => x.length > 1).map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'gi'); return esc(text).replace(re, '<mark>$1</mark>'); };
const play = (r, h) => {
closeSheet(); const key = r.key, track = { key, title: r.title, artist: r.artist, album: '', thumb: r.thumb, src: r.src, nocache: true };
if (pdPlaying() === key.slice(4) && String(cur().key) === key) { pl.a.currentTime = h.t / 1000; pl.a.play().catch(() => {}); return; }
playQueue([track], 0); pl.a.addEventListener('loadedmetadata', () => { const c = cur(); if (c && c.key === key) pl.a.currentTime = h.t / 1000; }, { once: true });
};
const run = async () => {
const term = q.value.trim(), my = ++seq; if (term.length < 2) { res.innerHTML = ''; return; }
res.innerHTML = '<p class="empty">Searching…</p>';
try {
const out = (await jfetch('/api/transcript/search?q=' + encodeURIComponent(term))).results; if (my !== seq) return; found = out;
res.innerHTML = out.length ? out.map((r, i) => `<div class="pdhit"><div class="row"><span class="grow"><b>${esc(r.title)}</b><small>${esc(r.artist)}${r.n > r.hits.length ? ' · ' + r.n + ' places' : ''}</small></span></div>
${r.hits.map((h, j) => `<button class="row" data-i="${i}" data-j="${j}"><span class="grow"><small style="white-space:normal">${hl(h.text, term)}</small></span><small>${pdClock(h.t)}</small></button>`).join('')}</div>`).join('')
: '<p class="empty">Nothing found. Only episodes that already have a transcript can be searched.</p>';
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => play(found[b.dataset.i], found[b.dataset.i].hits[b.dataset.j]));
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus();
}
async function pdShow(id) {
const [, show, items0] = await Promise.all([pdLoadProg(), pdApi('/podcasts/' + encodeURIComponent(id)), pdApi('/podcasts/' + encodeURIComponent(id) + '/items')]);
if (!location.hash.startsWith('#/podcast')) return;
+46 -28
View File
@@ -38,41 +38,59 @@ function rdoApplyNow(t, n) {
setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPlaying()) setTimeout(rdoPoll, 1500); });
function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `<small>Now playing</small><b>${esc(n.title)}</b>${n.artist ? '<span> · ' + esc(n.artist) + '</span>' : ''}` : '<small>Live</small>'; }
async function viewRadio() {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Radio</h1><p class="empty">Loading…</p></div>';
let feat, mine;
try { ({ featured: feat, mine } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Radio</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/radio')) return;
rdoSaved = mine; const S = { q: '', tag: 'christian', items: [], more: false, busy: false, seq: 0, off: 0 };
const saved = id => rdoSaved.some(x => x.id === id);
const row = (s, fav) => `<div class="row rdorow${rdoPlaying() && rdoPlaying().key === 'radio:' + s.id ? ' playing' : ''}" data-id="${esc(s.id)}"><button class="rdoplay" data-play aria-label="Play ${esc(s.name)}"><span class="art">${s.favicon ? `<img alt="" loading="lazy" src="${esc(rdoIcon(s))}" onerror="this.remove()">` : ''}</span>
<span class="grow"><b>${esc(s.name)}</b><small>${esc(rdoMeta(s))}</small></span>${icon('play')}</button>${s.featured ? '' : `<button class="ibtn" data-fav aria-label="${saved(s.id) ? 'Remove from my stations' : 'Save to my stations'}" title="${saved(s.id) ? 'Remove from my stations' : 'Save to my stations'}" style="${saved(s.id) ? 'color:var(--danger)' : ''}">${icon('heart')}</button>`}</div>`;
const j = feat[0];
v.innerHTML = `<div class="wrap"><h1>Radio</h1>
<div class="card rdofeat" style="max-width:none"><div class="sub" style="margin:0">Featured</div><h2 style="margin:2px 0 0;font-size:22px;text-transform:none;letter-spacing:0;color:var(--text)">${esc(j.name)}</h2>
async function viewRadio(host) { // host: { el, hash } when shown inside the Store (the station finder); on the Radio tab itself only what was added from the Store is listed
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '<div class="wrap"><h1>Radio</h1><p class="empty">Loading…</p></div>';
let feat, mine, added;
try { ({ featured: feat, mine, added } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Radio</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!here()) return;
rdoSaved = mine; const inStore = !!host, finder = inStore || !allowed('store'), S = { q: '', tag: 'christian', items: [], more: false, busy: false, seq: 0, off: 0 }; // (someone without the Store tab can still find stations here)
const isFeat = id => feat.some(f => f.id === id), isAdded = id => isFeat(id) ? added.includes(id) : rdoSaved.some(x => x.id === id);
const j = feat[0], showJ = !inStore && added.includes(j.id);
const row = s => { const on = isAdded(s.id), label = on ? 'Remove from my stations' : 'Add to Radio';
const act = inStore ? `<button class="btn${on ? ' ghost' : ''}" data-fav style="min-height:34px;flex:none;margin-left:6px" title="${on ? 'Tap to remove it from Radio' : 'Show it in Radio under Listen'}">${on ? 'Added' : 'Add to Radio'}</button>`
: isFeat(s.id) ? '' : `<button class="ibtn" data-fav aria-label="${label}" title="${label}" style="${on ? 'color:var(--danger)' : ''}">${icon('heart')}</button>`;
return `<div class="row rdorow${rdoPlaying() && rdoPlaying().key === 'radio:' + s.id ? ' playing' : ''}" data-id="${esc(s.id)}"><button class="rdoplay" data-play aria-label="Play ${esc(s.name)}"><span class="art">${s.favicon ? `<img alt="" loading="lazy" src="${esc(rdoIcon(s))}" onerror="this.remove()">` : ''}</span>
<span class="grow"><b>${esc(s.name)}</b><small>${esc(rdoMeta(s))}</small></span>${icon('play')}</button>${act}</div>`; };
const empty = !inStore && !showJ && !rdoSaved.length;
const findHtml = finder ? `<h2>Find stations</h2><input type="search" id="rdoq" placeholder="Search stations by name" autocomplete="off" autocapitalize="off" style="width:100%">
<div class="seg" id="rdochips">${rdoChips.map(([l, t]) => `<a href="#" data-t="${t}" class="${t === S.tag ? 'on' : ''}">${l}</a>`).join('')}</div><div id="rdores" class="list"></div><div id="rdomore" class="bar" style="justify-content:center"></div>`
: empty ? '' : `<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/store/radio">${icon('search')}Find more stations in the Store</a></div>`;
v.innerHTML = `<div class="wrap">${host ? '' : '<h1>Radio</h1>'}
${inStore ? `<p class="sub">Add a station and it shows up in Radio under Listen.</p><h2>Featured</h2><div class="list" id="rdofeat"></div>` : showJ ? `<div class="card rdofeat" style="max-width:none"><div class="sub" style="margin:0">Featured</div><h2 style="margin:2px 0 0;font-size:22px;text-transform:none;letter-spacing:0;color:var(--text)">${esc(j.name)}</h2>
<p class="sub" style="margin:2px 0 10px">${esc(rdoMeta(j))}</p><div id="rdonow" data-id="${esc(j.id)}" class="rdonow"><small>Live</small></div>
<div class="bar"><button class="btn" id="rdojp">${icon('play')}Listen live</button><button class="btn ghost" id="rdojl">${icon('lyrics')}Lyrics</button><button class="btn ghost" id="rdojv">Visuals</button><a class="btn ghost" href="${esc(j.homepage)}" target="_blank" rel="noopener">${icon('ext')}Website</a><button class="btn ghost" id="rdojr">Recently played</button></div></div>
<div id="rdomine"></div><h2>Find stations</h2><input type="search" id="rdoq" placeholder="Search stations by name" autocomplete="off" autocapitalize="off" style="width:100%">
<div class="seg" id="rdochips">${rdoChips.map(([l, t]) => `<a href="#" data-t="${t}" class="${t === S.tag ? 'on' : ''}">${l}</a>`).join('')}</div><div id="rdores" class="list"></div><div id="rdomore" class="bar" style="justify-content:center"></div></div>`;
<div class="bar"><button class="btn" id="rdojp">${icon('play')}Listen live</button><button class="btn ghost" id="rdojl">${icon('lyrics')}Lyrics</button><button class="btn ghost" id="rdojv">Visuals</button><a class="btn ghost" href="${esc(j.homepage)}" target="_blank" rel="noopener">${icon('ext')}Website</a><button class="btn ghost" id="rdojr">Recently played</button><button class="btn ghost" id="rdojx">Remove</button></div></div>` : ''}
${inStore ? '' : '<div id="rdomine"></div>'}${empty ? `<p class="empty">Nothing here yet. ${allowed('store') ? 'Add stations from the Store and they will show up here.' : 'Find a station below and tap the heart.'}</p>${allowed('store') ? `<div class="bar" style="justify-content:center"><a class="btn" href="#/store/radio">${icon('search')}Open the Store</a></div>` : ''}` : ''}
${findHtml}</div>`;
const playStation = s => { const t = rdoTrack(s); playQueue([t], 0); toast('Tuning in to ' + s.name); drawAll(); rdoApi('/now/' + encodeURIComponent(s.id)).then(n => { if (rdoPlaying() && rdoPlaying().key === t.key) { rdoNow[s.id] = n; rdoApplyNow(rdoPlaying(), n); const el = document.getElementById('rdonow'); if (el && el.dataset.id === s.id) rdoDrawNow(el, n); } }).catch(() => {}); setTimeout(rdoPoll, 2500); };
const bindRows = (root, byId) => { root.querySelectorAll('.rdorow').forEach(r => { const s = byId(r.dataset.id); r.querySelector('[data-play]').onclick = () => playStation(s);
const f = r.querySelector('[data-fav]'); if (f) f.onclick = async () => { try { if (saved(s.id)) { await rdoApi('/stations/' + encodeURIComponent(s.id), rdoOpt('DELETE')); rdoSaved = rdoSaved.filter(x => x.id !== s.id); } else { await rdoApi('/stations', rdoOpt('POST', { id: s.id })); rdoSaved.push(s); } drawAll(); } catch (e) { toast(e.message); } }; }); };
const drawMine = () => { $('#rdomine').innerHTML = rdoSaved.length ? `<h2>My stations</h2><div class="list">${rdoSaved.map(s => row(s)).join('')}</div>` : ''; bindRows($('#rdomine'), id => rdoSaved.find(x => x.id === id)); };
const drawRes = () => { $('#rdores').innerHTML = S.items.length ? S.items.map(s => row(s)).join('') : S.busy ? '<p class="empty">Searching…</p>' : '<p class="empty">No stations found.</p>'; bindRows($('#rdores'), id => S.items.find(x => x.id === id));
const toggle = async s => {
if (isAdded(s.id)) { await rdoApi('/stations/' + encodeURIComponent(s.id), rdoOpt('DELETE')); if (isFeat(s.id)) added = added.filter(x => x !== s.id); else rdoSaved = rdoSaved.filter(x => x.id !== s.id); }
else { await rdoApi('/stations', rdoOpt('POST', { id: s.id })); if (isFeat(s.id)) added.push(s.id); else rdoSaved.push(s); }
};
const bindRows = (root, byId) => { if (!root) return; root.querySelectorAll('.rdorow').forEach(r => { const s = byId(r.dataset.id); r.querySelector('[data-play]').onclick = () => playStation(s);
const f = r.querySelector('[data-fav]'); if (f) f.onclick = async () => { try { await toggle(s); drawAll(); } catch (e) { toast(e.message); } }; }); };
const drawFeat = () => { const el = $('#rdofeat'); if (!el) return; el.innerHTML = feat.map(row).join(''); bindRows(el, id => feat.find(x => x.id === id)); };
const drawMine = () => { const el = $('#rdomine'); if (!el) return; el.innerHTML = rdoSaved.length ? `<h2>My stations</h2><div class="list">${rdoSaved.map(row).join('')}</div>` : ''; bindRows(el, id => rdoSaved.find(x => x.id === id)); };
const drawRes = () => { const res = $('#rdores'); if (!res) return; res.innerHTML = S.items.length ? S.items.map(row).join('') : S.busy ? '<p class="empty">Searching…</p>' : '<p class="empty">No stations found.</p>'; bindRows(res, id => S.items.find(x => x.id === id));
$('#rdomore').innerHTML = S.more && !S.busy ? '<button class="btn ghost" id="rdomb">Show more</button>' : ''; if ($('#rdomb')) $('#rdomb').onclick = () => run(false); };
const drawAll = () => { drawMine(); drawRes(); };
const drawAll = () => { drawFeat(); drawMine(); drawRes(); };
const run = async reset => {
const my = ++S.seq; if (reset) { S.off = 0; S.items = []; } S.busy = true; if (reset) drawRes();
try { const r = await rdoApi(`/search?q=${encodeURIComponent(S.q)}&tag=${encodeURIComponent(S.q ? '' : S.tag)}&offset=${S.off}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; S.off += 30; }
catch (e) { if (my === S.seq) { $('#rdores').innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; S.busy = false; return; } }
if (my === S.seq) { S.busy = false; drawRes(); }
};
$('#rdojp').onclick = () => playStation(j);
$('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); };
$('#rdojv').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof vizOpen === 'function') vizOpen(); }, 400); };
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty">Loading…</p>'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `<div class="list">${n.recent.map(x => `<div class="row"><span class="grow"><b>${esc(x.title)}</b><small>${esc(x.artist)}</small></span></div>`).join('')}</div>` : '<p class="empty">Nothing to show right now.</p>'; } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; } };
$('#rdochips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.tag = a.dataset.t; S.q = ''; $('#rdoq').value = ''; $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); run(true); });
let t; $('#rdoq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', !S.q && x.dataset.t === S.tag)); run(true); }, 450); };
drawMine(); run(true);
rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {});
if (showJ) {
$('#rdojp').onclick = () => playStation(j);
$('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); };
$('#rdojv').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof vizOpen === 'function') vizOpen(); }, 400); };
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty">Loading…</p>'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `<div class="list">${n.recent.map(x => `<div class="row"><span class="grow"><b>${esc(x.title)}</b><small>${esc(x.artist)}</small></span></div>`).join('')}</div>` : '<p class="empty">Nothing to show right now.</p>'; } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; } };
$('#rdojx').onclick = async () => { try { await rdoApi('/stations/' + encodeURIComponent(j.id), rdoOpt('DELETE')); toast('Removed from Radio'); viewRadio(host); } catch (e) { toast(e.message); } };
rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {});
}
if (finder) {
$('#rdochips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.tag = a.dataset.t; S.q = ''; $('#rdoq').value = ''; $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); run(true); });
let t; $('#rdoq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', !S.q && x.dataset.t === S.tag)); run(true); }, 450); };
run(true);
}
drawFeat(); drawMine();
}
+10 -6
View File
@@ -27,7 +27,7 @@ const rdTitleOf = f => f.replace(/\.[^.]+$/, '').replace(/[_]+/g, ' ');
// ======================================================= Library
async function viewReader(args = []) {
if (args[0] === 'open' && args[1]) return rdOpen(args[1]);
if (args[0] === 'store') return rdStore();
if (args[0] === 'store') return allowed('store') ? location.replace('#/store/books') : rdStore();
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Books</h1><p class="empty">Loading…</p></div>';
try { await rdLibrary(); } catch (e) {
@@ -151,14 +151,14 @@ async function rdMobiOpen(u8) {
}
// ======================================================= Book store: free, DRM-free books from Project Gutenberg, Standard Ebooks, the Internet Archive and any OPDS catalogue
async function rdStore() {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Book store</h1><p class="empty">Loading…</p></div>';
async function rdStore(host) { // host: { el, hash } when shown inside the Store tab
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/read/store'); v.innerHTML = '<div class="wrap"><h1>Book store</h1><p class="empty">Loading…</p></div>';
let sources, mine;
try { [sources, mine] = await Promise.all([rdApi('/store/sources'), rdApi('/list')]); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/read/store')) return;
if (!here()) return;
const key = b => (b.title + '|' + (b.author || '')).toLowerCase().replace(/\s+/g, ' ').trim(), have = new Map(mine.map(b => [key(b), b.id]));
const S = { q: '', source: 'all', page: 1, items: [], more: false, seq: 0 };
v.innerHTML = `<div class="wrap" id="rdst"><div class="ptop"><a class="btn ghost" href="#/read">${icon('back')}Books</a><h1 style="margin:0">Book store</h1><span class="pspace grow"></span>${ME && ME.role === 'admin' ? `<button class="btn ghost" id="rdcat">${icon('cog')}Catalogues</button>` : ''}</div>
v.innerHTML = `<div class="wrap" id="rdst"><div class="ptop">${host ? '' : `<a class="btn ghost" href="#/read">${icon('back')}Books</a><h1 style="margin:0">Book store</h1>`}<span class="pspace grow"></span>${ME && ME.role === 'admin' ? `<button class="btn ghost" id="rdcat">${icon('cog')}Catalogues</button>` : ''}</div>
<p class="sub">Free books without DRM, from public sources. Search them all at once, then add a book to your library in one tap.</p>
<input type="search" id="rdsq" placeholder="Search titles and authors" autocomplete="off" autocapitalize="off" style="width:100%">
<div class="seg" id="rdchips"></div><div id="rdnote"></div><div id="rdres"></div><div id="rdmore" class="bar" style="justify-content:center"></div></div>`;
@@ -198,10 +198,14 @@ async function rdStore() {
};
const catalogues = () => {
const el = openSheet('Catalogues', 'Anything that speaks OPDS: Calibre-Web, Kavita, Komga, Standard Ebooks and others', '<div id="rdcl"></div>');
const list = async () => { sources = await rdApi('/store/sources'); chips(); el.querySelector('#rdcl').innerHTML = `<div class="list">${sources.map(x => `<div class="row"><span class="grow"><b>${esc(x.name)}</b><small>${x.builtin ? 'Built in' : 'Added by you'}</small></span>${x.builtin ? '' : `<button class="btn danger" data-d="${esc(x.id)}" style="min-height:36px">Remove</button>`}</div>`).join('')}</div>
const list = async () => { sources = await rdApi('/store/sources'); chips(); const sw = sources.find(x => x.id === 'smashwords'), acct = sw && sw.account; el.querySelector('#rdcl').innerHTML = `${sw ? `<div class="list"><div class="row"><span class="grow"><b>Smashwords account</b><small style="white-space:normal">${acct && acct.signedIn ? 'Signed in as ' + esc(acct.email) + '. Books are downloaded with this account.' : 'Free to make. Smashwords only lets signed-in members download, even free books. The sign-in is kept encrypted on this server.'}</small></span>${acct && acct.signedIn ? '<button class="btn danger" id="rdswout" style="min-height:36px">Remove</button>' : ''}</div></div>
${acct && acct.signedIn ? '' : '<form id="rdswf" style="margin:8px 0 16px"><input id="rdswe" type="email" placeholder="Smashwords email" autocomplete="off" autocapitalize="off" required><input id="rdswp" type="password" placeholder="Smashwords password" autocomplete="new-password" required style="margin-top:8px"><div class="err" id="rdswerr"></div><div class="bar"><button class="btn" style="flex:1;justify-content:center">Sign in to Smashwords</button></div></form>'}` : ''}<div class="list">${sources.map(x => `<div class="row"><span class="grow"><b>${esc(x.name)}</b><small>${x.builtin ? 'Built in' : 'Added by you'}</small></span>${x.builtin ? '' : `<button class="btn danger" data-d="${esc(x.id)}" style="min-height:36px">Remove</button>`}</div>`).join('')}</div>
<form id="rdcf"><label>Add a catalogue</label><input id="rdcn" placeholder="Name (for example My Calibre-Web)" maxlength="60" required><input id="rdcu" type="url" placeholder="Address, for example https://books.example.com/opds" required style="margin-top:8px">
<input id="rdcuser" placeholder="Username (if it needs one)" autocomplete="off" style="margin-top:8px"><input id="rdcp" type="password" placeholder="Password" autocomplete="new-password" style="margin-top:8px"><div class="err" id="rdce"></div>
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Add catalogue</button></div></form>`;
if (el.querySelector('#rdswout')) el.querySelector('#rdswout').onclick = async () => { try { await rdApi('/store/smashwords', { method: 'DELETE' }); toast('Smashwords sign-in removed'); await list(); } catch (e) { toast(e.message); } };
if (el.querySelector('#rdswf')) el.querySelector('#rdswf').onsubmit = async e => { e.preventDefault(); const err = el.querySelector('#rdswerr'), btn = e.submitter; err.textContent = ''; if (btn) { btn.disabled = true; btn.textContent = 'Signing in…'; }
try { await rdApi('/store/smashwords', rdJson('POST', { email: el.querySelector('#rdswe').value, pass: el.querySelector('#rdswp').value })); toast('Signed in to Smashwords'); await list(); } catch (x) { err.textContent = x.message; if (btn) { btn.disabled = false; btn.textContent = 'Sign in to Smashwords'; } } };
el.querySelectorAll('[data-d]').forEach(b => b.onclick = async () => { try { await rdApi('/store/sources/' + b.dataset.d, { method: 'DELETE' }); if (S.source === b.dataset.d) S.source = 'all'; await list(); } catch (e) { toast(e.message); } });
el.querySelector('#rdcf').onsubmit = async e => { e.preventDefault(); const err = el.querySelector('#rdce'), btn = e.submitter; err.textContent = ''; if (btn) btn.disabled = true;
try { await rdApi('/store/sources', rdJson('POST', { name: el.querySelector('#rdcn').value, url: el.querySelector('#rdcu').value, user: el.querySelector('#rdcuser').value, pass: el.querySelector('#rdcp').value })); toast('Catalogue added'); await list(); } catch (x) { err.textContent = x.message; if (btn) btn.disabled = false; } };
+104
View File
@@ -0,0 +1,104 @@
'use strict';
// ---- Recipes: your Mealie recipes and shopping list, through the hub (the server holds the Mealie token). A recipe's ingredients go on one shopping list, which is
// also kept in Tasks (a "Shopping" list there), so it can be ticked off from either place.
const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts);
const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; };
const rcTabs = on => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
let rcQuery = '', rcPage = 1;
async function viewRecipes(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Recipes</h1><p class="empty">Loading…</p></div>';
try {
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
if (args[0] === 'shop') return await rcShop();
return await rcHome();
} catch (e) {
if (!location.hash.startsWith('#/recipes')) return;
v.innerHTML = `<div class="wrap"><h1>Recipes</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rcretry">Try again</button></div></div>`;
$('#rcretry').onclick = () => route();
}
}
async function rcHome() {
const draw = (items, more, page) => {
const g = $('#rcgrid'); if (!g) return;
const html = items.map(r => `<a class="vcard" href="#/recipes/r/${encodeURIComponent(r.slug)}"><span class="vimg" style="aspect-ratio:4/3">${r.image ? `<img loading="lazy" alt="" src="/api/recipes/image/${encodeURIComponent(r.id)}" onerror="this.remove()">` : ''}</span>
<b>${esc(r.name)}</b><small>${esc([rcTime(r.minutes), r.rating ? '★'.repeat(Math.round(r.rating)) : ''].filter(Boolean).join(' · '))}</small></a>`).join('');
if (page > 1) g.insertAdjacentHTML('beforeend', html); else g.innerHTML = html || '<p class="empty">No recipes found.</p>';
const mb = $('#rcmore'); if (mb) mb.hidden = !more;
};
const first = await rcApi('/?page=1&q=' + encodeURIComponent(rcQuery)); if (!location.hash.startsWith('#/recipes')) return;
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1><span class="pspace grow"></span><button class="btn" id="rcimp">${icon('plus')}Import from a link</button></div>${rcTabs('all')}
<input type="search" id="rcq" placeholder="Search your recipes" value="${esc(rcQuery)}" autocomplete="off" aria-label="Search recipes" style="width:100%;margin-bottom:12px">
<div class="vgrid" id="rcgrid"></div><div class="bar"><button class="btn ghost" id="rcmore" hidden>Show more</button></div></div>`;
rcPage = 1; draw(first.items, first.more, 1);
let timer, seq = 0;
const load = async page => { const my = ++seq; try { const j = await rcApi('/?page=' + page + '&q=' + encodeURIComponent(rcQuery)); if (my === seq) { rcPage = page; draw(j.items, j.more, page); } } catch (e) { toast(e.message); } };
$('#rcq').oninput = e => { clearTimeout(timer); timer = setTimeout(() => { rcQuery = e.target.value.trim(); load(1); }, 350); };
$('#rcmore').onclick = () => load(rcPage + 1);
$('#rcimp').onclick = () => {
const el = openSheet('Import a recipe', 'Mealie reads the recipe from the web page', `<input type="url" id="rcurl" placeholder="https://…" autocomplete="off" autocapitalize="off" spellcheck="false"><div class="err" id="rce"></div>
<div class="bar"><button class="btn ghost" id="rcx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="rcgo" style="flex:1;justify-content:center">Import</button></div>`);
el.querySelector('#rcx').onclick = closeSheet; const go = el.querySelector('#rcgo'), err = el.querySelector('#rce'), inp = el.querySelector('#rcurl'); inp.focus();
const run = async () => {
err.textContent = ''; go.disabled = true; go.textContent = 'Importing…';
try { const r = await rcApi('/import', rcOpt('POST', { url: inp.value.trim() })); closeSheet(); toast('Imported'); location.hash = '#/recipes/r/' + encodeURIComponent(r.slug); }
catch (e) { err.textContent = e.message; go.disabled = false; go.textContent = 'Import'; }
};
go.onclick = run; inp.onkeydown = e => { if (e.key === 'Enter') run(); };
};
}
async function rcOne(slug) {
const r = await rcApi('/r/' + encodeURIComponent(slug)); if (!location.hash.startsWith('#/recipes')) return;
const facts = [['Prep', rcTime(r.prep)], ['Cook', rcTime(r.cook)], ['Total', rcTime(r.total)], ['Serves', r.servings]].filter(f => f[1]);
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/recipes">${icon('back')}Recipes</a></div>
<div class="rchero">${r.image ? `<span class="vimg" style="width:min(240px,40%);flex:none;aspect-ratio:4/3"><img alt="" src="/api/recipes/image/${encodeURIComponent(r.id)}?big=1" onerror="this.remove()"></span>` : ''}
<div style="min-width:0"><h1 style="margin:0">${esc(r.name)}</h1>${r.description ? `<p class="sub" style="margin:6px 0 0;white-space:normal">${esc(r.description)}</p>` : ''}
${facts.length ? `<p class="sub" style="margin:8px 0 0">${facts.map(f => `<b>${esc(f[0])}</b> ${esc(f[1])}`).join(' · ')}</p>` : ''}
${r.orgUrl ? `<p class="sub" style="margin:4px 0 0"><a href="${esc(r.orgUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Original page</a></p>` : ''}</div></div>
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
<div class="rccols"><section><div class="rchead"><h2>Ingredients</h2><button class="btn ghost" id="rcreset" style="min-height:32px">Uncheck all</button></div>
<div class="list">${r.ingredients.map((i, n) => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<label class="row rcing" style="cursor:pointer"><input type="checkbox" data-n="${n}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty">No ingredients.</p>'}</div></section>
<section><div class="rchead"><h2>Steps</h2></div><ol class="rcsteps">${r.steps.map((s, n) => `<li data-s="${n}">${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol></section></div>
${r.notes.length ? `<h2>Notes</h2>${r.notes.map(n => `<p>${n.title ? `<b>${esc(n.title)}</b><br>` : ''}${esc(n.text).replace(/\n/g, '<br>')}</p>`).join('')}` : ''}</div>`;
// What you have used and done is remembered on this device for each recipe, so you can leave and come back while cooking.
const sk = 'rc_done:' + r.id; let done = store.get(sk, { i: [], s: [] }); if (!done || !Array.isArray(done.i)) done = { i: [], s: [] };
const paint = () => {
document.querySelectorAll('.rcing input').forEach(c => { const on = done.i.includes(+c.dataset.n); c.checked = on; c.nextElementSibling.style.cssText = on ? 'text-decoration:line-through;color:var(--muted)' : ''; });
document.querySelectorAll('.rcsteps li[data-s]').forEach(li => li.classList.toggle('done', done.s.includes(+li.dataset.s)));
$('#rcreset').hidden = !done.i.length && !done.s.length;
};
const flip = (list, n) => { const k = done[list].indexOf(n); if (k < 0) done[list].push(n); else done[list].splice(k, 1); if (done.i.length || done.s.length) store.set(sk, done); else store.del(sk); paint(); };
document.querySelectorAll('.rcing input').forEach(c => c.onchange = () => flip('i', +c.dataset.n));
document.querySelectorAll('.rcsteps li[data-s]').forEach(li => li.onclick = () => flip('s', +li.dataset.s));
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
paint();
if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
$('#rcadd').onclick = async e => {
const b = e.currentTarget; b.disabled = true;
try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; }
catch (err) { toast(err.message); b.disabled = false; }
};
}
async function rcShop() {
const draw = async l => {
if (!location.hash.startsWith('#/recipes/shop')) return;
const open = l.items.filter(i => !i.checked), done = l.items.filter(i => i.checked), row = i => `<label class="row rcitem" style="cursor:pointer"><input type="checkbox" data-id="${esc(i.id)}"${i.checked ? ' checked' : ''}><span class="grow"${i.checked ? ' style="text-decoration:line-through;color:var(--muted)"' : ''}>${esc(i.text)}</span></label>`;
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Shopping list</h1></div>${rcTabs('shop')}
<form id="rcnew" class="bar" style="margin:0 0 12px;gap:8px"><input type="text" id="rctext" placeholder="Add an item" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
<div class="list">${open.map(row).join('') || '<p class="empty">Nothing to buy. Open a recipe and add its ingredients.</p>'}</div>
${done.length ? `<h2>Got it</h2><div class="list">${done.map(row).join('')}</div><div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="rcclear">Remove ticked items</button></div>` : ''}
<p class="sub" style="margin-top:16px">Also in Tasks as the “Shopping” list: ticking an item there ticks it here.</p></div>`;
document.querySelectorAll('.rcitem input').forEach(c => c.onchange = async () => {
c.disabled = true; try { await rcApi('/shopping/check', rcOpt('POST', { id: c.dataset.id, checked: c.checked })); draw(await rcApi('/shopping')); } catch (e) { toast(e.message); c.checked = !c.checked; c.disabled = false; }
});
$('#rcnew').onsubmit = async e => { e.preventDefault(); const t = $('#rctext').value.trim(); if (!t) return; try { await rcApi('/shopping/add', rcOpt('POST', { text: t })); draw(await rcApi('/shopping')); } catch (err) { toast(err.message); } };
if ($('#rcclear')) $('#rcclear').onclick = async () => { try { await rcApi('/shopping/clear', rcOpt('POST', {})); draw(await rcApi('/shopping')); } catch (e) { toast(e.message); } };
};
try { await rcApi('/shopping/sync', rcOpt('POST', {})); } catch {} // (opening the list also brings Tasks up to date)
await draw(await rcApi('/shopping'));
}
+31
View File
@@ -0,0 +1,31 @@
'use strict';
// ---- iPad and Screen Time (admin): which web addresses the app needs, the ones devices were seen reaching, and a check you run on the device itself.
const stOk = u => /^https?:\/\//i.test(u || '') ? '/api/img?u=' + encodeURIComponent(u) : (u || ''); // a picture from another site, passed through the hub so the browser only talks to the hub
async function screenTimeSheet() {
let d; try { d = await jfetch('/api/screentime'); } catch (e) { return toast(e.message); }
const row = (x, extra = '') => `<div class="row"><span class="grow"><b>${esc(x.host)}</b><small style="white-space:normal">${esc(x.why || ((x.what || []).join(', ') + ' · seen ' + x.n + (x.n === 1 ? ' time' : ' times')))}</small></span>${extra}<span class="stres" data-h="${esc(x.host)}"></span><button class="ibtn" data-c="${esc(x.host)}" aria-label="Copy ${esc(x.host)}" title="Copy">${icon('copy')}</button></div>`;
const hub = d.required[0].host;
const el = openSheet('iPad and Screen Time', 'Let the hub through Screen Time without adding each site one by one', `
<p style="margin:0 0 8px"><b>The simple way.</b> On the iPad: <b>Settings → Screen Time → Content &amp; Privacy Restrictions → Content Restrictions → Web Content</b>. Choose <b>Limit Adult Websites</b>, then under <b>Always Allow</b> tap <b>Add Website</b> and enter <b>${esc(hub)}</b>. Nothing else needs adding: the hub carries the music, books, podcasts and pictures itself.</p>
<p class="sub" style="white-space:normal">Only if you use <b>Allowed Websites Only</b> do you need to add every address below.</p>
<h2>Addresses the app needs</h2><div class="list">${d.required.map(x => row(x)).join('')}</div>
${d.seen.length ? `<h2>Others your devices reached</h2><p class="sub" style="white-space:normal">Seen while people used the app (a podcast cover, a weather extension, a site you added). Add only what you want available.</p><div class="list">${d.seen.map(x => row(x)).join('')}</div>` : '<p class="sub" style="white-space:normal">No other addresses have been seen yet. Use the app for a day on the iPad and any others will show up here.</p>'}
<h2>Only sometimes</h2><div class="list">${d.optional.map(x => row(x)).join('')}</div>
<div class="bar"><button class="btn ghost" id="stcopy" style="flex:1;justify-content:center">Copy the list</button><button class="btn" id="stchk" style="flex:1;justify-content:center">Check this device</button></div>
<p class="sub" style="white-space:normal">“Check this device” tries each address from the device you are on, and shows which ones Screen Time (or the network) is blocking. Run it on the iPad.</p>
${d.seen.length ? '<div class="bar"><button class="btn ghost" id="stclr">Clear the seen list</button></div>' : ''}`);
el.querySelector('.sheet').classList.add('wide');
el.querySelectorAll('[data-c]').forEach(b => b.onclick = () => { navigator.clipboard.writeText(b.dataset.c).then(() => toast('Copied ' + b.dataset.c), () => toast(b.dataset.c)); });
const all = [...d.required, ...d.seen, ...d.optional].map(x => x.host);
el.querySelector('#stcopy').onclick = () => navigator.clipboard.writeText(all.join('\n')).then(() => toast('Copied ' + all.length + ' addresses'), () => toast('Could not copy'));
el.querySelector('#stchk').onclick = async e => {
const b = e.currentTarget; b.disabled = true; b.textContent = 'Checking…';
await Promise.all(all.map(async h => {
const mark = el.querySelector(`.stres[data-h="${CSS.escape(h)}"]`); if (!mark) return;
try { await fetch(h === location.hostname ? '/manifest.webmanifest' : 'https://' + h + '/', { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(8000) }); mark.textContent = ' ✓'; mark.style.color = 'var(--accent)'; mark.title = 'Reachable'; }
catch { mark.textContent = ' ✗'; mark.style.color = 'var(--danger)'; mark.title = 'Blocked or not reachable from this device'; }
}));
b.disabled = false; b.textContent = 'Check again';
};
if (el.querySelector('#stclr')) el.querySelector('#stclr').onclick = async () => { try { await jfetch('/api/screentime', { method: 'DELETE' }); closeSheet(); toast('Cleared'); } catch (x) { toast(x.message); } };
}
+1 -1
View File
@@ -107,7 +107,7 @@ function viewSearch() {
const mailHtml = (mailR.status === 'fulfilled' ? mailR.value.items || [] : []).slice(0, 8).map(m => `<a class="row" href="#/mail/m/${enc(m.id)}"><span class="ic" style="background:#d4392f">${icon('mail')}</span><span class="grow"><b>${esc(m.subject)}</b><small>${esc((m.from && (m.from.name || m.from.address)) || '')} · ${esc(new Date(m.date).toLocaleDateString())}</small></span></a>`).join('');
const d = ddg.status === 'fulfilled' ? ddg.value : {};
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
${d.image ? `<img alt="" src="${esc(stOk(d.image))}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
<div style="min-width:0"><small style="color:var(--muted)">From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''}</small>
${d.heading ? `<div style="font-weight:700;margin-top:2px">${esc(d.heading)}</div>` : ''}${d.answer ? `<div style="font-size:18px;margin-top:2px">${esc(d.answer)}</div>` : ''}
${d.text ? `<p style="margin:4px 0 0;font-size:14px">${esc(d.text)}</p>` : ''}${d.url ? `<a href="${esc(d.url)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:14px">Read more</a>` : ''}</div></div>` : '';
+2 -1
View File
@@ -95,6 +95,7 @@ function viewSettings() {
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
<h2>Health</h2><div class="list" id="health"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
<h2>iPad and Screen Time</h2><p class="sub" style="white-space:normal">Allow the hub on an iPad with Screen Time web limits, without adding every site one by one.</p><div class="bar"><button class="btn ghost" id="stbtn">Set up Screen Time</button></div>
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
</div></div>`;
@@ -130,7 +131,7 @@ function viewSettings() {
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
const lk = $('#lookups'); if (lk) { lk.checked = store.get('lookups', true) !== false; lk.onchange = () => { store.set('lookups', lk.checked); toast(lk.checked ? 'Lookups on' : 'Lookups off'); }; }
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); $('#stbtn').onclick = () => screenTimeSheet(); }
if (isAdmin) jfetch('/api/health').then(h => {
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${bad ? '⚠ ' : '✓ '}${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
const gb = n => (n / 1e9).toFixed(1) + ' GB', days = t => Math.floor((Date.now() - t) / 864e5);
+1 -1
View File
@@ -29,7 +29,7 @@ function sgChime() {
o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); });
}
function sgBadgeEverywhere() { // Signal chats, unread mail and tasks due: one number on the tab title and the installed app's icon
const n = (sg.unread || 0) + (typeof mailUnread === 'number' ? mailUnread : 0) + (typeof taskDue === 'number' ? taskDue : 0);
const n = (sg.unread || 0) + (typeof ch === 'object' ? ch.unread || 0 : 0) + (typeof mailUnread === 'number' ? mailUnread : 0) + (typeof taskDue === 'number' ? taskDue : 0);
document.title = (n ? '(' + (n > 99 ? '99+' : n) + ') ' : '') + 'NE Hub';
try { if (navigator.setAppBadge) { n ? navigator.setAppBadge(n) : navigator.clearAppBadge(); } } catch {}
}
+233
View File
@@ -0,0 +1,233 @@
'use strict';
// ---- Store: everything you can get, in one place. Books (free sources), free audiobooks, radio stations, and My Store: content the admin adds themselves (books,
// music, apps to download, and extensions that add features to the hub). The first three are the same screens as before, shown inside this one.
const stApi = (p, opts) => jfetch('/api/store/my' + p, opts);
const stOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
const ST_KINDS = [['book', 'Books', 'book'], ['music', 'Music', 'music'], ['app', 'Apps', 'files'], ['extension', 'Extensions', 'cog'], ['link', 'Sites', 'link']];
const stKind = k => ST_KINDS.find(x => x[0] === k) || ST_KINDS[2];
const stSize = b => fmtSize(+b || 0);
const stCover = it => it.cover ? `/api/store/my/items/${it.id}/cover?v=${it.updated}` : '';
const stHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
let stItems = [], stInstalled = [], stAdmin = false, stFilter = 'all';
/** The sections this person may open (each needs its own tab's permission, except My Store). */
const stSections = () => [['books', 'Books', allowed('read')], ['audiobooks', 'Audiobooks', allowed('audio')], ['radio', 'Radio', allowed('radio')], ['mine', 'My Store', true]].filter(s => s[2]);
async function viewStore(args = []) {
const secs = stSections(), want = args[0] === 'ext' || args[0] === 'site' ? 'mine' : args[0], sec = (secs.find(s => s[0] === want) || secs[0])[0];
const v = $('#view');
v.innerHTML = `<div class="wrap" id="stwrap"><div class="ptop"><h1>Store</h1></div><div class="seg">${secs.map(s => `<a href="#/store/${s[0]}" class="${s[0] === sec ? 'on' : ''}">${esc(s[1])}</a>`).join('')}</div><div id="stbody"></div></div>`;
const el = $('#stbody'), host = { el, hash: '#/store/' + sec };
if (sec === 'books') return rdStore(host);
if (sec === 'audiobooks') return viewFreeBooks(host);
if (sec === 'radio') return viewRadio(host);
if (args[0] === 'ext' && args[1]) return stRunExt(el, args[1]);
if (args[0] === 'site' && args[1]) return stRunSite(el, args[1]);
return stMine(el);
}
// ---------------------------------------------------------------- My Store
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stAdmin = r.admin; return r; }
async function stMine(el) {
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const draw = () => {
if (!$('#stbody')) return;
const mine = stItems.filter(i => i.kind === 'extension' && stInstalled.includes(i.id)), shown = stItems.filter(i => stFilter === 'all' || i.kind === stFilter);
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><p class="sub" style="margin:0;flex:1">Books, music, apps and extensions added just for this hub.</p>${stAdmin ? `<button class="btn" id="stadd">${icon('plus')}Add</button>` : ''}</div>
${mine.length ? `<h2>My extensions</h2><div class="vgrid">${mine.map(i => stCard(i, true)).join('')}</div>` : ''}
<div class="seg" id="stkinds"><a href="#" data-k="all" class="${stFilter === 'all' ? 'on' : ''}">All</a>${ST_KINDS.map(k => `<a href="#" data-k="${k[0]}" class="${stFilter === k[0] ? 'on' : ''}">${k[1]}</a>`).join('')}</div>
${shown.length ? `<div class="vgrid">${shown.map(i => stCard(i)).join('')}</div>` : `<p class="empty">${stItems.length ? 'Nothing of that kind yet.' : stAdmin ? 'Nothing here yet. Add your first book, album, app or extension.' : 'Nothing has been added yet.'}</p>`}`;
el.querySelectorAll('#stkinds a').forEach(a => a.onclick = e => { e.preventDefault(); stFilter = a.dataset.k; draw(); });
el.querySelectorAll('[data-it]').forEach(a => a.onclick = e => { e.preventDefault(); const it = stItems.find(x => x.id === a.dataset.it); if (a.dataset.run) location.hash = '#/store/ext/' + it.id; else stDetail(it, draw); });
if ($('#stadd')) $('#stadd').onclick = () => stEdit(null, draw);
};
draw();
}
const stCard = (it, run) => `<a class="vcard" href="#" data-it="${esc(it.id)}"${run ? ' data-run="1"' : ''}><span class="vimg" style="aspect-ratio:${it.kind === 'book' ? '2/3' : '1/1'};background:linear-gradient(135deg,hsl(${stHue(it.title)} 55% 40%),hsl(${stHue(it.title) + 40} 55% 28%));display:grid;place-items:center;color:#fff;font-size:40px">${it.cover ? `<img loading="lazy" alt="" src="${stCover(it)}" onerror="this.remove()">` : esc(((it.kind === 'extension' || it.kind === 'link') && it.icon) || (it.title.match(/\p{L}|\p{N}/u) || ['?'])[0].toUpperCase())}</span>
<b>${esc(it.title)}</b><small>${esc([stKind(it.kind)[1].replace(/s$/, ''), it.creator].filter(Boolean).join(' · '))}</small></a>`;
function stDetail(it, redraw) {
const k = it.kind, f0 = it.files[0], isMine = k === 'extension' && stInstalled.includes(it.id);
const url = f => `/api/store/my/items/${it.id}/files/${f.id}`;
const el = openSheet(it.title, [stKind(k)[1].replace(/s$/, ''), it.creator, it.version && 'v' + it.version].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start">${it.cover ? `<span class="vimg" style="width:96px;flex:none;aspect-ratio:${k === 'book' ? '2/3' : '1/1'}"><img alt="" src="${stCover(it)}"></span>` : ''}
<div style="min-width:0">${it.description ? `<p style="margin:0;white-space:normal">${esc(it.description).replace(/\n/g, '<br>')}</p>` : '<p class="sub" style="margin:0">No description.</p>'}</div></div>
${k === 'music' || k === 'app' ? `<div class="list" style="margin-top:12px">${it.files.map((f, i) => `<div class="row"><span class="grow"><b>${esc(f.name)}</b><small>${esc(stSize(f.size))}</small></span>${k === 'music' ? `<button class="ibtn" data-play="${i}" aria-label="Play">${icon('play')}</button>` : ''}<a class="ibtn" href="${url(f)}?dl=1" download aria-label="Download">${icon('download')}</a></div>`).join('') || '<p class="empty">No files.</p>'}</div>` : ''}
${k === 'link' ? `<p class="sub" style="margin:10px 0 0;word-break:break-all">${esc(it.url)}</p>` : ''}<div class="err" id="sterr"></div><div class="bar" id="stbar" style="flex-wrap:wrap">
${k === 'book' && f0 ? `${allowed('read') ? '<button class="btn" id="stlib">Add to my library</button>' : ''}<a class="btn ghost" href="${url(f0)}?dl=1" download>Download</a>` : ''}
${k === 'music' && it.files.length ? `<button class="btn" id="stplay">${icon('play')}Play</button>` : ''}
${k === 'app' && it.url ? `<a class="btn" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">Open link</a>` : ''}
${k === 'link' ? `<button class="btn" id="stopen">Open</button><a class="btn ghost" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">New tab</a>` : ''}
${k === 'extension' && f0 ? `<button class="btn" id="stinst">${isMine ? 'Open' : 'Install'}</button>${isMine ? '<button class="btn ghost" id="stun">Remove</button>' : ''}` : ''}
${stAdmin ? '<button class="btn ghost" id="stedit">Edit</button>' : ''}</div>`);
const err = t => { el.querySelector('#sterr').textContent = t; };
const tracks = () => it.files.map(f => ({ key: `my:${it.id}:${f.id}`, title: f.name.replace(/\.[^.]+$/, '').replace(/^\d+[\s._-]+/, ''), artist: it.creator || '', album: it.title, thumb: stCover(it), src: url(f), nocache: true }));
const play = i => { playQueue(tracks(), i || 0); closeSheet(); };
if (el.querySelector('#stplay')) el.querySelector('#stplay').onclick = () => play(0);
el.querySelectorAll('[data-play]').forEach(b => b.onclick = () => play(+b.dataset.play));
if (el.querySelector('#stlib')) el.querySelector('#stlib').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const r = await stApi(`/items/${it.id}/to-library`, stOpt('POST')); closeSheet(); toast(r.duplicate ? 'Already in your library' : 'Added to your library', { label: 'Open', run: () => { location.hash = '#/read/open/' + r.id; } }); } catch (x) { err(x.message); b.disabled = false; } };
if (el.querySelector('#stopen')) el.querySelector('#stopen').onclick = () => { closeSheet(); if (it.embed) location.hash = '#/store/site/' + it.id; else window.open(it.url, '_blank', 'noopener,noreferrer'); };
if (el.querySelector('#stinst')) el.querySelector('#stinst').onclick = async e => { const b = e.currentTarget; try { if (!isMine) { b.disabled = true; stInstalled = (await stApi(`/ext/${it.id}/install`, stOpt('POST'))).installed; } closeSheet(); location.hash = '#/store/ext/' + it.id; } catch (x) { err(x.message); b.disabled = false; } };
if (el.querySelector('#stun')) el.querySelector('#stun').onclick = () => { closeSheet(); confirmSheet('Remove this extension?', 'Its saved settings are forgotten too.', 'Remove', async () => { try { stInstalled = (await stApi(`/ext/${it.id}/uninstall`, stOpt('POST'))).installed; toast('Removed'); redraw(); } catch (x) { toast(x.message); } }); };
if (el.querySelector('#stedit')) el.querySelector('#stedit').onclick = () => { closeSheet(); stEdit(it, redraw); };
}
// ---- adding and changing (admin)
function stEdit(it, redraw) {
const x = it || { kind: 'book', title: '', creator: '', description: '', version: '', url: '', icon: '', files: [], cover: false };
let created = false;
const el = openSheet(it ? 'Edit item' : 'Add to My Store', '', `<form id="stf">
${it ? '' : `<label for="stk">What is it?</label><select id="stk">${ST_KINDS.map(k => `<option value="${k[0]}">${k[1].replace(/s$/, '')}</option>`).join('')}</select>`}
<label for="stt">Title</label><input type="text" id="stt" value="${esc(x.title)}" autocomplete="off" required>
<label for="stc" id="stcl">Author</label><input type="text" id="stc" value="${esc(x.creator)}" autocomplete="off">
<label for="std">Description</label><textarea id="std" rows="3">${esc(x.description)}</textarea>
<div id="stver" hidden><label for="stv">Version</label><input type="text" id="stv" value="${esc(x.version)}" autocomplete="off"></div>
<div id="stlnk" hidden><label for="stu" id="stul">Link (optional)</label><input type="url" id="stu" value="${esc(x.url)}" placeholder="https://…" autocapitalize="off"></div>
<label id="stemb" class="row" style="cursor:pointer" hidden><span class="grow"><b>Open inside the hub</b><small>Some sites refuse to be shown this way; they open in a new tab instead</small></span><input type="checkbox" id="ste"${x.embed ? ' checked' : ''}></label>
<div id="stext" hidden><label for="sti">Icon (one letter or emoji)</label><input type="text" id="sti" value="${esc(x.icon || '')}" maxlength="2" style="width:80px"></div>
<div id="stfiles"><label>Files</label><div id="stfl" class="list"></div>
<div class="bar" style="justify-content:flex-start;margin:6px 0"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('plus')}Upload<input type="file" id="stup" hidden multiple></label>${allowed('files') ? `<button type="button" class="btn ghost" id="stsf">${icon('files')}From Files</button>` : ''}</div></div>
<label>Cover picture</label><div class="bar" style="justify-content:flex-start;margin:4px 0"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('plus')}Choose<input type="file" id="stcv" accept="image/jpeg,image/png,image/gif,image/webp" hidden></label><small id="stcvn" style="color:var(--muted)">${x.cover ? 'A cover is set' : ''}</small></div>
<div class="err" id="sterr"></div><div class="bar">${it ? '<button type="button" class="btn danger" id="stdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="stx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="stok" style="flex:1;justify-content:center">${it ? 'Save' : 'Add'}</button></div></form>`);
const $e = s => el.querySelector(s), err = t => { $e('#sterr').textContent = t; };
let kind = x.kind; const files = [], existing = [...x.files]; let cover = null;
const kindUi = () => { kind = it ? x.kind : $e('#stk').value; $e('#stcl').textContent = kind === 'music' ? 'Artist' : kind === 'book' ? 'Author' : 'Made by'; $e('#stver').hidden = kind !== 'app' && kind !== 'extension'; $e('#stlnk').hidden = kind !== 'app' && kind !== 'link'; $e('#stul').textContent = kind === 'link' ? 'Address of the site' : 'Link (optional)'; $e('#stemb').hidden = kind !== 'link'; $e('#stext').hidden = kind !== 'extension' && kind !== 'link'; $e('#stfiles').hidden = kind === 'link';
$e('#stup').accept = kind === 'book' ? '.epub,.pdf,.mobi,.azw3,.azw,.docx' : kind === 'music' ? 'audio/*,.mp3,.m4a,.flac,.ogg,.opus,.wav' : kind === 'extension' ? '.html' : ''; $e('#stup').multiple = kind === 'music' || kind === 'app'; };
const drawFiles = () => { $e('#stfl').innerHTML = [...existing.map((f, i) => [f.name, stSize(f.size), 'e', i]), ...files.map((f, i) => [f.name || f.path.split('/').pop(), f.size != null ? stSize(f.size) : 'from Files', 'n', i])].map(([n, s, t, i]) => `<div class="row"><span class="grow"><b>${esc(n)}</b><small>${esc(s)}</small></span><button type="button" class="ibtn" data-t="${t}" data-i="${i}" aria-label="Remove">${icon('trash')}</button></div>`).join('') || '<p class="sub" style="margin:8px 12px">No files yet.</p>';
el.querySelectorAll('#stfl [data-t]').forEach(b => b.onclick = () => { (b.dataset.t === 'e' ? existing : files).splice(+b.dataset.i, 1); drawFiles(); }); };
if (!it) $e('#stk').onchange = kindUi; kindUi(); drawFiles();
$e('#stup').onchange = e => { const single = kind !== 'music' && kind !== 'app'; if (single) { files.length = 0; existing.length = 0; } for (const f of e.target.files) files.push(f); e.target.value = ''; drawFiles(); };
if ($e('#stsf')) $e('#stsf').onclick = async () => { const p = await stPickFile(); if (!p) return; if (kind !== 'music' && kind !== 'app') { files.length = 0; existing.length = 0; } files.push({ path: p.path, repo: p.repo, name: p.path.split('/').pop() }); drawFiles(); };
$e('#stcv').onchange = e => { cover = e.target.files[0] || null; $e('#stcvn').textContent = cover ? cover.name : ''; };
$e('#stx').onclick = closeSheet;
if ($e('#stdel')) $e('#stdel').onclick = () => { closeSheet(); confirmSheet('Delete this item?', it.title, 'Delete', async () => { try { await stApi('/items/' + it.id, { method: 'DELETE' }); toast('Deleted'); await stLoad(); redraw(); } catch (e) { toast(e.message); } }); };
$e('#stf').onsubmit = async e => {
e.preventDefault(); const ok = $e('#stok'); ok.disabled = true; err('');
try {
if (!files.length && !existing.length && kind !== 'app' && kind !== 'link') throw new Error('Add the file first');
let saved = await stApi('/items', stOpt('POST', { id: it && it.id, kind, title: $e('#stt').value, creator: $e('#stc').value, description: $e('#std').value, version: $e('#stv').value, url: $e('#stu').value, embed: !!$e('#ste').checked, icon: $e('#sti') ? $e('#sti').value : '' }));
if (!it) created = true;
for (const f of x.files) if (!existing.some(y => y.id === f.id)) saved = await stApi(`/items/${saved.id}/files/${f.id}`, { method: 'DELETE' });
let n = 0;
for (const f of files) {
ok.textContent = `Adding ${++n} of ${files.length}…`;
saved = f instanceof File ? await stApi(`/items/${saved.id}/files?name=${encodeURIComponent(f.name)}`, { method: 'POST', body: f, headers: { 'content-type': 'application/octet-stream' } }) : await stApi(`/items/${saved.id}/files`, stOpt('POST', { repo: f.repo, path: f.path }));
}
if (cover) saved = await stApi(`/items/${saved.id}/cover`, { method: 'POST', body: cover, headers: { 'content-type': cover.type } });
closeSheet(); toast(it ? 'Saved' : 'Added'); await stLoad(); redraw();
} catch (x2) { err(x2.message + (created ? ' The item was created without it: open it to finish.' : '')); ok.disabled = false; ok.textContent = it ? 'Save' : 'Add'; }
};
}
/** Pick one file from your Seafile libraries. Resolves { repo, path } or null. */
function stPickFile() {
return new Promise(resolve => {
if (!sf.token()) { toast('Open Files and sign in first'); return resolve(null); }
let repo = null, dir = '/', done = false;
const el = openSheet('Choose a file from Files', '', '<div id="stpk"></div>'); const finish = v => { done = true; closeSheet(); resolve(v); };
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
const draw = async () => {
const box = el.querySelector('#stpk'); box.innerHTML = '<p class="empty">Loading…</p>';
try {
if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted).sort((a, b) => a.name.localeCompare(b.name));
box.innerHTML = `<div class="list" style="max-height:50dvh;overflow:auto">${repos.map(r => `<button class="row" data-r="${esc(r.id)}">${icon('files')}<span class="grow"><b>${esc(r.name)}</b></span></button>`).join('')}</div><div class="bar"><button class="btn ghost" id="pcx" style="flex:1;justify-content:center">Cancel</button></div>`;
box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; dir = '/'; draw(); });
} else {
const items = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).sort((a, b) => (a.type === b.type ? 0 : a.type === 'dir' ? -1 : 1) || a.name.localeCompare(b.name));
box.innerHTML = `<p class="sub" style="word-break:break-all">${esc(dir)}</p><div class="list" style="max-height:50dvh;overflow:auto"><button class="row" data-up="1">${icon('back')}<span class="grow"><b>${dir === '/' ? 'All libraries' : 'Up'}</b></span></button>
${items.map(x => `<button class="row" data-${x.type === 'dir' ? 'd' : 'f'}="${esc(x.name)}">${icon(x.type === 'dir' ? 'files' : 'doc')}<span class="grow"><b>${esc(x.name)}</b>${x.type === 'dir' ? '' : `<small>${esc(stSize(x.size))}</small>`}</span></button>`).join('')}</div><div class="bar"><button class="btn ghost" id="pcx" style="flex:1;justify-content:center">Cancel</button></div>`;
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = (dir === '/' ? '' : dir) + '/' + b.dataset.d; draw(); });
box.querySelectorAll('[data-f]').forEach(b => b.onclick = () => finish({ repo, path: (dir === '/' ? '' : dir) + '/' + b.dataset.f }));
box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); };
}
box.querySelector('#pcx').onclick = () => finish(null);
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
draw();
});
}
// ---------------------------------------------------------------- running an extension
// The extension is one HTML file in a sandboxed frame (no access to your sign-in or the hub's data). It talks to the hub only through the messages below.
async function stRunExt(el, id) {
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const it = stItems.find(x => x.id === id && x.kind === 'extension');
if (!it) { el.innerHTML = '<p class="empty">That extension is not in the store any more.</p>'; return; }
if (!stInstalled.includes(id)) { el.innerHTML = `<p class="empty">Install “${esc(it.title)}” from My Store first.</p>`; return; }
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}</b></div>
<iframe id="stframe" title="${esc(it.title)}" sandbox="allow-scripts allow-forms allow-popups allow-modals" referrerpolicy="no-referrer" style="width:100%;height:calc(100dvh - 190px);min-height:360px;border:1px solid var(--line);border-radius:12px;background:#fff"></iframe>`;
const frame = el.querySelector('#stframe'); let data = null, timer;
// (the page is fetched by the hub and shown from here, so the frame needs no sign-in of its own; its sandbox keeps it from touching anything of the hub's)
fetch(`/api/store/my/ext/${id}/page`).then(r => r.ok ? r.text() : Promise.reject(new Error('Could not load it'))).then(h => { frame.srcdoc = h; }).catch(e => { el.querySelector('#stframe').outerHTML = `<p class="err">${esc(e.message)}</p>`; });
const load = async () => data || (data = await stApi(`/ext/${id}/data`).catch(() => ({})));
const save = () => { clearTimeout(timer); timer = setTimeout(() => stApi(`/ext/${id}/data`, stOpt('POST', data)).catch(e => toast(e.message)), 400); };
// What an extension may ask the hub for. Each kind of access is asked of the person the first time and remembered until the extension is removed.
const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar' };
let grants = null; const refused = new Set();
const allowedTo = async perm => {
if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants;
if (grants.includes(perm)) return true; if (refused.has(perm) || !allowed(perm.split('.')[0] === 'calendar' ? 'calendar' : perm.split('.')[0])) return false;
const yes = await new Promise(ok => { let done = false; const el2 = openSheet('Allow access?', it.title, `<p>“${esc(it.title)}” would like to <b>${esc(WHY[perm])}</b>.</p><div class="bar"><button class="btn ghost" id="pno" style="flex:1;justify-content:center">Don't allow</button><button class="btn" id="pyes" style="flex:1;justify-content:center">Allow</button></div>`);
const fin = v => { if (!done) { done = true; closeSheet(); ok(v); } }; el2.querySelector('#pyes').onclick = () => fin(true); el2.querySelector('#pno').onclick = () => fin(false); el2.querySelector('.scrim').addEventListener('click', () => fin(false)); });
if (!yes) { refused.add(perm); return false; }
grants = [...grants, perm]; stApi(`/ext/${id}/grants`, stOpt('POST', { grants })).catch(() => {}); return true;
};
const jf = (p, o) => jfetch(p, o), pj = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
const txt = (v, n) => String(v == null ? '' : v).slice(0, n), ymd = v => /^\d{4}-\d{2}-\d{2}$/.test(String(v)) ? String(v) : '';
const hubCall = async (method, a = {}) => {
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write' }[method];
if (!need) throw new Error('Unknown request'); if (!a || typeof a !== 'object') a = {};
if (!(await allowedTo(need))) throw new Error('The person did not allow that');
const localList = async name => { const l = (await jf('/api/tasks/lists')).lists.filter(x => x.local), n = txt(name, 60).toLowerCase(); return (n && l.find(x => x.name.toLowerCase() === n)) || l.find(x => x.id === 'local:inbox') || l[0]; };
if (method === 'tasks.lists') return (await jf('/api/tasks/lists')).lists.map(l => ({ id: l.id, name: l.name }));
if (method === 'tasks.list') return (await jf('/api/tasks?list=' + encodeURIComponent(txt(a.list, 80)))).map(t => ({ id: t.id, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done }));
if (method === 'tasks.add') { const l = await localList(a.list); if (!l) throw new Error('No task list'); await jf('/api/tasks', pj('POST', { list: l.id, fields: { title: txt(a.title, 200), notes: txt(a.notes, 2000), due: ymd(a.due), done: false } })); return { ok: true, list: l.name }; }
if (method === 'tasks.complete') { const list = txt(a.list, 80), t = (await jf('/api/tasks?list=' + encodeURIComponent(list))).find(x => x.id === a.id); if (!t) throw new Error('Task not found'); await jf('/api/tasks', pj('POST', { list, id: t.id, fields: { title: t.title, notes: t.notes || '', due: t.due || '', done: true } })); return { ok: true }; }
if (method === 'contacts.list') return (await ctApi('/')).map(c => ({ id: c.id, name: c.name, org: c.org, emails: c.emails.map(e => e.value), phones: c.phones.map(p => p.value), birthday: c.birthday }));
if (method === 'contacts.add') { const c = await ctApi('/', pj('POST', { name: txt(a.name, 120), org: txt(a.org, 120), emails: a.email ? [{ label: '', value: txt(a.email, 120) }] : [], phones: a.phone ? [{ label: '', value: txt(a.phone, 60) }] : [], birthday: txt(a.birthday, 12), notes: txt(a.notes, 2000) })); ctList = null; return { id: c.id }; }
if (method === 'calendar.events') { const f = ymd(a.from), t = ymd(a.to); if (!f || !t) throw new Error('Give from and to as YYYY-MM-DD'); const q = new URLSearchParams({ from: new Date(f + 'T00:00:00').getTime(), to: new Date(t + 'T23:59:59').getTime(), fd: f, td: t });
return (await jf('/api/cal/events?' + q)).events.slice(0, 500).map(e => ({ title: e.title, start: e.start, end: e.end, allDay: !!e.allDay, calendar: e.cal })); }
if (method === 'calendar.add') { const cals = await jf('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('No calendar to add to'); const allDay = !!a.allDay, re = allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
if (!re.test(a.start || '')) throw new Error('start must be ' + (allDay ? 'YYYY-MM-DD' : 'YYYY-MM-DDTHH:MM')); const end = re.test(a.end || '') ? a.end : a.start;
await jf('/api/cal/event', pj('POST', { cal: c.id, fields: { title: txt(a.title, 200), location: txt(a.location, 200), notes: txt(a.notes, 2000), allDay, start: a.start, end, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, repeat: 'none' } })); return { ok: true }; }
};
const onMsg = async e => {
if (!document.body.contains(frame)) return removeEventListener('message', onMsg);
if (e.source !== frame.contentWindow || !e.data || !e.data.hub) return;
const { id: mid, method, args = [] } = e.data; let result, error;
try {
if (method === 'storage.get') result = (await load())[String(args[0])] ?? null;
else if (method === 'storage.all') result = { ...(await load()) };
else if (method === 'storage.set') { const d = await load(); d[String(args[0]).slice(0, 100)] = args[1]; if (JSON.stringify(d).length > 256 * 1024) { delete d[String(args[0]).slice(0, 100)]; throw new Error('Out of space (256 KB)'); } save(); result = true; }
else if (method === 'storage.remove') { delete (await load())[String(args[0])]; save(); result = true; }
else if (method === 'toast') { toast(String(args[0]).slice(0, 200)); result = true; }
else if (method === 'user') result = { name: ME ? ME.name : '' };
else if (method.startsWith('tasks.') || method.startsWith('contacts.') || method.startsWith('calendar.')) result = await hubCall(method, args[0]);
else if (method === 'open') { if (/^https?:\/\//i.test(String(args[0]))) window.open(String(args[0]), '_blank', 'noopener,noreferrer'); result = true; }
else throw new Error('Unknown request');
} catch (x) { error = x.message; }
frame.contentWindow.postMessage({ hub: 1, id: mid, result, error }, '*');
};
addEventListener('message', onMsg);
}
/** A site shown inside the hub. It is another website in a frame of its own, so it cannot see anything of the hub's. */
async function stRunSite(el, id) {
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const it = stItems.find(x => x.id === id && x.kind === 'link');
if (!it) { el.innerHTML = '<p class="empty">That site is not in the store any more.</p>'; return; }
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}</b><a class="btn ghost" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">New tab</a></div>
<iframe title="${esc(it.title)}" src="${esc(it.url)}" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" style="width:100%;height:calc(100dvh - 190px);min-height:360px;border:1px solid var(--line);border-radius:12px;background:#fff"></iframe>
<p class="sub" style="margin-top:8px">If the page stays blank, that site does not allow being shown here. Use New tab.</p>`;
}
/** On Home, under the app tiles: the extensions this person installed, and the Sites that were added to My Store, one tap away. */
async function stHomeExt(el) {
if (!el) return;
try { await stLoad(); } catch { return; }
if (!el.isConnected) return;
const mine = stItems.filter(i => (i.kind === 'extension' && stInstalled.includes(i.id)) || i.kind === 'link');
if (!mine.length) { el.innerHTML = ''; return; }
const tileOf = i => { const href = i.kind === 'extension' ? '#/store/ext/' + i.id : i.embed ? '#/store/site/' + i.id : i.url, ext = i.kind === 'link' && !i.embed;
return `<a class="tile" href="${esc(href)}" ${ext ? 'target="_blank" rel="noopener noreferrer"' : ''}><span class="ic" style="background:hsl(${stHue(i.title)} 55% 42%);font-weight:700;font-size:20px">${esc(i.icon || (i.title.match(/\p{L}|\p{N}/u) || ['?'])[0].toUpperCase())}</span><b>${esc(i.title)}</b><small>${i.kind === 'extension' ? 'Extension' : 'Site'}</small></a>`; };
el.innerHTML = `<h2>My apps</h2><div class="grid">${mine.map(tileOf).join('')}</div>`;
}
+75 -4
View File
@@ -310,17 +310,25 @@ body:not(.editing) #view { padding-top: 8px; }
.navtoggle { display: none; }
.navcol { display: none; }
@media (min-width: 768px) {
#nav { overflow-y: auto; scrollbar-width: none; }
#nav { overflow: hidden; } /* the menu never scrolls: when the window is short its entries shrink to fit instead */
#nav a { flex: 1 1 0; min-height: 28px; max-height: 64px; }
#nav .ngrp { flex: none; }
.navtoggle { display: grid; }
body.nav-hidden #nav { display: none; }
body.nav-hidden #navbar { padding-left: 12px; }
}
@media (min-width: 1024px) { body:not(.nav-mini) #nav a { max-height: 48px; } }
@media (min-width: 768px) and (max-height: 700px) { /* a short window: tighter spacing so every entry still fits without scrolling */
#nav { padding-top: calc(var(--sat) + 8px); padding-bottom: 6px; gap: 0; }
#nav a { min-height: 26px; font-size: 13px; } #nav a svg { width: 18px; height: 18px; }
.brand { margin-bottom: 6px !important; } #nav .ngrp { margin-top: 2px; padding-top: 2px; } #nav .ngrp span { display: none; } .navcol { min-height: 30px; }
}
@media (min-width: 1024px) {
.navcol { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 14px; border: 0; background: none; border-radius: 12px; color: var(--muted); font-size: 14px; }
.navcol:hover { background: var(--panel2); color: var(--text); }
.navcol svg { width: 20px; height: 20px; }
body.nav-mini #nav { width: 76px; padding: calc(var(--sat) + 12px) 0 12px var(--sal); }
body.nav-mini #nav a { flex-direction: column; justify-content: center; gap: 2px; padding: 0; font-size: 10px; min-height: 50px; border-radius: 0; flex: none; }
body.nav-mini #nav a { flex-direction: column; justify-content: center; gap: 2px; padding: 0; font-size: 10px; min-height: 0; max-height: 50px; border-radius: 0; flex: 1 1 0; }
body.nav-mini #nav a svg { width: 22px; height: 22px; }
body.nav-mini #nav a.on { background: none; }
body.nav-mini .brand { display: block; width: 36px; height: 36px; margin: 4px auto 12px; }
@@ -539,14 +547,14 @@ body.lyropen { overflow: hidden; }
.mact, .matt { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
.mact .btn, .matt .btn { min-height: 38px; }
.mimg { background: var(--panel2); border-radius: 10px; padding: 8px 12px; font-size: 14px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.mbody { width: 100%; flex: 1; min-height: 55dvh; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
.mbody { width: 100%; flex: none; height: 60dvh; min-height: 0; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
@media (min-width: 1024px) {
.mailapp { grid-template-columns: 340px minmax(0, 1fr); height: calc(100dvh - 40px); min-height: 0; }
.mailapp[data-mode] .mlist, .mailapp[data-mode] .mread { display: block; }
.mailapp[data-mode] .mread { display: flex; }
.mlist, .mread { overflow-y: auto; min-height: 0; }
.mback { display: none; }
.mbody { min-height: 0; }
.mbody { flex: 1; height: auto; }
}
@media (min-width: 1500px) {
.mailapp { grid-template-columns: 190px 340px minmax(0, 1fr); }
@@ -950,3 +958,66 @@ body.vizopen { overflow: hidden; }
#viz .viztop small { opacity: .75; font-size: 12px; }
#viz .btn, #viz .ibtn { color: #fff; border-color: rgba(255,255,255,.35); background: rgba(0,0,0,.35); }
#viz.quiet > .viztop, #viz.quiet > .vizfoot { opacity: 0; pointer-events: none; }
/* Recipes and Contacts */
.rchero { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 12px; }
@media (max-width: 560px) { .rchero { flex-direction: column; } .rchero .vimg { width: 100% !important; } }
.rcsteps { padding-left: 22px; line-height: 1.5; } .rcsteps li { margin: 0 0 12px; cursor: pointer; } .rcsteps li.done { color: var(--muted); text-decoration: line-through; }
.rchead { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .rchead h2 { margin: 16px 0 8px; }
.rccols { display: block; }
@media (min-width: 900px) { .rccols { display: grid; grid-template-columns: minmax(280px, 2fr) 3fr; gap: 28px; align-items: start; } .rccols > section:first-child { position: sticky; top: 8px; } }
.rcitem input { width: 22px; height: 22px; flex: none; }
.ctgrp { margin: 14px 0 4px; font-size: 13px; font-weight: 600; color: var(--muted); }
.ctav { width: 40px; height: 40px; border-radius: 50%; flex: none; display: grid; place-items: center; color: #fff; font-weight: 600; }
.ctsug { position: absolute; z-index: 30; left: 0; right: 0; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 6px 20px rgba(0,0,0,.15); max-height: 220px; overflow: auto; }
.ctsug button { display: block; width: 100%; text-align: left; padding: 8px 12px; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; } .ctsug button:hover, .ctsug button.on { background: rgba(127,127,127,.15); }
.pdhit { margin-bottom: 8px; } mark { background: rgba(255, 200, 0, .45); color: inherit; border-radius: 3px; }
/* Store */
#stbody .wrap { padding: 0; max-width: none; }
#stbody .vimg img { width: 100%; height: 100%; object-fit: cover; }
/* Music: featured artist and album */
.featgrid { display: grid; grid-template-columns: 1fr; gap: 16px; }
@media (min-width: 760px) { .featgrid { grid-template-columns: 1fr 1fr; } }
.featcard { display: flex; gap: 16px; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 14px; }
.featcard .art { width: 150px; height: 150px; border-radius: 12px; object-fit: cover; flex: none; display: block; } .featcard .art.round { border-radius: 50%; }
.featinfo { min-width: 0; } .featinfo h2 { margin: 2px 0 0; font-size: 22px; text-transform: none; letter-spacing: 0; color: var(--text); overflow-wrap: anywhere; }
@media (max-width: 420px) { .featcard { flex-direction: column; align-items: flex-start; } .featcard .art { width: 100%; height: auto; aspect-ratio: 1 / 1; } }
/* Music: mixes, stats, notes, cover colors */
.npcard, .np { background: linear-gradient(160deg, color-mix(in srgb, var(--np-a, transparent) 30%, var(--panel)), var(--panel)); transition: background .6s; }
.mixtile .mixart, .mixart { display: flex; align-items: flex-end; width: 100%; aspect-ratio: 1 / 1; border-radius: 12px; padding: 12px; box-sizing: border-box; color: #fff; }
.mixart b { font-size: 18px; line-height: 1.15; text-shadow: 0 1px 6px rgba(0,0,0,.4); }
.mhead .mixart { width: 120px; height: 120px; flex: none; aspect-ratio: auto; }
.mmabout { margin: 8px 0 12px; } .mmabout summary { cursor: pointer; color: var(--accent); } .mmabout p { margin: 8px 0 0; line-height: 1.5; white-space: normal; max-width: 70ch; }
.mmv { margin-top: 18px; padding: 14px 16px; border-radius: 14px; background: var(--panel); border: 1px solid var(--line); } .mmv:empty { display: none; }
.mmverse { margin: 6px 0; font-size: 18px; line-height: 1.5; font-style: italic; }
.statgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 8px 0 4px; } @media (min-width: 640px) { .statgrid { grid-template-columns: repeat(4, 1fr); } }
.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px; } .stat b { display: block; font-size: 26px; } .stat small { color: var(--muted); }
.chart { display: flex; align-items: flex-end; gap: 3px; height: 120px; padding: 6px 0; } .chart span { flex: 1; height: 100%; display: flex; align-items: flex-end; } .chart i { display: block; width: 100%; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; }
.statbar { height: 6px; border-radius: 3px; background: var(--panel2); margin-top: 6px; overflow: hidden; } .statbar i { display: block; height: 100%; background: var(--accent); }
[data-fv][aria-pressed="true"] svg, #npfav.on svg, #cnfav.on svg { fill: currentColor; }
/* Chat: pins, notices, mentions, forwarded */
.sgpin { display: flex; gap: 10px; align-items: center; width: 100%; padding: 8px 14px; border: 0; border-bottom: 1px solid var(--line); background: var(--panel2); color: inherit; font: inherit; text-align: left; cursor: pointer; } .sgpin[hidden] { display: none; }
.sgpin b, .sgpin small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgpin small { color: var(--accent); }
.sgsys { font-size: 12px; color: var(--muted); font-style: italic; }
.mention { color: var(--accent); } .sgb.out .mention { color: inherit; text-decoration: underline; }
.sgfwd { display: block; font-size: 12px; opacity: .7; font-style: italic; margin-bottom: 2px; }
.sgb.flash { animation: chflash 1.2s; } @keyframes chflash { 0%, 60% { box-shadow: 0 0 0 3px var(--accent); } 100% { box-shadow: 0 0 0 0 transparent; } }
/* Chat: voice messages, polls, link previews, emoji */
.sgvoice { display: flex; align-items: center; gap: 8px; min-width: 200px; cursor: pointer; } .sgvoice .sgvp { flex: none; background: rgba(255,255,255,.18); border-radius: 50%; width: 36px; height: 36px; }
.sgbars { flex: 1; display: flex; align-items: center; gap: 2px; height: 30px; } .sgbars i { flex: 1; min-width: 2px; background: currentColor; opacity: .35; border-radius: 2px; } .sgbars i.on { opacity: 1; } .sgvt { font-size: 12px; opacity: .8; min-width: 32px; text-align: right; }
.sgpoll { min-width: 220px; } .sgpoll b { display: block; margin-bottom: 2px; cursor: pointer; } .sgpoll > small { display: block; opacity: .75; margin: 2px 0 6px; }
.sgopt { position: relative; display: flex; align-items: center; gap: 8px; width: 100%; margin: 4px 0; padding: 8px 10px; border: 1px solid rgba(127,127,127,.35); border-radius: 10px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; overflow: hidden; }
.sgopt i { position: absolute; inset: 0 auto 0 0; background: rgba(127,127,127,.25); z-index: 0; } .sgopt span, .sgopt small { position: relative; z-index: 1; } .sgopt span { flex: 1; } .sgopt.on { border-color: currentColor; font-weight: 600; } .sgopt:disabled { cursor: default; opacity: .8; }
.sgprev { display: flex; gap: 10px; margin: 6px 0 2px; padding: 8px; border-radius: 10px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgprev img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; flex: none; }
.sgprev span { min-width: 0; } .sgprev small, .sgprev b, .sgprev em { display: block; overflow: hidden; text-overflow: ellipsis; } .sgprev small { opacity: .7; } .sgprev b { white-space: nowrap; } .sgprev em { font-style: normal; opacity: .8; font-size: 13px; max-height: 2.6em; }
.chemo { position: absolute; bottom: 100%; left: 0; right: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 2px; max-height: 190px; overflow: auto; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px 12px 0 0; z-index: 25; }
.chemo button { border: 0; background: none; font-size: 24px; padding: 4px; cursor: pointer; border-radius: 8px; } .chemo button:hover { background: var(--panel2); }
.sgc { position: relative; } .sgc.rec > :not(.chrecbar) { visibility: hidden; } .chrecbar { position: absolute; inset: 0; display: flex; align-items: center; gap: 10px; padding: 0 10px; background: var(--panel); } .recdot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); animation: chblink 1s infinite; } @keyframes chblink { 50% { opacity: .2; } }
.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; }
+2 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v49';
const CACHE = 'hub-v62';
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'];
@@ -46,6 +46,7 @@ self.addEventListener('fetch', e => {
const u = new URL(e.request.url);
if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/podcast\/podcasts\/[^/]+\/file$/.test(u.pathname)) return e.respondWith(offlineAudio(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/plex\/library\/parts\//.test(u.pathname)) return e.respondWith(offlineAudio(e.request)); // (albums saved on this device)
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
e.respondWith(fetch(e.request).then(r => {
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
+2 -1
View File
@@ -9,7 +9,8 @@ async function viewTasks() {
const v = $('#view');
v.innerHTML = '<div class="wrap">' + plannerHead('tasks') + '<p class="empty">Loading…</p></div>';
try {
const { lists, icloud } = await jfetch('/api/tasks/lists');
let { lists, icloud } = await jfetch('/api/tasks/lists');
if (lists.some(l => l.mealie) && allowed('recipes')) { try { await jfetch('/api/recipes/shopping/sync', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); } catch {} } // (the Shopping list is shared with Mealie: bring it up to date first)
if (!location.hash.startsWith('#/tasks')) return;
taskLists = lists;
let cur = store.get('task_list'); if (cur !== 'all' && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together)
+1 -1
View File
@@ -17,7 +17,7 @@ pl.a.addEventListener('pause', () => vbSave()); pl.a.addEventListener('ended', (
const vbLeft = key => { const p = vbProg[key]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; };
const vbDone = key => { const p = vbProg[key]; return !!(p && p.dur && p.pos >= p.dur - 30); };
const vbDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; };
const vbCover = (s, big) => `<span class="vimg" style="aspect-ratio:1/1;background:var(--panel2)"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(s.cover || '')}" onerror="this.remove()"></span>`;
const vbCover = (s, big) => `<span class="vimg" style="aspect-ratio:1/1;background:var(--panel2)"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(stOk(s.cover))}" onerror="this.remove()"></span>`;
async function viewVbv(args = []) {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Verse By Verse</h1><p class="empty">Loading…</p></div>';
+6 -3
View File
@@ -23,7 +23,7 @@ function keys() {
return vapid;
}
const DEFAULTS = { mail: true, mailSeen: null, tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' };
const DEFAULTS = { chat: true, mail: true, mailSeen: null, tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' };
const file = dir => path.join(dir, 'push.json');
const load = dir => { try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(file(dir), 'utf8')) }; } catch { return { ...DEFAULTS, subs: [], sent: [] }; } };
const save = (dir, o) => atomicWrite(file(dir), JSON.stringify(o));
@@ -113,7 +113,7 @@ function start() { if (!timer) { timer = setInterval(() => tick().catch(e => con
async function handle(req, res) {
const p = new URL(req.url, 'http://x').pathname, dir = users.dir(), st = load(dir);
const pub = () => ({ mail: st.mail, tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length });
const pub = () => ({ chat: st.chat, mail: st.mail, tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length });
if (p === '/api/push/key' && req.method === 'GET') return json(res, 200, { publicKey: keys().publicKey, ...pub() });
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
const b = JSON.parse((await readBody(req)) || '{}');
@@ -129,6 +129,7 @@ async function handle(req, res) {
if (typeof b.tasks === 'boolean') st.tasks = b.tasks;
if (typeof b.events === 'boolean') st.events = b.events;
if (typeof b.mail === 'boolean') st.mail = b.mail;
if (typeof b.chat === 'boolean') st.chat = b.chat;
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(b.tasksAt || '')) st.tasksAt = b.tasksAt;
if ([5, 10, 15, 30, 60].includes(b.eventMins)) st.eventMins = b.eventMins;
if (b.tz && validTz(b.tz)) st.tz = b.tz;
@@ -141,4 +142,6 @@ async function handle(req, res) {
}
json(res, 404, { error: 'Not found' });
}
module.exports = { handle, start, tick, localNow };
/** A notification for one person's devices (a chat message, say), unless they turned that kind off. */
async function notify(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length || st.chat === false) return; await send(dir, st, payload); }
module.exports = { handle, start, tick, localNow, notify };
+7 -3
View File
@@ -57,6 +57,10 @@ async function station(id) {
const favFile = () => path.join(users.dir(), 'radio.json');
const mine = () => { try { const l = JSON.parse(fs.readFileSync(favFile(), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; } };
const saveMine = l => { const f = favFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(l), { mode: 0o600 }); fs.renameSync(tmp, f); };
// The featured stations (The Journey FM) are not on anyone's Radio until they add them from the Store, so each person has a list of the ones they added.
const addedFile = () => path.join(users.dir(), 'radio-featured.json');
const added = () => { try { const l = JSON.parse(fs.readFileSync(addedFile(), 'utf8')); return Array.isArray(l) ? l.filter(id => FEATURED.some(f => f.id === id)) : []; } catch { return []; } };
const saveAdded = l => { const f = addedFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify([...new Set(l)]), { mode: 0o600 }); fs.renameSync(tmp, f); };
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } };
// ---- now playing: the Journey's own feed, or the song title carried inside the stream
@@ -124,12 +128,12 @@ async function handle(req, res) {
for (const st of items) known.set(st.id, { t: Date.now(), st }); if (known.size > 500) known.clear(); // saving or playing one of these needs no second look-up
return json(res, 200, { items, more: rows.length >= 30 });
}
if (p === '/stations' && req.method === 'GET') return json(res, 200, { featured: FEATURED, mine: mine() });
if (p === '/stations' && req.method === 'GET') return json(res, 200, { featured: FEATURED, added: added(), mine: mine() });
if (p === '/stations' && req.method === 'POST') {
const b = await readBody(req), s = await station(String(b.id || '')); if (s.featured) return json(res, 200, { saved: true });
const b = await readBody(req), s = await station(String(b.id || '')); if (s.featured) { saveAdded([...added(), s.id]); return json(res, 200, { saved: true }); }
const l = mine().filter(x => x.id !== s.id); l.push(s); saveMine(l.slice(-200)); return json(res, 200, { saved: true });
}
if ((m = /^\/stations\/([\w-]+)$/.exec(p)) && req.method === 'DELETE') { saveMine(mine().filter(x => x.id !== m[1])); return json(res, 200, { removed: true }); }
if ((m = /^\/stations\/([\w-]+)$/.exec(p)) && req.method === 'DELETE') { saveMine(mine().filter(x => x.id !== m[1])); saveAdded(added().filter(x => x !== m[1])); return json(res, 200, { removed: true }); }
if ((m = /^\/now\/([\w-]+)$/.exec(p)) && req.method === 'GET') {
if (m[1] === 'journeyfm') { let now = null, recent = []; try { recent = await journeyNow(true); now = recent[0] || null; } catch {} return json(res, 200, { title: now ? now.title : '', artist: now ? now.artist : '', startedAt: now ? now.startedAt : 0, recent: recent.slice(0, 12) }); }
const n = nowMap.get(me.id + ':' + m[1]); const fresh = n && Date.now() - n.t < 600e3, sp = fresh ? /^(.+?)\s+-\s+(.+)$/.exec(n.title) : null; // many stations send "Artist - Title"
+148
View File
@@ -0,0 +1,148 @@
// Recipes and the shopping list, from Mealie (https://mealie.io). The hub talks to Mealie's own API with a token that stays on the server
// (MEALIE_URL, MEALIE_TOKEN), so the browser only ever talks to the hub. Recipes are searched and read in the hub, a link can be imported as a new
// recipe, and a recipe's ingredients can be added to Mealie's shopping list, which Mealie merges (two onions and one onion become three). That list is
// also mirrored into a "Shopping" list in Tasks, so it is checked off from either place.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const BASE = () => (process.env.MEALIE_URL || 'https://mealie.necloud.us').replace(/\/+$/, '');
const TOKEN = () => process.env.MEALIE_TOKEN || '';
const enabled = () => !!TOKEN();
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) => Object.assign(new Error(message), { code });
async function mealie(p, { method = 'GET', body, accept = 'application/json', timeout = 20000, raw } = {}) {
if (!TOKEN()) throw fail(503, 'Recipes are not set up on this server (MEALIE_TOKEN)');
let r;
try { r = await fetch(BASE() + p, { method, headers: { authorization: 'Bearer ' + TOKEN(), accept, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined, redirect: 'manual', signal: AbortSignal.timeout(timeout) }); }
catch (e) { throw fail(502, e.name === 'TimeoutError' ? 'Mealie did not answer in time' : 'Could not reach Mealie (' + ((e.cause && (e.cause.code || e.cause.message)) || e.message) + ')'); }
if (raw) return r;
if (r.status === 401 || r.status === 403) throw fail(502, 'Mealie refused the token (make a new one in Mealie: your profile, API tokens)');
if (!r.ok) { let m = ''; try { const j = await r.json(); m = typeof j.detail === 'string' ? j.detail : j.message || ''; } catch {} const e = fail(r.status === 404 ? 404 : 502, 'Mealie answered ' + r.status + (m ? ': ' + String(m).slice(0, 120) : '')); e.status = r.status; throw e; }
const t = await r.text(); try { return t ? JSON.parse(t) : {}; } catch { return t; }
}
const slugOk = s => /^[\w-]{1,200}$/.test(s || '');
const idOk = s => /^[0-9a-f-]{8,64}$/i.test(s || '');
// ---- recipes
const minutes = v => { // Mealie gives times as text ("1 hour 30 minutes", "PT45M" or "45 minutes")
const s = String(v || ''); if (!s) return 0; let m;
if ((m = /^PT(?:(\d+)H)?(?:(\d+)M)?/i.exec(s))) return (+m[1] || 0) * 60 + (+m[2] || 0);
return (/(\d+)\s*(?:h|hour)/i.exec(s) ? +RegExp.$1 * 60 : 0) + (/(\d+)\s*(?:m|min)/i.exec(s) ? +RegExp.$1 : 0) || (/^\d+$/.test(s) ? +s : 0);
};
const card = r => ({ id: r.id, slug: r.slug, name: r.name || r.slug, description: String(r.description || '').slice(0, 200), minutes: minutes(r.totalTime), servings: r.recipeServings || r.recipeYield || '', rating: r.rating || 0, image: !!r.image });
const ingredientText = i => String(i.display || i.originalText || [i.quantity ? +(+i.quantity).toFixed(2) : '', i.unit && (i.unit.name || i.unit), i.food && (i.food.name || i.food), i.note].filter(Boolean).join(' ') || '').trim();
function full(r) {
return { ...card(r), orgUrl: r.orgURL || '', prep: minutes(r.prepTime), cook: minutes(r.performTime || r.cookTime), total: minutes(r.totalTime), notes: (r.notes || []).map(n => ({ title: n.title || '', text: n.text || '' })),
ingredients: (r.recipeIngredient || []).map(i => ({ text: ingredientText(i), title: i.title || '' })).filter(i => i.text || i.title),
steps: (r.recipeInstructions || []).map(s => ({ title: s.title || '', text: s.text || '' })).filter(s => s.text || s.title),
categories: (r.recipeCategory || []).map(c => c.name).filter(Boolean), tags: (r.tags || []).map(c => c.name).filter(Boolean) };
}
// ---- the shopping list (Mealie moved it from /groups to /households; the one that answers is remembered)
let shopBase = '';
async function shop(p, opt) {
if (shopBase) return mealie(shopBase + p, opt);
let last; for (const b of ['/api/households/shopping', '/api/groups/shopping']) { try { const r = await mealie(b + p, opt); shopBase = b; return r; } catch (e) { last = e; if (e.status !== 404) throw e; } }
throw last;
}
const itemOut = i => ({ id: i.id, text: String(i.display || i.note || (i.food && i.food.name) || '').trim(), checked: !!i.checked, label: (i.label && i.label.name) || '', position: i.position || 0 });
async function mainList() { // the first shopping list (the one called "Shopping List" if there is more than one)
const l = await shop('/lists?perPage=50'), items = Array.isArray(l) ? l : l.items || [];
let pick = items.find(x => /shopping/i.test(x.name || '')) || items[0];
if (!pick) pick = await shop('/lists', { method: 'POST', body: { name: 'Shopping List' } });
return pick;
}
async function readList() { const l = await mainList(), d = await shop('/lists/' + l.id); return { id: l.id, name: d.name || l.name, items: (d.listItems || []).map(itemOut).sort((a, b) => a.checked - b.checked || a.position - b.position) }; }
// ---- mirror into Tasks: a "Shopping" list in the person's Tasks, one task per Mealie item
const tasksFile = () => path.join(users.dir(), 'tasks.json');
const loadTasks = () => { try { const d = JSON.parse(fs.readFileSync(tasksFile(), 'utf8')); if (d && Array.isArray(d.lists) && Array.isArray(d.tasks)) return d; } catch {} return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; };
/** Make Tasks and Mealie agree, in both directions: ticking, adding and deleting an item in either place shows up in the other. Each task remembers what Mealie last said
* (`mealieChecked`), so a change on one side is told apart from a change on the other. */
async function sync() {
const list = await readList(), d = loadTasks(), items = new Map(((await shop('/lists/' + list.id)).listItems || []).map(i => [i.id, i]));
let tl = d.lists.find(l => l.name === 'Shopping' && l.mealie);
if (!tl) { tl = { id: crypto.randomBytes(4).toString('hex'), name: 'Shopping', mealie: true, known: [] }; d.lists.push(tl); }
const known = new Set(tl.known || []), mine = d.tasks.filter(t => t.list === tl.id), byItem = new Map(mine.filter(t => t.mealieId).map(t => [t.mealieId, t]));
const out = { added: 0, toTasks: 0, toMealie: 0, sent: 0, removed: 0 }, gone = [];
for (const it of list.items) {
const t = byItem.get(it.id), raw = items.get(it.id);
if (!t) {
if (known.has(it.id)) { gone.push(it.id); continue; } // it was on the Tasks list and has been deleted there
d.tasks.push({ id: crypto.randomBytes(6).toString('hex'), list: tl.id, title: it.text, notes: '', due: '', done: it.checked, completedAt: it.checked ? Date.now() : null, mealieId: it.id, mealieChecked: it.checked, updated: Date.now() }); out.added++; continue;
}
const last = t.mealieChecked === undefined ? it.checked : t.mealieChecked;
if (!!t.done !== last) { await shop('/items/' + it.id, { method: 'PUT', body: { ...raw, checked: !!t.done } }); t.mealieChecked = !!t.done; out.toMealie++; } // (changed in Tasks)
else if (it.checked !== !!t.done) { t.done = it.checked; t.completedAt = it.checked ? Date.now() : null; t.mealieChecked = it.checked; t.updated = Date.now(); out.toTasks++; } // (changed in Mealie)
else t.mealieChecked = it.checked;
}
if (gone.length) { await shop('/items?' + gone.map(i => 'ids=' + i).join('&'), { method: 'DELETE' }); out.removed += gone.length; }
const ids = new Set(list.items.map(i => i.id));
for (const t of mine) {
if (t.mealieId && !ids.has(t.mealieId)) { d.tasks = d.tasks.filter(x => x !== t); out.removed++; continue; } // gone from Mealie
if (!t.mealieId && !t.done && t.title) { // typed straight into the Shopping list in Tasks
const n = await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: t.title, quantity: 1, checked: false } }), made = n && (n.createdItems ? n.createdItems[0] : n);
if (made && made.id) { t.mealieId = made.id; t.mealieChecked = false; ids.add(made.id); } out.sent++;
}
}
tl.known = [...ids].filter(id => !gone.includes(id));
atomicWrite(tasksFile(), JSON.stringify(d, null, 2), { mode: 0o600 });
return { ...out, list: tl.id };
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/recipes'.length) || '/', q = u.searchParams;
try {
if (p === '/status') return json(res, 200, { enabled: enabled(), host: new URL(BASE()).host });
let m;
if (p === '/' && req.method === 'GET') {
const page = Math.max(1, Math.min(200, +q.get('page') || 1)), term = (q.get('q') || '').trim().slice(0, 100);
const j = await mealie(`/api/recipes?page=${page}&perPage=24&orderBy=name&orderDirection=asc${term ? '&search=' + encodeURIComponent(term) : ''}`);
return json(res, 200, { items: (j.items || []).map(card), more: page < (j.total_pages || j.totalPages || 1) });
}
if ((m = /^\/r\/([\w-]+)$/.exec(p)) && req.method === 'GET') return json(res, 200, full(await mealie('/api/recipes/' + m[1])));
if ((m = /^\/image\/([0-9a-f-]+)$/i.exec(p)) && req.method === 'GET') { // the picture (small by default)
if (!idOk(m[1])) throw fail(400, 'Bad recipe');
for (const f of q.get('big') ? ['original.webp'] : ['min-original.webp', 'original.webp']) {
const r = await mealie(`/api/media/recipes/${m[1]}/images/${f}`, { accept: 'image/*', raw: true }).catch(() => null);
if (r && r.ok) { const type = (r.headers.get('content-type') || '').split(';')[0]; if (!/^image\//.test(type)) continue; res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=86400' }); return res.end(Buffer.from(await r.arrayBuffer())); }
}
return json(res, 404, { error: 'No picture' });
}
if (p === '/import' && req.method === 'POST') { // make a recipe from a web page's address
const b = JSON.parse(await readBody(req, 1e4) || '{}'), url = String(b.url || '').trim();
if (!/^https?:\/\/\S+$/i.test(url)) throw fail(400, 'Paste the address of a recipe page (starting with http)');
let slug; try { slug = await mealie('/api/recipes/create/url', { method: 'POST', body: { url, includeTags: false }, timeout: 90000 }); } catch (e) { if (e.status !== 404 && e.status !== 405) throw e; slug = await mealie('/api/recipes/create-url', { method: 'POST', body: { url }, timeout: 90000 }); }
slug = typeof slug === 'string' ? slug : slug && slug.slug; if (!slugOk(slug)) throw fail(502, 'Mealie could not read a recipe from that page');
return json(res, 200, full(await mealie('/api/recipes/' + slug)));
}
if (p === '/shopping' && req.method === 'GET') return json(res, 200, await readList());
if (p === '/shopping/add-recipe' && req.method === 'POST') { // the recipe's ingredients go on the list; Mealie adds up what is already there
const b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await mealie('/api/recipes/' + (slugOk(b.slug) ? b.slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +b.scale || 1));
try { await shop(`/lists/${list.id}/recipe/${r.id}`, { method: 'POST', body: { recipeIncrementQuantity: mult } }); }
catch (e) { if (![404, 405, 422].includes(e.status)) throw e; await shop(`/lists/${list.id}/recipe`, { method: 'POST', body: [{ recipeId: r.id, recipeIncrementQuantity: mult }] }); } // (newer Mealie takes a list of recipes)
const s = await sync(); return json(res, 200, { ok: true, ...s, name: r.name });
}
if (p === '/shopping/add' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').trim().slice(0, 200); if (!text) throw fail(400, 'Type what to buy');
const list = await mainList(); await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: text, quantity: 1, checked: false } }); await sync(); return json(res, 200, { ok: true });
}
if (p === '/shopping/check' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!idOk(b.id)) throw fail(400, 'Bad item');
const list = await mainList(), raw = ((await shop('/lists/' + list.id)).listItems || []).find(i => i.id === b.id); if (!raw) throw fail(404, 'That item is not on the list');
await shop('/items/' + b.id, { method: 'PUT', body: { ...raw, checked: !!b.checked } }); await sync(); return json(res, 200, { ok: true });
}
if (p === '/shopping/clear' && req.method === 'POST') { // take the ticked items off
const list = await readList(), ids = list.items.filter(i => i.checked).map(i => i.id);
if (ids.length) await shop('/items?' + ids.map(i => 'ids=' + i).join('&'), { method: 'DELETE' });
await sync(); return json(res, 200, { ok: true, removed: ids.length });
}
if (p === '/shopping/sync' && req.method === 'POST') return json(res, 200, await sync());
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, enabled, minutes, full, ingredientText };
+64
View File
@@ -0,0 +1,64 @@
// Helping an iPad on Screen Time "Allowed Websites Only": the app should need as few web addresses as possible, and an admin should be able to see exactly which ones.
// 1. The server sends a report-only Content-Security-Policy that says "only this site"; the browser reports every other address a page tries to load (nothing is blocked),
// and those are kept here, so the list comes from real use on the family's devices.
// 2. /api/img passes a picture from another site through the hub, so the browser only ever talks to the hub for it.
const fs = require('fs'), path = require('path');
const atomicWrite = require('./atomic');
const cal = require('./cal');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'hosts-seen.json');
const MAX_HOSTS = 300, MAX_IMG = 3 * 1024 * 1024;
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const load = () => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return j && typeof j === 'object' ? j : {}; } catch { return {}; } };
const save = o => atomicWrite(FILE(), JSON.stringify(o), { mode: 0o600 });
const ooHost = () => { try { return new URL(process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').host; } catch { return ''; } };
/** The report-only policy: only this site (and the document editor's), for everything the browser loads from the network. */
function policy() {
const oo = (() => { try { return new URL(process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').origin; } catch { return ''; } })();
return `default-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: ${oo}; style-src 'self' 'unsafe-inline' ${oo}; img-src 'self' data: blob:; media-src 'self' data: blob:; connect-src 'self' blob: data:; font-src 'self' data:; frame-src 'self' blob: data: ${oo}; worker-src 'self' blob:; report-uri /api/csp-report`;
}
/** A browser's report of one address a page loaded from outside: kept as host -> { n, first, last, what }. */
function record(body, hubHost) {
let r; try { r = JSON.parse(body)['csp-report']; } catch { return; } if (!r) return;
let u; try { u = new URL(String(r['blocked-uri'] || r['blockedURI'] || '')); } catch { return; }
if (!/^https?:$/.test(u.protocol) || u.host === hubHost || /^(localhost|127\.|\[::1\])/.test(u.hostname)) return;
const db = load(), what = String(r['effective-directive'] || r['violated-directive'] || '').split(/\s/)[0].replace(/-src$/, '') || 'other', now = Date.now(), e = db[u.hostname];
if (e) { e.n++; e.last = now; if (!e.what.includes(what)) e.what.push(what); } else if (Object.keys(db).length < MAX_HOSTS) db[u.hostname] = { n: 1, first: now, last: now, what: [what] }; else return;
save(db);
}
function overview(req) {
const hub = req.headers['x-forwarded-host'] || req.headers.host || '', required = [{ host: hub.split(':')[0], why: 'The hub itself. Almost everything the app needs goes through it.' }];
if (ooHost() && ooHost() !== hub) required.push({ host: ooHost(), why: 'The document editor (the Office tab).' });
const seen = Object.entries(load()).filter(([h]) => !required.some(r => r.host === h)).map(([host, v]) => ({ host, ...v })).sort((a, b) => b.n - a.n);
const optional = [{ host: 'plex.tv', why: 'Only the first time, to sign in to Plex (a link that opens in the browser).' }];
return { required, optional, seen };
}
async function image(req, res, u) {
let url; try { url = new URL(u.searchParams.get('u') || ''); } catch { return json(res, 400, { error: 'Bad address' }); }
try {
let r; for (let hop = 0; hop < 4; hop++) {
await cal.assertPublic(url);
r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0', accept: 'image/*' }, redirect: 'manual', signal: AbortSignal.timeout(10000) });
if ([301, 302, 303, 307, 308].includes(r.status) && r.headers.get('location')) { url = new URL(r.headers.get('location'), url); try { await r.body?.cancel(); } catch {} continue; } break;
}
const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!r.ok || !/^image\/(jpeg|png|gif|webp)$/.test(type)) return json(res, 404, { error: 'Not a picture' });
if ((+r.headers.get('content-length') || 0) > MAX_IMG) return json(res, 413, { error: 'Too large' });
const chunks = []; let n = 0; for await (const c of r.body) { n += c.length; if (n > MAX_IMG) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800', 'x-content-type-options': 'nosniff' }); return res.end(Buffer.concat(chunks));
} catch { return json(res, 502, { error: 'Could not get that picture' }); }
}
async function handle(req, res, me) {
const u = new URL(req.url, 'http://x');
if (u.pathname === '/api/csp-report' && req.method === 'POST') { try { record(await readBody(req, 2e4), req.headers['x-forwarded-host'] || req.headers.host); } catch {} res.writeHead(204); return res.end(); }
if (u.pathname === '/api/img' && req.method === 'GET') return image(req, res, u);
if (u.pathname === '/api/screentime') {
if (!me || me.role !== 'admin') return json(res, 403, { error: 'Only an admin can see this' });
if (req.method === 'DELETE') { save({}); return json(res, 200, overview(req)); }
if (req.method === 'GET') return json(res, 200, overview(req));
}
return json(res, 404, { error: 'Not found' });
}
module.exports = { handle, policy, record, overview };
+25 -2
View File
@@ -41,6 +41,12 @@ const abapi = require('./abapi'); // audiobook player library, audio and progre
const bible = require('./bible'); // Bible: each person's place, highlights and notes (the text is a static file)
const savefiles = require('./savefiles'); // "Save to Files": copy books, lessons and audiobooks into the person's Seafile
const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies)
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 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
const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM)
const librivox = require('./librivox'); // free audiobooks from LibriVox
const books = require('./books'); // e-book reader: EPUB, PDF and DOCX library with synced progress and notes
@@ -166,7 +172,7 @@ const sessions = new Map(); // callback id -> { token, repo, path, exp }; keeps
setInterval(() => { for (const [k, v] of sessions) if (v.exp < Date.now()) sessions.delete(k); }, 3600e3).unref();
const DOC_TYPE = { word: 'doc docx odt rtf txt', cell: 'xls xlsx ods csv', slide: 'ppt pptx odp' };
const docType = e => Object.keys(DOC_TYPE).find(k => DOC_TYPE[k].split(' ').includes(e));
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const { readBody } = require('./body');
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const sfGet = async (p, token) => {
const r = await fetch(SEAFILE_URL + p, { headers: { Authorization: 'Token ' + token } });
@@ -551,9 +557,14 @@ const ACCESS = [
[/^\/api\/bible\//, ['bible']],
[/^\/api\/read\//, ['read']],
[/^\/api\/radio\//, ['radio']],
[/^\/api\/chat(\/|$)/, ['chat']],
[/^\/api\/music(\/|$)/, ['music']],
[/^\/api\/store\/my(\/|$)/, ['store']],
[/^\/api\/recipes(\/|$)/, ['recipes']],
[/^\/api\/contacts(\/|$)/, ['contacts']],
[/^\/api\/signal\//, ['signal']],
[/^\/api\/lyrics$/, ['music', 'radio']],
[/^\/api\/transcript$/, ['podcast', 'audio']],
[/^\/api\/transcript(\/search)?$/, ['podcast', 'audio']],
[/^\/api\/mail\//, ['mail']],
[/^\/api\/cal\//, ['calendar']],
[/^\/api\/tasks/, ['tasks']],
@@ -567,6 +578,7 @@ function securityHeaders(res) {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'same-origin');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self'; base-uri 'self'; object-src 'none'; form-action 'self'");
res.setHeader('Content-Security-Policy-Report-Only', screentime.policy()); // (reports only: nothing is blocked)
res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), payment=()');
if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000');
}
@@ -638,6 +650,7 @@ async function handleRequest(req, res) {
const to = audiobooksLogin(user);
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
}
if (pathname === '/api/transcript/search') return transcripts.searchHandle(req, res);
if (pathname === '/api/transcript') return transcripts.handle(req, res);
if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server');
if (pathname.startsWith('/api/ab/free/')) return librivox.handle(req, res);
@@ -645,6 +658,12 @@ async function handleRequest(req, res) {
if (pathname.startsWith('/api/podcast/')) return podcast.handle(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.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);
if (pathname === '/api/recipes' || pathname.startsWith('/api/recipes/')) return recipes.handle(req, res);
if (pathname === '/api/contacts' || pathname.startsWith('/api/contacts/')) return contacts.handle(req, res);
if (pathname.startsWith('/api/vbv/')) return vbv.handle(req, res);
if (pathname.startsWith('/api/save/')) return savefiles.handle(req, res);
if (pathname.startsWith('/api/bible/')) return bible.handle(req, res);
@@ -661,9 +680,13 @@ async function handleRequest(req, res) {
http.createServer(async (req, res) => {
try { await handleRequest(req, res); }
catch (e) {
if (e && e.status === 413) { if (!res.headersSent) { res.writeHead(413, { 'content-type': 'application/json', connection: 'close' }); res.end('{"error":"That is too large"}'); } return; }
console.error('[error]', req.method, req.url.split('?')[0], e && e.stack || e);
if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Server error: ' + (e && e.message || 'unknown') })); } else res.destroy();
}
}).listen(PORT, () => console.log(`Hub on :${PORT} (Seafile ${SEAFILE_URL})`));
process.on('unhandledRejection', e => console.error('[unhandledRejection]', e && e.stack || e));
process.on('uncaughtException', e => console.error('[uncaughtException]', e && e.stack || e));
// Docker sends SIGTERM on stop: leave at once instead of waiting to be killed.
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { console.log('[stop] ' + sig); process.exit(0); });
+1 -1
View File
@@ -9,7 +9,7 @@ const users = require('./users');
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
// Only these keys are accepted. (The music queue stays in the browser.)
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']);
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'music_feat_pls', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']);
const SECRET = new Set(['ab_creds', 'sf_token']);
const MAX_VALUE = 20000; // bytes of JSON per key
+139
View File
@@ -0,0 +1,139 @@
// Smashwords as a book source for the Store: the free, DRM-free EPUBs. Browsing uses Smashwords' own shelf feed (no sign-in needed) and every book is run through a
// strict family filter first: anything Smashwords flags as adult or erotica, anything filed under LGBTQ+, and anything whose categories, tags or description say so, is
// dropped. Downloading a book needs a (free) Smashwords account, so an admin saves its sign-in once; it is kept encrypted on the server and used only to sign in at
// smashwords.com. Smashwords has no public search, so searching looks through a remembered set of its popular free books.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const auth = require('./auth');
const BASE = () => (process.env.SMASHWORDS_URL || 'https://www.smashwords.com').replace(/\/+$/, '');
const COVERS = () => (process.env.SMASHWORDS_COVERS || 'https://dwtr67e3ikfml.cloudfront.net').replace(/\/+$/, '');
const LANG = () => process.env.SMASHWORDS_LANG || '1'; // 1 = English
const UA = 'Mozilla/5.0 (compatible; NE-Hub/1.0; self-hosted e-book reader)';
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'store-smashwords.json');
const MAX_BOOK = 300 * 1024 * 1024;
const fail = (code, message) => Object.assign(new Error(message), { code });
// ---- the family filter
const LGBTQ = '886'; // Smashwords' own category for it
const BAD_WORDS = '(?:erotic\\w*|erotica|bdsm|smut\\w*|lgbt\\w*|queer|lesbian|bisexual|transgender|non-?binary|same-sex|m/m|f/f|m/f/m|sex scenes?|sexually explicit|explicit (?:sex|content|scenes?)|adult (?:content|themes?|romance|fiction)|mature (?:content|audiences?|themes?)|steamy|spicy romance|harem|omegaverse|gay (?:romance|fiction|love|couple|men|man|male|teen|characters?|protagonist)|18\\+|nsfw)';
const TEXT_BAD = new RegExp('(?<![\\w/])' + BAD_WORDS + '(?![\\w/])', 'i');
const TAG_BAD = new RegExp('(?<![\\w/])(?:' + BAD_WORDS + '|gay|trans (?:man|woman|girl|boy|character\\w*)|pegging|kink\\w*|fetish\\w*|alpha male)(?![\\w/])', 'i');
const ROMANCE = '1235';
const CLEAN = /\b(clean|sweet|wholesome|christian|inspirational|faith|cozy|family[- ]friendly|no sex|closed door|inspy|regency)\b/i;
const CAT_BAD = /erotic|lgbt|gay\b|lesbian|queer|bdsm|adult|mature|fetish|taboo/i;
const plain = s => String(s || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#0?39;/g, "'").replace(/\s+/g, ' ').trim();
/** true when a book (Smashwords' own record for it) is fine to show. */
function keep(b) {
if (!b || b.adult || b.erotica || b.taboo_erotica || b.need_erotic_cert) return false;
for (const c of b.categories || []) {
if (String(c.id) === LGBTQ || CAT_BAD.test(c.name || '')) return false;
for (const p of c.parents || []) if (String(p.id) === LGBTQ || CAT_BAD.test(p.name || '')) return false;
}
if ((b.tags || []).some(t => TAG_BAD.test(String(t)))) return false;
// Romance is where adult content hides without being flagged, so a romance is only shown when it says it is clean (sweet, Christian, inspirational...)
const romance = (b.categories || []).some(c => String(c.id) === ROMANCE || /romance/i.test(c.name || '') || (c.parents || []).some(p => String(p.id) === ROMANCE || /romance/i.test(p.name || ''))) || (b.tags || []).some(t => /romance/i.test(String(t)));
if (romance && !CLEAN.test([...(b.tags || []), ...(b.categories || []).flatMap(c => [c.name, ...(c.parents || []).map(p => p.name)])].join(' | '))) return false;
if (TEXT_BAD.test(plain(b.title) + ' ' + plain(b.short_description) + ' ' + plain(b.long_description))) return false;
return true;
}
const authorOf = b => (b.contributors || []).filter(c => !c.role || /^A0?1$/.test(c.role)).map(c => c.account && c.account.display_name).filter(Boolean).slice(0, 3).join(', ');
const free = b => !b.price || (b.price.prices || []).every(p => +p.amount === 0) || b.price.type === 'FREE';
const hasEpub = b => !b.formats || b.formats.some(f => /^epub$/i.test(f));
const item = b => ({ ref: String(b.id), format: 'epub', title: plain(b.title), author: authorOf(b), summary: plain(b.short_description).slice(0, 600), cover: b.cover_url ? COVERS() + '/' + String(b.cover_url).replace(/^\/+/, '') : '', _t: [b.title, authorOf(b), (b.tags || []).join(' '), plain(b.short_description)].join(' ').toLowerCase() });
const clean = ({ _t, ...rest }) => rest;
// ---- Smashwords' shelf feed
async function getJson(url) {
let r; try { r = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' }, signal: AbortSignal.timeout(20000) }); } catch (e) { throw fail(502, e.name === 'TimeoutError' ? 'Smashwords did not answer in time' : 'Could not reach Smashwords'); }
if (!r.ok) throw fail(502, 'Smashwords answered ' + r.status);
try { return await r.json(); } catch { throw fail(502, 'Smashwords sent something unexpected'); }
}
const shelf = (type, cat, page) => getJson(`${BASE()}/api/bookshelves/category/v3/shelf/${type}/${cat}/free/any/no_erotica/${LANG()}/${page}/10`);
const PER_API_PAGE = 10, API_PAGES_PER_PAGE = 3; // a page of the Store is three of Smashwords' pages of ten, since the filter removes some
/** The most downloaded free books, a page at a time. */
async function browse(page) {
const first = (page - 1) * API_PAGES_PER_PAGE + 1, pages = await Promise.all([0, 1, 2].map(i => shelf('mostdownloads', 1, first + i).catch(e => { if (i === 0) throw e; return { book_list: [], total_books: 0 }; })));
const seen = new Set(), items = [];
for (const p of pages) for (const e of p.book_list || []) { const b = e.book; if (b && !seen.has(b.id) && free(b) && hasEpub(b) && keep(b)) { seen.add(b.id); items.push(clean(item(b))); } }
const total = pages[0].total_books || 0;
return { items, more: first + API_PAGES_PER_PAGE - 1 < Math.ceil(total / PER_API_PAGE) && (pages[2].book_list || []).length > 0 };
}
// Smashwords has no public search, so one is made from its three best free shelves (most downloaded, newest, highest rated), remembered for a few hours.
let poolP = null, poolAt = 0, poolDone = false;
async function buildPool() {
const seen = new Set(), out = [];
for (const type of ['mostdownloads', 'highlyrated', 'newest']) {
const first = await shelf(type, 1, 1), n = Math.min(16, Math.ceil((first.total_books || 10) / PER_API_PAGE)), pages = [first];
for (let i = 2; i <= n; i += 6) pages.push(...await Promise.all(Array.from({ length: Math.min(6, n - i + 1) }, (_, k) => shelf(type, 1, i + k).catch(() => ({ book_list: [] })))));
for (const p of pages) for (const e of p.book_list || []) { const b = e.book; if (b && !seen.has(b.id) && free(b) && hasEpub(b) && keep(b)) { seen.add(b.id); out.push(item(b)); } }
}
return out;
}
const pool = () => { if (!poolP || Date.now() - poolAt > 6 * 3600e3) { poolAt = Date.now(); poolDone = false; poolP = buildPool().then(l => { poolDone = true; return l; }, e => { poolP = null; throw e; }); } return poolP; };
async function search(q, page, wait = true) { // (wait: false is for a search across every source, which must not stall for the first-time build; it starts and the next search has it)
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return browse(page);
if (!wait && !poolDone) { pool().catch(() => {}); return { items: [], more: false }; }
const hits = (await pool()).filter(b => words.every(w => b._t.includes(w)));
return { items: hits.slice((page - 1) * 20, page * 20).map(clean), more: hits.length > page * 20 };
}
// ---- the sign-in (admin saves it once; encrypted with a key from the server secret)
let _key; const key = () => _key || (_key = crypto.scryptSync(auth.secret, 'nehub-smashwords', 32));
const seal = v => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key(), iv), d = Buffer.concat([c.update(JSON.stringify(v), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); };
const unseal = s => { try { const b = Buffer.from(s, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); } catch { return null; } };
const saved = () => { try { return unseal(JSON.parse(fs.readFileSync(FILE(), 'utf8')).data); } catch { return null; } };
const account = () => { const s = saved(); return { signedIn: !!s, email: s ? s.email : '' }; };
let session = null; // { cookie, t }
function signOut() { try { fs.unlinkSync(FILE()); } catch {} session = null; }
/** Cookies from a response ("name=value" pairs), merged into the jar. */
function eat(jar, r) { for (const c of (r.headers.getSetCookie ? r.headers.getSetCookie() : [])) { const [kv] = c.split(';'), i = kv.indexOf('='); if (i > 0) { const v = kv.slice(i + 1).trim(); if (v && v !== 'deleted') jar.set(kv.slice(0, i).trim(), v); else jar.delete(kv.slice(0, i).trim()); } } }
const header = jar => [...jar].map(([k, v]) => k + '=' + v).join('; ');
async function req(url, jar, init = {}) { // one request; redirects are followed by hand so the cookies from each step are kept, and only smashwords' own address is used
for (let hop = 0; hop < 6; hop++) {
const u = new URL(url); if (u.origin !== new URL(BASE()).origin) throw fail(502, 'That address is not part of Smashwords');
const r = await fetch(u, { ...init, headers: { 'user-agent': UA, ...(init.headers || {}), cookie: header(jar) }, redirect: 'manual', signal: AbortSignal.timeout(init.timeout || 30000) }); eat(jar, r);
if ([301, 302, 303, 307, 308].includes(r.status) && r.headers.get('location')) { url = new URL(r.headers.get('location'), u).href; if (r.status !== 307 && r.status !== 308) { const { 'content-type': _c, ...hs } = init.headers || {}; init = { ...init, method: 'GET', body: undefined, headers: hs }; } try { await r.body?.cancel(); } catch {} if (/\/(signin|signup\/login)\b/.test(url) && init.stopAtLogin) return { r: { status: 401, headers: new Headers(), body: null }, url }; continue; }
return { r, url };
}
throw fail(502, 'Too many redirects from Smashwords');
}
async function login(email, pass) {
const jar = new Map(), page = await req(BASE() + '/signin', jar), html = await page.r.text(), token = (/name="loginToken"\s+value="([^"]*)"/.exec(html) || [])[1];
if (!token) throw fail(502, 'Smashwords\' sign-in page was not what was expected');
const out = await req(BASE() + '/auth/index', jar, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', referer: BASE() + '/signin' }, body: new URLSearchParams({ email, password: pass, loginToken: token, secToken: '' }).toString() });
const home = await (await req(BASE() + '/', jar)).r.text(); // the front page says whether this cookie is a signed-in one
if (!/loggedIn\s*=\s*true/.test(home)) { try { await out.r.body?.cancel(); } catch {} throw fail(401, 'Smashwords did not accept that email and password'); }
return jar;
}
/** Check a sign-in and save it. */
async function signIn(email, pass) {
email = String(email || '').trim(); pass = String(pass || ''); if (!/^\S+@\S+\.\S+$/.test(email) || !pass) throw fail(400, 'Enter your Smashwords email and password');
const jar = await login(email, pass); session = { jar, t: Date.now() };
fs.mkdirSync(DATA_DIR, { recursive: true }); atomicWrite(FILE(), JSON.stringify({ data: seal({ email, pass }) }), { mode: 0o600 }); return account();
}
async function jar() {
if (session && Date.now() - session.t < 3 * 3600e3) return session.jar;
const s = saved(); if (!s) throw fail(401, 'An admin needs to add a Smashwords sign-in first (Store, Books, Catalogues)');
session = { jar: await login(s.email, s.pass), t: Date.now() }; return session.jar;
}
/** The EPUB of a free book, as the signed-in account. */
async function download(id) {
if (!/^\d{1,9}$/.test(id)) throw fail(400, 'Bad book');
for (let attempt = 0; attempt < 2; attempt++) {
const j = await jar(), { r } = await req(`${BASE()}/books/download/${id}/8/latest/0/0/book.epub`, j, { timeout: 120000, stopAtLogin: true, headers: { accept: 'application/epub+zip, application/octet-stream, */*;q=0.5' } });
if (r.status === 401) { session = null; continue; } // the saved cookie ran out: sign in again once
if (!r.ok) throw fail(r.status === 404 ? 404 : 502, 'Smashwords answered ' + r.status);
if ((+r.headers.get('content-length') || 0) > MAX_BOOK) throw fail(413, 'That file is too large');
const chunks = []; let n = 0; for await (const c of r.body) { n += c.length; if (n > MAX_BOOK) throw fail(413, 'That file is too large'); chunks.push(c); }
const buf = Buffer.concat(chunks); if (buf.subarray(0, 2).toString('latin1') !== 'PK') throw fail(502, 'Smashwords did not send an EPUB for that book (it may not be free, or may not have one)');
return buf;
}
throw fail(401, 'Smashwords would not sign in with the saved email and password');
}
module.exports = { keep, browse, search, account, signIn, signOut, download, item, COVERS };
+28 -7
View File
@@ -1,8 +1,8 @@
// Book store: free, DRM-free books from Project Gutenberg (its own catalogue feed), Standard Ebooks (its public pages), the Internet Archive (public-domain EPUBs) and any OPDS catalogue an
// Book store: free, DRM-free books from Project Gutenberg (its own catalogue feed), Standard Ebooks (its public pages), the Internet Archive (public-domain EPUBs), Open Library (its catalogue of free-to-read editions, which are held by the Internet Archive) and any OPDS catalogue an
// admin adds (Calibre-Web, Kavita, ...). The hub server does all the talking to those sites: searching, covers and downloading a chosen book straight onto the
// signed-in person's shelf (books.js), so the browser never contacts them and no catalogue sign-in reaches the browser.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const users = require('./users'), auth = require('./auth'), books = require('./books');
const users = require('./users'), auth = require('./auth'), books = require('./books'), smashwords = require('./smashwords');
const UA = 'NE-Hub/1.0 (self-hosted e-book reader)';
const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_TEXT = 4 * 1024 * 1024;
@@ -11,6 +11,8 @@ const BUILTIN = {
gutenberg: { name: 'Project Gutenberg', base: () => env('GUTENBERG_URL', 'https://www.gutenberg.org'), hosts: ['gutenberg.org'] }, // its own catalogue feed (fast and official)
standardebooks: { name: 'Standard Ebooks', base: () => env('STANDARD_EBOOKS_URL', 'https://standardebooks.org'), hosts: ['standardebooks.org'] }, // its public pages: the OPDS feed is for paying members only
archive: { name: 'Internet Archive', base: () => env('ARCHIVE_URL', 'https://archive.org'), hosts: ['archive.org'], needsQuery: true },
smashwords: { name: 'Smashwords', base: () => env('SMASHWORDS_URL', 'https://www.smashwords.com'), hosts: ['smashwords.com', 'dwtr67e3ikfml.cloudfront.net'] }, // free EPUBs, with a family filter; downloading needs an account (see smashwords.js)
openlibrary: { name: 'Open Library', base: () => env('OPENLIBRARY_URL', 'https://openlibrary.org'), hosts: ['openlibrary.org'], needsQuery: true }, // search here; the book itself comes from the Internet Archive
};
const FORMAT_OF = { 'application/epub+zip': 'epub', 'application/pdf': 'pdf', 'application/x-mobipocket-ebook': 'mobi', 'application/vnd.amazon.mobi8-ebook': 'azw3', 'application/vnd.amazon.ebook': 'azw' };
const PREFER = ['epub', 'azw3', 'mobi', 'azw', 'pdf'];
@@ -133,14 +135,26 @@ async function archiveResolve(src, ref) {
return { url: `${src.base}/download/${ref}/${f.name.split('/').map(encodeURIComponent).join('/')}`, format: 'epub' };
}
async function searchOne(src, q, page) {
async function openLibrarySearch(src, q, page) {
const j = await getJson(src, `${src.base}/search.json?q=${encodeURIComponent(q)}&has_fulltext=true&fields=key,title,author_name,cover_i,ia,ebook_access&limit=20&page=${page}`);
const docs = j.docs || [], items = [];
for (const d of docs) { // only editions anyone may read (not "borrowable", which has DRM); the Internet Archive identifier is what gets downloaded
const ia = [].concat(d.ia || []).find(x => /^[\w.-]+$/.test(x)); if (!ia || d.ebook_access !== 'public') continue;
items.push({ ref: ia, format: 'epub', title: d.title || ia, author: [].concat(d.author_name || []).slice(0, 3).join(', '), summary: '', cover: d.cover_i ? `https://covers.openlibrary.org/b/id/${+d.cover_i}-M.jpg` : '' });
}
return { items, more: page * 20 < (j.numFound || 0) };
}
async function searchOne(src, q, page, everywhere) {
if (src.id === 'gutenberg') return gutenbergSearch(src, q, page);
if (src.id === 'standardebooks') return standardSearch(src, q, page);
if (src.id === 'archive') return q ? archiveSearch(src, q, page) : { items: [], more: false };
if (src.id === 'smashwords') return smashwords.search(q, page, !everywhere);
if (src.id === 'openlibrary') return q ? openLibrarySearch(src, q, page) : { items: [], more: false };
return opdsSearch(src, q, page);
}
const stamp = (src, it) => ({ ...it, source: src.id, sourceName: src.name, cover: it.cover ? `/api/read/store/cover?s=${encodeURIComponent(src.id)}&u=${encodeURIComponent(it.cover)}` : '', coverUrl: it.cover || '' });
const listSources = () => [...Object.keys(BUILTIN), ...customs().map(c => c.id)].map(id => { const s = sourceOf(id); return { id, name: s.name, builtin: s.builtin, needsQuery: !!s.needsQuery }; });
const listSources = () => [...Object.keys(BUILTIN), ...customs().map(c => c.id)].map(id => { const s = sourceOf(id); return { id, name: s.name, builtin: s.builtin, needsQuery: !!s.needsQuery, ...(id === 'smashwords' ? { account: smashwords.account() } : {}) }; });
async function handle(req, res, p) {
const me = users.current();
@@ -156,6 +170,11 @@ async function handle(req, res, p) {
catch (e) { saveCustoms(customs().filter(x => x.id !== id)); throw fail(e.code || 502, e.code ? e.message : 'Could not read that catalogue: ' + e.message); }
return json(res, 200, { id, name });
}
if (p === '/smashwords' && req.method === 'POST') { // the Smashwords sign-in (admin): checked by signing in, then kept encrypted
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can add the Smashwords sign-in');
const b = await readBody(req); return json(res, 200, await smashwords.signIn(b.email, b.pass));
}
if (p === '/smashwords' && req.method === 'DELETE') { if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can remove the Smashwords sign-in'); smashwords.signOut(); return json(res, 200, smashwords.account()); }
let m;
if ((m = /^\/sources\/(\w+)$/.exec(p)) && req.method === 'DELETE') {
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can remove a catalogue');
@@ -164,7 +183,7 @@ async function handle(req, res, p) {
if (p === '/search' && req.method === 'GET') {
const u = new URL(req.url, 'http://x'), q = (u.searchParams.get('q') || '').trim().slice(0, 120), page = Math.min(20, Math.max(1, +u.searchParams.get('page') || 1)), want = u.searchParams.get('source') || 'all';
const ids = want === 'all' ? listSources().map(s => s.id) : [want], lists = [], errors = {}; let more = false;
await Promise.all(ids.map(async id => { let src; try { src = sourceOf(id); const r = await searchOne(src, q, page); lists.push([id, r.items.map(it => stamp(src, it))]); more = more || r.more; } catch (e) { const nm = src ? src.name : id, msg = e.name === 'TimeoutError' ? 'did not answer in time' : e.message; errors[id] = msg.startsWith(nm) ? msg : nm + ': ' + msg; } }));
await Promise.all(ids.map(async id => { let src; try { src = sourceOf(id); const r = await searchOne(src, q, page, want === 'all'); lists.push([id, r.items.map(it => stamp(src, it))]); more = more || r.more; } catch (e) { const nm = src ? src.name : id, msg = e.name === 'TimeoutError' ? 'did not answer in time' : e.message; errors[id] = msg.startsWith(nm) ? msg : nm + ': ' + msg; } }));
lists.sort((a, b) => ids.indexOf(a[0]) - ids.indexOf(b[0]));
const items = []; for (let i = 0; lists.some(l => l[1][i]); i++) for (const l of lists) if (l[1][i]) items.push(l[1][i]); // one from each catalogue in turn
return json(res, 200, { items, errors, more });
@@ -176,13 +195,15 @@ async function handle(req, res, p) {
}
if (p === '/add' && req.method === 'POST') {
const b = await readBody(req), src = sourceOf(String(b.source || '')), ref = String(b.ref || '');
let target;
let target, dl = src;
if (src.id === 'gutenberg') target = await gutenbergResolve(src, ref);
else if (src.id === 'standardebooks') target = await standardResolve(src, ref);
else if (src.id === 'archive') target = await archiveResolve(src, ref);
else if (src.id === 'smashwords') target = { format: 'epub' };
else if (src.id === 'openlibrary') { dl = sourceOf('archive'); target = await archiveResolve(dl, ref); } // (Open Library's free editions are Internet Archive items)
else { if (!sameSite(src, new URL(ref, src.base))) throw fail(400, 'Bad book address'); target = { url: ref, format: String(b.format || 'epub') }; }
const ext = books.TYPES[target.format] ? target.format : 'epub';
const buf = await limited(await get(src, target.url, { accept: 'application/epub+zip, application/octet-stream, */*;q=0.5', timeout: 120000 }), MAX_BOOK);
const buf = src.id === 'smashwords' ? await smashwords.download(ref) : await limited(await get(dl, target.url, { accept: 'application/epub+zip, application/octet-stream, */*;q=0.5', timeout: 120000 }), MAX_BOOK);
let cover; const cu = String(b.cover || ''); if (cu) { try { const r = await get(src, cu, { accept: 'image/*' }), type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); cover = { type, data: await limited(r, MAX_COVER) }; } catch {} }
let book; try { book = books.addBook(buf, ext, { title: String(b.title || '').slice(0, 200), author: String(b.author || '').slice(0, 200) }, cover); } catch (e) { throw fail(400, e.message); }
return json(res, 200, book);
+2 -2
View File
@@ -12,7 +12,7 @@ const users = require('./users');
const file = () => path.join(users.dir(), 'tasks.json'); // each person has their own
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; } };
const save = d => atomicWrite(file(), JSON.stringify(d, null, 2), { mode: 0o600 });
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const { readBody } = require('./body');
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const isLocal = id => String(id || '').startsWith('local:');
const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
@@ -78,7 +78,7 @@ async function handle(req, res) {
try {
if (p === '/api/tasks/lists' && req.method === 'GET') {
const d = load(), st = caldav.status();
return json(res, 200, { lists: [...d.lists.map(l => ({ id: 'local:' + l.id, name: l.name, local: true })), ...caldav.todoLists().map(l => ({ ...l, icloud: true }))],
return json(res, 200, { lists: [...d.lists.map(l => ({ id: 'local:' + l.id, name: l.name, local: true, ...(l.mealie ? { mealie: true } : {}) })), ...caldav.todoLists().map(l => ({ ...l, icloud: true }))],
icloud: { connected: !!st.connected, reminderLists: st.reminderLists || 0 } });
}
if (p === '/api/tasks/lists' && req.method === 'POST') {
+8
View File
@@ -0,0 +1,8 @@
const http = require('http'), assert = require('assert'), { readBody } = require('../body');
const s = http.createServer(async (req, res) => { try { const t = await readBody(req, 1000); res.end('ok:' + t.length); } catch (e) { res.writeHead(e.status || 500); res.end(e.message); } });
s.listen(0, async () => {
const P = s.address().port, post = b => fetch(`http://127.0.0.1:${P}/`, { method: 'POST', body: b }).then(async r => [r.status, await r.text()]);
assert.deepStrictEqual(await post('x'.repeat(900)), [200, 'ok:900']);
const big = await post('x'.repeat(5000)); assert.strictEqual(big[0], 413);
console.log('body test ok'); s.close();
});
+13
View File
@@ -0,0 +1,13 @@
// iCloud connect: when the account does not report its calendar home at the principal, the home beside it (/<id>/calendars/) is tried.
const http = require('http'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cd-')); process.env.CALDAV_TEST = '1';
const root = path.join(__dirname, '..');
const srv = http.createServer((rq, rs) => { const p = new URL(rq.url, 'http://x').pathname, x = b => { rs.writeHead(207, { 'content-type': 'application/xml' }); rs.end('<multistatus xmlns="DAV:">' + b + '</multistatus>'); };
if (p === '/') return x('<response><href>/</href><propstat><prop><current-user-principal><href>/9/principal/</href></current-user-principal></prop><status>HTTP/1.1 200 OK</status></propstat></response>');
if (p === '/9/principal/') return x('<response><href>/9/principal/</href><propstat><prop><calendar-home-set/></prop><status>HTTP/1.1 404 Not Found</status></propstat></response>');
if (p === '/9/calendars/') return x('<response><href>/9/calendars/home/</href><propstat><prop><displayname>Home</displayname><resourcetype><collection/><calendar xmlns="urn:ietf:params:xml:ns:caldav"/></resourcetype></prop><status>HTTP/1.1 200 OK</status></propstat></response>');
rs.writeHead(404); rs.end(); }).listen(0, async () => {
process.env.CALDAV_BASE = 'http://127.0.0.1:' + srv.address().port;
const users = require(root + '/users'), caldav = require(root + '/caldav'), me = users.create({ name: 'A', role: 'admin', handle: 'YQ' });
await users.runAs(me, async () => { try { const s = await caldav.connect('a@icloud.com', 'abcd-efgh'); require('assert').deepEqual(s.calendars.map(c => c.name), ['Home']); console.log('caldav fallback ok'); } catch (e) { console.error(e); process.exitCode = 1; } });
srv.close(); process.exit(process.exitCode || 0); });
+94
View File
@@ -0,0 +1,94 @@
// Family Chat: conversations, messages, attachments, replies, reactions, edit and delete, unread counts, search, mute and archive, live events, and who may see 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-chat-'));
const users = require('../users'), chat = require('../chat');
const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chat'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chat'], 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'], () => chat.handle(req, res))).listen(0, async () => {
const B = 'http://127.0.0.1:' + hub.address().port + '/api/chat', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json();
try {
// who is in the family: only people with the chat tab
assert.deepEqual((await j(await call('/people', {}, 'a'))).map(x => x.name).sort(), ['Bob', 'Cy'], 'people without Chat are not offered');
let r = await post('/new', { type: 'direct', with: P.d.id }, 'a'); assert.equal(r.status, 400, 'cannot chat with someone who has no Chat');
let dm = (await j(await post('/new', { type: 'direct', with: P.b.id }, 'a'))).conv; assert.equal(dm.name, 'Bob'); assert.equal((await j(await post('/new', { type: 'direct', with: P.a.id }, 'b'))).conv.id, dm.id, 'the same chat from the other side, with the other name'); assert.equal((await j(await post('/new', { type: 'direct', with: P.a.id }, 'b'))).conv.name, 'Ann');
// live events
const events = []; const ctl = new AbortController(); const es = await fetch(B + '/events', { headers: { 'x-user': 'b' }, signal: ctl.signal }); const reader = es.body.getReader(); (async () => { const dec = new TextDecoder(); let buf = ''; try { for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value); let i; while ((i = buf.indexOf('\n\n')) >= 0) { const chunk = buf.slice(0, i); buf = buf.slice(i + 2); const d = /^data: (.*)$/m.exec(chunk); if (d) events.push(JSON.parse(d[1])); } } } catch { /* closed */ } })();
await new Promise(ok => setTimeout(ok, 100));
// sending
r = await post('/send', { conv: dm.id, text: ' Hello Bob ' }, 'a'); const m1 = (await j(r)).msg; assert.equal(m1.text, 'Hello Bob'); assert.equal(m1.out, true);
await new Promise(ok => setTimeout(ok, 150)); const ev = events.find(e => e.t === 'msg'); assert.ok(ev && ev.out === false && ev.name === 'Ann' && ev.preview === 'Hello Bob' && ev.unread === 1, 'Bob is told at once, with the unread count');
assert.equal((await post('/send', { conv: dm.id, text: '' }, 'a')).status, 400); assert.equal((await post('/send', { conv: dm.id, text: 'x' }, 'c')).status, 404, 'a stranger cannot write in it');
let list = await j(await call('/convs', {}, 'b')); assert.equal(list.unread, 1); assert.equal(list.convs[0].unread, 1); assert.equal(list.convs[0].preview, 'Ann: '.slice(0, 0) + 'Hello Bob', 'a direct chat preview has no name'); assert.equal((await j(await call('/convs', {}, 'c'))).convs.length, 0, 'Cy sees nothing');
assert.equal((await j(await call('/conv/' + encodeURIComponent(dm.id), {}, 'b'))).msgs.length, 1, 'a chat id works with its colons written as %3A'); await post('/read', { id: dm.id }, 'b'); assert.equal((await j(await call('/convs', {}, 'b'))).unread, 0, 'reading clears it');
// replies, reactions
const m2 = (await j(await post('/send', { conv: dm.id, text: 'Sure!', quote: m1.id }, 'b'))).msg; assert.equal(m2.quote.text, 'Hello Bob'); assert.equal(m2.quote.name, 'Ann');
assert.equal((await post('/react', { conv: dm.id, msg: m1.id, emoji: '👍' }, 'b')).status, 200); assert.equal((await post('/react', { conv: dm.id, msg: m1.id, emoji: '<b>' }, 'b')).status, 400, 'only emoji');
let th = await j(await call('/conv/' + dm.id, {}, 'a')); assert.deepEqual(th.msgs[0].react, { [P.b.id]: '👍' }); await post('/react', { conv: dm.id, msg: m1.id, emoji: '' }, 'b'); assert.deepEqual((await j(await call('/conv/' + dm.id, {}, 'a'))).msgs[0].react, {}, 'an empty emoji takes it back');
// attachments
const png = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64');
r = await call('/upload?conv=' + dm.id + '&name=../../pic.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a'); const up = await j(r); assert.equal(up.name, 'pic.gif', 'a file name cannot reach other folders'); assert.equal(up.size, png.length);
assert.equal((await call('/upload?conv=' + dm.id + '&name=x', { method: 'POST', body: png }, 'c')).status, 404);
let ma = (await j(await post('/send', { conv: dm.id, text: 'a photo', files: [up.id] }, 'a'))).msg; assert.equal(ma.att[0].type, 'image/gif'); assert.equal((await post('/send', { conv: dm.id, text: 'again', files: [up.id] }, 'a')).status, 400, 'an upload is attached once');
r = await call('/file/' + up.id, {}, 'b'); assert.equal(r.status, 200); assert.equal(r.headers.get('content-type'), 'image/gif'); assert.equal(Buffer.from(await r.arrayBuffer()).length, png.length); assert.equal((await call('/file/' + up.id, {}, 'c')).status, 404, 'only the chat sees its files');
const html = (await j(await call('/upload?conv=' + dm.id + '&name=x.html', { method: 'POST', body: '<script>alert(1)</script>', headers: { 'content-type': 'text/html' } }, 'a'))); r = await call('/file/' + html.id, {}, 'b'); assert.equal(r.headers.get('content-type'), 'application/octet-stream', 'a web page is never shown inline'); assert.match(r.headers.get('content-disposition'), /attachment/);
r = await call('/file/' + up.id, { headers: { range: 'bytes=0-3' } }, 'b'); assert.equal(r.status, 206);
// edit and delete
assert.equal((await post('/edit', { conv: dm.id, msg: m1.id, text: 'Hello again' }, 'b')).status, 403, 'only the author edits'); assert.equal((await post('/edit', { conv: dm.id, msg: m1.id, text: 'Hello again' }, 'a')).status, 200);
assert.equal((await j(await call('/conv/' + dm.id, {}, 'b'))).msgs[0].edited, true); assert.equal((await post('/delete', { conv: dm.id, msg: ma.id }, 'a')).status, 200); th = await j(await call('/conv/' + dm.id, {}, 'b')); const gone = th.msgs.find(x => x.id === ma.id); assert.equal(gone.deleted, true); assert.equal(gone.att, undefined); assert.equal((await call('/file/' + up.id, {}, 'b')).status, 404, 'its file is gone too');
// groups
assert.equal((await post('/new', { type: 'group', name: '', members: [P.b.id] }, 'a')).status, 400);
const g = (await j(await post('/new', { type: 'group', name: 'Family', members: [P.b.id, P.c.id, P.d.id] }, 'a'))).conv; assert.deepEqual(g.members.map(x => x.name).sort(), ['Ann', 'Bob', 'Cy'], 'someone without Chat is left out');
await post('/send', { conv: g.id, text: 'Dinner at 6' }, 'a'); const gl = (await j(await call('/convs', {}, 'c'))).convs; assert.equal(gl[0].name, 'Family'); assert.equal(gl[0].preview, 'Ann: Dinner at 6', 'a group preview names who wrote it'); assert.equal(gl[0].unread, 1);
assert.equal((await post('/conv/update', { id: g.id, remove: [P.c.id] }, 'b')).status, 403, 'only the maker or an admin removes people'); assert.equal((await post('/conv/update', { id: g.id, name: 'Our Family', remove: [P.c.id] }, 'a')).status, 200); assert.equal((await j(await call('/convs', {}, 'c'))).convs.length, 0, 'Cy no longer has the group');
assert.equal((await post('/conv/update', { id: g.id, leave: true }, 'b')).status, 200); assert.deepEqual((await j(await call('/conv/' + g.id, {}, 'a'))).conv.members.map(x => x.name), ['Ann']);
// mute, archive, search
await post('/conv/mute', { id: dm.id, on: true }, 'b'); await post('/send', { conv: dm.id, text: 'psst' }, 'a'); let bl = await j(await call('/convs', {}, 'b')); assert.equal(bl.convs.find(x => x.id === dm.id).muted, true); assert.equal(bl.unread, 0, 'a muted chat does not add to the badge'); assert.equal(bl.convs.find(x => x.id === dm.id).unread, 1, 'but still counts in itself');
await post('/conv/archive', { id: dm.id, on: true }, 'b'); assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === dm.id).archived, true);
const s = await j(await call('/search?q=hello', {}, 'b')); assert.equal(s.results.length, 1); assert.equal(s.results[0].convName, 'Ann'); assert.equal((await j(await call('/search?q=h', {}, 'b'))).results.length, 0, 'too short');
// ---- phase 2: ticks, typing, presence, disappearing messages, mentions, pins, forwarding
const g2 = (await j(await post('/new', { type: 'group', name: 'Crew', members: [P.b.id, P.c.id] }, 'a'))).conv; const st = async (who, conv) => (await j(await call('/conv/' + encodeURIComponent(conv), {}, who)));
let sm = (await j(await post('/send', { conv: g2.id, text: 'Hi @Bob and @Cy!' }, 'a'))).msg; assert.deepEqual(sm.mentions.sort(), [P.b.id, P.c.id].sort(), 'mentions are found by name'); assert.equal(sm.status, 'sent', 'nobody has the app open: just sent');
await st('b', g2.id); let a1 = (await st('a', g2.id)).msgs.find(x => x.id === sm.id); assert.equal(a1.status, 'sent', 'delivered needs everyone in a group'); await st('c', g2.id); a1 = (await st('a', g2.id)).msgs.find(x => x.id === sm.id); assert.equal(a1.status, 'delivered', 'everyone has fetched it');
await post('/read', { id: g2.id }, 'b'); assert.equal((await st('a', g2.id)).msgs.find(x => x.id === sm.id).status, 'delivered', 'read by one is not read by all'); await post('/read', { id: g2.id }, 'c'); assert.equal((await st('a', g2.id)).msgs.find(x => x.id === sm.id).status, 'read');
assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sm.id).status, undefined, 'ticks are only for what you sent');
// typing and presence (Bob has the live stream open)
const t0 = events.length; await post('/typing', { conv: dm.id }, 'a'); await new Promise(ok => setTimeout(ok, 120)); const ty = events.slice(t0).find(e => e.t === 'typing'); assert.ok(ty && ty.name === 'Ann' && ty.conv === dm.id, 'Bob sees that Ann is typing');
let pv = (await j(await call('/convs', {}, 'a'))).convs.find(x => x.id === dm.id); assert.equal(pv.presence.online, true, 'Bob has the app open'); assert.ok(pv.presence.seen > 0);
// disappearing messages
assert.equal((await post('/conv/ttl', { id: g2.id, ttl: 5 }, 'a')).status, 400); assert.equal((await post('/conv/ttl', { id: g2.id, ttl: 3600 }, 'b')).status, 200);
let gv = await st('a', g2.id); assert.equal(gv.conv.ttl, 3600); const sys = gv.msgs.at(-1); assert.equal(sys.sys, true); assert.match(sys.text, /Bob set messages to disappear after 1 hour/); assert.equal((await j(await call('/convs', {}, 'a'))).convs.find(x => x.id === g2.id).preview, 'Bob set messages to disappear after 1 hour', 'a notice has no name in front');
const upg = await j(await call('/upload?conv=' + g2.id + '&name=t.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); const tm = (await j(await post('/send', { conv: g2.id, text: 'secret', files: [upg.id] }, 'a'))).msg; assert.ok(tm.exp > Date.now(), 'it has a time to go');
assert.equal((await call('/file/' + upg.id, {}, 'b')).status, 200); const realNow = Date.now; Date.now = () => realNow() + 2 * 3600e3; chat.purge(); Date.now = realNow;
gv = await st('a', g2.id); assert.ok(!gv.msgs.some(x => x.id === tm.id), 'it is gone when its time is up'); assert.ok(gv.msgs.some(x => x.id === sm.id), 'older messages from before the timer stay'); assert.equal((await call('/file/' + upg.id, {}, 'b')).status, 404, 'and so is its file');
// pins
assert.equal((await post('/pin', { conv: g2.id, msg: sm.id, on: true }, 'c')).status, 200); gv = await st('b', g2.id); assert.deepEqual(gv.pinned.map(x => x.id), [sm.id]); await post('/pin', { conv: g2.id, msg: sm.id, on: false }, 'b'); assert.deepEqual((await st('a', g2.id)).pinned, []);
assert.equal((await post('/pin', { conv: g2.id, msg: (await st('a', g2.id)).msgs.find(x => x.sys).id, on: true }, 'a')).status, 404, 'a notice cannot be pinned');
// forwarding copies the message and its file into another chat
const fp = await j(await call('/upload?conv=' + g2.id + '&name=f.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); const fm = (await j(await post('/send', { conv: g2.id, text: 'look at this', files: [fp.id] }, 'a'))).msg;
const fw = (await j(await post('/forward', { from: g2.id, to: dm.id, msg: fm.id }, 'a'))).msg; assert.equal(fw.text, 'look at this'); assert.equal(fw.fwd.name, 'Ann'); assert.notEqual(fw.att[0].id, fp.id, 'the file is copied, not shared'); assert.equal((await call('/file/' + fw.att[0].id, {}, 'b')).status, 200);
assert.equal((await post('/forward', { from: g2.id, to: dm.id, msg: fm.id }, 'c')).status, 404, 'only into chats you are in');
// ---- phase 3: voice notes, polls, link previews
const vp = await j(await call('/upload?conv=' + g2.id + '&name=Voice.m4a&voice=1&wave=10,50,200,-5,abc,80&dur=12', { method: 'POST', body: 'audio-bytes', headers: { 'content-type': 'audio/mp4' } }, 'a')); assert.equal(vp.voice, true);
const vm = (await j(await post('/send', { conv: g2.id, files: [vp.id] }, 'a'))).msg; assert.deepEqual(vm.att[0].wave, [10, 50, 100, 0, 80], 'the waveform is cleaned (0 to 100)'); assert.equal(vm.att[0].dur, 12); assert.equal(vm.att[0].voice, true);
assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, 'Ann: 🎤 Voice message', 'a voice note with no words is listed as one');
const notV = await j(await call('/upload?conv=' + g2.id + '&name=x.gif&voice=1', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); assert.equal(notV.voice, undefined, 'only audio can be a voice note');
assert.equal((await post('/poll', { conv: g2.id, question: 'Pizza?', options: ['Yes'] }, 'a')).status, 400, 'a poll needs two choices'); const pm = (await j(await post('/poll', { conv: g2.id, question: 'Pizza or tacos?', options: ['Pizza', 'Tacos', ' ', 'Salad'] }, 'a'))).msg; assert.deepEqual(pm.poll.options.map(o => o.text), ['Pizza', 'Tacos', 'Salad']); assert.equal(pm.poll.multi, false);
assert.equal((await post('/vote', { conv: g2.id, msg: pm.id, opts: ['0', '1', '9'] }, 'b')).status, 200); let pv2 = (await st('a', g2.id)).msgs.find(x => x.id === pm.id); assert.deepEqual(pv2.poll.votes, { [P.b.id]: ['0'] }, 'a single-choice poll keeps one vote');
await post('/vote', { conv: g2.id, msg: pm.id, opts: ['1'] }, 'c'); await post('/vote', { conv: g2.id, msg: pm.id, opts: ['2'] }, 'b'); pv2 = (await st('a', g2.id)).msgs.find(x => x.id === pm.id); assert.deepEqual(pv2.poll.votes, { [P.b.id]: ['2'], [P.c.id]: ['1'] }, 'changing a vote replaces it'); await post('/vote', { conv: g2.id, msg: pm.id, opts: [] }, 'c'); assert.deepEqual((await st('a', g2.id)).msgs.find(x => x.id === pm.id).poll.votes, { [P.b.id]: ['2'] }, 'an empty vote takes it back');
const mp = (await j(await post('/poll', { conv: g2.id, question: 'Which days?', options: ['Sat', 'Sun'], multi: true }, 'a'))).msg; await post('/vote', { conv: g2.id, msg: mp.id, opts: ['0', '1'] }, 'b'); assert.deepEqual((await st('a', g2.id)).msgs.find(x => x.id === mp.id).poll.votes[P.b.id], ['0', '1'], 'a multiple-choice poll keeps both');
assert.equal((await post('/poll/close', { conv: g2.id, msg: pm.id }, 'b')).status, 403, 'only the asker closes it'); assert.equal((await post('/poll/close', { conv: g2.id, msg: pm.id }, 'a')).status, 200); assert.equal((await post('/vote', { conv: g2.id, msg: pm.id, opts: ['0'] }, 'b')).status, 400, 'a closed poll takes no votes');
assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, 'Ann: 📊 Which days?', 'a poll is listed by its question');
const pp = chat.parsePreview('<html><head><title>Fallback</title><meta property="og:title" content="Pot &amp; Pan Recipes"><meta property="og:description" content="Best stew"><meta property="og:image" content="/img/a.jpg"><meta property="og:site_name" content="Cook Site"></head></html>', 'https://cook.example.com/stew'); assert.deepEqual(pp, { url: 'https://cook.example.com/stew', site: 'Cook Site', title: 'Pot & Pan Recipes', desc: 'Best stew', image: 'https://cook.example.com/img/a.jpg' }, 'a preview reads the page\'s own words');
assert.equal(chat.parsePreview('<title>Plain</title>', 'https://x.example.com/').title, 'Plain'); assert.equal(chat.parsePreview('<meta property="og:title" content="T"><meta property="og:image" content="http://x.example.com/a.jpg">', 'https://x.example.com/').image, '', 'only https pictures'); assert.equal(chat.parsePreview('<html></html>', 'https://x.example.com/'), null);
// ---- family extras: announcement and quiet hours
assert.equal(await j(await call('/announcement', {}, 'b')), null); assert.equal((await post('/announcement', { text: 'Dinner at 6' }, 'b')).status, 403, 'only an admin posts it'); const an = await j(await post('/announcement', { text: ' Dinner at 6 ' }, 'a')); assert.equal(an.text, 'Dinner at 6'); assert.equal((await j(await call('/announcement', {}, 'b'))).by, 'Ann'); await post('/announcement', { text: '' }, 'a'); assert.equal(await j(await call('/announcement', {}, 'b')), null, 'an empty one clears it');
assert.deepEqual((await j(await call('/prefs', {}, 'b'))).quiet, { on: false, from: '21:00', to: '07:00' }); const qp = await j(await post('/prefs', { quiet: { on: true, from: '20:30', to: 'nope' } }, 'b')); assert.deepEqual(qp.quiet, { on: true, from: '20:30', to: '07:00' }, 'a bad time is ignored'); assert.equal((await j(await call('/prefs', {}, 'c'))).quiet.on, false, 'each person has their own');
ctl.abort(); console.log('chat ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 80);
});
+34
View File
@@ -0,0 +1,34 @@
// Contacts: adding, editing, finding, vCard import/export (merging people already there), birthdays for the calendar, and each person's own book.
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-ct-'));
const contacts = require('../contacts'), users = require('../users');
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', handle: 'Yg' }) };
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => contacts.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port + '/api/contacts', post = (p, b, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(b), headers: h });
try {
let r = await post('/', { name: 'Zoe Zed', emails: [{ label: 'work', value: 'Zoe@Example.com' }, { value: 'not an email' }], phones: [{ value: '555 1234' }], birthday: '1990-04-15' }); let c = await r.json();
assert.equal(r.status, 200); assert.deepEqual(c.emails, [{ label: 'work', value: 'zoe@example.com' }], 'addresses are lower-cased and bad ones dropped');
assert.equal((await post('/', { name: '', emails: [] })).status, 400, 'a contact needs a name or an address');
assert.equal((await (await post('/', { emails: [{ value: 'amy@x.org' }] })).json()).name, 'amy', 'with no name the address stands in');
assert.equal((await (await post('/', { name: 'Bad Day', birthday: '1990-02-31' })).json()).birthday, '', 'a date that does not exist is dropped');
c = await (await post('/', { id: c.id, name: 'Zoe Zed', org: 'Acme', emails: c.emails, birthday: '--04-15' })).json(); assert.equal(c.org, 'Acme'); assert.equal((await (await fetch(base + '/')).json()).length, 3, 'editing does not add another');
assert.equal((await post('/', { id: 'nope', name: 'X' })).status, 404);
// vCard in and out
const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zoe Zed\r\nEMAIL;TYPE=HOME:zoe@example.com\r\nEMAIL:zoe2@example.com\r\nTEL;TYPE=CELL:+1 555 0000\r\nEND:VCARD\r\nBEGIN:VCARD\r\nVERSION:4.0\r\nN:Smith;Jo;;;\r\nORG:Big\\, Co;Sales\r\nEMAIL;TYPE=work:jo@big.co\r\nBDAY:19801225\r\nNOTE:Line one\\nLine two\r\n continued\r\nADR;TYPE=home:;;1 Main St;Town;ST;12345;USA\r\nEND:VCARD\r\nBEGIN:VCARD\r\nEND:VCARD\r\n';
j = await (await post('/import', { vcf })).json(); assert.deepEqual(j, { added: 1, merged: 1, skipped: 1 }, 'Zoe is filled in, Jo is new, the empty card is skipped');
const all = await (await fetch(base + '/')).json(), zoe = all.find(x => x.name === 'Zoe Zed'), jo = all.find(x => x.name === 'Jo Smith');
assert.deepEqual(zoe.emails.map(e => e.value), ['zoe@example.com', 'zoe2@example.com']); assert.equal(zoe.phones.length, 1); assert.equal(jo.org, 'Big, Co'); assert.equal(jo.birthday, '1980-12-25'); assert.equal(jo.notes, 'Line one\nLine twocontinued', 'folded lines are joined'); assert.match(jo.address, /1 Main St/);
const out = await (await fetch(base + '/export')).text(); assert.match(out, /BEGIN:VCARD/); assert.match(out, /FN:Jo Smith/); assert.match(out, /BDAY:19801225/); assert.match(out, /BDAY:--0415/);
const back = contacts.parseVcf(out); assert.equal(back.length, all.length, 'what is exported can be read back');
// calendar birthdays
const ev = contacts.birthdayEvents({ fd: '2026-04-01', td: '2026-04-30' }, people.a.id); assert.equal(ev.length, 1); assert.equal(ev[0].start, '2026-04-15'); assert.equal(ev[0].end, '2026-04-16'); assert.equal(ev[0].title, "Zoe Zed's birthday");
const dec = contacts.birthdayEvents({ fd: '2026-12-01', td: '2027-01-10' }, people.a.id); assert.equal(dec[0].title, "Jo Smith's birthday (46)", 'the age is shown when the year is known');
assert.equal(contacts.birthdayEvents({ fd: '2026-06-01', td: '2026-06-30' }, people.a.id).length, 0);
// each person has their own book
assert.deepEqual(await (await fetch(base + '/', { headers: { 'x-user': 'b' } })).json(), []);
r = await post('/delete', { id: zoe.id }); assert.equal(r.status, 200); assert.equal((await (await fetch(base + '/')).json()).some(x => x.id === zoe.id), false);
console.log('contacts ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});
var j;
+4 -1
View File
@@ -12,6 +12,8 @@ const fake = http.createServer((req, res) => {
if (u.searchParams.get('title') === 'prejudice' || u.searchParams.get('author') === 'austen') return J({ books: [book] });
return J({ error: 'Audiobooks could not be found' }, 404);
}
if (p === '/dl/1.mp3') { const rg = /^bytes=(\d+)-(\d+)$/.exec(req.headers.range || ''), data = Buffer.from('0123456789'); if (rg) { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': `bytes ${rg[1]}-${rg[2]}/10`, 'content-length': +rg[2] - +rg[1] + 1 }); return res.end(data.subarray(+rg[1], +rg[2] + 1)); } res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': 10 }); return res.end(data); }
if (p === '/dl/moved.mp3') { res.writeHead(302, { location: '/dl/1.mp3' }); return res.end(); }
if (p === '/cover.gif') { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(gif); }
res.writeHead(404); res.end();
}).listen(0, () => {
@@ -25,7 +27,8 @@ const fake = http.createServer((req, res) => {
assert.deepEqual((await j(await call('/search?q=zzz'))).items, [], 'nothing found is an empty list, not an error');
assert.ok((await j(await call('/search'))).items.length >= 0, 'no search words lists the catalogue');
r = await call('/book/55'); const b = await j(r); assert.equal(b.chapters.length, 2, 'chapters on other sites are dropped'); assert.ok(!JSON.stringify(b).includes('127.0.0.1:' + port + '/dl'), 'audio addresses are not sent to the browser'); assert.equal(b.chapters[1].title, 'Chapter 3'); assert.equal(b.description, 'A novel');
r = await call('/audio/55/0'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), `http://127.0.0.1:${port}/dl/1.mp3`);
r = await call('/audio/55/0'); assert.equal(r.status, 200); assert.equal(r.headers.get('content-type'), 'audio/mpeg'); assert.equal(await r.text(), '0123456789', 'the hub passes the recording on itself');
r = await call('/audio/55/0', { headers: { range: 'bytes=2-4' } }); assert.equal(r.status, 206); assert.equal(await r.text(), '234'); assert.equal(r.headers.get('content-range'), 'bytes 2-4/10', 'seeking works');
assert.equal((await call('/audio/55/9')).status, 404); assert.equal((await call('/book/abc')).status, 404);
r = await call('/cover/55'); assert.equal(r.headers.get('content-type'), 'image/gif');
assert.equal((await call('/cover/55?u=' + encodeURIComponent('http://169.254.169.254/x'))).status, 200, 'an address off the allowed sites is ignored (the book\'s own cover is used)');
+37
View File
@@ -0,0 +1,37 @@
// Music data: plays, history and stats, favorites, smart playlist rules, and Family playlists shared by everyone with Music.
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-md-'));
const users = require('../users'), md = require('../musicdata');
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['music'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['music'], handle: 'Yw' }) };
const hub = http.createServer((req, res) => users.runAs(people[req.headers['x-user'] || 'a'], () => md.handle(req, res))).listen(0, async () => {
const B = 'http://127.0.0.1:' + hub.address().port + '/api/music', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who, m = 'POST') => call(p, { method: m, body: JSON.stringify(b) }, who), j = async r => r.json();
const s = (n, o = {}) => ({ key: String(100 + n), title: 'Song ' + n, artist: 'Artist ' + (n % 2), album: 'Album ' + n, thumb: '/library/metadata/1/thumb/1', artistKey: String(10 + n % 2), albumKey: String(50 + n), dur: 180000, ...o });
try {
// plays
assert.equal((await post('/played', { key: 'abc', title: 'x' })).status, 400, 'only Plex songs');
for (const n of [1, 2, 1, 3, 1]) assert.equal((await post('/played', s(n))).status, 200);
const h = await j(await call('/history?limit=3')); assert.equal(h.length, 3); assert.equal(h[0].title, 'Song 1', 'newest first');
assert.equal((await j(await post('/played', s(1)))).duplicate, true, 'the same song twice in a moment counts once'); assert.equal((await j(await call('/history'))).length, 5);
const st = await j(await call('/stats?range=week'));
assert.ok(st.plays >= 4 && st.minutes >= 9); assert.equal(st.songs[0].title, 'Song 1'); assert.equal(st.songs[0].plays, 3, 'the most played song is first'); assert.equal(st.artists[0].name, 'Artist 1'); assert.equal(st.series.length, 7, 'a week has seven days'); assert.equal(st.series.at(-1).minutes, st.minutes, 'all of it was today'); assert.equal(st.streak, 1);
assert.equal((await j(await call('/stats?range=year'))).series.length, 1, 'a year is shown per month'); assert.deepEqual((await j(await call('/stats', {}, 'b'))).plays, 0, "each person's own history");
assert.equal((await j(await call('/top?days=30&limit=2'))).length, 2);
// favorites
await post('/favs', s(7)); await post('/favs', s(8)); await post('/favs', s(7)); let f = await j(await call('/favs')); assert.equal(f.length, 2, 'saving twice keeps one'); assert.equal(f[0].key, '107', 'the one just saved again is newest');
await call('/favs/107', { method: 'DELETE' }); assert.deepEqual((await j(await call('/favs'))).map(x => x.key), ['108']); assert.deepEqual(await j(await call('/favs', {}, 'b')), []);
// smart playlist rules are cleaned
let sp = await j(await post('/smart', { name: 'Old gospel', rules: { genres: ['Gospel', '<script>', 'Soul'], yearFrom: 1950, yearTo: 1989, addedDays: -5, order: 'weird', limit: 9999, onlyFavorites: 1 } })); assert.deepEqual(sp.rules.genres, ['Gospel', 'Soul']); assert.equal(sp.rules.addedDays, 0); assert.equal(sp.rules.order, 'random'); assert.equal(sp.rules.limit, 50, 'a silly limit falls back'); assert.equal(sp.rules.onlyFavorites, true);
sp = await j(await post('/smart', { id: sp.id, name: 'Renamed', rules: { order: 'newest', limit: 20 } })); assert.equal((await j(await call('/smart'))).length, 1); assert.equal(sp.rules.order, 'newest'); assert.equal((await post('/smart', { name: '' })).status, 400);
await call('/smart/' + sp.id, { method: 'DELETE' }); assert.deepEqual(await j(await call('/smart')), []);
// family playlists
let fam = await j(await post('/family', { name: 'Road trip', tracks: [s(1), s(2)] }, 'b')); assert.equal(fam.count, 2); assert.equal(fam.ownerName, 'Bob'); assert.equal(fam.tracks[0].by, 'Bob');
assert.equal((await j(await call('/family', {}, 'c'))).length, 1, 'everyone sees it');
fam = await j(await post('/family/' + fam.id + '/add', { tracks: [s(2), s(3)] }, 'c')); assert.equal(fam.added, 1, 'a repeat is not added again'); assert.equal(fam.count, 3);
fam = await j(await call('/family/' + fam.id + '/tracks/101', { method: 'DELETE' }, 'c')); assert.equal(fam.count, 2, 'anyone can take a song off');
assert.equal((await post('/family/' + fam.id, { name: 'Mine now' }, 'c')).status, 403, 'only the owner or an admin renames'); assert.equal((await post('/family/' + fam.id, { name: 'Trip' }, 'b')).status, 200); assert.equal((await post('/family/' + fam.id, { name: 'Admin said' }, 'a')).status, 200);
assert.equal((await call('/family/' + fam.id, { method: 'DELETE' }, 'c')).status, 403); assert.equal((await call('/family/' + fam.id, { method: 'DELETE' }, 'a')).status, 200); assert.deepEqual(await j(await call('/family')), []);
assert.equal((await call('/history', { method: 'DELETE' })).status, 200); assert.deepEqual(await j(await call('/history')), []);
console.log('musicdata ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});
+62
View File
@@ -0,0 +1,62 @@
// My Store: the admin adds items and files, everyone reads and downloads, books go onto a shelf, music streams with ranges, extensions are served sandboxed.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), cp = require('child_process');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-ms-'));
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-msfx-')); cp.execFileSync('python3', [path.join(__dirname, 'mkbooks.py'), fx]);
const epub = fs.readFileSync(path.join(fx, 'test.epub')), gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64');
const users = require('../users'), books = require('../books'), ms = require('../mystore');
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['store', 'read'], handle: 'Yg' }) };
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => req.url.startsWith('/api/read/') ? books.handle(req, res) : ms.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port, B = base + '/api/store/my', bob = { 'x-user': 'b' };
const post = (p, b, h = {}) => fetch(B + p, { method: 'POST', body: JSON.stringify(b), headers: { 'content-type': 'application/json', ...h } });
const put = (p, body, h = {}) => fetch(B + p, { method: 'POST', body, headers: h });
try {
assert.equal((await post('/items', { kind: 'book', title: 'X' }, bob)).status, 403, 'only the admin adds things');
assert.equal((await post('/items', { kind: 'nope', title: 'X' })).status, 400); assert.equal((await post('/items', { kind: 'book', title: ' ' })).status, 400);
// a book: one file, goes on the shelf
let book = await (await post('/items', { kind: 'book', title: 'My Own Book', creator: 'Me', description: 'Hello' })).json(); assert.match(book.id, /^[0-9a-f]{12}$/);
assert.equal((await put(`/items/${book.id}/files?name=x.exe`, epub, bob)).status, 403);
assert.equal((await put(`/items/${book.id}/files?name=x.exe`, epub)).status, 400, 'a book must be a book file');
book = await (await put(`/items/${book.id}/files?name=../../my%20book.epub`, epub)).json(); assert.equal(book.files[0].name, 'my book.epub', 'a name cannot reach other folders'); assert.equal(book.files[0].size, epub.length);
assert.equal((await put(`/items/${book.id}/files?name=two.epub`, epub)).status, 400, 'a book holds one file');
assert.equal((await put(`/items/${book.id}/cover`, 'not a picture', { 'content-type': 'text/html' })).status, 400); book = await (await put(`/items/${book.id}/cover`, gif, { 'content-type': 'image/gif' })).json(); assert.equal(book.cover, true);
let r = await fetch(`${B}/items/${book.id}/cover`, { headers: bob }); assert.equal(r.headers.get('content-type'), 'image/gif');
let list = await (await fetch(B + '/items', { headers: bob })).json(); assert.equal(list.items.length, 1); assert.equal(list.admin, false); assert.equal((await (await fetch(B + '/items')).json()).admin, true);
r = await fetch(`${base}/api/store/my/items/${book.id}/files/${book.files[0].id}`, { headers: bob }); assert.equal(r.status, 200); assert.match(r.headers.get('content-disposition'), /attachment/); assert.equal(Buffer.from(await r.arrayBuffer()).length, epub.length);
r = await fetch(`${B}/items/${book.id}/to-library`, { method: 'POST', headers: bob }); const sh = await r.json(); assert.equal(r.status, 200); assert.equal(sh.title, 'My Own Book'); assert.equal(sh.cover, true);
assert.equal((await (await fetch(base + '/api/read/list', { headers: bob })).json()).length, 1, 'it is on Bob\'s shelf, not Ann\'s'); assert.equal((await (await fetch(base + '/api/read/list')).json()).length, 0);
// music: several files, streamed with ranges
let alb = await (await post('/items', { kind: 'music', title: 'Album', creator: 'Band' })).json(); const mp3 = Buffer.from('0123456789abcdefghij');
alb = await (await put(`/items/${alb.id}/files?name=01.mp3`, mp3)).json(); alb = await (await put(`/items/${alb.id}/files?name=02.mp3`, mp3)).json(); assert.equal(alb.files.length, 2);
r = await fetch(`${B}/items/${alb.id}/files/${alb.files[0].id}`, { headers: { ...bob, range: 'bytes=2-5' } }); assert.equal(r.status, 206); assert.equal(await r.text(), '2345'); assert.equal(r.headers.get('content-range'), 'bytes 2-5/20'); assert.equal(r.headers.get('content-type'), 'audio/mpeg');
r = await fetch(`${B}/items/${alb.id}/files/${alb.files[0].id}`, { headers: { range: 'bytes=-4' } }); assert.equal(await r.text(), 'ghij'); assert.equal((await fetch(`${B}/items/${alb.id}/files/${alb.files[0].id}`, { headers: { range: 'bytes=50-60' } })).status, 416);
alb = await (await fetch(`${B}/items/${alb.id}/files/${alb.files[1].id}`, { method: 'DELETE' })).json(); assert.equal(alb.files.length, 1);
// an app: any file, plus a link
let app = await (await post('/items', { kind: 'app', title: 'Tool', version: '1.2', url: 'https://example.com/tool' })).json(); app = await (await put(`/items/${app.id}/files?name=tool.apk`, 'apk-bytes')).json(); assert.equal(app.version, '1.2');
assert.equal((await post('/items', { kind: 'app', title: 'Bad', url: 'javascript:alert(1)' })).status, 400);
// an extension: one html file, framed with a sandbox
let ext = await (await post('/items', { kind: 'extension', title: 'Counter', icon: '#' })).json(); assert.equal((await put(`/items/${ext.id}/files?name=a.js`, 'x')).status, 400);
ext = await (await put(`/items/${ext.id}/files?name=counter.html`, '<!doctype html><html><head><title>c</title></head><body>hi</body></html>')).json();
r = await fetch(`${B}/ext/${ext.id}/page`, { headers: bob }); const page = await r.text(); assert.match(r.headers.get('content-security-policy'), /^sandbox allow-scripts/); assert.match(r.headers.get('content-security-policy'), /default-src 'none'/); assert.ok(!/allow-same-origin/.test(r.headers.get('content-security-policy')), 'no same-origin: it cannot reach the hub');
assert.match(page, /window\.hub=/); assert.match(page, /<body>hi/);
assert.deepEqual((await (await post(`/ext/${ext.id}/install`, {}, bob)).json()).installed, [ext.id]); assert.deepEqual((await (await fetch(B + '/items')).json()).installed, [], 'installing is per person');
assert.equal((await post(`/ext/${ext.id}/data`, { n: 3 }, bob)).status, 200); assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/data`, { headers: bob })).json(), { n: 3 }); assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/data`)).json(), {});
assert.equal((await post(`/ext/${ext.id}/data`, { big: 'x'.repeat(300000) }, bob)).status, 413);
assert.deepEqual((await (await post(`/ext/${ext.id}/uninstall`, {}, bob)).json()).installed, []); assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/data`, { headers: bob })).json(), {}, 'uninstalling forgets its settings');
assert.equal((await fetch(`${B}/ext/${book.id}/page`)).status, 404, 'only extensions are framed');
// permissions an extension has been given
assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json(), { grants: [] }); assert.equal((await post(`/ext/${ext.id}/install`, {}, bob)).status, 200);
assert.deepEqual((await (await post(`/ext/${ext.id}/grants`, { grants: ['tasks.read', 'tasks.read', 'root.everything'] }, bob)).json()).grants, ['tasks.read'], 'only known permissions are kept');
assert.deepEqual(await (await fetch(`${B}/ext/${ext.id}/grants`)).json(), { grants: [] }, 'they are per person'); await post(`/ext/${ext.id}/uninstall`, {}, bob); assert.deepEqual((await (await fetch(`${B}/ext/${ext.id}/grants`, { headers: bob })).json()).grants, [], 'removing the extension takes them back');
// a site (a link, optionally shown inside the hub)
assert.equal((await post('/items', { kind: 'link', title: 'Site' })).status, 400, 'a site needs an address'); let site = await (await post('/items', { kind: 'link', title: 'Docs', url: 'https://example.com/', embed: true, icon: 'D' })).json(); assert.equal(site.embed, true); assert.equal(site.icon, 'D');
assert.equal((await put(`/items/${site.id}/files?name=a.html`, 'x')).status, 400, 'a site has no files');
// a Seafile copy needs a sign-in
r = await post(`/items/${alb.id}/files`, { repo: 'a'.repeat(36), path: '/x.mp3' }); assert.equal(r.status, 401);
// changing, deleting
book = await (await post('/items', { id: book.id, title: 'Renamed' })).json(); assert.equal(book.title, 'Renamed'); assert.equal(book.creator, '', 'details are replaced as a whole');
assert.equal((await fetch(`${B}/items/${alb.id}`, { method: 'DELETE', headers: bob })).status, 403); assert.equal((await fetch(`${B}/items/${alb.id}`, { method: 'DELETE' })).status, 200);
assert.equal(fs.readdirSync(path.join(process.env.DATA_DIR, 'mystore', 'files')).filter(f => f.startsWith(alb.id)).length, 0, 'its files are removed too');
console.log('mystore ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});
+9 -1
View File
@@ -9,10 +9,12 @@ const fake = http.createServer((req, res) => {
if (req.url === '/podcasts' && req.method === 'POST') { const u = JSON.parse(b).url; if (u === 'https://a.test/feed') { res.writeHead(409, { 'content-type': 'application/json' }); return res.end('{"message":"exists"}'); } res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"ID":"p2","Title":"New","URL":"' + u + '"}'); }
if (req.url === '/podcastitems/e1/file') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); }
if (req.url === '/podcastitems/e2/file') { res.writeHead(302, { location: 'https://cdn.example.com/a.mp3' }); return res.end(); }
if (req.url.startsWith('/pi/')) { const d = Math.floor(Date.now() / 1000); const okAuth = req.headers['x-auth-key'] === 'k' && req.headers.authorization === require('crypto').createHash('sha1').update('k' + 's' + req.headers['x-auth-date']).digest('hex') && Math.abs(+req.headers['x-auth-date'] - d) < 60; res.writeHead(okAuth ? 200 : 401, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ feeds: [{ title: 'Bread Talk', url: 'https://b.test/feed', author: 'Baker', image: 'https://b.test/i.jpg' }, { title: 'Dead', url: 'https://d.test/feed', dead: 1 }, { title: 'Dup', url: 'https://a.test/feed' }] })); }
if (req.url.startsWith('/apple?')) { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ results: [{ collectionName: 'Dup again', artistName: 'X', feedUrl: 'https://a.test/feed/' }, { collectionName: 'Apple Only', feedUrl: 'https://c.test/feed' }] })); }
res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}');
});
}).listen(0, async () => {
process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret';
process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret'; Object.assign(process.env, { PODCASTINDEX_URL: 'http://127.0.0.1:' + fake.address().port + '/pi', APPLE_PODCASTS_URL: 'http://127.0.0.1:' + fake.address().port + '/apple' });
const podcast = require('../podcast'), users = require('../users');
const people = { admin: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), other: users.create({ name: 'Bob', role: 'user', tabs: ['podcast'], handle: 'Yg' }) };
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'other' ? people.other : people.admin, () => podcast.handle(req, res))).listen(0, async () => {
@@ -22,6 +24,12 @@ const fake = http.createServer((req, res) => {
assert.equal(seen[0].auth, 'Basic ' + Buffer.from('podgrab:secret').toString('base64'), 'password added by the server');
r = await get('/podcastitems/e1/file', { headers: { range: 'bytes=0-3' } }); assert.equal(r.status, 206); assert.equal(await r.text(), 'abcd'); assert.equal(seen.at(-1).range, 'bytes=0-3');
r = await get('/podcastitems/e2/file'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), 'https://cdn.example.com/a.mp3');
assert.deepEqual(await (await get('/discover/topics')).json(), []); // no key yet: Apple only
assert.deepEqual((await (await get('/discover?q=bread')).json()).map(x => x.title), ['Dup again', 'Apple Only']);
Object.assign(process.env, { PODCASTINDEX_KEY: 'k', PODCASTINDEX_SECRET: 's' });
assert.ok((await (await get('/discover/topics')).json()).includes('History'), 'topics are offered once Podcast Index is set up');
let dj = await (await get('/discover?q=bread')).json(); assert.deepEqual(dj.map(x => x.title), ['Bread Talk', 'Dup', 'Apple Only'], 'both directories, a feed listed twice shown once, dead feeds dropped'); assert.equal(dj[0].author, 'Baker');
dj = await (await get('/discover?topic=History')).json(); assert.equal(dj[0].title, 'Bread Talk', 'a topic lists that category\'s trending shows (request signed with the key)');
const other = { 'x-user': 'other' };
r = await get('/podcasts', { headers: other }); assert.deepEqual(await r.json(), [], 'a new person starts with their own empty list');
r = await get('/podcasts/p1/items', { headers: other }); assert.equal(r.status, 404, 'a show not on your list is not reachable');
+3 -1
View File
@@ -27,7 +27,7 @@ const fake = http.createServer((req, res) => {
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => radio.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port + '/api/radio', call = (p, o = {}) => fetch(base + p, o), j = r => r.json();
try {
let r = await j(await call('/stations')); assert.equal(r.featured[0].name, 'The Journey FM'); assert.equal(r.featured[0].id, 'journeyfm'); assert.deepEqual(r.mine, []);
let r = await j(await call('/stations')); assert.equal(r.featured[0].name, 'The Journey FM'); assert.equal(r.featured[0].id, 'journeyfm'); assert.deepEqual(r.mine, []); assert.deepEqual(r.added, [], 'nothing is on Radio until it is added from the Store');
r = await j(await call('/search?q=good')); assert.deepEqual(r.items.map(s => s.name), ['Good FM'], 'streams a browser cannot play (HLS, odd codecs) and nameless ones are left out'); assert.equal(r.items[0].tags, 'christian,worship,pop,extra'); assert.equal(r.items[0].id, 'aaaaaaaa-1111');
assert.equal((await call('/search')).status, 200, 'no words: Christian stations');
// the stream
@@ -48,6 +48,8 @@ const fake = http.createServer((req, res) => {
assert.equal((await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'aaaaaaaa-1111' }) })).status, 200); r = await j(await call('/stations')); assert.deepEqual(r.mine.map(s => s.name), ['Good FM']);
await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'aaaaaaaa-1111' }) }); assert.equal((await j(await call('/stations'))).mine.length, 1, 'saving twice keeps one');
assert.deepEqual((await j(await call('/stations', { headers: { 'x-user': 'b' } }))).mine, [], "each person's list is their own");
await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'journeyfm' }) }); assert.deepEqual((await j(await call('/stations'))).added, ['journeyfm'], 'the Journey is added like any other'); assert.deepEqual((await j(await call('/stations'))).mine.map(s => s.name), ['Good FM'], 'and is not mixed into the saved list'); assert.deepEqual((await j(await call('/stations', { headers: { 'x-user': 'b' } }))).added, []);
await call('/stations/journeyfm', { method: 'DELETE' }); assert.deepEqual((await j(await call('/stations'))).added, []);
await call('/stations/aaaaaaaa-1111', { method: 'DELETE' }); assert.deepEqual((await j(await call('/stations'))).mine, []);
r = await call('/icon?u=' + encodeURIComponent(`http://127.0.0.1:${port}/logo.png`)); assert.equal(r.headers.get('content-type'), 'image/png'); assert.equal((await call('/icon?u=' + encodeURIComponent(`http://127.0.0.1:${port}/stream`))).status, 404, 'only pictures come through as icons');
// private addresses
+65
View File
@@ -0,0 +1,65 @@
// Recipes: reading recipes and the shopping list from Mealie, importing a link, adding a recipe to the list, and keeping the list in step with Tasks.
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-rcp-'));
const seen = []; let items = [{ id: 'abcdef01-1', note: 'milk', display: 'milk', checked: false, position: 1 }], nextId = 1, newApi = true;
const recipe = { id: '0123abcd-1', slug: 'bread', name: 'Bread', description: 'Simple', image: 'x', totalTime: 'PT1H30M', prepTime: '20 minutes', performTime: '1 hour', recipeServings: 4, orgURL: 'https://a.test/bread',
recipeIngredient: [{ display: '3 cups flour' }, { title: 'Glaze' }, { quantity: 1, unit: { name: 'tbsp' }, food: { name: 'butter' } }], recipeInstructions: [{ text: 'Mix.' }, { title: 'Bake', text: 'Bake it.' }], recipeCategory: [{ name: 'Baking' }], tags: [] };
const fake = http.createServer((req, res) => {
let b = ''; req.on('data', c => b += c); req.on('end', () => {
const u = new URL(req.url, 'http://x'), p = u.pathname, J = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
seen.push(req.method + ' ' + p + (u.search || '')); if (req.headers.authorization !== 'Bearer tok') return J({ detail: 'Unauthorized' }, 401);
if (p === '/api/recipes' && req.method === 'GET') return J({ page: 1, total_pages: 2, items: [{ id: recipe.id, slug: 'bread', name: 'Bread', totalTime: '45 minutes', image: 'x', rating: 4 }] });
if (p === '/api/recipes/bread') return J(recipe);
if (p === '/api/recipes/create/url') { assert.equal(JSON.parse(b).url, 'https://a.test/new'); return J('new-one'); }
if (p === '/api/recipes/new-one') return J({ ...recipe, id: '0123abcd-2', slug: 'new-one', name: 'New One' });
if (p.startsWith('/api/media/recipes/0123abcd-1/images/')) { res.writeHead(200, { 'content-type': 'image/webp' }); return res.end('img'); }
const sh = p.startsWith('/api/households/shopping') ? p.slice('/api/households/shopping'.length) : null;
if (sh === null) return J({}, 404);
if (sh === '/lists' && req.method === 'GET') return J({ items: [{ id: '1111aaaa-1', name: 'Shopping List' }] });
if (sh === '/lists/1111aaaa-1' && req.method === 'GET') return J({ id: '1111aaaa-1', name: 'Shopping List', listItems: items });
if (sh === '/lists/1111aaaa-1/recipe/0123abcd-1' && req.method === 'POST') { if (!newApi) return J({}, 404); items.push({ id: 'abcdef01-' + (++nextId), note: '3 cups flour', display: '3 cups flour', checked: false, position: 2 }); return J({}); }
if (sh === '/lists/1111aaaa-1/recipe' && req.method === 'POST') { assert.equal(JSON.parse(b)[0].recipeId, '0123abcd-1'); items.push({ id: 'abcdef01-' + (++nextId), note: '3 cups flour', display: '3 cups flour', checked: false, position: 2 }); return J({}); }
if (sh === '/items' && req.method === 'POST') { const j = JSON.parse(b), it = { id: 'abcdef01-' + (++nextId), note: j.note, display: j.note, checked: false, position: 9 }; items.push(it); return J({ createdItems: [it] }); }
if (sh === '/items' && req.method === 'DELETE') { const ids = u.searchParams.getAll('ids'); items = items.filter(i => !ids.includes(i.id)); return J({}); }
let m; if ((m = /^\/items\/([\w-]+)$/.exec(sh)) && req.method === 'PUT') { const j = JSON.parse(b), it = items.find(i => i.id === m[1]); Object.assign(it, j); return J(it); }
J({}, 404);
});
}).listen(0, async () => {
const port = fake.address().port; process.env.MEALIE_URL = 'http://127.0.0.1:' + port; process.env.MEALIE_TOKEN = 'tok';
const recipes = require('../recipes'), users = require('../users'), me = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' });
assert.equal(recipes.minutes('PT1H30M'), 90); assert.equal(recipes.minutes('1 hour 15 minutes'), 75); assert.equal(recipes.minutes('45 minutes'), 45); assert.equal(recipes.minutes(''), 0);
const hub = http.createServer((req, res) => users.runAs(me, () => recipes.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port + '/api/recipes', get = p => fetch(base + p), post = (p, b = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(b) });
const tasks = () => JSON.parse(fs.readFileSync(path.join(users.dirOf(me.id), 'tasks.json'), 'utf8'));
try {
let j = await (await get('/?q=bre')).json(); assert.equal(j.items[0].name, 'Bread'); assert.equal(j.items[0].minutes, 45); assert.equal(j.more, true); assert.ok(seen.some(x => x.includes('search=bre')));
j = await (await get('/r/bread')).json(); assert.equal(j.total, 90); assert.equal(j.prep, 20); assert.equal(j.cook, 60); assert.deepEqual(j.ingredients.map(i => i.text || i.title), ['3 cups flour', 'Glaze', '1 tbsp butter']); assert.equal(j.steps[1].title, 'Bake');
assert.equal((await get('/r/..%2Fx')).status, 404, 'a recipe name cannot reach other Mealie pages');
let r = await get('/image/0123abcd-1'); assert.equal(r.headers.get('content-type'), 'image/webp'); assert.equal((await get('/image/not%20ok')).status, 404);
j = await (await post('/import', { url: 'https://a.test/new' })).json(); assert.equal(j.slug, 'new-one'); assert.equal((await post('/import', { url: 'javascript:1' })).status, 400);
// the shopping list
j = await (await get('/shopping')).json(); assert.deepEqual(j.items.map(i => i.text), ['milk']);
j = await (await post('/shopping/add-recipe', { slug: 'bread' })).json(); assert.equal(j.ok, true); assert.equal(j.added, 2, 'the whole list came across to Tasks the first time');
let t = tasks(), sl = t.lists.find(l => l.name === 'Shopping'); assert.ok(sl && sl.mealie); assert.deepEqual(t.tasks.filter(x => x.list === sl.id).map(x => x.title).sort(), ['3 cups flour', 'milk']);
// ticked in Tasks -> ticked in Mealie
t.tasks.find(x => x.title === 'milk').done = true; fs.writeFileSync(path.join(users.dirOf(me.id), 'tasks.json'), JSON.stringify(t));
j = await (await post('/shopping/sync')).json(); assert.equal(j.toMealie, 1); assert.equal(items.find(i => i.note === 'milk').checked, true, 'finishing a task ticks the item in Mealie');
// ticked in Mealie -> done in Tasks
items.find(i => i.note === '3 cups flour').checked = true; j = await (await post('/shopping/sync')).json(); assert.equal(j.toTasks, 1); assert.equal(tasks().tasks.find(x => x.title === '3 cups flour').done, true);
// typed into Tasks -> on the Mealie list; deleted in Tasks -> deleted in Mealie
t = tasks(); t.tasks.push({ id: 'zz', list: sl.id, title: 'eggs', done: false }); t.tasks = t.tasks.filter(x => x.title !== 'milk'); fs.writeFileSync(path.join(users.dirOf(me.id), 'tasks.json'), JSON.stringify(t));
j = await (await post('/shopping/sync')).json(); assert.equal(j.sent, 1); assert.ok(items.some(i => i.note === 'eggs')); assert.ok(!items.some(i => i.note === 'milk'), 'a task deleted in Tasks leaves Mealie too');
// typed in the hub -> a task; ticked in the hub -> ticked; removing ticked items
await post('/shopping/add', { text: 'butter' }); assert.ok(tasks().tasks.some(x => x.title === 'butter'));
const bid = items.find(i => i.note === 'butter').id; await post('/shopping/check', { id: bid, checked: true }); assert.equal(items.find(i => i.id === bid).checked, true); assert.equal(tasks().tasks.find(x => x.title === 'butter').done, true);
j = await (await post('/shopping/clear')).json(); assert.ok(j.removed >= 2); assert.ok(!items.some(i => i.checked)); assert.ok(!tasks().tasks.some(x => x.title === 'butter'), 'removed items leave Tasks too');
// an older Mealie that only knows the list form of "add recipe"
newApi = false; await post('/shopping/add-recipe', { slug: 'bread' }); assert.ok(seen.includes('POST /api/households/shopping/lists/1111aaaa-1/recipe'));
// a wrong token is explained
process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/);
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
console.log('recipes ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); fake.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});
});
+24
View File
@@ -0,0 +1,24 @@
// Screen Time helper: the report-only policy, what devices report reaching, the admin overview, and the picture pass-through refusing what it should.
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-st-')); process.env.ONLYOFFICE_URL = 'https://docs.example.org';
const users = require('../users'), st = require('../screentime');
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', handle: 'Yg' }) };
const rep = (uri, dir = 'img-src') => JSON.stringify({ 'csp-report': { 'blocked-uri': uri, 'effective-directive': dir, 'violated-directive': dir + " 'self'" } });
const hub = http.createServer((req, res) => { const me = req.headers['x-user'] === 'b' ? people.b : people.a; users.runAs(me, () => st.handle(req, res, me)); }).listen(0, async () => {
const port = hub.address().port, B = 'http://127.0.0.1:' + port, call = (p, o = {}) => fetch(B + p, { ...o, headers: { 'x-forwarded-host': 'hub.example.org', ...(o.headers || {}) } });
try {
const pol = st.policy(); assert.match(pol, /report-uri \/api\/csp-report/); assert.match(pol, /frame-src[^;]*https:\/\/docs\.example\.org/, 'the document editor is allowed'); assert.match(pol, /img-src 'self' data: blob:;/, 'pictures only from the hub');
for (const [u, d] of [['https://is1-ssl.mzstatic.com/a.jpg', 'img-src'], ['https://is1-ssl.mzstatic.com/b.jpg', 'img-src'], ['https://api.open-meteo.com/v1/forecast?x=1', 'connect-src'], ['inline', 'script-src'], ['data', 'img-src'], ['https://hub.example.org/x.png', 'img-src'], ['http://localhost:3000/x', 'img-src']]) assert.equal((await call('/api/csp-report', { method: 'POST', body: rep(u, d) })).status, 204);
assert.equal((await call('/api/csp-report', { method: 'POST', body: 'not json' })).status, 204, 'a garbled report is ignored, not an error');
let o = await (await call('/api/screentime')).json();
assert.equal(o.required[0].host, 'hub.example.org'); assert.deepEqual(o.required.map(x => x.host), ['hub.example.org', 'docs.example.org'], 'the hub and the editor');
assert.deepEqual(o.seen.map(x => [x.host, x.n]), [['is1-ssl.mzstatic.com', 2], ['api.open-meteo.com', 1]], 'outside addresses are counted; the hub itself, local addresses and non-addresses are left out'); assert.deepEqual(o.seen[1].what, ['connect']);
assert.equal((await call('/api/screentime', { headers: { 'x-user': 'b' } })).status, 403, 'only an admin sees the list');
o = await (await call('/api/screentime', { method: 'DELETE' })).json(); assert.deepEqual(o.seen, []);
// pictures from other sites
assert.equal((await call('/api/img?u=notaurl')).status, 400); assert.equal((await call('/api/img?u=' + encodeURIComponent('http://example.com/a.png'))).status, 502, 'only https');
assert.equal((await call('/api/img?u=' + encodeURIComponent('https://127.0.0.1/a.png'))).status, 502, 'never a private address'); assert.equal((await call('/api/img?u=' + encodeURIComponent('https://localhost/a.png'))).status, 502);
console.log('screentime ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
});
+32 -5
View File
@@ -3,7 +3,7 @@ const http = require('http'), assert = require('assert'), fs = require('fs'), os
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-store-'));
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-storefx-')); cp.execFileSync('python3', [path.join(__dirname, 'mkbooks.py'), fx]);
const epub = fs.readFileSync(path.join(fx, 'test.epub')), pdf = fs.readFileSync(path.join(fx, 'test.pdf')), gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64');
const seen = []; let port;
const seen = [], swPosts = []; let port;
const feed = (body, links = '') => `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom">${links}${body}</feed>`;
const acq = (id, title, author, href, type = 'application/epub+zip') => `<entry><title>${title}</title><author><name>${author}</name></author><id>${id}</id><summary>A &amp; B</summary><link rel="http://opds-spec.org/image" href="/img/${id}.gif" type="image/gif"/><link rel="http://opds-spec.org/acquisition/open-access" href="${href}" type="${type}"/></entry>`;
const seCard = (slug, title, who) => `<li typeof="schema:Book" about="/ebooks/${slug}"><div><a href="/ebooks/${slug}"><picture><img src="/img/${slug.replace(/\W/g, '')}.gif" alt="" property="schema:image"/></picture></a></div><p><a href="/ebooks/${slug}" property="schema:url"><span property="schema:name">${title}</span></a></p><p class="author" typeof="schema:Person"><a href="https://x/y" property="schema:url"><span property="schema:name">${who}</span></a></p></li>`;
@@ -26,6 +26,19 @@ const fake = http.createServer((req, res) => {
if (p === '/ia/metadata/moby1') return J({ metadata: {}, files: [{ name: 'moby1.pdf', format: 'Text PDF' }, { name: 'moby1.epub', format: 'EPUB' }] });
if (p === '/ia/metadata/locked') return J({ metadata: { 'access-restricted-item': 'true' }, files: [{ name: 'x.epub', format: 'EPUB' }] });
if (p === '/ia/download/moby1/moby1.epub') { res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(Buffer.concat([epub, Buffer.from('ia')])); }
// Open Library (search only; the book is an Internet Archive item)
if (p === '/ol/search.json') return J({ numFound: 2, docs: [{ title: 'Moby Dick', author_name: ['Melville'], cover_i: 7, ia: ['moby1'], ebook_access: 'public' }, { title: 'Borrowable', ia: ['locked'], ebook_access: 'borrowable' }] });
// Smashwords: its shelf feed (filtered), a sign-in form, and downloads that need the signed-in cookie
if (p === '/sw/signin') { res.writeHead(200, { 'content-type': 'text/html', 'set-cookie': 'XSRF-TOKEN=deleted; Path=/' }); return res.end('<form action="/sw/auth/index" method="post" id="loginForm"><input type="hidden" name="secToken" value="" /><input type="hidden" name="loginToken" value="tok123" /></form>'); }
if (p === '/sw/auth/index' && req.method === 'POST') { let b = ''; req.on('data', c => b += c); req.on('end', () => { const f = new URLSearchParams(b); swPosts.push(Object.fromEntries(f)); if (f.get('email') === 'me@x.org' && f.get('password') === 'pw' && f.get('loginToken') === 'tok123') { res.writeHead(302, { location: '/sw/', 'set-cookie': 'sess=abc; Path=/; HttpOnly' }); } else res.writeHead(200); res.end(); }); return; }
if (p === '/sw/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(`<script>window.angularData.user.loggedIn = ${/sess=abc/.test(req.headers.cookie || '')};</script>`); }
if (p === '/sw/books/download/101/8/latest/0/0/book.epub') { if (!/sess=abc/.test(req.headers.cookie || '')) { res.writeHead(302, { location: '/sw/signup/login/books/101' }); return res.end(); } res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(epub); }
if (p === '/sw/signup/login/books/101') { res.writeHead(200); return res.end('login'); }
if (p.startsWith('/sw/api/bookshelves/category/v3/shelf/')) { const pg = +p.split('/').slice(-2)[0]; // (.../{page}/{pageSize})
const bk = (id, o = {}) => ({ book: { id: String(id), title: 'Book ' + id, price: { type: 'FIXED_PRICE', prices: [{ amount: '0.00' }] }, short_description: 'A fine tale.', cover_url: 'bookCovers/c' + id + '.gif', adult: false, erotica: false, taboo_erotica: false, need_erotic_cert: false, categories: [{ id: '1', name: 'Fiction', parents: [] }], tags: ['adventure'], formats: ['EPUB'], contributors: [{ role: 'A01', account: { display_name: 'Ann Author' } }], ...o } });
const list = pg === 1 ? [bk(101, { title: 'Pilgrim Story' }), bk(102, { categories: [{ id: '5', name: 'Romance', parents: [{ id: '886', name: 'LGBTQ+' }] }] }), bk(103, { erotica: true }), bk(104, { tags: ['gay romance'] }), bk(105, { title: 'Gaylord\'s Garden', contributors: [{ role: 'A01', account: { display_name: 'Gay Talese' } }] }), bk(106, { short_description: 'A steamy love story' }), bk(107, { price: { type: 'FIXED_PRICE', prices: [{ amount: '2.99' }] } }), bk(109, { categories: [{ id: '1235', name: 'Romance', parents: [{ id: '3', name: 'Fiction' }] }], tags: ['billionaire romance'] }), bk(110, { title: 'Sweet Home', categories: [{ id: '1235', name: 'Romance', parents: [{ id: '3', name: 'Fiction' }] }], tags: ['sweet romance'] }), bk(108, { categories: [{ id: '886', name: 'LGBTQ+', parents: [{ id: '3', name: 'Fiction' }] }] })] : [];
res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ name: 'Most Downloaded', type: 'mostdownloads', book_list: list, total_books: 8 })); }
if (p.startsWith('/swc/')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(gif); }
// an OPDS catalogue that needs a sign-in (Calibre-Web style)
if (p.startsWith('/cal/')) {
if (req.headers.authorization !== 'Basic ' + Buffer.from('reader:pw').toString('base64')) { res.writeHead(401); return res.end(); }
@@ -37,15 +50,15 @@ const fake = http.createServer((req, res) => {
res.writeHead(404); res.end('no');
}).listen(0, async () => {
port = fake.address().port; const B = 'http://127.0.0.1:' + port;
Object.assign(process.env, { GUTENBERG_URL: B, ARCHIVE_URL: B + '/ia', STANDARD_EBOOKS_URL: B });
Object.assign(process.env, { GUTENBERG_URL: B, ARCHIVE_URL: B + '/ia', OPENLIBRARY_URL: B + '/ol', SMASHWORDS_URL: B + '/sw', SMASHWORDS_COVERS: B + '/swc', STANDARD_EBOOKS_URL: B });
const books = require('../books'), users = require('../users');
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['read'], handle: 'Yg' }) };
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => books.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port + '/api/read/store', call = (p, o = {}) => fetch(base + p, o), post = (p, b, h = {}) => call(p, { method: 'POST', body: JSON.stringify(b), headers: h });
try {
let r = await call('/sources'); assert.deepEqual((await r.json()).map(s => s.id), ['gutenberg', 'standardebooks', 'archive']);
let r = await call('/sources'); assert.deepEqual((await r.json()).map(s => s.id), ['gutenberg', 'standardebooks', 'archive', 'smashwords', 'openlibrary']);
r = await call('/search?q=alice'); let j = await r.json();
assert.deepEqual(j.items.map(i => i.source), ['gutenberg', 'standardebooks', 'archive', 'gutenberg', 'standardebooks'].slice(0, j.items.length), 'one result from each catalogue in turn'); assert.deepEqual(j.errors, {});
assert.deepEqual(j.items.map(i => i.source), ['gutenberg', 'standardebooks', 'archive', 'openlibrary', 'gutenberg', 'standardebooks'].slice(0, j.items.length), 'one result from each catalogue in turn'); assert.deepEqual(j.errors, {});
const g = j.items.filter(i => i.source === 'gutenberg'); assert.deepEqual(g.map(i => i.ref), ['11', '345'], 'catalogue entries that are not books, and repeats, are left out'); assert.equal(g[0].author, 'Lewis Carroll'); assert.equal(g[1].title, 'Dracula & Co'); assert.match(g[0].cover, /^\/api\/read\/store\/cover\?s=gutenberg/); assert.equal(j.more, true);
const se = j.items.filter(i => i.source === 'standardebooks'); assert.equal(se[0].title, 'Dracula alice'); assert.equal(se[0].author, 'Bram Stoker'); assert.equal(se[1].title, 'The Pilgrim’s Progress'); assert.equal(se[0].ref, '/ebooks/bram-stoker/dracula');
assert.ok(!j.items.some(i => i.ref === '../bad'), 'odd identifiers are dropped'); assert.ok(!seen.some(x => x.startsWith('/feeds/opds')), 'the members-only feed is never asked for');
@@ -63,12 +76,26 @@ const fake = http.createServer((req, res) => {
r = await post('/add', { source: 'standardebooks', ref: '/ebooks/bram-stoker/dracula', title: 'Dracula', author: 'Bram Stoker' }); assert.equal(r.status, 200, 'the real EPUB is fetched, not the "download has started" page'); assert.ok(seen.some(x => x.includes('bram-stoker_dracula.epub?source=download')) && !seen.some(x => x.includes('kepub') && x.includes('source')), 'the plain EPUB (not the Kobo or advanced one) is chosen');
r = await post('/add', { source: 'standardebooks', ref: '/ebooks/../etc/passwd' }); assert.equal(r.status, 400);
r = await post('/add', { source: 'archive', ref: 'moby1', title: 'Moby', author: 'Melville' }); assert.equal((await r.json()).title, 'Moby'); assert.ok(seen.includes('/ia/download/moby1/moby1.epub'), 'the EPUB is chosen over the PDF');
const ol = (await (await call('/search?q=moby&source=openlibrary')).json()).items; assert.deepEqual(ol.map(i => i.ref), ['moby1'], 'only freely readable editions are listed'); assert.equal(ol[0].author, 'Melville'); assert.match(decodeURIComponent(ol[0].coverUrl), /covers\.openlibrary\.org\/b\/id\/7-M\.jpg/);
r = await post('/add', { source: 'openlibrary', ref: 'moby1', title: 'Moby Dick' }); assert.equal(r.status, 200, 'an Open Library book is fetched from the Internet Archive');
// Smashwords: the family filter, the sign-in, and the download
r = await call('/search?q=&source=smashwords'); j = await r.json(); assert.deepEqual(j.items.map(i => i.ref), ['101', '105', '110'], 'adult, LGBTQ+, keyword-flagged, unlabelled romance and paid books are left out; a clean romance and a harmless name are kept'); assert.equal(j.items[0].author, 'Ann Author'); assert.match(j.items[0].cover, /^\/api\/read\/store\/cover\?s=smashwords/);
j = await (await call('/search?q=pilgrim&source=smashwords')).json(); assert.deepEqual(j.items.map(i => i.ref), ['101'], 'searching looks through the free books it can see'); assert.deepEqual((await (await call('/search?q=gay&source=smashwords')).json()).items.map(i => i.ref), ['105']);
j = await (await call('/sources')).json(); assert.deepEqual(j.find(s => s.id === 'smashwords').account, { signedIn: false, email: '' });
r = await post('/add', { source: 'smashwords', ref: '101', title: 'Pilgrim Story' }); assert.equal(r.status, 401); assert.match((await r.json()).error, /sign-in/, 'downloading needs the account');
assert.equal((await post('/smashwords', { email: 'me@x.org', pass: 'pw' }, { 'x-user': 'b' })).status, 403, 'only an admin saves the sign-in');
r = await post('/smashwords', { email: 'me@x.org', pass: 'wrong' }); assert.equal(r.status, 401); assert.equal((await (await call('/sources')).json()).find(s => s.id === 'smashwords').account.signedIn, false, 'a wrong password is not kept');
r = await post('/smashwords', { email: 'me@x.org', pass: 'pw' }); assert.equal(r.status, 200); assert.deepEqual(await r.json(), { signedIn: true, email: 'me@x.org' }); assert.equal(swPosts.at(-1).loginToken, 'tok123', 'the sign-in form\'s own token is sent back');
assert.ok(!fs.readFileSync(path.join(process.env.DATA_DIR, 'store-smashwords.json'), 'utf8').includes('"pw"') && !JSON.stringify(await (await call('/sources')).json()).includes('"pw"'), 'the password is encrypted on disk and never sent to the browser');
r = await post('/add', { source: 'smashwords', ref: '101', title: 'Pilgrim Story', author: 'Ann Author', cover: B + '/swc/c101.gif' }); assert.equal(r.status, 200, 'the signed-in download is accepted'); assert.ok(seen.includes('/sw/books/download/101/8/latest/0/0/book.epub'), 'the EPUB is fetched with the signed-in cookie');
assert.equal((await post('/add', { source: 'smashwords', ref: '../x' })).status, 400);
assert.equal((await call('/smashwords', { method: 'DELETE' })).status, 200); assert.equal((await post('/add', { source: 'smashwords', ref: '101' })).status, 401, 'removing the sign-in stops downloads');
r = await post('/add', { source: 'archive', ref: 'locked' }); assert.equal(r.status, 403); assert.match((await r.json()).error, /DRM/);
r = await post('/add', { source: 'gutenberg', ref: '../x' }); assert.equal(r.status, 400);
// custom catalogue
assert.equal((await post('/sources', { name: 'Mine', url: B + '/cal/opds' }, { 'x-user': 'b' })).status, 403, 'only an admin adds catalogues');
assert.equal((await post('/sources', { name: 'Bad', url: B + '/cal/opds', user: 'reader', pass: 'wrong' })).status, 502, 'a catalogue that refuses the sign-in is not added');
assert.equal((await call('/sources').then(x => x.json())).length, 3);
assert.equal((await call('/sources').then(x => x.json())).length, 5);
r = await post('/sources', { name: 'Mine', url: B + '/cal/opds', user: 'reader', pass: 'pw' }); const mine = await r.json(); assert.equal(r.status, 200);
assert.ok(!JSON.stringify(await (await call('/sources')).json()).includes('pw'), 'sign-ins are never sent to the browser'); assert.ok(!fs.readFileSync(path.join(process.env.DATA_DIR, 'store-sources.json'), 'utf8').includes('"pw"'), 'and are encrypted on disk');
j = await (await call('/search?q=hello%20world&source=' + mine.id)).json(); assert.equal(j.items[0].title, 'Own Book hello world', 'search found through the OpenSearch description'); assert.equal(j.items[0].format, 'pdf');
+11
View File
@@ -1,5 +1,6 @@
// Transcripts: reading SRT/VTT/JSON, finding an episode's transcript link in a feed, refusing private addresses, and speech-to-text through a fake Whisper server.
const http = require('http'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'trt-u-'));
const tr = require('../transcripts');
let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m); if (!c) bad++; };
@@ -44,6 +45,16 @@ let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m);
const r = await tr.whisper(audio, 'audio'); ok(r && r.found && r.lines.length === 2 && r.lines[0].text === 'Welcome to the show.' && r.lines[1].text === 'Today we talk about bread.' && r.source === 'Transcribed on your server', 'speech-to-text segments become timed lines (blank ones dropped, tags removed)');
ok(calls.includes('POST /v1/models/Systran/faster-whisper-small') && calls.includes('POST /v1/models') && installed, 'a model that is not installed is downloaded first (trying the older form, then the newer)');
ok(r.lines[1].t === 2500 && r.lines[1].e === 2500 + 6000, 'times are in milliseconds');
// searching what was said
const users = require('../users'), me = users.create({ name: 'Zed', role: 'user', handle: 'Wg' });
const put = (name, ep, lines) => fs.writeFileSync(path.join(dir, 'transcripts', name + '.json'), JSON.stringify({ found: true, lines, ep }));
put('a1', { key: 'ab:book1', src: '/api/ab/books/x', title: 'Bread Book', artist: 'Baker' }, [{ t: 1000, text: 'Today we talk about sourdough bread.' }, { t: 65000, text: 'Knead the dough well' }, { t: 90000, text: 'Let it rest, then bake the bread.' }]);
put('a2', { key: 'pod:77', src: '/api/podcast/podcastitems/77/file', title: 'Not Mine', artist: 'Other' }, [{ t: 0, text: 'sourdough bread everywhere' }]);
put('a3', null, [{ t: 0, text: 'sourdough bread with no name' }]);
const found = await users.runAs(me, () => tr.search(me, 'Sourdough bread'));
ok(found.length === 1 && found[0].title === 'Bread Book' && found[0].hits[0].t === 1000 && found[0].exact, 'a search finds the moment, ignoring capitals; other people\'s podcasts and unnamed files are left out');
const two = await users.runAs(me, () => tr.search(me, 'knead well')); ok(two.length === 1 && two[0].hits[0].t === 65000 && !two[0].exact, 'all the words on a line match, not only the exact phrase');
ok((await users.runAs(me, () => tr.search(me, 'x'))).length === 0 && (await users.runAs(me, () => tr.search(me, 'zebra'))).length === 0, 'too short, or not said: nothing');
srv.close(); fs.rmSync(dir, { recursive: true, force: true });
console.log(bad ? '\n' + bad + ' check(s) failed' : '\nAll transcript checks passed'); process.exit(bad ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+44 -7
View File
@@ -110,11 +110,11 @@ async function fetchAudio(src, cookie, tmp) {
let n = 0; const body = Readable.fromWeb(r.body); body.on('data', c => { n += c.length; if (n > MAX_AUDIO) body.destroy(new Error('That audio file is too large to transcribe')); });
await pipeline(body, fs.createWriteStream(tmp, { mode: 0o600 }));
}
function startJob(key, src, cookie, getAudio) {
function startJob(key, src, cookie, getAudio, ep) {
jobs.set(key, { state: 'running', since: Date.now() });
chain = chain.then(async () => {
const tmp = path.join(os.tmpdir(), 'nehub-tr-' + key);
try { if (getAudio) await saveAudio(await getAudio(), tmp); else await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(r)); jobs.delete(key); }
try { if (getAudio) await saveAudio(await getAudio(), tmp); else await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(ep ? { ...r, ep } : r)); jobs.delete(key); }
catch (e) { jobs.set(key, { state: 'error', since: Date.now(), error: e.message }); setTimeout(() => jobs.delete(key), 10 * 60e3).unref(); }
finally { fs.rm(tmp, { force: true }, () => {}); }
});
@@ -132,8 +132,8 @@ async function autoTick() {
if (fs.existsSync(fileOf(key)) || jobs.has(key) || t.n >= 3 || Date.now() - t.at < 6 * 3600e3 * t.n) continue; // (a failing one is retried a few times, further apart each time)
auto.tried.set(key, { n: t.n + 1, at: Date.now() });
let r = { why: '' }; try { r = await nativeTranscript(ep); } catch {}
if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify(r.transcript)); continue; } // (the show publishes one: no need to make one)
startJob(key, '', '', () => podcast.episodeAudio(ep.id)); queued++;
if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify({ ...r.transcript, ep: { key: 'pod:' + ep.id, src: podSrc(ep.id), title: ep.title, artist: ep.show } })); continue; } // (the show publishes one: no need to make one)
startJob(key, '', '', () => podcast.episodeAudio(ep.id), { key: 'pod:' + ep.id, src: podSrc(ep.id), title: ep.title, artist: ep.show }); queued++;
}
}
function startAuto({ on, keep }) {
@@ -144,6 +144,7 @@ function startAuto({ on, keep }) {
}
function status() { let saved = 0; try { saved = fs.readdirSync(cfg.dir).filter(x => x.endsWith('.json')).length; } catch {} return { enabled: !!cfg.api, auto: auto.on, keep: auto.keep, saved, working: [...jobs.values()].filter(j => j.state === 'running').length, failed: [...jobs.values()].filter(j => j.state === 'error').length }; }
const metaOf = q => ({ key: String(q.get('key') || ''), src: String(q.get('src') || ''), title: String(q.get('title') || '').slice(0, 200), artist: String(q.get('artist') || '').slice(0, 200), thumb: String(q.get('thumb') || '').slice(0, 300) });
const SRC_OK = [/^\/api\/podcast\/podcastitems\/[\w-]+\/file$/, /^\/api\/ab\/books\/[^?#\s]+$/]; // only audio this hub already serves to the person
const nativeMiss = new Map(); // key -> { t, why }: a show with no transcript is not asked again for a day
async function handle(req, res) {
@@ -157,7 +158,7 @@ async function handle(req, res) {
const job = jobs.get(key); if (job) return json(res, 200, { state: job.state, since: job.since, error: job.error, ...base });
if (req.method === 'POST') {
if (!cfg.api) return json(res, 400, { error: 'Transcribing is not set up on this server (see the whisper service in docker-compose.yml)' });
startJob(key, src, req.headers.cookie); return json(res, 200, { state: 'running', since: Date.now(), ...base });
startJob(key, src, req.headers.cookie, null, metaOf(q)); return json(res, 200, { state: 'running', since: Date.now(), ...base });
}
let why = '';
if (kind === 'pod') {
@@ -165,11 +166,47 @@ async function handle(req, res) {
if (miss && miss.t > Date.now() - 6 * 3600e3) why = miss.why;
else {
let r; try { r = await nativeTranscript(await podcast.feedInfo(me, key0.slice(4))); } catch (e) { r = { why: 'Checking the show\'s feed failed (' + e.message + ').' }; }
if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify(r.transcript)); return json(res, 200, { state: 'ready', ...base, transcript: r.transcript }); }
if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify({ ...r.transcript, ep: metaOf(q) })); return json(res, 200, { state: 'ready', ...base, transcript: r.transcript }); }
why = r.why; nativeMiss.set(key, { t: Date.now(), why });
}
}
return json(res, 200, { state: 'none', why, ...base });
} catch (e) { json(res, e.status || 500, { error: e.message }); }
}
module.exports = { init, startAuto, status, handle, parseCues, parseJson, transcriptLinks, shape, whisper, getText, nativeTranscript, enabled: () => true };
// ---- searching what has been said: every transcript on disk, for the words typed. Each file remembers which episode it is (`ep`); older ones are matched up from the person's own episodes.
const idx = new Map(); // file -> { mtime, ep, lines }
const fold = t => String(t).toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
function load(f) {
const st = fs.statSync(path.join(cfg.dir, f)), c = idx.get(f); if (c && c.mtime === st.mtimeMs) return c;
let j; try { j = JSON.parse(fs.readFileSync(path.join(cfg.dir, f), 'utf8')); } catch { return null; }
const v = { mtime: st.mtimeMs, ep: j.ep || null, lines: (j.lines || []).map(l => ({ t: l.t, text: l.text || '', f: fold(l.text || '') })).filter(l => l.f) };
idx.set(f, v); return v;
}
/** Episodes whose transcript says all of the words (an exact phrase ranks first), with the moments they say it. Podcasts: only those on the person's list. */
async function search(me, q) {
const words = fold(q).split(' ').filter(Boolean), phrase = words.join(' ');
if (!words.length || phrase.length < 2) return [];
const mine = new Map((await podcast.myEpisodes(me)).map(e => [e.id, e])), byKey = new Map();
for (const e of mine.values()) byKey.set(keyFor('pod', 'pod:' + e.id, podSrc(e.id)) + '.json', e);
const out = [];
for (const f of fs.readdirSync(cfg.dir).filter(x => x.endsWith('.json'))) {
const d = load(f); if (!d) continue;
let ep = d.ep, m = byKey.get(f);
if (m) ep = { key: 'pod:' + m.id, src: podSrc(m.id), title: ep && ep.title || m.title, artist: ep && ep.artist || m.show, thumb: m.thumb }; // (a podcast on your list: always allowed, and old files get their name here)
else if (!ep || !ep.key || !String(ep.key).startsWith('ab:')) continue; // (other people's podcasts, and files that cannot be told apart, are not shown)
// a line is also checked joined to the next one, so a phrase split across two lines is found
const hits = []; let all = 0;
for (let i = 0; i < d.lines.length; i++) {
const l = d.lines[i], two = l.f + ' ' + (d.lines[i + 1] ? d.lines[i + 1].f : ''), ok = words.every(w => l.f.includes(w)) || (words.length > 1 && words.every(w => two.includes(w)) && !words.every(w => (d.lines[i + 1] ? d.lines[i + 1].f : '').includes(w)));
if (ok) { all++; if (hits.length < 4) hits.push({ t: l.t, text: l.text, exact: l.f.includes(phrase) || two.includes(phrase) }); }
}
if (all) out.push({ key: ep.key, src: ep.src, title: ep.title || 'Episode', artist: ep.artist || '', thumb: ep.thumb || '', n: all, exact: hits.some(h => h.exact), hits });
}
return out.sort((a, b) => (b.exact - a.exact) || (b.n - a.n)).slice(0, 30);
}
async function searchHandle(req, res) {
const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim().slice(0, 100), me = users.current();
try { json(res, 200, { results: me ? await search(me, q) : [] }); } catch (e) { json(res, 500, { error: e.message }); }
}
module.exports = { search, searchHandle, init, startAuto, status, handle, parseCues, parseJson, transcriptLinks, shape, whisper, getText, nativeTranscript, enabled: () => true };
+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', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', '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', 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', 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; };