52 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 4752aca43c Passwords: add and edit logins and notes, delete to trash, password generator, text Send
build-container / build (push) In progress
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 11:10:47 -04:00
nmemmertandClaude Sonnet 5.5 70dc75d688 Passwords: do not silence bw (quiet mode hid the session key)
build-container / build (push) Successful in 5m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 11:05:36 -04:00
nmemmertandClaude Sonnet 5.5 af939869a7 Passwords tab: unlock your Vaultwarden vault through the Bitwarden CLI, search, view and copy entries
build-container / build (push) Successful in 5m34s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:56:09 -04:00
nmemmertandClaude Sonnet 5.5 d9d3743816 Listen and Read: sub-tabs share the Back row, duplicate headings hidden on phones, page tab rows scroll on one line
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:46:48 -04:00
nmemmertandClaude Sonnet 5.5 3fb336b3c7 Audiobooks: Newly added shelf, New badges, library filters and sorting, narrator and length details
build-container / build (push) Successful in 4m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 10:39:00 -04:00
nmemmertandClaude Sonnet 5.5 522bd64b46 Family board: greets the family by name, cheers on finished chores with confetti, and says what it takes to reach the goal
build-container / build (push) Successful in 4m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 09:14:09 -04:00
nmemmert 6ba9992e55 Family board: choosing a goal from the chore picker returns to it with the new progress bar
build-container / build (push) Canceled after 0s
2026-10-29 09:09:31 -04:00
nmemmertandClaude Sonnet 5.5 32db6256e8 Family board: chore picker shows a progress bar toward the child's goal
build-container / build (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 09:07:27 -04:00
nmemmertandClaude Sonnet 5.5 0991185eb4 Family board: one compact header band (greeting, everyone's tile, clock); tapping a child opens their chores, with Open my account inside
build-container / build (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 09:06:08 -04:00
nmemmertandClaude Sonnet 5.5 2e6ee15ef2 Family board: chore picker and sign-in tiles sit side by side at the top
build-container / build (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 09:03:34 -04:00
nmemmertandClaude Sonnet 5.5 c47611dd17 Family board: compact Pick your chores row on the wall display, picker header avatar
build-container / build (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 09:02:53 -04:00
nmemmertandClaude Sonnet 5.5 3aaff55ade Chores: parents (and the child) are notified when a child's points reach their chosen reward
build-container / build (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 09:01:38 -04:00
nmemmertandClaude Sonnet 5.5 132a8eab5d Family board: Pick your chores row under the clock (tap your name, PIN once, do, take or put back chores)
build-container / build (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:59:43 -04:00
nmemmertandClaude Sonnet 5.5 48c65779d3 Family board: rewards show only the chosen goal, with a Choose a reward pop-up for children without one
build-container / build (push) Successful in 3m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:45:44 -04:00
nmemmertandClaude Sonnet 5.5 0a33119807 Parental controls: approvals lift bedtime, bedtime follows the child's clock, urgent push + banner + reminders for requests, reward cap raised to 60
build-container / build (push) Successful in 4m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:29:47 -04:00
nmemmertandClaude Sonnet 5.5 d0e4e82e0a Signal and Chat photos open in an in-app viewer (X, tap outside, Escape or Back to return to the chat)
build-container / build (push) Successful in 3m5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:53:26 -04:00
nmemmertandClaude Sonnet 5.5 c4b830dd43 Admin health: push devices, pushes sent, expired devices and the last failure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:52:05 -04:00
nmemmertandClaude Sonnet 5.5 551f970721 Polish: npm test runs every test file, two silent failures now log, Loading placeholders shimmer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:43:35 -04:00
nmemmertandClaude Sonnet 5.5 d173555398 Player: a song stuck on Buffering is reloaded after 10s, switched to the converted stream after 20s, and skipped after 30s; a play() that fails for any reason now recovers
build-container / build (push) Successful in 3m2s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:58:27 -04:00
nmemmertandClaude Sonnet 5.5 4e26bea455 My Store: select books and add them to chosen people's shelves
build-container / build (push) Successful in 3m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 09:50:30 -04:00
nmemmertandClaude Sonnet 5.5 3376499805 My Store: Add many books, with title, author, description and cover read out of each EPUB
build-container / build (push) Successful in 3m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 09:48:04 -04:00
nmemmertandClaude Sonnet 5.5 272760c321 iPad: no text selection or image menu on press-and-hold, no accidental pinch or double-tap zoom, fields stay at 16px so focus does not zoom
build-container / build (push) Successful in 2m55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 08:37:27 -04:00
nmemmertandClaude Sonnet 5.5 bbe32bae83 Music: on wide screens the Queue button docks an Up next panel down the right side that stays open while browsing
build-container / build (push) Successful in 3m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 08:15:43 -04:00
nmemmertandClaude Sonnet 5.5 1293ff0673 Bottom player bars: add a visible playhead you can grab and drag, music and video
build-container / build (push) Successful in 3m2s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 08:04:43 -04:00
nmemmertandClaude Sonnet 5.5 94ad5a8e1d Music: close button no longer offsets the Now Playing art, Family playlists get a 2x2 art cover, phone tabs fit one row with a More menu
build-container / build (push) Successful in 3m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 07:58:51 -04:00
Claude e248bac143 Merge music-iphone
build-container / build (push) Successful in 2m59s
2026-10-28 02:57:53 +00:00
Claude d025e250fe Music on iPhone: keep 3-5 songs ready (size-capped), skip a song that will not load instead of sticking on Buffering, tidy the album buttons, lock-screen art in more sizes, and accept the lookups and song-cache settings on the server
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-28 02:56:18 +00:00
Claude 1a797c972e Merge video-bar
build-container / build (push) Successful in 3m15s
2026-10-27 23:26:06 +00:00
Claude 2b4da8d9a2 Video full screen: a tap while the bar is hidden brings it back (the touch no longer wakes and then hides it again)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-27 23:24:21 +00:00
Claude 139ecf4ab9 Merge board-chores
build-container / build (push) Successful in 2m53s
2026-10-27 23:13:02 +00:00
Claude 45c4dbe120 Family board: look for new chores every 15 seconds instead of every minute
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-27 23:10:12 +00:00
Claude b0d6473e4d Visualizer test: recognise the player's new button layout
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-27 23:08:19 +00:00
Claude 0ae68d6842 Family board: tapping a chore assigned to several kids opens the right kid's chore, and each kid has their own column
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-27 23:05:19 +00:00
nmemmertandClaude Sonnet 5.5 fd5812c5c2 Player: keep retrying after a lost connection, resume songs cut short, recover on tab return, never let a screen error block playback
build-container / build (push) Successful in 3m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:27:53 -04:00
nmemmertandClaude Sonnet 5.5 8b77bacb7a Video full screen: the bottom bar hides after a few idle seconds and returns on movement
build-container / build (push) Successful in 4m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:12:49 -04:00
nmemmertandClaude Sonnet 5.5 ee4674cdad Video: controls move from the video box to the bottom bar, in full screen too
build-container / build (push) Successful in 3m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:04:40 -04:00
nmemmertandClaude Sonnet 5.5 1c6fff51bb Player bar: look up vizOpen/sleepSheet when tapped, so the player test (which doesn't load those files) passes
build-container / build (push) Successful in 3m49s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 16:49:02 -04:00
nmemmertandClaude Sonnet 5.5 3db4e4aa1e Music: drop the duplicate player card, add Visuals/Queue/Sleep to the bottom bar, redesign Now Playing sheet, add full-screen view, show queue when no lyrics
build-container / build (push) Failing after 52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 16:45:55 -04:00
nmemmertandClaude Sonnet 5.5 4f482a1e1a Move a Plex playlist to NE Hub as a Family playlist, with the option to remove the Plex copy
build-container / build (push) Successful in 3m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 15:44:07 -04:00
nmemmertandClaude Sonnet 5.5 8b295dddef Merge branch 'signal-inside-chat'
build-container / build (push) Successful in 3m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:02:45 -04:00
nmemmertandClaude Sonnet 5.5 7a07ac5e11 Signal conversations open inside Chat instead of switching to the Signal screen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:01:57 -04:00
nmemmertandClaude Sonnet 5.5 78fd07bd0d Merge branch 'single-chat-tab'
build-container / build (push) Successful in 3m40s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 13:41:06 -04:00
nmemmertandClaude Sonnet 5.5 1b45b477d6 One Chat entry: Signal is listed inside Chat, so the separate Signal tab is gone for people who have both
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 13:39:04 -04:00
nmemmertandClaude Sonnet 5.5 32dbda5c78 Merge branch 'office-fixes'
build-container / build (push) Successful in 3m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 13:23:02 -04:00
nmemmertandClaude Sonnet 5.5 da6c30fac4 Office: ready-made templates, the file-type badge no longer overlaps the date, and a Full editor and Edit elsewhere option for phones
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 13:21:59 -04:00
nmemmertandClaude Sonnet 5.5 16ba5de7c5 Merge branch 'chat-gif-place-inbox'
build-container / build (push) Successful in 3m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:16:46 -04:00
nmemmertandClaude Sonnet 5.5 651d0def2f GIFs from Wikimedia Commons (MediaWiki search) with no key; shared places link to Apple Maps
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:15:19 -04:00
nmemmertandClaude Sonnet 5.5 584a95753d Chat: a GIF picker kept family-friendly, sharing a place, and Signal conversations in the Chat list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:13:26 -04:00
nmemmertandClaude Sonnet 5.5 3ddf9942c2 Merge branch 'pantry-scan'
build-container / build (push) Successful in 4m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:02:10 -04:00
nmemmertandClaude Sonnet 5.5 c236120a72 Pantry units and recipes: amounts in pounds, cups and so on, Use some, and Cooked it with a preview of what comes out
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 12:00:11 -04:00
nmemmertandClaude Sonnet 5.5 ac55520a8c Pantry: barcode scanning with several barcodes per item, a Take one screen for everyone including children, and no longer tied to Mealie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 11:47:14 -04:00
nmemmertandClaude Sonnet 5.5 a7483164b8 Merge branch 'v2-remaining'
build-container / build (push) Successful in 3m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 11:36:47 -04:00
92 changed files with 2276 additions and 301 deletions

No files matched your search

+3
View File
@@ -7,6 +7,9 @@ ONLYOFFICE_URL=https://onlyoffice.necloud.us
# Optional: keeps Plex/session cookies valid across restarts. Any long random string.
COOKIE_SECRET=
AUDIOBOOKS_URL=https://audiobooks.necloud.us
# Passwords tab: your Vaultwarden / Bitwarden server (leave empty to switch the tab off), and minutes of inactivity before a vault locks itself
VAULT_URL=https://key.necloud.us
VAULT_IDLE_MIN=10
# First-time passkey enrolment. If unset, a random code is printed in the server log while no passkey exists.
HUB_SETUP_CODE=
VERSE_URL=https://versebyversewithnate.us/admin
+3
View File
@@ -34,6 +34,9 @@ jobs:
- run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
- run: node tools/pantry-test.js # pantry: counts, low levels, barcodes (several per item), kids can only take
- run: node tools/chatextras-test.js # chat: GIFs (family-friendly filter, kept in the chat) and sharing a place
- run: node tools/units-test.js # units: reading recipe lines, converting, what a recipe takes out of the pantry
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
+2
View File
@@ -4,6 +4,8 @@ COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY *.js ./
COPY public ./public
# The Bitwarden command-line tool, used by the Passwords tab to read each person's Vaultwarden vault
RUN npm install -g @bitwarden/cli@2024.9.0 && npm cache clean --force
ENV PORT=8080 DATA_DIR=/data
RUN mkdir -p /data && chown node:node /data
VOLUME /data
+8
View File
@@ -112,6 +112,11 @@ It listens to the sound the browser is playing. Where it cannot (iPhone Safari,
- **Continue reading:** Home lists the ebooks you have started, with progress, next to Continue watching and Continue listening.
- **Privacy:** Settings, Connections has a switch for looking up lyrics and song covers online. Off keeps the artist and title from LRCLIB and iTunes; radio then shows the station logo only.
## Music on a phone
- **Songs kept ready:** the song playing and the next few (3 to 5, set in Settings under Connections) are saved on the device while you listen, up to about 120 MB, so changing songs is instant and a weak signal does not stop the music.
- **A song that will not play** is tried once through Plex's converter, then skipped with a message, instead of leaving the player stuck on "Buffering".
## Install on iPhone / iPad (PWA)
Open the site in **Safari** (it must be the `https://` address), tap **Share -> Add to Home Screen**. The home-screen app has proper
@@ -148,6 +153,7 @@ playing an episode or pressing the button still works. Settings -> Health shows
- **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.
- **Chat extras**: a **GIF** button (with no key it uses Wikimedia Commons through MediaWiki's search API: free animated pictures of animals, nature, space and science; set `TENOR_API_KEY` or `GIPHY_API_KEY` for reaction GIFs instead, or `GIF_PROVIDER=wikimedia|tenor|giphy|off` to choose; the hub does all the talking to the service, asks only for G-rated results, refuses searches and drops results containing words that could bring up adult material, keeps a sent GIF in the chat like a photo, and a sender or admin can delete one that slips through), **Place or location** (your phone's location after it asks, or a place search through OpenStreetMap; shared as a card whose link is an Apple Maps link, `maps.apple.com`, which opens the Maps app on an iPhone or iPad), and the Chat list also shows **Signal** conversations, labelled, on screens where Signal is available.
- **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.
@@ -157,6 +163,8 @@ One **Store** tab (turn it on per person) with sections for **Books** (the free
**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.
- **Add many books** (admin): pick a whole batch of book files at once. Each one is uploaded on its own and the server reads the **title, author, description and cover picture out of the EPUB itself** (`epubmeta.js`, no libraries); a book already in the store (same file) is skipped. PDF, MOBI, AZW3 and DOCX carry little usable metadata, so they are named after the file, with no author or cover. When it finishes it lists any book with no cover or no author so you can fix those with *Edit*.
- **Select and add to people** (admin): tap *Select* in My Store, tick books (or *Select all shown*), then *Add to people…* and tick the people. The books land on each person's own shelf; someone without the Books tab is skipped and a book they already have is left alone.
- **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).
+1 -1
View File
@@ -27,7 +27,7 @@ const alerted = new Map(); // child + kind -> when a parent was last told, so a
function alertParents(kid, kind, text) {
const key = kid.id + ':' + kind + ':' + text; if (Date.now() - (alerted.get(key) || 0) < 30 * 60e3) return; alerted.set(key, Date.now()); if (alerted.size > 500) alerted.clear();
if (kind === 'blocked') log(kid.id, 'blocked', text);
for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: kid.name, body: text, url: kind === 'limit' ? '#/settings/family' : '#/settings', tag: 'kid:' + kid.id + ':' + kind });
for (const a of users.all()) if (!a.disabled && a.role === 'admin') { inbox.add(a.id, { kind: 'kid', title: kid.name, body: text, url: kind === 'limit' ? '#/settings/family' : '#/settings', tag: 'kid:' + kid.id + ':' + kind }); if (kind === 'limit') { try { require('./push').notifyUrgent(a.id, { title: kid.name, body: text, url: '#/settings/family', tag: 'kid:' + kid.id + ':limit' }).catch(() => {}); } catch {} } }
}
/** Once a week (Monday morning) every parent gets a short summary of each child's week in their Notifications. */
+19 -1
View File
@@ -32,6 +32,13 @@ const ymdOk = s => /^\d{4}-\d{2}-\d{2}$/.test(s || '');
const isKid = u => !!(u.kid && u.kid.on);
const palette = ['#e0742b', '#2f9e6e', '#4f7cff', '#8a5cf0', '#d9453a', '#2aa6a0', '#e5a00d'];
const onBoard = u => !u.disabled && u.board !== false; // the admin picks who is on the family board
const NAME_FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family-name.json');
const savedName = () => { try { return String(JSON.parse(fs.readFileSync(NAME_FILE(), 'utf8')).name || ''); } catch { return ''; } };
/** What the board calls the family: the name an admin set, or "<surname> Family" when two or more people share a last name, else "family". */
function familyName() {
const s = savedName(); if (s) return s; const last = {}; for (const u of users.all().filter(onBoard)) { const w = String(u.name || '').trim().split(/\s+/); if (w.length > 1) { const k = w[w.length - 1]; last[k] = (last[k] || 0) + 1; } }
const top = Object.entries(last).sort((a, b) => b[1] - a[1])[0]; return top && top[1] >= 2 ? top[0] + ' Family' : 'family';
}
const people = () => users.all().filter(onBoard).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), pinLen: (db()[u.id] || {}).n || 0, color: palette[i % palette.length] }));
/** Check it is really this person: a child with no PIN just taps their name; everyone else needs their PIN. */
@@ -48,7 +55,7 @@ function eventFields(title, b) {
async function board(day) {
const t1 = chores.addDays(day, 1), from = new Date(day + 'T00:00:00').getTime() - 864e5, to = new Date(t1 + 'T23:59:59').getTime() + 864e5;
let events = []; try { events = cal.familyEvents({ from, to, fd: day, td: t1 }); } catch {}
return { day, people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given, goal: p.goal })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
return { day, familyName: familyName(), people: people(), announcement: chat.announcement(), events, chores: chores.board(day).filter(i => !i.user || onBoard(users.get(i.user) || {})).map(i => ({ chore: i.chore, title: i.title, points: i.points, user: i.user, userName: i.userName, done: !!i.done, doneBy: i.done ? i.done.byName : '', claimable: !!i.claimable, claimed: !!i.claimed })), week: (w => ({ from: w.from, tiers: w.tiers, people: w.people.filter(p => onBoard(users.get(p.id) || {})).map(p => ({ id: p.id, name: p.name, points: p.points, count: p.count, tiers: p.tiers, earned: p.earned, given: p.given, goal: p.goal })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
}
async function handle(req, res) {
@@ -62,6 +69,17 @@ async function handle(req, res) {
const body = JSON.stringify({ chore: b.chore, date: ymdOk(b.date) ? b.date : undefined, undo: b.action === 'unclaim' || b.action === 'undo' || undefined }), r = Readable.from([Buffer.from(body)]); r.url = path; r.method = 'POST'; r.headers = {};
return users.runAs(t, () => chores.handle(r, res)); // (as that person: all of the chore rules apply to them)
}
if (p === '/api/board/family' && req.method === 'GET') { if (!me) throw fail(403, 'Sign in first'); return json(res, 200, { name: savedName(), shown: familyName() }); }
if (p === '/api/board/family' && req.method === 'POST') { // an admin sets what the family board calls the family
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can change this'); const b = JSON.parse(await readBody(req, 1e3) || '{}'), name = String(b.name || '').replace(/[\u0000-\u001f<>]/g, ' ').trim().slice(0, 40);
if (name) { fs.mkdirSync(path.dirname(NAME_FILE()), { recursive: true }); atomicWrite(NAME_FILE(), JSON.stringify({ name })); } else fs.rmSync(NAME_FILE(), { force: true }); return json(res, 200, { name, shown: familyName() });
}
if (p === '/api/board/goal' && req.method === 'POST') { // a child chooses the reward they are aiming for this week, from the shared screen
if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 2e3) || '{}'), t = users.get(String(b.uid));
if (!t || !onBoard(t)) throw fail(404, 'Person not found'); if (!users.can(t, 'chores')) throw fail(403, 'Chores are not turned on for that person'); identify(t, b.pin);
const r = Readable.from([Buffer.from(JSON.stringify({ reward: String(b.reward || ''), date: ymdOk(b.date) ? b.date : undefined }))]); r.url = '/api/chores/goal'; r.method = 'POST'; r.headers = {};
return users.runAs(t, () => chores.handle(r, res)); // (as that person: the same rules as choosing it in Chores)
}
if (p === '/api/board/add' && req.method === 'POST') { // put something on one of the family's lists from the shared screen, as the person who picked their name
if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 8e3) || '{}'), t = users.get(String(b.uid));
if (!t || !onBoard(t)) throw fail(404, 'Person not found'); const text = String(b.text || '').replace(/\s+/g, ' ').trim().slice(0, 200); if (!text) throw fail(400, 'Write something first');
+25 -1
View File
@@ -1,6 +1,8 @@
// 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 gifs = require('./gifs');
let placeAt = 0;
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const { pipeline } = require('stream/promises');
const atomicWrite = require('./atomic');
@@ -18,11 +20,16 @@ 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');
// ---- share cards: a song, film, book... from elsewhere in the hub, with the picture and a link back to it
const SHARE = { song: ['🎵', 'a song'], album: ['💿', 'an album'], artist: ['🎤', 'an artist'], playlist: ['📃', 'a playlist'], movie: ['🎬', 'a movie'], show: ['📺', 'a show'], episode: ['📺', 'an episode'], book: ['📖', 'a book'], audiobook: ['🎧', 'an audiobook'], podcast: ['🎙️', 'a podcast'], podcastep: ['🎙️', 'a podcast episode'], radio: ['📻', 'a radio station'] };
const SHARE = { song: ['🎵', 'a song'], album: ['💿', 'an album'], artist: ['🎤', 'an artist'], playlist: ['📃', 'a playlist'], movie: ['🎬', 'a movie'], show: ['📺', 'a show'], episode: ['📺', 'an episode'], book: ['📖', 'a book'], audiobook: ['🎧', 'an audiobook'], podcast: ['🎙️', 'a podcast'], podcastep: ['🎙️', 'a podcast episode'], radio: ['📻', 'a radio station'], place: ['📍', 'a place'] };
const ART_OK = /^\/(plex|api)\/[^\s"'<>\\]*$/, LINK_OK = /^#\/[\w-]+(\/[\w.~%:@+,=&?-]*)*$/;
/** What a client sent as a share, made safe: whitelisted kind, short words, a picture from this hub only and a link that is a hash route (never another site). */
function cleanShare(s) {
if (!s || typeof s !== 'object' || !SHARE[s.kind]) throw fail(400, 'That cannot be shared');
if (s.kind === 'place') { // a place on the map: the coordinates are checked and the link is made here (Apple Maps), never taken from the sender
const lat = Math.round(+s.lat * 1e5) / 1e5, lng = Math.round(+s.lng * 1e5) / 1e5, title = str(s.title, 120).trim() || 'Shared location';
if (!(Math.abs(lat) <= 90) || !(Math.abs(lng) <= 180) || s.lat === '' || s.lng === '' || s.lat == null || s.lng == null) throw fail(400, 'That place has no location');
return { kind: 'place', title, sub: str(s.sub, 160).trim(), art: '', link: `https://maps.apple.com/?ll=${lat},${lng}&q=${encodeURIComponent(title)}`, lat, lng };
}
const title = str(s.title, 120).trim(), link = String(s.link || ''), art = String(s.art || '');
if (!title) throw fail(400, 'Nothing to share');
if (link.length > 300 || !LINK_OK.test(link)) throw fail(400, 'That link cannot be shared');
@@ -173,6 +180,23 @@ async function handle(req, res) {
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 === '/places' && req.method === 'GET') { // search for a place by name (OpenStreetMap's Nominatim, one request a second at most, asked from here)
const term = str(q.get('q'), 100).trim(); if (term.length < 3) throw fail(400, 'Type at least three letters');
const wait = Math.max(0, placeAt + 1100 - Date.now()); placeAt = Date.now() + wait; if (wait) await new Promise(r => setTimeout(r, wait));
let j; try { const r = await fetch(`${(process.env.NOMINATIM_URL || 'https://nominatim.openstreetmap.org').replace(/\/+$/, '')}/search?format=jsonv2&limit=6&addressdetails=0&q=${encodeURIComponent(term)}`, { headers: { 'user-agent': 'NE-Hub/1.0 (family chat)', accept: 'application/json' }, signal: AbortSignal.timeout(8000) }); if (!r.ok) throw new Error('status ' + r.status); j = await r.json(); } catch { throw fail(502, 'The map search did not answer. Try again in a moment.'); }
return json(res, 200, { items: (Array.isArray(j) ? j : []).filter(x => isFinite(+x.lat) && isFinite(+x.lon)).slice(0, 6).map(x => { const parts = String(x.display_name || '').split(', '); return { title: str(x.name || parts[0] || term, 120), sub: str(parts.slice(1, 4).join(', '), 160), lat: +x.lat, lng: +x.lon }; }) });
}
if (p === '/gif/status') return json(res, 200, { enabled: gifs.enabled(), provider: gifs.provider() });
if (p === '/gif' && req.method === 'GET') { try { return json(res, 200, await gifs.search(q.get('q'), q.get('pos'))); } catch (e) { throw fail(e.code && e.code < 600 ? e.code : 502, e.message); } }
if (p === '/gifimg' && req.method === 'GET') { // a preview, fetched here so the phone never contacts the GIF service
try { const g = await gifs.fetchBytes(String(q.get('u') || ''), 2 * 1024 * 1024); res.writeHead(200, { 'content-type': g.type, 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff', 'content-length': g.buf.length }); return res.end(g.buf); } catch (e) { throw fail(e.code && e.code < 600 ? e.code : 502, e.message); }
}
if (p === '/gif/send' && req.method === 'POST') { // the chosen GIF is downloaded and kept in the chat like a photo
const b = JSON.parse(await readBody(req, 5e3) || '{}'), c = convOf(String(b.conv || ''), me); let g; try { g = await gifs.fetchBytes(String(b.url || '')); } catch (e) { throw fail(e.code && e.code < 600 ? e.code : 502, e.message); }
const id = newId(); fs.mkdirSync(path.dirname(blob(id)), { recursive: true, mode: 0o700 }); fs.writeFileSync(blob(id), g.buf, { mode: 0o600 });
files()[id] = { conv: c.id, by: me.id, name: (str(b.title, 60).replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ').trim() || 'GIF') + (g.type === 'image/gif' ? '.gif' : '.' + g.type.slice(6)), type: g.type, size: g.buf.length, ts: Date.now() }; saveFiles();
return json(res, 200, { msg: send(me, { conv: c.id, files: [id] }) });
}
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';
+15 -5
View File
@@ -6,7 +6,7 @@ const users = require('./users');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'chores.json');
const REPEATS = ['daily', 'weekdays', 'weekly', 'monthly', 'once'], MAX_CHORES = 300, MAX_LOG = 50000;
const REPEATS = ['daily', 'weekdays', 'weekly', 'monthly', 'once'], MAX_CHORES = 300, MAX_REWARDS = 60, MAX_LOG = 50000;
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => 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);
@@ -18,7 +18,7 @@ const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n);
const weekday = s => utc(s).getUTCDay(); // 0 = Sunday
const monday = s => addDays(s, -((weekday(s) + 6) % 7));
let cache = null;
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [], goals: j.goals && typeof j.goals === 'object' && !Array.isArray(j.goals) ? j.goals : {}, reminded: j.reminded && typeof j.reminded === 'object' && !Array.isArray(j.reminded) ? j.reminded : {} }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [], goals: {}, reminded: {} }; } })());
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return { chores: Array.isArray(j.chores) ? j.chores : [], log: Array.isArray(j.log) ? j.log : [], claims: Array.isArray(j.claims) ? j.claims : [], rewards: Array.isArray(j.rewards) ? j.rewards : [], given: Array.isArray(j.given) ? j.given : [], goals: j.goals && typeof j.goals === 'object' && !Array.isArray(j.goals) ? j.goals : {}, reminded: j.reminded && typeof j.reminded === 'object' && !Array.isArray(j.reminded) ? j.reminded : {}, hit: Array.isArray(j.hit) ? j.hit : [] }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [], goals: {}, reminded: {}, hit: [] }; } })());
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); };
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chores'); };
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
@@ -102,7 +102,7 @@ async function handle(req, res) {
const idx = db().log.findIndex(l => l.chore === c.id && l.key === key && (who === null || l.user === who));
if (b.undo) { if (idx < 0) return json(res, 200, { ok: true }); const l = db().log[idx]; if (l.by !== me.id && !admin) throw fail(403, 'Only the person who ticked it can take it back'); db().log.splice(idx, 1); save(); return json(res, 200, { ok: true }); }
if (idx >= 0) return json(res, 200, { ok: true, already: true });
const by = who || me.id, entry = { chore: c.id, key, date, user: who, by, at: Date.now(), pts: c.points }; db().log.push(entry); if (db().log.length > MAX_LOG) db().log.splice(0, db().log.length - MAX_LOG); save(); return json(res, 200, { ok: true, pts: c.points });
const by = who || me.id, entry = { chore: c.id, key, date, user: who, by, at: Date.now(), pts: c.points }; db().log.push(entry); if (db().log.length > MAX_LOG) db().log.splice(0, db().log.length - MAX_LOG); save(); goalReached(by, date); return json(res, 200, { ok: true, pts: c.points });
}
if (p === '/claim' && req.method === 'POST') { // take a chore from the "anyone" pool (or put it back)
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = db().chores.find(x => x.id === String(b.chore)), date = b.date || ymd(new Date()); if (!c) throw fail(404, 'Chore not found'); if (!ymdOk(date)) throw fail(400, 'Bad date');
@@ -117,7 +117,7 @@ async function handle(req, res) {
if (p === '/reward' && req.method === 'POST') { // make, change or remove a reward tier (admin)
if (!admin) throw fail(403, 'Only an admin can change the rewards'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), old = b.id ? d.rewards.find(x => x.id === b.id) : null;
if (b.id && !old) throw fail(404, 'Reward not found'); if (b.remove) { d.rewards = d.rewards.filter(x => x !== old); d.given = d.given.filter(g => g.reward !== b.id); for (const k of Object.keys(d.goals)) for (const w of Object.keys(d.goals[k])) if (d.goals[k][w] === b.id) delete d.goals[k][w]; save(); return json(res, 200, { ok: true }); }
if (!old && d.rewards.length >= 12) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
if (!old && d.rewards.length >= MAX_REWARDS) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
}
if (p === '/give' && req.method === 'POST') { // note that a reward was handed over (admin), or take it back
if (!admin) throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), d = db(), wk = monday(ymdOk(b.week) ? b.week : day), i = d.given.findIndex(g => g.week === wk && g.user === String(b.user) && g.reward === String(b.reward));
@@ -129,13 +129,23 @@ async function handle(req, res) {
if (!liveKid(uid)) throw fail(400, 'Goals are for child accounts');
const near = ymdOk(b.date) && Math.abs(Date.parse(b.date + 'T12:00:00Z') - Date.now()) < 3 * 864e5, wk = monday(near ? b.date : ymd(new Date())); // (this week, by the child's own calendar; a goal is for one week and starts empty each Monday)
if (!b.reward) { if (d.goals[uid]) delete d.goals[uid][wk]; save(); return json(res, 200, { ok: true, goal: null }); }
const t = d.rewards.find(x => x.id === String(b.reward)); if (!t || !forMe(t, uid)) throw fail(400, 'That reward is not available'); const mine = d.goals[uid] = d.goals[uid] || {}; mine[wk] = t.id; for (const w of Object.keys(mine).sort().slice(0, -12)) delete mine[w]; save(); try { require('./inbox').markTagRead(uid, 'goal:' + wk); } catch {} return json(res, 200, { ok: true, goal: t.id });
const t = d.rewards.find(x => x.id === String(b.reward)); if (!t || !forMe(t, uid)) throw fail(400, 'That reward is not available'); const mine = d.goals[uid] = d.goals[uid] || {}; mine[wk] = t.id; for (const w of Object.keys(mine).sort().slice(0, -12)) delete mine[w]; save(); try { require('./inbox').markTagRead(uid, 'goal:' + wk); } catch {} goalReached(uid, wk); return json(res, 200, { ok: true, goal: t.id });
}
if (p === '/summary' && req.method === 'GET') return json(res, 200, summary(q.get('range') === 'month' ? 'month' : 'week', day));
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 }); }
}
/** A child's points have reached the reward they are aiming for: tell the parents (so they can hand it over) and the child, once per reward per week. */
function goalReached(uid, day) {
try {
if (!liveKid(uid)) return; const d = db(), wk = monday(day), e = week(wk).people.find(x => x.id === uid), t = e && e.goal && d.rewards.find(x => x.id === e.goal); if (!t || e.points < t.points) return;
const key = uid + '|' + wk + '|' + t.id; if (d.hit.includes(key)) return; d.hit.push(key); if (d.hit.length > 500) d.hit.splice(0, d.hit.length - 500); save();
const kid = users.get(uid), inbox = require('./inbox'), push = require('./push'), body = t.title + ' (' + t.points + ' pts)' + (t.minutes ? ' · +' + t.minutes + ' min screen time' : '') + '. Ready to give.';
for (const a of users.all()) if (!a.disabled && a.role === 'admin') { inbox.add(a.id, { kind: 'kid', title: '🎉 ' + kid.name + ' reached their goal', body, url: '#/chores', tag: 'reached:' + key }); push.notifyUrgent(a.id, { title: '🎉 ' + kid.name + ' reached their goal', body, url: '#/chores', tag: 'reached:' + key }).catch(() => {}); }
inbox.add(uid, { kind: 'info', title: '🎉 You reached your goal!', body: t.title + '. A parent will give it to you.', url: '#/chores', tag: 'reached:' + key }); push.notifyUrgent(uid, { title: '🎉 You reached your goal!', body: t.title, url: '#/chores', tag: 'reached:' + key }).catch(() => {});
} catch (e) { console.warn('[chores] goal notice:', e.message); }
}
/** The Monday of this week, when a child with rewards to choose from still has no goal for it and has not been reminded yet (otherwise null). */
function goalNeeded(uid, day) { const d = db(), wk = monday(day); if (!liveKid(uid) || d.reminded[uid] === wk || (d.goals[uid] || {})[wk]) return null; return d.rewards.some(t => forMe(t, uid)) ? wk : null; }
function goalReminded(uid, wk) { db().reminded[uid] = wk; save(); }
+4
View File
@@ -15,6 +15,8 @@ services:
SEAFILE_URL: ${SEAFILE_URL:-https://seafile.necloud.us}
ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us}
AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us}
VAULT_URL: ${VAULT_URL:-https://key.necloud.us} # Passwords tab (Vaultwarden through the bundled Bitwarden CLI); leave empty to switch it off
VAULT_IDLE_MIN: ${VAULT_IDLE_MIN:-10}
AUDIOBOOKS_LOGIN_LINK: ${AUDIOBOOKS_LOGIN_LINK:-}
AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-}
AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-}
@@ -32,6 +34,8 @@ services:
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:-}
TENOR_API_KEY: ${TENOR_API_KEY:-} # GIFs in Family Chat work with no key (Wikimedia Commons). For reaction GIFs, a Tenor key (Google Cloud, Tenor API) ...
GIPHY_API_KEY: ${GIPHY_API_KEY:-} # ... or a GIPHY key; either replaces Commons. GIF_PROVIDER=off turns the button off. Tenor and GIPHY results are G-rated only; Commons has no ratings, so its results are checked by words and categories.
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
+4 -4
View File
@@ -22,12 +22,12 @@ A messenger inside the hub for the family, so family chats do not need Signal. E
- [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**
**Phase 3: richer messages (M): done**
- [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
- [x] GIF picker: **Done (gifs.js, chat-extras.js): works with no key through Wikimedia Commons (MediaWiki search), or with a Tenor or GIPHY key; a blocklist on searches and results (and categories for Commons), previews and the sent GIF go through the hub.**
- [x] Share a place as a link: **Done: your location (the phone asks) or a place search through OpenStreetMap, shown as a card with an Apple Maps (maps.apple.com) link.**
**Family extras (M): mostly done**
- [x] Turn a message into a task, an event or a shopping item in one tap
@@ -46,7 +46,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos)
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
- [x] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.**
- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry counts are kept by hand; they do not go down when you cook.**
- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry items can have units (10 lb of flour); **Cooked it** on a recipe (or on a meal plan entry) shows what it would take out, converting cups to pounds for common ingredients, and takes it out when confirmed; **Use some** takes an amount in any convertible unit. These are estimates, and lines that cannot be converted (a whole onion against pounds of onions, pinches) are left alone and listed. The Pantry lives at Recipes > Pantry and works without Mealie. A phone can scan a barcode to add or use one (several barcodes can belong to one item, so different brands of corn share a count), and a Take one screen lets anyone, children included, tap an item to take one; children can do nothing else to the pantry.**
- [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).**
## 3. Kids and safety
+46
View File
@@ -0,0 +1,46 @@
// Reads the title, author, description and cover picture out of an EPUB (a zip with an OPF file inside), with no libraries.
const zlib = require('zlib'), path = require('path');
const MAX_PART = 8 * 1024 * 1024; // the biggest piece of an EPUB this reads (the OPF and the cover picture)
const IMAGE = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };
/** The entries of a zip: name -> where to find it. */
function entries(buf) {
let e = -1; for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) if (buf.readUInt32LE(i) === 0x06054b50) { e = i; break; }
if (e < 0) throw new Error('Not a zip');
const n = buf.readUInt16LE(e + 10); let p = buf.readUInt32LE(e + 16); const out = new Map();
for (let k = 0; k < n && p + 46 <= buf.length && buf.readUInt32LE(p) === 0x02014b50; k++) {
const method = buf.readUInt16LE(p + 10), csize = buf.readUInt32LE(p + 20), usize = buf.readUInt32LE(p + 24), nl = buf.readUInt16LE(p + 28), xl = buf.readUInt16LE(p + 30), cl = buf.readUInt16LE(p + 32), off = buf.readUInt32LE(p + 42);
out.set(buf.toString('utf8', p + 46, p + 46 + nl), { method, csize, usize, off }); p += 46 + nl + xl + cl;
}
return out;
}
function read(buf, ent) {
if (!ent || ent.usize > MAX_PART || buf.readUInt32LE(ent.off) !== 0x04034b50) return null;
const s = ent.off + 30 + buf.readUInt16LE(ent.off + 26) + buf.readUInt16LE(ent.off + 28), raw = buf.subarray(s, s + ent.csize);
return ent.method === 0 ? raw : ent.method === 8 ? zlib.inflateRawSync(raw, { maxOutputLength: MAX_PART }) : null;
}
const unxml = s => String(s).replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, ' ').replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (_, c) =>
({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" })[c.toLowerCase()] || String.fromCodePoint(c[1].toLowerCase() === 'x' ? parseInt(c.slice(2), 16) : +c.slice(1))).replace(/\s+/g, ' ').trim();
const tagsOf = (xml, name) => [...xml.matchAll(new RegExp(`<(?:\\w+:)?${name}\\b([^>]*?)(?:/>|>([\\s\\S]*?)</(?:\\w+:)?${name}>)`, 'gi'))].map(m => ({ attrs: m[1] || '', text: m[2] == null ? '' : unxml(m[2]) }));
const attr = (attrs, name) => { const m = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i').exec(attrs); return m ? (m[2] != null ? m[2] : m[3]) : ''; };
const resolve = (base, href) => { try { href = decodeURIComponent(href); } catch {} return path.posix.normalize(path.posix.join(path.posix.dirname(base), href.split('#')[0])).replace(/^\/+/, ''); };
/** { title, author, description, cover: { type, data } | null } (any part may be missing). Throws if the file is not a readable EPUB. */
function readEpub(buf) {
const z = entries(buf), container = read(buf, z.get('META-INF/container.xml')); if (!container) throw new Error('No container.xml');
const opfPath = attr((tagsOf(container.toString('utf8'), 'rootfile')[0] || {}).attrs || '', 'full-path'); if (!opfPath) throw new Error('No package file');
const opf = (read(buf, z.get(opfPath)) || Buffer.alloc(0)).toString('utf8');
const meta = { title: (tagsOf(opf, 'title')[0] || {}).text || '', author: tagsOf(opf, 'creator').map(c => c.text).filter(Boolean).join(', '), description: ((tagsOf(opf, 'description')[0] || {}).text || '').slice(0, 2000), cover: null };
// the cover picture: the item the package calls the cover-image, else the one <meta name="cover"> points at, else an image named "cover"
const items = tagsOf(opf, 'item').map(i => ({ id: attr(i.attrs, 'id'), href: attr(i.attrs, 'href'), type: attr(i.attrs, 'media-type').toLowerCase(), props: attr(i.attrs, 'properties') }));
const metaCover = tagsOf(opf, 'meta').find(m => attr(m.attrs, 'name').toLowerCase() === 'cover'), cid = metaCover && attr(metaCover.attrs, 'content');
const pick = items.find(i => /\bcover-image\b/.test(i.props)) || items.find(i => cid && i.id === cid) || items.find(i => /^image\//.test(i.type) && /cover/i.test(i.id + ' ' + i.href));
if (pick) {
const data = read(buf, z.get(resolve(opfPath, pick.href))), ext = path.extname(pick.href).slice(1).toLowerCase(), type = /^image\/(jpeg|png|gif|webp)$/.test(pick.type) ? pick.type : IMAGE[ext];
if (data && type && data.length > 500) meta.cover = { type, data };
}
return meta;
}
module.exports = { readEpub };
+67
View File
@@ -0,0 +1,67 @@
// GIFs for Family Chat, from Tenor or GIPHY (whichever key the admin gives: TENOR_API_KEY or GIPHY_API_KEY), or with no key at all from Wikimedia Commons (MediaWiki's search API): free animated
// pictures of animals, space, science and nature rather than reaction memes. GIF_PROVIDER=tenor|giphy|wikimedia|off picks one; the default is the first of Tenor, GIPHY, Wikimedia that works. The hub does all the talking to the service, so phones never contact it:
// searches come back through here, previews are fetched here, and a GIF that is sent is downloaded and kept in the chat like any other picture.
// Everything is kept family-friendly, for everybody: only the services' G-rated results are asked for, words that could bring up adult material are refused in a search and in what comes
// back (title and tags), and only GIFs this server itself listed can be sent. A filter like this is a strong guard, not a promise: a sender or an admin can delete anything that slips through.
const TENOR = () => (process.env.TENOR_URL || 'https://tenor.googleapis.com').replace(/\/+$/, ''), GIPHY = () => (process.env.GIPHY_URL || 'https://api.giphy.com').replace(/\/+$/, '');
const WIKI = () => (process.env.WIKIMEDIA_URL || 'https://commons.wikimedia.org').replace(/\/+$/, '');
const provider = () => { const w = String(process.env.GIF_PROVIDER || '').toLowerCase(); if (w === 'off') return ''; if (w === 'wikimedia' || w === 'tenor' && process.env.TENOR_API_KEY || w === 'giphy' && process.env.GIPHY_API_KEY) return w; return process.env.TENOR_API_KEY ? 'tenor' : process.env.GIPHY_API_KEY ? 'giphy' : 'wikimedia'; };
const enabled = () => !!provider();
// whole words (and stems) that are never searched for and never shown
const BLOCK = ['sex', 'sexy', 'sexual', 'sexually', 'sexier', 'sexiest', 'porn', 'porno', 'pornography', 'pornographic', 'xxx', 'nsfw', 'nude', 'nudes', 'nudity', 'naked', 'topless', 'bottomless', 'boob', 'boobs', 'boobies', 'breast', 'breasts', 'tit', 'tits', 'titty', 'titties', 'nipple', 'nipples', 'cleavage', 'butt', 'butts', 'booty', 'twerk', 'twerking', 'thong', 'thongs', 'lingerie', 'bikini', 'bikinis', 'underwear', 'panties', 'bra', 'bras', 'strip', 'stripper', 'strippers', 'striptease', 'erotic', 'erotica', 'fetish', 'kink', 'kinky', 'bdsm', 'hentai', 'orgasm', 'orgasms', 'penis', 'vagina', 'dick', 'dicks', 'cock', 'cocks', 'pussy', 'pussies', 'cum', 'cumming', 'horny', 'slut', 'sluts', 'whore', 'hooker', 'hookers', 'masturbate', 'masturbation', 'blowjob', 'handjob', 'anal', 'milf', 'onlyfans', 'playboy', 'adult', 'adults', 'lust', 'seduce', 'seductive', 'foreplay', 'grope', 'groping', 'fuck', 'fucking', 'fucker', 'fucked', 'shit', 'bitch', 'bitches', 'asshole', 'ass', 'drugs', 'cocaine', 'marijuana', 'gore', 'suicide'];
const BLOCKED = new Set(BLOCK);
// Commons files carry categories, so those are checked too, against a few more phrases (a category is a phrase, not a word)
const RISKY = /nudity|nude|naked|sexual|sexuality|erotic|pornograph|genital|fetish|bdsm|topless|underwear|swimwear|bikini|lingerie|breast|buttock|nipple|prostitut|obscene|sex education|human sexuality|anatomy of the|violence|corpse|dead bod|execution|torture|massacre|gore|suicide|drug use|cannabis|cocaine|heroin/i;
/** Does this text (a search, a title, tags) contain a word that is not allowed? Whole words only, so "class" and "assistant" are fine. */
const unsafe = text => (String(text || '').toLowerCase().match(/[a-z0-9]+/g) || []).some(w => BLOCKED.has(w) || BLOCKED.has(w.replace(/s$/, '')));
// only GIFs this server has just listed can be sent or previewed (so the download address cannot be chosen by the sender)
const known = new Map(); // url -> expiry
const remember = urls => { const t = Date.now() + 3600e3; for (const u of urls) known.set(u, t); if (known.size > 4000) for (const [k, v] of known) if (v < Date.now()) known.delete(k); };
const isKnown = u => (known.get(u) || 0) > Date.now();
const MEDIA = /^https:\/\/(media[0-9]*\.tenor\.com|media[0-9]*\.giphy\.com|i\.giphy\.com|c\.tenor\.com|upload\.wikimedia\.org)\//i;
const mediaOk = u => { try { const x = new URL(u); return MEDIA.test(u) || String(process.env.GIF_MEDIA_HOSTS || '').split(',').filter(Boolean).includes(x.host); } catch { return false; } };
async function getJson(url) {
const r = await fetch(url, { headers: { accept: 'application/json', 'user-agent': 'NE-Hub/1.0 (family chat)' }, signal: AbortSignal.timeout(8000) });
if (r.status === 403 || r.status === 401) throw Object.assign(new Error('The GIF service refused the key. Check it in the stack settings.'), { code: 502 });
if (r.status === 429) throw Object.assign(new Error('The GIF service is busy. Try again in a minute.'), { code: 429 });
if (!r.ok) throw Object.assign(new Error('The GIF service did not answer (' + r.status + ')'), { code: 502 });
return r.json();
}
/** Search (or, with no words, what is popular). Returns { items: [{ title, w, h, preview, url }], next }. */
async function search(q, pos) {
q = String(q || '').trim().slice(0, 60);
if (q && unsafe(q)) throw Object.assign(new Error('That search is not available. Try something else.'), { code: 400 });
const n = 24; let items = [], next = '';
if (provider() === 'tenor') {
const key = encodeURIComponent(process.env.TENOR_API_KEY), base = `${TENOR()}/v2/${q ? 'search?q=' + encodeURIComponent(q) + '&' : 'featured?'}key=${key}&client_key=nehub&limit=${n}&contentfilter=high&media_filter=tinygif,gif${pos ? '&pos=' + encodeURIComponent(pos) : ''}`;
const j = await getJson(base); next = j.next || '';
items = (j.results || []).map(x => { const t = (x.media_formats || {}).tinygif, g = (x.media_formats || {}).gif || t; return t && g ? { title: x.content_description || '', tags: (x.tags || []).join(' '), w: (t.dims || [])[0] || 0, h: (t.dims || [])[1] || 0, preview: t.url, url: g.url } : null; });
} else if (provider() === 'giphy') {
const off = Math.max(0, parseInt(pos, 10) || 0), key = encodeURIComponent(process.env.GIPHY_API_KEY), base = `${GIPHY()}/v1/gifs/${q ? 'search?q=' + encodeURIComponent(q) + '&' : 'trending?'}api_key=${key}&limit=${n}&offset=${off}&rating=g&bundle=messaging_non_clips`;
const j = await getJson(base); next = (j.data || []).length >= n ? String(off + n) : '';
items = (j.data || []).filter(x => !x.rating || x.rating === 'g').map(x => { const t = (x.images || {}).fixed_width_small || (x.images || {}).fixed_width, g = (x.images || {}).fixed_width || (x.images || {}).original; return t && g && t.url && g.url ? { title: x.title || '', tags: '', w: +t.width || 0, h: +t.height || 0, preview: t.url, url: g.url } : null; });
} else if (provider() === 'wikimedia') { // Wikimedia Commons: animated GIFs only, chosen by the search itself; no rating exists, so the word and category checks do the work
const off = Math.max(0, parseInt(pos, 10) || 0), term = (q || 'animal') + ' filemime:image/gif';
const url = `${WIKI()}/w/api.php?action=query&format=json&generator=search&gsrnamespace=6&gsrlimit=${n}&gsroffset=${off}&gsrsearch=${encodeURIComponent(term)}&prop=imageinfo&iiprop=url|size|mime|extmetadata&iiurlwidth=320&iiextmetadatafilter=ImageDescription|Categories|ObjectName`;
const j = await getJson(url); next = j.continue && j.continue.gsroffset != null ? String(j.continue.gsroffset) : '';
items = Object.values((j.query || {}).pages || {}).sort((a, b) => (a.index || 0) - (b.index || 0)).map(pg => {
const ii = (pg.imageinfo || [])[0]; if (!ii || ii.mime !== 'image/gif' || !ii.thumburl) return null; const em = ii.extmetadata || {}, plain = v => String(v && v.value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const title = String(pg.title || '').replace(/^File:/, '').replace(/\.gif$/i, '').replace(/[_]+/g, ' '), cats = plain(em.Categories).replace(/\|/g, ' · '), desc = plain(em.ImageDescription).slice(0, 300);
if (RISKY.test(cats) || RISKY.test(title) || RISKY.test(desc)) return null; return { title: plain(em.ObjectName) || title, tags: cats + ' ' + desc, w: ii.thumbwidth || 0, h: ii.thumbheight || 0, preview: ii.thumburl, url: ii.thumburl };
});
} else throw Object.assign(new Error('GIFs are not set up on this server'), { code: 503 });
items = items.filter(x => x && mediaOk(x.preview) && mediaOk(x.url) && !unsafe(x.title) && !unsafe(x.tags)).map(({ tags, ...x }) => x);
remember(items.flatMap(x => [x.preview, x.url])); return { items, next };
}
/** The bytes of a GIF this server listed (a preview or the full one), size-limited. */
async function fetchBytes(url, max = 8 * 1024 * 1024) {
if (!isKnown(url) || !mediaOk(url)) throw Object.assign(new Error('That GIF is not available'), { code: 400 });
const r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0 (family chat)' }, signal: AbortSignal.timeout(15000), redirect: 'error' });
if (!r.ok) throw Object.assign(new Error('The GIF could not be downloaded'), { code: 502 });
const type = String(r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (!/^image\/(gif|webp|png|jpeg)$/.test(type)) throw Object.assign(new Error('That is not a picture'), { code: 502 });
const buf = Buffer.from(await r.arrayBuffer()); if (buf.length > max) throw Object.assign(new Error('That GIF is too large'), { code: 413 }); return { buf, type };
}
module.exports = { enabled, provider, search, fetchBytes, unsafe, isKnown, remember };
+5 -5
View File
@@ -33,7 +33,7 @@ function status(u) {
const now = push.localNow(tzOf(u.id)), day = load()[u.id] && load()[u.id][now.day] || { secs: 0, extra: 0 }, base = weekend(now.day) && k.weekendMin ? k.weekendMin : k.limitMin, limit = base ? base + (day.extra || 0) : 0, used = Math.floor((day.secs || 0) / 60);
const areas = {}, lockedAreas = []; for (const a of AREAS) { const lim = (k.areaLimits || {})[a]; if (!lim) continue; const um = Math.floor(((day.areas || {})[a] || 0) / 60); areas[a] = { used: um, limit: lim, left: Math.max(0, lim - um) }; if (um >= lim) lockedAreas.push(a); }
const q = k.bedtime || {}, asleep = !!q.on && (q.from <= q.to ? now.hm >= q.from && now.hm < q.to : now.hm >= q.from || now.hm < q.to);
const locked = asleep ? 'bedtime' : limit && used >= limit ? 'time' : null;
const locked = asleep && !Object.values(load()[u.id] || {}).some(x => x && x.bedUntil > Date.now()) ? 'bedtime' : limit && used >= limit ? 'time' : null;
return { on: true, used, limit, left: limit ? Math.max(0, limit - used) : null, areas, lockedAreas, extra: day.extra || 0, bedtime: q.on ? { from: q.from, to: q.to } : null, locked, day: now.day };
}
function ping(u, area) {
@@ -47,7 +47,7 @@ function ping(u, area) {
/** Minutes per day (newest first) with the split by area, for the parents' view. */
function usageByDay(uid, days = 7) { const m = load()[uid] || {}; return Object.keys(m).sort().reverse().slice(0, days).map(day => ({ day, minutes: Math.floor((m[day].secs || 0) / 60), extra: m[day].extra || 0, areas: Object.fromEntries(Object.entries(m[day].areas || {}).map(([a, s]) => [a, Math.floor(s / 60)])) })); }
const st0 = u => status(u);
function give(uid, minutes) { const u = users.get(uid); if (!u || !u.kid || !u.kid.on) throw fail(400, 'That is not a child account'); const d = load(), now = push.localNow(tzOf(uid)), mine = d[uid] || (d[uid] = {}), day = mine[now.day] || (mine[now.day] = { secs: 0, extra: 0, last: 0 }); day.extra = Math.max(0, Math.min(600, (day.extra || 0) + Math.round(+minutes || 0))); save(d); return status(u); }
function give(uid, minutes, liftBedtime) { const u = users.get(uid); if (!u || !u.kid || !u.kid.on) throw fail(400, 'That is not a child account'); const d = load(), now = push.localNow(tzOf(uid)), mine = d[uid] || (d[uid] = {}), day = mine[now.day] || (mine[now.day] = { secs: 0, extra: 0, last: 0 }); const wasBed = liftBedtime && +minutes > 0 && status(u).locked === 'bedtime'; day.extra = Math.max(0, Math.min(600, (day.extra || 0) + Math.round(+minutes || 0))); if (wasBed) day.bedUntil = Date.now() + Math.round(+minutes) * 60e3; /* (a yes at bedtime opens the app for that long) */ save(d); return status(u); }
const ALLOWED_WHILE_LOCKED = /^\/api\/(me|kid(\/|$)|csp-report|push\/)/;
/** For the server: should this request be refused because the account is locked? Returns the status when it should. */
function lockedFor(u, pathname) {
@@ -59,14 +59,14 @@ async function handle(req, res, me, hooks = {}) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/kid'.length) || '/';
try {
if (p === '/status' && req.method === 'GET') return json(res, 200, status(me));
if (p === '/ping' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e3) || '{}'); return json(res, 200, ping(me, String(b.area || ''))); }
if (p === '/ping' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e3) || '{}'); if (b.tz) push.setTz(me.id, String(b.tz)); /* (bedtime follows the child's own clock) */ return json(res, 200, ping(me, String(b.area || ''))); }
if (p === '/activity' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 4e3) || '{}'); activity.log(me.id, String(b.kind || ''), b.title); return json(res, 200, { ok: true }); } // (what a child played or read, reported by the app)
if (p === '/activity' && req.method === 'GET') { // the parents' view of one child
if (me.role !== 'admin') throw fail(403, 'Only an admin can see this'); const t = users.get(u.searchParams.get('user') || ''); if (!t || !activity.isKid(t)) throw fail(404, 'Child not found'); const days = Math.max(1, Math.min(30, +u.searchParams.get('days') || 7));
return json(res, 200, { name: t.name, days: usageByDay(t.id, days), recent: activity.recent(t.id, days).slice(0, 200), labels: AREA_LABEL });
}
if (p === '/ask' && req.method === 'POST') { if (!me.kid || !me.kid.on) throw fail(400, 'This is for child accounts'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').slice(0, 300).trim() || 'Can I have a little more time, please?'; const n = hooks.askParents ? hooks.askParents(me, text) : 0; require('./requests').askTime(me, text, st0(me).locked === 'bedtime' ? 15 : 30); return json(res, 200, { ok: true, told: n }); }
if (p === '/extra' && req.method === 'POST') { if (me.role !== 'admin') throw fail(403, 'Only a parent (an admin) can give more time'); const b = JSON.parse(await readBody(req, 1e4) || '{}'); return json(res, 200, give(String(b.user || ''), b.minutes)); }
if (p === '/ask' && req.method === 'POST') { if (!me.kid || !me.kid.on) throw fail(400, 'This is for child accounts'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').slice(0, 300).trim() || 'Can I have a little more time, please?'; const n = users.all().filter(x => x.role === 'admin' && !x.disabled && x.id !== me.id).length; /* (parents get one urgent notification from the request itself, not a second one from a chat message) */ require('./requests').askTime(me, text, st0(me).locked === 'bedtime' ? 15 : 30); return json(res, 200, { ok: true, told: n }); }
if (p === '/extra' && req.method === 'POST') { if (me.role !== 'admin') throw fail(403, 'Only a parent (an admin) can give more time'); const b = JSON.parse(await readBody(req, 1e4) || '{}'); return json(res, 200, give(String(b.user || ''), b.minutes, true)); }
if (p === '/usage' && req.method === 'GET') { if (me.role !== 'admin') throw fail(403, 'Only an admin can see this'); const t = users.get(u.searchParams.get('user') || ''); if (!t) throw fail(404, 'Person not found'); return json(res, 200, status(t)); }
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
+1 -1
View File
@@ -49,7 +49,7 @@ const fail = (status, message) => Object.assign(new Error(message), { status });
const accFile = () => path.join(users.dir(), 'mail-accounts.json');
const legacyFile = () => path.join(users.dir(), 'mail.json'); // one Outlook.com account, from before there were several
const accounts = () => {
try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch {}
try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch (e) { if (e.code !== 'ENOENT') console.warn('[mail] could not read the saved mail accounts (they will look empty until this is fixed):', e.message); }
try { // migrate the old single-account file
const old = vault.unseal(JSON.parse(fs.readFileSync(legacyFile(), 'utf8')).data);
if (old) { const list = [{ id: crypto.randomBytes(3).toString('hex'), type: 'outlook', ...old }]; saveAccounts(list); fs.rmSync(legacyFile(), { force: true }); return list; }
+34
View File
@@ -7,11 +7,13 @@ const { pipeline } = require('stream/promises');
const atomicWrite = require('./atomic');
const users = require('./users'), settings = require('./settings'), books = require('./books');
const { readBody } = require('./body');
const { readEpub } = require('./epubmeta');
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 MAX_BOOK_IMPORT = 300 * 1024 * 1024;
const KINDS = ['book', 'music', 'app', 'extension', 'link'];
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write', 'notes.read', 'notes.write', 'recipes.read', 'music.read']; // what an extension may ask the person for
const BOOK_EXT = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'docx'];
@@ -107,6 +109,21 @@ async function handle(req, res) {
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 (p === '/import' && req.method === 'POST') { // bulk add: the body is one book file (its name is in ?name=); the title, author, description and cover come out of the book itself (admin)
needAdmin(me); const name = safeName(u.searchParams.get('name')), ext = extOf(name);
if (!BOOK_EXT.includes(ext)) throw fail(400, 'A book must be an EPUB, PDF, MOBI, AZW3 or DOCX file');
const l = loadItems(); if (l.length >= 2000) throw fail(400, 'That is plenty of items');
const it = { id: newId(), kind: 'book', files: [], cover: false, added: Date.now() }, fid = newId(), file = fileOn(it, fid);
const size = await receive(req, file, MAX_BOOK_IMPORT), buf = fs.readFileSync(file), hash = crypto.createHash('sha1').update(buf).digest('hex');
const dup = l.find(x => x.kind === 'book' && x.files.some(f => f.hash === hash));
if (dup) { fs.rmSync(file, { force: true }); return json(res, 200, { duplicate: true, item: view(dup) }); }
let meta = {}; if (ext === 'epub') { try { meta = readEpub(buf); } catch { meta = {}; } }
const plain = path.basename(name, '.' + ext).replace(/_+/g, ' ').trim();
Object.assign(it, { title: str(meta.title, 120) || str(plain, 120) || 'Untitled', creator: str(meta.author, 120), description: str(meta.description, 2000), version: '', url: '', updated: Date.now() });
it.files.push({ id: fid, name, size, ext, hash });
if (meta.cover && meta.cover.data.length <= MAX_COVER) { fs.writeFileSync(coverOn(it), meta.cover.data, { mode: 0o600 }); it.cover = meta.cover.type; }
l.push(it); saveItems(l); return json(res, 200, { item: view(it), noAuthor: !it.creator, noCover: !it.cover });
}
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 });
@@ -137,6 +154,23 @@ async function handle(req, res) {
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 (p === '/people' && req.method === 'GET') { needAdmin(me); return json(res, 200, users.all().filter(u => !u.disabled).map(u => ({ id: u.id, name: u.name, books: users.can(u, 'read') }))); }
if (p === '/assign' && req.method === 'POST') { // put the chosen books on the chosen people's shelves (admin). A book they already have is left alone.
needAdmin(me); const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = loadItems();
const its = [...new Set(Array.isArray(b.items) ? b.items : [])].map(id => l.find(x => x.id === id)).filter(x => x && x.kind === 'book' && x.files[0]);
const who = [...new Set(Array.isArray(b.users) ? b.users : [])].map(id => users.get(id)).filter(u => u && !u.disabled);
if (!its.length) throw fail(400, 'Choose at least one book'); if (!who.length) throw fail(400, 'Choose at least one person');
const out = [];
for (const u of who) {
const r = { id: u.id, name: u.name, added: 0, had: 0, failed: 0, skipped: !users.can(u, 'read') };
if (!r.skipped) await users.runAs(u, async () => { for (const it of its) {
try { 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 {} }
books.addBook(buf, f.ext, { title: it.title, author: it.creator }, cover).duplicate ? r.had++ : r.added++; } catch { r.failed++; }
} });
out.push(r);
}
return json(res, 200, { books: its.length, people: out });
}
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');
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "for t in smoke frontend tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
"test": "node tools/run-tests.js",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
+115
View File
@@ -0,0 +1,115 @@
// The pantry: what the family keeps in the kitchen, one shared list. Each item has a name, how many are left (blank when you do not count it), the level it counts as running low
// at (blank: never), and any number of barcodes linked to it, so Green Giant corn and the store brand are both "Corn" with one count. Scanning a barcode (on a phone) looks the product
// up in Open Food Facts (through this server, so the phone never contacts it) and adds or uses one; the first time a barcode is seen the person says which item it is.
// An item going low starts the "pantry item runs low" automations.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const U = require('./units');
const DATA = () => process.env.DATA_DIR || path.join(__dirname, 'data');
const file = () => path.join(DATA(), 'pantry.json'), cacheFile = () => path.join(DATA(), 'barcode-cache.json');
const OFF = () => (process.env.OPENFOODFACTS_URL || 'https://world.openfoodfacts.org').replace(/\/+$/, '');
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 unitClean = v => U.UNITS.includes(String(v || '')) ? String(v || '') : '';
const num = v => v === '' || v == null || !isFinite(+v) || +v < 0 ? null : Math.min(100000, Math.round(+v * 100) / 100);
const CODE = /^\d{8,14}$/;
const read = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return (Array.isArray(a) ? a : []).map(x => typeof x === 'string' ? { id: crypto.randomBytes(4).toString('hex'), name: x, qty: null, low: null, unit: '', codes: [] } : { ...x, unit: unitClean(x.unit), codes: Array.isArray(x.codes) ? x.codes : [] }).filter(x => x && x.name); } catch { return []; } };
const write = l => atomicWrite(file(), JSON.stringify(l), { mode: 0o600 });
const isLow = x => x.qty != null && x.low != null && x.qty <= x.low;
const view = l => l.map(x => ({ id: x.id, name: x.name, qty: x.qty, unit: x.unit || '', label: U.fmt(x.qty, x.unit || ''), low: x.low, lowLabel: U.fmt(x.low, x.unit || ''), isLow: isLow(x), codes: x.codes })).sort((a, b) => b.isLow - a.isLow || a.name.localeCompare(b.name));
// Who took what: the last few uses, so parents can see where the snacks went.
const logFile = () => path.join(DATA(), 'pantry-log.json');
const readLog = () => { try { const a = JSON.parse(fs.readFileSync(logFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
function logUse(me, it, delta, note) { const l = readLog(); l.unshift({ t: Date.now(), who: me.name, item: it.name, delta, ...(note ? { note: String(note).slice(0, 80) } : {}) }); atomicWrite(logFile(), JSON.stringify(l.slice(0, 60)), { mode: 0o600 }); }
const isKid = u => !!(u && u.kid && u.kid.on);
/** An item has just gone low: tell the automations of everyone who asked to hear about it. */
function lowNow(it) { try { require('./automations').fireAll('pantry.low', { title: it.name }); } catch (e) { console.warn('[pantry] rules failed:', e.message); } }
/** Change an item's count, firing the "went low" rules when it crosses into low. */
function bump(it, delta) { const before = isLow(it); it.qty = Math.max(0, Math.round(((it.qty || 0) + delta) * 10000) / 10000); if (isLow(it) && !before) lowNow(it); }
// ---- matching words: "3 cups flour" has "flour"; "Green Giant Sweet Corn" has "Corn"
const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, '');
const wordsOf = t => (String(t).toLowerCase().match(/[a-z]+/g) || []).map(stem);
/** The pantry names (from items or strings) that every word of is in `text`. */
function inPantry(text, items) { const have = new Set(wordsOf(text)); return items.map(x => typeof x === 'string' ? x : x.name).filter(n => { const ws = wordsOf(n); return ws.length && ws.every(w => have.has(w)); }); }
/** A short name for a product: the brand and the size taken off ("Heinz Tomato Ketchup 500 ml" -> "Tomato Ketchup"). */
function shortName(name, brand) {
let n = String(name || '').replace(/\b\d+([.,]\d+)?\s?(x\s?\d+\s?)?(ml|cl|l|g|kg|oz|fl\.?\s?oz|lb|lbs|ct|count|pack|pk)\b\.?/gi, ' ');
for (const b of String(brand || '').split(',').map(x => x.trim()).filter(Boolean)) n = n.replace(new RegExp('\\b' + b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'ig'), ' ');
n = n.replace(/[()\[\],]/g, ' ').replace(/\s+/g, ' ').trim(); return (n || String(name || '').trim()).replace(/\b\w/g, c => c.toUpperCase()).slice(0, 60);
}
// ---- the product database (Open Food Facts), remembered so a barcode is only looked up once
const loadCache = () => { try { return JSON.parse(fs.readFileSync(cacheFile(), 'utf8')) || {}; } catch { return {}; } };
async function product(code) {
const c = loadCache(), hit = c[code]; if (hit && (hit.found || Date.now() - hit.t < 24 * 3600e3)) return hit.found ? hit : null; // (a miss is tried again after a day)
let found = null;
try {
const r = await fetch(`${OFF()}/api/v2/product/${code}.json?fields=product_name,brands,quantity,generic_name`, { headers: { 'user-agent': 'NE-Hub/1.0 (family pantry)', accept: 'application/json' }, signal: AbortSignal.timeout(6000) });
if (r.ok) { const j = await r.json(), p = j && j.status === 1 && j.product; if (p && (p.product_name || p.generic_name)) found = { name: String(p.product_name || p.generic_name).slice(0, 120), brand: String(p.brands || '').slice(0, 80), quantity: String(p.quantity || '').slice(0, 30) }; }
} catch { return undefined; } // (could not ask: not remembered)
c[code] = found ? { found: true, t: Date.now(), ...found } : { found: false, t: Date.now() };
const keys = Object.keys(c); if (keys.length > 3000) for (const k of keys.sort((a, b) => c[a].t - c[b].t).slice(0, 500)) delete c[k];
fs.mkdirSync(DATA(), { recursive: true }); atomicWrite(cacheFile(), JSON.stringify(c), { mode: 0o600 }); return found ? c[code] : null;
}
async function handle(req, res) {
const me = users.current(), u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/pantry'.length) || '/';
try {
if (!me) throw fail(401, 'Sign in');
if (p === '/' && req.method === 'GET') return json(res, 200, { items: view(read()), recent: readLog().slice(0, 12), kid: isKid(me) });
if (p === '/lookup' && req.method === 'GET') { // what is this barcode? Linked to an item already, or which products and items it might be
const code = u.searchParams.get('code') || ''; if (!CODE.test(code)) throw fail(400, 'That is not a barcode number (8 to 14 digits)');
const l = read(), linked = l.find(x => x.codes.includes(code)); if (linked) return json(res, 200, { code, linked: { id: linked.id, name: linked.name } });
const pr = await product(code), name = pr ? shortName(pr.name, pr.brand) : '';
return json(res, 200, { code, linked: null, product: pr ? { name: pr.name, brand: pr.brand, quantity: pr.quantity, short: name } : null, lookedUp: pr !== undefined, matches: pr ? view(l.filter(x => inPantry(pr.name, [x.name]).length)).map(x => ({ id: x.id, name: x.name })) : [] });
}
if (req.method !== 'POST') throw fail(404, 'Not found');
const b = JSON.parse(await readBody(req, 4e3) || '{}'), l = read();
if (p === '/use') { // the simple way: take one (or put it back, to undo). Open to everyone, children included.
const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); const delta = b.undo ? 1 : -1;
if (it.unit) throw fail(409, 'Use an amount for ' + it.name.toLowerCase() + ' (it is kept in ' + it.unit + ')');
if (it.qty != null) { if (delta < 0 && it.qty <= 0) throw fail(409, 'There is none left of ' + it.name); bump(it, delta); write(l); }
logUse(me, it, b.undo ? 0 : -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, isLow: isLow(it) }, items: view(l), recent: readLog().slice(0, 12) });
}
if (isKid(me) && !(p === '/scan' && b.mode === 'use' && l.some(x => x.codes.includes(String(b.code))))) throw fail(403, 'Ask a grown-up to change the pantry. You can tap an item to take one.');
if (p === '/item') { // add an item, or change one (the same name changes the existing item)
const name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the item a name');
const i = b.id ? l.findIndex(x => x.id === b.id) : l.findIndex(x => x.name.toLowerCase() === name.toLowerCase()); if (b.id && i < 0) throw fail(404, 'That item is gone'); if (i < 0 && l.length >= 400) throw fail(400, 'The pantry is full');
const before = i >= 0 ? isLow(l[i]) : false, it = { id: i >= 0 ? l[i].id : crypto.randomBytes(4).toString('hex'), name: i >= 0 && !b.id ? l[i].name : name, qty: num(b.qty), unit: unitClean(b.unit), low: num(b.low), codes: i >= 0 ? l[i].codes : [] };
if (i >= 0) l[i] = it; else l.push(it); write(l); if (isLow(it) && !before) lowNow(it); return json(res, 200, { items: view(l) });
}
if (p === '/adjust') { const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); bump(it, +b.delta || 0); write(l); return json(res, 200, { items: view(l) }); }
if (p === '/delete') { const k = l.filter(x => x.id !== b.id); write(k); return json(res, 200, { items: view(k) }); }
if (p === '/take') { // take amounts out (cooking, or a spoonful from the bag): [{ id, amount, unit }], the unit being any that can be turned into the item's own
const takes = Array.isArray(b.takes) ? b.takes.slice(0, 80) : [], done = [], skipped = [];
for (const t of takes) {
const it = l.find(x => x.id === t.id), amt = +t.amount; if (!it || !(amt > 0)) { skipped.push({ id: t.id, why: 'nothing to take' }); continue; }
if (it.qty == null) { skipped.push({ id: it.id, name: it.name, why: 'not counted' }); continue; }
const unit = t.unit == null ? (it.unit || '') : U.unitOf(t.unit) || '', c = unit === (it.unit || '') ? amt : U.convert(amt, unit, it.unit || '', it.name);
if (c == null) { skipped.push({ id: it.id, name: it.name, why: `cannot turn ${unit || 'a count'} into ${it.unit || 'a count'} for ${it.name.toLowerCase()}` }); continue; }
const before = it.qty; bump(it, -Math.round(c * 10000) / 10000); done.push({ id: it.id, name: it.name, took: Math.round((before - it.qty) * 10000) / 10000, label: U.fmt(it.qty, it.unit || ''), isLow: isLow(it) });
}
if (done.length) { write(l); const first = done.map(x => x.name).slice(0, 3).join(', '); logUse(me, { name: first + (done.length > 3 ? '…' : '') }, -1, b.note || 'cooked'); }
return json(res, 200, { done, skipped, items: view(l), recent: readLog().slice(0, 12) });
}
if (p === '/scan') { // one scanned: add one ("add") or use one ("use"). A new barcode must say which item it is (itemId) or name a new one (name).
const code = String(b.code || ''); if (!CODE.test(code)) throw fail(400, 'That is not a barcode number (8 to 14 digits)'); const delta = b.mode === 'use' ? -1 : 1;
let it = l.find(x => x.codes.includes(code)), made = false;
if (!it && b.itemId) { it = l.find(x => x.id === b.itemId); if (!it) throw fail(404, 'That item is gone'); }
if (!it && String(b.name || '').trim()) { const name = String(b.name).replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); it = l.find(x => x.name.toLowerCase() === name.toLowerCase()); if (!it) { if (l.length >= 400) throw fail(400, 'The pantry is full'); it = { id: crypto.randomBytes(4).toString('hex'), name, qty: 0, low: null, unit: unitClean(b.unit), codes: [] }; l.push(it); made = true; } }
if (!it) throw fail(400, 'Say which item this is');
if (!it.codes.includes(code)) { for (const x of l) x.codes = x.codes.filter(c => c !== code); it.codes.push(code); it.codes = it.codes.slice(-30); }
let step = delta; // an item measured in pounds, cups and so on goes up and down by the size of the package scanned (a 15 oz can into an item in ounces); a counted item by one
if (it.unit) { const pk = (loadCache()[code] || {}).quantity, pi = pk ? U.parseIngredient(pk) : null; if (pi && pi.amount && pi.unit) { const c = U.convert(pi.amount, pi.unit, it.unit, it.name); if (c && c > 0) step = delta * Math.round(c * 100) / 100; } }
bump(it, step); write(l); if (delta < 0) logUse(me, it, -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, label: U.fmt(it.qty, it.unit || ''), isLow: isLow(it) }, step, made, items: view(l), recent: readLog().slice(0, 12) });
}
if (p === '/unlink') { const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); it.codes = it.codes.filter(c => c !== String(b.code)); write(l); return json(res, 200, { items: view(l) }); }
throw fail(404, 'Not found');
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
}
module.exports = { handle, read, isLow, inPantry, shortName };
+40 -15
View File
@@ -11,7 +11,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', 'music_feat_pls']);
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', 'song_cache']);
const outbox = new Map(); let outTimer = null, warned = false;
function flushSettings() {
outTimer = null;
@@ -55,6 +55,9 @@ async function loadSettings() {
const ICONS = {
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
queue: '<path d="M4 6h12M4 11h12M4 16h7M15 14v6l5-3z"/>',
visuals: '<path d="M5 10v4M9 6v12M13 9v6M17 4v16M21 11v2"/>',
moon: '<path d="M20 14.5A8 8 0 019.5 4 8 8 0 1020 14.5z"/>',
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
@@ -106,6 +109,7 @@ const ICONS = {
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"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
link: '<path d="M10 14a4 4 0 005.7 0l3-3a4 4 0 00-5.7-5.7l-1 1M14 10a4 4 0 00-5.7 0l-3 3a4 4 0 005.7 5.7l1-1"/>',
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
@@ -114,6 +118,7 @@ const ICONS = {
heart: '<path d="M12 20s-7-4.4-7-10a4 4 0 017-2.5A4 4 0 0119 10c0 5.6-7 10-7 10z"/>',
bible: '<path d="M5 5a2 2 0 012-2h12v15H7a2 2 0 00-2 2z"/><path d="M5 20a2 2 0 002 2h12v-4M12 7v7M9.5 9.5h5"/>',
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
pin: '<path d="M12 21s-7-6.2-7-11a7 7 0 0114 0c0 4.8-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/>',
font: '<path d="M3 19l5-13 5 13M5 14h6M15 19l3.5-9 3.5 9M16.2 16h4.6"/>',
};
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
@@ -169,25 +174,27 @@ 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'], ['family', 'Family'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['chores', 'Chores'], ['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', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['notes', 'Notes'], ['chores', 'Chores'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['vault', 'Passwords'], ['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', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', vault: 'key', 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];
const sgInChat = () => allowed('chat') && allowed('signal'); // with both, Signal conversations are in the Chat list and there is one entry, not two
const chatUnread = () => (ch.unread || 0) + (sgInChat() ? (sg.unread || 0) : 0);
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0]) || (t[0] === 'signal' && sgInChat())) continue; let id = t[0], label = t[1];
if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; }
if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; };
/** Every page one by one (what search and the command palette list), with the merged entries split back out. */
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', 'family', 'search']], ['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
const NAV_GROUPS = [['', ['home', 'family', 'search']], ['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'vault', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
const navGroups = (tabs = navTabs()) => {
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
return out.filter(g => g.tabs.length);
};
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', family: '#d9578a', notes: '#e5b800', chores: '#2f9e6e', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', vault: '#1f6fb2', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', family: '#d9578a', notes: '#e5b800', chores: '#2f9e6e', 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); };
@@ -201,13 +208,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}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
const tab = ([id, label]) => `<a href="#/${id}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(chatUnread()) : 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] === 'chat') ? badge(ch.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(chatUnread()) : ''}${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>`;
@@ -218,7 +225,7 @@ phone.addEventListener('change', () => renderNav());
/** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */
function openLauncher() {
const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; };
const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'chat' ? chatUnread() : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; };
const tile = ([id, label]) => `<a class="lt" href="#/${id}"><span class="lti" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}${badge(id)}</span><span class="ltl">${esc(label)}</span></a>`;
const el = openSheet('Apps', '', `<input type="search" id="lnq" placeholder="Search apps" autocomplete="off" autocapitalize="off" aria-label="Search apps"><div id="lnb"></div>
<div class="bar" style="margin-bottom:0"><button class="btn ghost" id="lnpin" style="flex:1;justify-content:center">${icon('menu')}Edit bottom bar</button></div>`);
@@ -250,14 +257,16 @@ const lastSub = g => { let l = null; try { l = JSON.parse(localStorage.getItem(n
/** The row of buttons under the top bar that switches between the parts of Listen and Read. */
function drawSubnav(name) {
const el = $('#subnav'); if (!el) return; const g = leafOf(name), items = g ? MERGED[g][1].filter(allowed) : [];
document.body.classList.remove('subtabs');
if (!g || items.length < 2) { el.hidden = true; el.innerHTML = ''; return; }
try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {}
el.innerHTML = items.map(id => `<a href="#/${id}" class="${id === name ? 'on' : ''}" ${id === name ? 'aria-current="page"' : ''}>${esc(tabLabel(id))}</a>`).join(''); el.hidden = false;
document.body.classList.add('subtabs'); const on = el.querySelector('a.on'); if (on) on.scrollIntoView({ inline: 'center', block: 'nearest' });
}
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
const navIdOf = name => name === 'signal' && sgInChat() ? 'chat' : name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
let curRoute = '';
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), 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, vault: viewVault, 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 {}; } };
@@ -284,6 +293,7 @@ function drawNav() {
$('#nvb').onclick = () => { if (navIdx > 0) history.back(); else if (parentHash()) location.hash = parentHash(); };
$('#nvf').onclick = () => history.forward();
}
const sn = $('#subnav'); if (sn && sn.parentNode !== el) el.insertBefore(sn, $('#nvt')); // Listen/Read buttons share the Back/Forward row
$('#nvb').disabled = !canBack; $('#nvf').disabled = !canFwd;
const t = TABS.find(x => x[0] === (curRoute || activeTab)); $('#nvt').textContent = t ? t[1] : '';
el.hidden = false;
@@ -327,7 +337,7 @@ function homeApps() {
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
chores: () => tile('#/chores', '#2f9e6e', 'check', 'Chores', 'Jobs & points'),
notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'),
chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', ch.unread ? ch.unread + ' unread' : 'Family messages'),
chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', chatUnread() ? chatUnread() + ' unread' : sgInChat() ? 'Family and Signal' : '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'),
@@ -338,8 +348,8 @@ 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', 'notes', 'chores', '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('');
const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'vault', '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) && !(id === 'signal' && sgInChat()))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
}
function viewHome() {
@@ -402,9 +412,24 @@ const kidUi = { st: null, timer: null, told: new Set() };
async function kidStart() {
if (!ME || !ME.kid || !ME.kid.on || kidUi.timer) return;
kidAskButton();
const tick = async () => { try { kidUi.st = document.visibilityState === 'visible' ? await jfetch('/api/kid/ping', { method: 'POST', body: JSON.stringify({ area: kidArea() }) }) : await jfetch('/api/kid/status'); } catch (e) { return; } kidPaint(); };
const tick = async () => { try { kidUi.st = document.visibilityState === 'visible' ? await jfetch('/api/kid/ping', { method: 'POST', body: JSON.stringify({ area: kidArea(), tz: Intl.DateTimeFormat().resolvedOptions().timeZone }) }) : await jfetch('/api/kid/status'); } catch (e) { return; } kidPaint(); };
kidUi.tick = tick; await tick(); kidUi.timer = setInterval(tick, 60e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') tick(); });
}
// ---- A parent: a pill at the top of every screen while a child is asking (with a beep and a title count), so a request is never missed. Tap it to answer.
const askWatch = { n: 0, timer: null, tick: null };
async function askWatchStart() {
if (!ME || ME.role !== 'admin' || askWatch.timer) return; const base = document.title.replace(/^\(\d+\) /, '');
const tick = async () => {
let r; try { r = await jfetch('/api/kid/requests'); } catch { return; }
const open = (r.requests || []).filter(x => x.status === 'open'), who = [...new Set(open.map(x => x.userName))]; let p = document.getElementById('askpill');
if (open.length > askWatch.n) { try { toast('🙋 ' + who.join(', ') + (open.some(x => x.time) ? ' is asking for time' : ' is asking')); const a = new (window.AudioContext || window.webkitAudioContext)(), o = a.createOscillator(), g = a.createGain(); o.connect(g); g.connect(a.destination); o.frequency.value = 880; g.gain.setValueAtTime(.15, a.currentTime); g.gain.exponentialRampToValueAtTime(.001, a.currentTime + .5); o.start(); o.stop(a.currentTime + .5); } catch {} }
askWatch.n = open.length; document.title = (open.length ? '(' + open.length + ') ' : '') + base;
if (!open.length) { if (p) p.remove(); return; }
if (!p) { p = document.createElement('button'); p.id = 'askpill'; p.type = 'button'; p.onclick = () => { location.hash = '#/settings/family'; if (askWatch.tick) askWatch.tick(); }; document.body.appendChild(p); }
p.textContent = '🙋 ' + (who.length > 2 ? who.length + ' children are' : who.join(' and ') + (who.length > 1 ? ' are' : ' is')) + ' asking' + (open.some(x => x.time) ? ' for time' : '') + (open.length > 1 ? ' (' + open.length + ')' : '');
};
askWatch.tick = tick; await tick(); askWatch.timer = setInterval(tick, 20e3); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') tick(); });
}
/** Which part of the app the child is in, so the parents can see where the time goes (and limit one part). */
function kidArea() { const r = (location.hash.slice(2).split('/')[0] || 'home'), h = location.hash; return r === 'video' ? 'video' : r === 'music' ? 'music' : /^(audio|podcast|radio|listen|vbv)$/.test(r) || /^#\/store\/(audiobooks|radio)/.test(h) ? 'listen' : /^(read|reading|bible)$/.test(r) || /^#\/store\/books/.test(h) ? 'read' : /^(chat|signal)$/.test(r) ? 'chat' : 'other'; }
const actSeen = new Map();
+31 -9
View File
@@ -7,6 +7,11 @@ const abId = b => String(abPick(b, 'id', 'asin', 'ASIN', 'book_id'));
const abTitle = b => abPick(b, 'title', 'title_short', 'name') || '(untitled)';
const abAuthor = b => { const a = abPick(b, 'author', 'author_name', 'authors', 'author_display') || ''; return Array.isArray(a) ? a.join(', ') : a; };
const abSeries = b => abPick(b, 'series_name', 'series') || '';
const abNarr = b => { const a = abPick(b, 'narrated_by', 'narrator', 'narrators', 'narrator_name') || ''; return Array.isArray(a) ? a.join(', ') : a; };
const abSecs = b => { const d = Number(abPick(b, 'seconds', 'duration', 'duration_sec', 'length_sec', 'runtime_seconds')); return d > 0 ? (d > 1e5 ? d / 1000 : d) : 0; }; // (some servers give milliseconds)
/** When the book was added to the library (ms), from whichever date the server gives; 0 when it gives none. */
const abAdded = b => { for (const k of ['added', 'added_at', 'date_added', 'created_at', 'created', 'purchase_date', 'mtime']) { const v = b && b[k]; if (v == null || v === '') continue; const t = typeof v === 'number' ? (v < 1e11 ? v * 1000 : v) : Date.parse(v); if (t > 0) return t; } return 0; };
const abLen = s => { const h = Math.floor(s / 3600), m = Math.round(s % 3600 / 60); return h ? `${h} h${m ? ' ' + m + ' m' : ''}` : `${m} m`; };
const abCover = b => `/api/ab/books/${encodeURIComponent(abId(b))}/cover`;
const abFmt = s => { s = Math.max(0, Math.floor(s || 0)); const 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'); };
const abHue = t => { let h = 0; for (const c of t) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
@@ -61,7 +66,7 @@ const abLoadProg = async () => { try { for (const [id, p] of Object.entries(awai
async function viewBooks(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
const [data] = await Promise.all([abApi('/books'), abLoadProg(), lvPrime()]);
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
@@ -74,21 +79,38 @@ async function viewBooks(args = []) {
if (!location.hash.startsWith('#/audio')) return;
const pct = b => { const p = abProg[abId(b)]; return p && p.dur ? Math.min(100, p.pos / p.dur * 100) : 0; };
const card = (b, wide) => `<button class="vcard${wide ? ' wide' : ''}" data-id="${esc(abId(b))}" style="text-align:left;background:none;border:0;padding:0;color:inherit;font:inherit;cursor:pointer;${wide ? 'width:140px;flex:none' : ''}">
<span class="vimg" style="aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(abTitle(b))} 55% 40%),hsl(${abHue(abTitle(b)) + 40} 55% 28%))"><img loading="lazy" alt="" src="${abCover(b)}" onerror="this.remove()">${pct(b) > 1 ? `<i class="vprog"><b style="width:${pct(b)}%"></b></i>` : ''}</span>
<b>${esc(abTitle(b))}</b><small>${esc(abAuthor(b))}</small></button>`;
<span class="vimg" style="aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(abTitle(b))} 55% 40%),hsl(${abHue(abTitle(b)) + 40} 55% 28%))"><img loading="lazy" alt="" src="${abCover(b)}" onerror="this.remove()">${pct(b) > 1 ? `<i class="vprog"><b style="width:${pct(b)}%"></b></i>` : ''}${isNew(b) ? '<em class="abnew">New</em>' : ''}</span>
<b>${esc(abTitle(b))}</b><small>${esc(abAuthor(b))}</small>${!wide && abSecs(b) ? `<small>${esc(abLen(abSecs(b)))}${abSeries(b) ? ' · ' + esc(abSeries(b)) : ''}</small>` : ''}</button>`;
const WEEKS = 3 * 7 * 864e5, isNew = b => { const t = abAdded(b); return t && Date.now() - t < WEEKS && pct(b) < 1; };
const dated = abBooks.some(abAdded), newest = [...abBooks].sort(dated ? (a, b) => abAdded(b) - abAdded(a) : () => 0).slice(0, 12); // (no dates: the server's own order, newest last)
if (!dated) newest.splice(0, newest.length, ...abBooks.slice(-12).reverse());
const stat = b => { const p = abProg[abId(b)]; return !p || !p.dur || p.pos <= 10 ? 'new' : p.pos >= p.dur - 30 ? 'done' : 'going'; };
const view = (() => { try { return JSON.parse(localStorage.getItem('ab_view')) || {}; } catch { return {}; } })();
let sort = view.sort || 'title', filt = view.filt || 'all';
const cont = abBooks.filter(b => { const p = abProg[abId(b)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; }).sort((a, b) => abProg[abId(b)].t - abProg[abId(a)].t).slice(0, 10);
const contFree = lvShelf.filter(b => lvLeft(b.id)).sort((a, b) => lvProg['lv:' + b.id].t - lvProg['lv:' + a.id].t).slice(0, 10);
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Audiobooks</h1><span class="pspace grow"></span><a class="btn ghost" href="#/audio/free">Free audiobooks</a><a class="btn ghost" href="#/audio/classic" title="The audiobook player's own page">Classic player</a></div>
${cont.length || contFree.length ? `<h2>Continue listening</h2><div class="vrow">${[...cont.map(b => ({ t: abProg[abId(b)].t, h: card(b, true) })), ...contFree.map(b => ({ t: lvProg['lv:' + b.id].t, h: lvCard(b, true) }))].sort((a, b) => b.t - a.t).map(x => x.h).join('')}</div>` : ''}
${newest.length > 1 ? `<h2>Newly added</h2><div class="vrow">${newest.map(b => card(b, true)).join('')}</div>` : ''}
${lvShelf.length ? `<h2>My free audiobooks</h2><div class="vgrid">${lvRecent().map(b => lvCard(b)).join('')}</div>` : ''}
<input type="search" id="abq" placeholder="Search your books" autocomplete="off" autocapitalize="off" style="margin-top:14px">
<h2>Library</h2><div class="vgrid" id="abgrid"></div></div>`;
<h2>Library <small id="abn" style="color:var(--muted);font-weight:400"></small></h2>
<div class="bar" style="flex-wrap:wrap;margin-top:6px">${[['all', 'All'], ['going', 'In progress'], ['new', 'Not started'], ['done', 'Finished']].map(([k, l]) => `<button class="btn ghost abf" data-f="${k}">${l}</button>`).join('')}
<span class="pspace grow"></span><label class="sub" style="display:flex;gap:6px;align-items:center">Sort <select id="abs0"><option value="title">Title</option><option value="author">Author</option><option value="added">Recently added</option><option value="series">Series</option><option value="length">Longest</option></select></label></div>
<div class="vgrid" id="abgrid"></div></div>`;
const draw = () => {
const q = $('#abq').value.trim().toLowerCase(), list = abBooks.filter(b => !q || (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(q))
.sort((a, b) => abTitle(a).localeCompare(abTitle(b)));
const q = $('#abq').value.trim().toLowerCase(), by = { title: (a, b) => abTitle(a).localeCompare(abTitle(b)), author: (a, b) => abAuthor(a).localeCompare(abAuthor(b)) || abTitle(a).localeCompare(abTitle(b)),
added: (a, b) => abAdded(b) - abAdded(a) || abTitle(a).localeCompare(abTitle(b)), length: (a, b) => abSecs(b) - abSecs(a),
series: (a, b) => (abSeries(a) || '\uffff').localeCompare(abSeries(b) || '\uffff') || (Number(abPick(a, 'series_sequence', 'series_index')) || 0) - (Number(abPick(b, 'series_sequence', 'series_index')) || 0) || abTitle(a).localeCompare(abTitle(b)) }[sort],
list = abBooks.filter(b => (filt === 'all' || stat(b) === filt) && (!q || (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b) + ' ' + abNarr(b)).toLowerCase().includes(q))).sort(by);
$('#abgrid').innerHTML = list.map(b => card(b)).join('') || `<p class="empty">${abBooks.length ? 'No books match.' : 'No books in the library yet.'}</p>`;
$('#abn').textContent = list.length === abBooks.length ? `${abBooks.length} books` : `${list.length} of ${abBooks.length}`;
v.querySelectorAll('.abf').forEach(x => x.classList.toggle('on', x.dataset.f === filt));
$('#abs0').value = sort;
try { localStorage.setItem('ab_view', JSON.stringify({ sort, filt })); } catch {}
};
draw(); $('#abq').oninput = draw;
draw(); $('#abq').oninput = draw; $('#abs0').onchange = e => { sort = e.target.value; draw(); };
v.querySelectorAll('.abf').forEach(x => x.onclick = () => { filt = x.dataset.f; draw(); });
const byId = new Map(abBooks.map(b => [abId(b), b]));
v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
lvBind(v, () => route());
@@ -98,14 +120,14 @@ async function viewBooks(args = []) {
function abSheet(b) {
const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30;
const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1"><img alt="" src="${abCover(b)}" onerror="this.remove()"></span>
<div style="min-width:0"><p class="sub" style="margin:0">${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}</p></div></div>
<div style="min-width:0">${abNarr(b) ? `<p class="sub" style="margin:0 0 4px">Read by ${esc(abNarr(b))}</p>` : ''}${abSecs(b) ? `<p class="sub" style="margin:0 0 4px">${esc(abLen(abSecs(b)))}${abAdded(b) ? ' · added ' + new Date(abAdded(b)).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : ''}</p>` : ''}<p class="sub" style="margin:0">${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}</p></div></div>
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button>${allowed('chat') ? `<button class="btn ghost" id="abh">${icon('send')}Share</button>` : ''}</div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
el.querySelector('#abp').onclick = () => go();
if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); };
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
el.querySelector('#abc').onclick = async () => {
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
const ch = await abChapters(id);
box.innerHTML = ch.length ? ch.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${abFmt(c.start)}</small></span></button>`).join('') : '<p class="empty">This book has no chapter list.</p>';
box.querySelectorAll('button[data-i]').forEach(x => x.onclick = () => go(ch[x.dataset.i].start));
+1 -1
View File
@@ -63,7 +63,7 @@ function bbPaint(root) { // highlights, notes and picked verses, on top of the t
}
async function viewBible(args = []) {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Bible</h1><p class="empty">Loading the Bible…</p></div>';
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Bible</h1><p class="empty loading" role="status">Loading the Bible…</p></div>';
try { await bbLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Bible</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="bbretry">Try again</button></div></div>`; $('#bbretry').onclick = () => route(); return; }
if (!location.hash.startsWith('#/bible')) return;
try { const s = await bbApi('/state'); bbSt = s; } catch {}
+6 -3
View File
@@ -46,7 +46,10 @@
<style>
body { padding: 18px; }
.bd { max-width: 1280px; margin: 0 auto; }
.bdtop { display: flex; justify-content: space-between; align-items: flex-end; gap: 14px; flex-wrap: wrap; }
.bdtop { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } .bdl { flex: none; } .pickrow { flex: 1 1 320px; min-width: 0; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; } .bdclock { flex: none; margin-left: auto; }
.cheer { margin: 6px 0 4px; padding: 10px 14px; border-radius: 14px; background: #2f9e6e; color: #fff; font-weight: 700; font-size: 18px; text-align: center; animation: cpop .35s ease-out; } @keyframes cpop { from { transform: scale(.85); opacity: 0; } to { transform: none; opacity: 1; } } @keyframes cfall { to { transform: translateY(105vh) rotate(540deg); opacity: .9; } }
.chrow { display: flex; align-items: center; gap: 8px; padding: 9px 0; border-bottom: 1px solid var(--line); text-align: left; } .chrow .g { flex: 1; min-width: 0; font-size: 18px; } .chrow.dn .g { text-decoration: line-through; opacity: .6; } .chrow .btn { min-height: 44px; padding: 0 14px; }
.pickrow .pk { display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-radius: 18px; background: var(--panel); border: 2px solid var(--line); color: var(--text); font: inherit; text-align: left; cursor: pointer; } .pickrow .pk.has { border-color: var(--accent); } .pickrow .pk:active { transform: scale(.97); } .pickrow .pk .av { width: 46px; height: 46px; border-radius: 50%; color: #fff; display: grid; place-items: center; font-size: 21px; font-weight: 700; flex: none; } .pickrow .pk b { display: block; font-size: 18px; } .pickrow .pk small { color: var(--muted); font-size: 13px; white-space: nowrap; }
.bdtop h1 { margin: 0; font-size: 34px; } .bdclock { font-size: 40px; font-weight: 700; font-variant-numeric: tabular-nums; }
.who { margin: 16px 0; } .who h2 { margin: 0 0 8px; font-size: 18px; }
.tiles { display: flex; gap: 12px; flex-wrap: wrap; }
@@ -76,6 +79,7 @@
@media (min-width: 700px) and (min-height: 700px) {
html, body { height: 100%; overflow: hidden; } body { padding: 14px 18px; }
.bd { height: 100%; display: flex; flex-direction: column; max-width: none; }
.pickrow { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; } .pickrow .pk { flex: none; padding: 6px 12px; } .pickrow .pk .av { width: 38px; height: 38px; font-size: 18px; } .pickrow .pk b { font-size: 16px; }
.bdtop h1 { font-size: 28px; } .bdclock { font-size: 32px; } .bdtop p { margin: 0; }
.ann { margin: 8px 0; padding: 8px 14px; font-size: 20px; } .who { margin: 8px 0; } .who h2 { margin-bottom: 6px; font-size: 15px; }
.tiles { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; gap: 10px; } .tile { width: 92px; padding: 8px 4px; border-radius: 16px; flex: none; } .tile .av { width: 44px; height: 44px; font-size: 20px; } .tile b { font-size: 14px; }
@@ -92,9 +96,8 @@
</head>
<body>
<div class="bd">
<div class="bdtop"><div><h1 id="hi">Family board</h1><p class="sub" id="date"></p></div><div class="bdclock" id="clock"></div></div>
<div class="bdtop"><div class="bdl"><h1 id="hi">Family board</h1><p class="sub" id="date"></p></div><section class="pickrow" id="pickrow"></section><div class="bdclock" id="clock"></div></div>
<div id="annc"></div>
<section class="who"><h2>Who's using this? Tap your name</h2><div class="tiles" id="tiles"></div></section>
<div class="grid" id="grid"></div>
<p class="sub" style="text-align:center;margin-top:18px"><a href="/login" style="color:var(--accent)">Sign in with a passkey instead</a></p>
</div>
+66 -11
View File
@@ -5,23 +5,36 @@ const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&a
const ymd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
let data = null;
function clock() { const d = new Date(); $('#clock').textContent = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); const h = d.getHours(); $('#hi').textContent = (h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening') + ', family'; $('#date').textContent = d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); }
function clock() { const d = new Date(); $('#clock').textContent = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); const h = d.getHours(); $('#hi').textContent = (h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening') + ', ' + ((data && data.familyName) || 'family'); $('#date').textContent = d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); }
const card = (t, body, add, cls) => `<section class="card ${cls || ''}"><h2 style="display:flex;justify-content:space-between;align-items:center">${t}${add ? `<button class="pbtn" data-add="${add}" aria-label="Add">+</button>` : ''}</h2>${body}</section>`;
function weekCard(w) {
if (!w || !w.people.length) return none('No points yet');
return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...mine.map(t => t.points), p.points), next = mine.find(t => t.points > p.points), goal = mine.find(t => t.id === p.goal), got = mine.filter(t => p.earned.includes(t.id)); return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(p.name)}</b><small class="sub">${p.points} pts · ${p.count} done</small></div><div class="bar2" style="position:relative">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${goal ? (got.length ? ' · ' : '') + (p.points >= goal.points ? '🎯 ' + esc(goal.title) + ' reached!' : '🎯 ' + (goal.points - p.points) + ' more for ' + esc(goal.title)) : next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
return w.people.map(p => { const mine = w.tiers.filter(t => p.tiers.includes(t.id)), goal = mine.find(t => t.id === p.goal), top = Math.max(1, goal ? goal.points : 0, p.points); return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(p.name)}</b><small class="sub">${p.points} pts · ${p.count} done</small></div><div class="bar2" style="position:relative">${goal ? `<span style="position:absolute;top:0;left:${Math.min(100, Math.round(goal.points / top * 100))}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>` : ''}<i style="width:${Math.round(p.points / top * 100)}%"></i></div>${goal ? `<small>${p.points >= goal.points ? '🎯 ' + esc(goal.title) + ' reached! 🎉' : '🎯 ' + (goal.points - p.points) + ' more for ' + esc(goal.title)}</small>` : mine.length ? `<button type="button" class="btn ghost" data-goalfor="${esc(p.id)}" style="margin-top:4px;min-height:40px">Choose a reward</button>` : ''}</div>`; }).join('');
}
// ---- Choose a reward to aim for: pick it from a pop-up, then PIN (unless a child with no PIN), and it is set as that person
function goalFlow(pid, back) {
const p = data.people.find(x => x.id === pid), w = data.week; if (!p || !w) return; const mine = w.tiers.filter(t => ((w.people.find(x => x.id === pid) || {}).tiers || []).includes(t.id));
const { box, close } = sheet(`<h3 style="margin:0 0 4px">${esc(p.name.split(' ')[0])}, what are you aiming for?</h3><p class="sub" style="margin:0 0 8px">Pick one reward for this week.</p><div class="list" style="text-align:left">${mine.map(t => `<button class="row" data-g="${esc(t.id)}" style="width:100%"><span class="grow"><b>${esc(t.title)}</b><small>${t.points} points in a week${t.minutes ? ' · +' + t.minutes + ' min screen time' : ''}</small></span></button>`).join('')}</div><div class="err" id="e"></div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Cancel</button></p>`);
box.querySelector('#no').onclick = close;
const send = async (reward, pin) => { const r = await fetch('/api/board/goal', { method: 'POST', body: JSON.stringify({ uid: p.id, pin, reward, date: ymd(new Date()) }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'That did not work'); const wp = data.week && data.week.people.find(x => x.id === p.id); if (wp) wp.goal = reward || null; close(); await load(); if (back) back(pin); else toast('Goal chosen'); };
box.querySelectorAll('[data-g]').forEach(b => b.onclick = () => { const reward = b.dataset.g;
if (p.kid && !p.pin) return send(reward).catch(e => { box.querySelector('#e').textContent = e.message; });
if (!p.pin) { box.querySelector('#e').textContent = p.name + ' has not set a PIN yet. Set one in Settings on your own device.'; return; }
let pin = ''; box.innerHTML = `<h3 style="margin:0 0 4px">${esc(p.name)}, enter your PIN</h3><div class="dots" id="dots"></div><div class="err" id="e"></div><div class="pad">${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => `<button data-k="${k}">${k}</button>`).join('')}</div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Cancel</button></p>`;
const paint = () => { box.querySelector('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => `<i class="${i < pin.length ? 'on' : ''}"></i>`).join(''); }; paint(); box.querySelector('#no').onclick = close;
box.querySelectorAll('.pad button').forEach(k2 => k2.onclick = async () => { let k = k2.dataset.k; box.querySelector('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await send(reward, pin); return; } catch (e) { box.querySelector('#e').textContent = e.message; pin = ''; } } paint(); }); });
}
const none = t => `<p class="none">${t}</p>`;
function draw() {
const t0 = ymd(new Date()), t1 = ymd(new Date(Date.now() + 864e5)), d = data;
$('#annc').innerHTML = d.announcement ? `<div class="ann">${esc(d.announcement.text)}<div class="sub" style="font-size:14px;font-weight:400">${esc(d.announcement.by)}</div></div>` : '';
$('#tiles').innerHTML = d.people.map(p => `<button class="tile" data-id="${esc(p.id)}"><span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><b>${esc(p.name.split(' ')[0])}</b><small>${p.kid ? '<span class="kid">KID</span>' : ''}</small></button>`).join('');
$('#tiles').querySelectorAll('.tile').forEach(b => b.onclick = () => pick(d.people.find(p => p.id === b.dataset.id)));
const day = (label, ds) => { const l = d.events.filter(e => e.allDay ? e.start <= ds && e.end > ds : ymd(new Date(e.start)) === ds).sort((a, b) => (b.allDay - a.allDay) || a.start - b.start); return `<small class="sub">${label}</small>` + (l.map(e => `<div class="ln"><span class="dot" style="background:${esc(e.color || 'var(--accent)')}"></span><span class="g">${esc(e.title)}${e.by ? ` <small>${esc(e.by)}</small>` : ''}</span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || none('Nothing')); };
const pill = (c, cls) => `<button class="pill ${cls || ''}" data-c="${esc(c.chore)}">${esc(c.title)}${c.points ? ` <small>${c.points} pts</small>` : ''}</button>`;
const pool = d.chores.filter(c => c.claimable && !c.done), per = {}; d.chores.filter(c => !(c.claimable && !c.done)).forEach(c => { (per[c.userName] = per[c.userName] || []).push(c); });
const SHOW = 6, chores = (pool.length ? `<button class="pill pool" id="poolall" style="width:100%;justify-content:space-between;font-size:17px"><span>Pick a chore: ${pool.length} up for grabs</span><span>See all ›</span></button>` : '') + '<div class="chcols">' + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length, left = l.filter(c => !c.done); return `<div class="chp"><div class="top"><b>${esc(k)}</b><small class="sub">${dn} of ${l.length}</small></div><div class="bar2"><i style="width:${Math.round(dn / l.length * 100)}%"></i></div><div class="chl">${left.map(c => pill(c)).join('')}${left.length ? '' : '<small class="sub">All done 🎉</small>'}</div></div>`; }).join('') + '</div>';
const left = p => d.chores.filter(c => c.user === p.id && !c.done).length, grabs = d.chores.filter(c => c.claimable && !c.done).length, has = p => p.kid || d.chores.some(c => c.user === p.id);
$('#pickrow').innerHTML = d.people.map(p => `<button class="pk${has(p) ? ' has' : ''}" data-hub="${esc(p.id)}"><span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><span><b>${esc(p.name.split(' ')[0])}</b><small>${has(p) ? (left(p) ? left(p) + ' to do' : 'All done') + (grabs ? ' · ' + grabs + ' to grab' : '') : 'Sign in'}</small></span></button>`).join('');
const pill = (c, cls) => `<button class="pill ${cls || ''}" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">${esc(c.title)}${c.points ? ` <small>${c.points} pts</small>` : ''}</button>`;
const pool = d.chores.filter(c => c.claimable && !c.done), per = {}; d.chores.filter(c => !(c.claimable && !c.done)).forEach(c => { (per[c.user || c.userName] = per[c.user || c.userName] || []).push(c); });
const SHOW = 6, chores = (pool.length ? `<button class="pill pool" id="poolall" style="width:100%;justify-content:space-between;font-size:17px"><span>Pick a chore: ${pool.length} up for grabs</span><span>See all ›</span></button>` : '') + '<div class="chcols">' + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length, left = l.filter(c => !c.done); return `<div class="chp"><div class="top"><b>${esc(l[0].userName)}</b><small class="sub">${dn} of ${l.length}</small></div><div class="bar2"><i style="width:${Math.round(dn / l.length * 100)}%"></i></div><div class="chl">${left.map(c => pill(c)).join('')}${left.length ? '' : '<small class="sub">All done 🎉</small>'}</div></div>`; }).join('') + '</div>';
$('#grid').innerHTML = card('Chores today', chores || none('No chores today'), '', 'wide')
+ card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1), 'event')
+ card('This week', weekCard(d.week)) + card('Shopping list', d.shopping.map(i => `<div class="ln"><span class="g">${esc(i.text)}</span>${i.by ? `<small>${esc(i.by)}</small>` : ''}</div>`).join('') || none('Nothing to buy'), 'shopping')
@@ -31,10 +44,10 @@ function draw() {
}
function poolAll() {
const list = data.chores.filter(c => c.claimable && !c.done), { box, close } = sheet(`<h3 style="margin:0 0 4px">Up for grabs</h3><p class="sub">${list.length} chores. Tap one to pick it</p><div style="text-align:left;max-height:58dvh;overflow:auto">${list.map(c => `<button class="pill pool" data-c="${esc(c.chore)}" style="width:100%;justify-content:space-between;font-size:18px;padding:12px 14px;margin:4px 0"><span>${esc(c.title)}</span>${c.points ? `<small>${c.points} pts</small>` : ''}</button>`).join('')}</div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Close</button></p>`);
box.querySelector('#no').onclick = close; box.querySelectorAll('.pill[data-c]').forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c)));
box.querySelector('#no').onclick = close; box.querySelectorAll('.pill[data-c]').forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c && (c.user || '') === (b.dataset.u || ''))));
}
function wireChores() { document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); const all = document.getElementById('poolall'); if (all) all.onclick = poolAll; document.querySelectorAll(".pill[data-c]").forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c))); }
async function load() { try { const r = await fetch('/api/board?day=' + ymd(new Date())); if (r.status === 403) { location.replace('/login'); return; } data = await r.json(); draw(); } catch {} }
function wireChores() { document.querySelectorAll('[data-hub]').forEach(b => b.onclick = () => { const p = data.people.find(x => x.id === b.dataset.hub); if (p && (p.kid || data.chores.some(c => c.user === p.id))) choreHub(p); else pick(p); }); document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); document.querySelectorAll('[data-goalfor]').forEach(b => b.onclick = () => goalFlow(b.dataset.goalfor)); const all = document.getElementById('poolall'); if (all) all.onclick = poolAll; document.querySelectorAll(".pill[data-c]").forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c && (c.user || '') === (b.dataset.u || '')))); }
async function load() { try { const r = await fetch('/api/board?day=' + ymd(new Date())); if (r.status === 403) { location.replace('/login'); return; } data = await r.json(); draw(); clock(); } catch {} }
function pick(p) {
if (!p) return;
@@ -49,7 +62,7 @@ function pick(p) {
paint(); $('#no').onclick = close;
box.querySelectorAll('.pad button').forEach(b => b.onclick = async () => { let k = b.dataset.k; $('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await go(pin); return; } catch (e) { $('#e').textContent = e.message; pin = ''; } } paint(); });
}
clock(); setInterval(clock, 15000); load(); setInterval(() => { if (!document.hidden && $('#ov').hidden) load(); }, 60000);
clock(); setInterval(clock, 15000); load(); setInterval(() => { if (!document.hidden && $('#ov').hidden) load(); }, 15000); // (a chore someone just added shows up within a few seconds)
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
// ---- tap a chore: (if it is up for grabs, choose what you are doing), pick your name, PIN if you are not a child, then it happens as you
@@ -100,3 +113,45 @@ function whoThen(title, payload, doneMsg) {
box.querySelectorAll('.pad button').forEach(b => b.onclick = async () => { let k = b.dataset.k; box.querySelector('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await send(p, pin); return; } catch (e) { box.querySelector('#e').textContent = e.message; pin = ''; } } paint(); }); });
}
function toast(msg) { let t = document.getElementById('bt'); if (!t) { t = document.createElement('div'); t.id = 'bt'; t.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--line);padding:12px 20px;border-radius:20px;font-size:18px;z-index:99'; document.body.appendChild(t); } t.textContent = msg; t.hidden = false; clearTimeout(t._t); t._t = setTimeout(() => { t.hidden = true; }, 2500); }
// ---- Pick your chores: tap your name, enter your PIN once (a child with no PIN goes straight in), then do, take or put back as many chores as you like
const CHEER = ['Great job, NAME! 🎉', 'Nailed it, NAME! 💪', 'You are on a roll, NAME! 🔥', 'Awesome work, NAME! ⭐', 'Look at you go, NAME! 🚀', 'Super helper, NAME! 🙌', 'Way to go, NAME! 👏', 'That is how it is done, NAME! 🌟'];
/** A burst of confetti over the screen (skipped for anyone who asks for less motion). */
function confetti(big) {
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return; const box = document.createElement('div'); box.style.cssText = 'position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:200';
const bits = big ? ['🎉', '🏆', '⭐', '🎊', '✨', '🌟'] : ['🎉', '⭐', '✨', '🌟']; for (let i = 0; i < (big ? 40 : 18); i++) { const e = document.createElement('span'); e.textContent = bits[i % bits.length]; e.style.cssText = `position:absolute;top:-30px;left:${Math.random() * 100}%;font-size:${18 + Math.random() * 22}px;animation:cfall ${1.4 + Math.random() * 1.4}s ease-in ${Math.random() * .5}s forwards`; box.appendChild(e); }
document.body.appendChild(box); setTimeout(() => box.remove(), 3500);
}
/** What it takes to reach the goal, in plain words: which chores would do it today, or how far today's chores get them. */
function goalHint(tier, points, chores) {
const need = tier.points - points, ds = Math.floor((Date.now() - new Date(data.week.from + 'T12:00:00').getTime()) / 864e5), left = 6 - ds; /* days left after today */
const list = chores.filter(c => c.points > 0).sort((a, b) => b.points - a.points), total = list.reduce((n, c) => n + c.points, 0), tail = left <= 0 ? ' Last day this week!' : left === 1 ? ' One more day after today.' : ' ' + left + ' more days after today.';
if (!list.length) return need + ' more points to go. New chores tomorrow can get you there.' + tail;
if (total >= need) { const pick = []; let sum = 0; for (const c of list) { if (sum >= need) break; pick.push(c); sum += c.points; } const names = pick.slice(0, 3).map(c => c.title).join(', ') + (pick.length > 3 ? ' and ' + (pick.length - 3) + ' more' : ''); return '⭐ Do ' + names + ' (' + sum + ' pts) and you reach it today!'; }
return 'Do all ' + list.length + ' chore' + (list.length > 1 ? 's' : '') + ' today (+' + total + ' pts) and you will be only ' + (need - total) + ' away. Tomorrow gets you there!' + tail;
}
function choreHub(p, pin0) {
if (!p) return; let pin = pin0 || '', cheer = '';
const { box, close } = sheet(''), today = ymd(new Date()), head = `<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px"><span class="av" style="background:${p.color};width:44px;height:44px;border-radius:50%;color:#fff;display:grid;place-items:center;font-size:20px;font-weight:700;flex:none">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><h3 style="margin:0;text-align:left">${esc(p.name.split(' ')[0])}'s chores</h3></div>`;
const act = async (c, action) => { const r = await fetch('/api/board/chore', { method: 'POST', body: JSON.stringify({ uid: p.id, pin, chore: c.chore, action, date: today }) }), j = await r.json().catch(() => ({})); if (!r.ok) { if (p.pin && /pin/i.test(j.error || '')) { pin = ''; return askPin(j.error); } box.querySelector('#e').textContent = j.error || 'That did not work'; return; } const wasPts = ((data.week && data.week.people.find(x => x.id === p.id)) || { points: 0 }).points; await load(); cheer = ''; if (action === 'done') { const w = (data.week && data.week.people.find(x => x.id === p.id)) || { points: wasPts, goal: null }, tier = data.week && data.week.tiers.find(t => t.id === w.goal), hit = tier && wasPts < tier.points && w.points >= tier.points, rest = data.chores.filter(x => x.user === p.id && !x.done).length;
cheer = hit ? '🏆 You reached your goal, ' + p.name.split(' ')[0] + '! Tell a parent!' : (CHEER[Math.floor(Math.random() * CHEER.length)].replace('NAME', p.name.split(' ')[0])) + (c.points ? ' +' + c.points + ' pts' : '') + (!rest ? ' ⭐ All your chores are done!' : ''); confetti(hit); } else if (action === 'claim') cheer = 'Good pick! It is yours. 👍'; paint(); };
const paint = () => {
const mine = data.chores.filter(c => c.user === p.id), todo = mine.filter(c => !c.done), done = mine.filter(c => c.done), pool = data.chores.filter(c => c.claimable && !c.done), me = (data.week && data.week.people.find(x => x.id === p.id)) || { points: 0 };
const row = (c, btns, cls) => `<div class="chrow ${cls || ''}"><span class="g">${esc(c.title)}${c.points ? ` <small class="sub">${c.points} pts</small>` : ''}</span>${btns}</div>`, h = t => `<p class="sub" style="margin:12px 0 2px;text-align:left"><b>${t}</b></p>`;
const tier = (data.week ? data.week.tiers : []).find(t => t.id === me.goal), pct = tier ? Math.min(100, Math.round(me.points / tier.points * 100)) : 0, got = tier && me.points >= tier.points;
box.innerHTML = head + (cheer ? `<div class="cheer">${esc(cheer)}</div>` : '') + (tier ? `<div style="text-align:left;margin:2px 0 4px"><div style="display:flex;justify-content:space-between;gap:10px;font-size:15px"><b>🎯 ${esc(tier.title)}</b><span class="sub">${me.points} / ${tier.points} pts</span></div><div class="bar2" style="height:20px;margin:6px 0 4px;border-radius:10px"><i style="width:${pct}%;border-radius:10px${got ? ';background:#2f9e6e' : ''}"></i></div><small class="sub">${got ? 'You did it! 🎉 Tell a parent' : esc(goalHint(tier, me.points, todo.concat(pool)))}</small></div>` : `<p class="sub" style="margin:0;text-align:left">${me.points} points this week${(data.week ? data.week.tiers : []).some(t => ((data.week.people.find(x => x.id === p.id) || {}).tiers || []).includes(t.id)) ? ' · <a href="#" id="pgl" style="color:var(--accent)">Choose a reward to aim for</a>' : ''}</p>`) + `<div style="max-height:50dvh;overflow:auto">`
+ h('To do today') + (todo.map(c => row(c, `<button class="btn" data-a="done" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">Done ✓</button>${c.claimed ? `<button class="btn ghost" data-a="unclaim" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">Put back</button>` : ''}`)).join('') || '<p class="none" style="text-align:left">Nothing left. Nice! 🎉</p>')
+ (pool.length ? h('Up for grabs. Take one!') + pool.map(c => row(c, `<button class="btn ghost" data-a="claim" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">Take it</button><button class="btn" data-a="done" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">I did it</button>`)).join('') : '')
+ (done.length ? h('Finished') + done.map(c => row(c, `<button class="btn ghost" data-a="undo" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">Undo</button>`, 'dn')).join('') : '')
+ `</div><div class="err" id="e"></div><div style="display:flex;gap:10px;margin-top:10px"><button class="btn ghost" id="sgn" style="flex:1;justify-content:center">Open my account</button><button class="btn" id="no" style="flex:1;justify-content:center">Close</button></div>`;
const pg = box.querySelector('#pgl'); if (pg) pg.onclick = e => { e.preventDefault(); close(); goalFlow(p.id, pn => choreHub(p, pn)); };
box.querySelector('#no').onclick = close; box.querySelector('#sgn').onclick = () => { close(); pick(p); };
box.querySelectorAll('[data-a]').forEach(b => b.onclick = () => { b.disabled = true; const c = data.chores.find(x => x.chore === b.dataset.c && (x.user || '') === b.dataset.u); if (c) act(c, b.dataset.a); else { b.disabled = false; load().then(paint); } });
};
const askPin = msg => {
box.innerHTML = head + `<p class="sub">${esc(p.name)}, enter your PIN</p><div class="dots" id="dots"></div><div class="err" id="e">${esc(msg || '')}</div><div class="pad">${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => `<button data-k="${k}">${k}</button>`).join('')}</div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Cancel</button></p>`;
const dots = () => { box.querySelector('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => `<i class="${i < pin.length ? 'on' : ''}"></i>`).join(''); }; dots(); box.querySelector('#no').onclick = close;
box.querySelectorAll('.pad button').forEach(b => b.onclick = () => { let k = b.dataset.k; box.querySelector('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { if (pin) return paint(); } dots(); });
};
if (p.kid && !p.pin || pin0) return paint(); if (!p.pin) { box.innerHTML = head + `<p>${esc(p.name)} has not set a PIN yet. Set one in Settings on your own device.</p><button class="btn ghost" id="no" style="width:100%;justify-content:center">OK</button>`; box.querySelector('#no').onclick = close; return; } askPin();
}
+3 -1
View File
@@ -1,5 +1,7 @@
'use strict';
// ---- Boot
// Stop the accidental pinch zoom on iPads (Safari ignores user-scalable=no). Double-tap zoom is stopped in the CSS (touch-action: manipulation).
for (const ev of ['gesturestart', 'gesturechange', 'gestureend']) document.addEventListener(ev, e => e.preventDefault(), { passive: false });
(async () => {
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
const loadedBuild = cfg.build;
@@ -19,7 +21,7 @@
await loadMe();
await loadSettings();
if (typeof themeStart === 'function') themeStart();
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof askWatchStart === 'function') askWatchStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
addEventListener('hashchange', route);
restorePlayer();
route();
+1 -1
View File
@@ -57,7 +57,7 @@ async function viewCalendar() {
const views = `<div class="sgrp" role="tablist" aria-label="Calendar view">${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" role="tab" data-v="${k}" aria-selected="${k === mode}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>`
+ (mode === 'agenda' ? `<select id="crange" class="psel" aria-label="How far ahead">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<option value="${n}" ${n === store.get('cal_range', 7) ? 'selected' : ''}>${l}</option>`).join('')}</select>` : '');
v.innerHTML = `<div class="wrap">${plannerHead('calendar', `<button class="btn" id="ne">${icon('cal')}Add event</button>`, views)}
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
<div id="cbody"><p class="empty loading" role="status">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
$('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
if ($('#crange')) $('#crange').onchange = e => { store.set('cal_range', +e.target.value); viewCalendar(); };
v.querySelectorAll('.sgrp a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
+52
View File
@@ -0,0 +1,52 @@
'use strict';
// ---- Family Chat extras: a GIF picker (kept family-friendly by the hub; phones never contact the GIF service) and sharing a place (your location, or a place you search for).
// The Chat list also shows Signal conversations, labelled, when Signal is available on this screen.
async function chGifOn() { if (ch.gifOn === undefined) { try { const st = await chApi('/gif/status'); ch.gifOn = !!st.enabled; ch.gifBy = st.provider; } catch { ch.gifOn = false; } } return ch.gifOn; }
function chGifPicker() {
const to = ch.cur, by = ch.gifBy === 'giphy' ? 'GIPHY' : ch.gifBy === 'wikimedia' ? 'Wikimedia Commons (free pictures; mostly animals, nature, space and science)' : 'Tenor';
const el = openSheet('GIF', 'Family-friendly only', `<form id="gpf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="search" id="gpq" placeholder="Search GIFs" autocomplete="off" maxlength="60" style="flex:1;min-width:0"><button class="btn ghost">Search</button></form>${ch.gifBy === 'wikimedia' ? `<div class="seg" id="gpc" style="flex-wrap:wrap">${['animals', 'space', 'weather', 'ocean', 'science', 'dance', 'cartoon', 'sports'].map(w => `<a href="#" data-w="${w}">${w}</a>`).join('')}</div>` : ''}<div class="gifgrid" id="gpg" role="list"></div><p class="err" id="gpe"></p><div class="bar" style="justify-content:center"><button class="btn ghost" id="gpm" hidden>More</button></div><p class="sub" style="text-align:center;margin:4px 0 0;font-size:12px">Powered by ${by}</p>`);
el.querySelector('.sheet').classList.add('wide'); let next = '', q = '', busy = false;
const load = async more => {
if (busy) return; busy = true; el.querySelector('#gpe').textContent = '';
try { const r = await chApi('/gif?q=' + encodeURIComponent(q) + (more && next ? '&pos=' + encodeURIComponent(next) : '')); next = r.next || ''; const g = el.querySelector('#gpg'); if (!more) g.innerHTML = '';
g.insertAdjacentHTML('beforeend', r.items.map(x => `<button type="button" class="gifc" role="listitem" data-u="${esc(x.url)}" data-t="${esc(x.title)}" aria-label="Send GIF ${esc(x.title || '')}"><img alt="${esc(x.title || 'GIF')}" loading="lazy" ${x.w && x.h ? `width="${x.w}" height="${x.h}"` : ''} src="/api/chat/gifimg?u=${encodeURIComponent(x.preview)}"></button>`).join('')); if (!more && !r.items.length) el.querySelector('#gpe').textContent = 'Nothing found. Try another word.';
el.querySelector('#gpm').hidden = !next; } catch (e) { el.querySelector('#gpe').textContent = e.message; } busy = false;
};
el.querySelector('#gpf').onsubmit = e => { e.preventDefault(); q = el.querySelector('#gpq').value.trim(); next = ''; load(false); };
el.querySelector('#gpm').onclick = () => load(true);
el.querySelectorAll('#gpc [data-w]').forEach(a => a.onclick = e => { e.preventDefault(); el.querySelector('#gpq').value = a.dataset.w; el.querySelector('#gpf').requestSubmit(); });
el.querySelector('#gpg').onclick = async e => { const b = e.target.closest('.gifc'); if (!b || b.disabled) return; b.disabled = true; b.style.opacity = '.5';
try { await chPost('/gif/send', { conv: to, url: b.dataset.u, title: b.dataset.t }); closeSheet(); } catch (er) { b.disabled = false; b.style.opacity = ''; el.querySelector('#gpe').textContent = er.message; } };
load(false);
}
function chPlacePicker() {
const to = ch.cur, el = openSheet('Share a place', '', `<div class="bar" style="justify-content:flex-start"><button class="btn" id="plme">📍 Send my location</button></div><p class="sub" id="plmm" style="margin:0 0 8px">Your phone asks first. This sends where you are right now, once; it does not keep following you.</p>
<form id="plf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="search" id="plq" placeholder="Or search for a place" autocomplete="off" maxlength="100" style="flex:1;min-width:0"><button class="btn ghost">Search</button></form><div class="list" id="plr"></div><p class="err" id="ple"></p>`);
const send = async (title, sub, lat, lng) => { try { await chPost('/send', { conv: to, share: { kind: 'place', title, sub, lat, lng } }); closeSheet(); } catch (e) { el.querySelector('#ple').textContent = e.message; } };
el.querySelector('#plme').onclick = () => {
if (!navigator.geolocation) { el.querySelector('#ple').textContent = 'This browser cannot share a location here. Search for the place instead.'; return; }
el.querySelector('#plmm').textContent = 'Finding you…';
navigator.geolocation.getCurrentPosition(p => send('My location', `${p.coords.latitude.toFixed(4)}, ${p.coords.longitude.toFixed(4)}`, p.coords.latitude, p.coords.longitude), e => { el.querySelector('#plmm').textContent = e.code === 1 ? 'Location is blocked. Allow it for this site in your browser settings, or search for the place instead.' : 'Could not find your location. Search for the place instead.'; }, { enableHighAccuracy: true, timeout: 12000, maximumAge: 30000 });
};
el.querySelector('#plf').onsubmit = async e => { e.preventDefault(); const q = el.querySelector('#plq').value.trim(), box = el.querySelector('#plr'); el.querySelector('#ple').textContent = ''; if (q.length < 3) { el.querySelector('#ple').textContent = 'Type at least three letters.'; return; }
box.innerHTML = '<div class="row"><span class="grow"><small>Searching…</small></span></div>';
try { const r = await chApi('/places?q=' + encodeURIComponent(q)); box.innerHTML = r.items.map((x, i) => `<a href="#" class="row" data-i="${i}"><span class="grow" style="white-space:normal"><b>${esc(x.title)}</b><small>${esc(x.sub)}</small></span></a>`).join('') || '<div class="row"><span class="grow"><small>No places found.</small></span></div>';
box.querySelectorAll('[data-i]').forEach(a => a.onclick = ev => { ev.preventDefault(); const x = r.items[+a.dataset.i]; send(x.title, x.sub, x.lat, x.lng); }); } catch (er) { box.innerHTML = ''; el.querySelector('#ple').textContent = er.message; } };
}
// ---- one list for Chat and Signal
async function chLoadSignal() {
ch.sig = []; if (typeof allowed !== 'function' || !allowed('signal') || typeof sgApi !== 'function') return;
try { const r = await sgApi('/convs'); ch.sig = (r.convs || []).map(c => ({ ...c, source: 'signal', name: c.name || c.id })); } catch { ch.sig = []; }
}
const chSigRow = c => `<a class="row sgrow ${c.id === ch.curSig ? 'on' : ''}" data-sig="1" href="${sgHref(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)} <small class="srcbadge">Signal</small></b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</span></a>`;
/** Signal changed (a message came in): redraw the Chat list if it is showing. */
function chSigChanged() { if (typeof ch !== 'undefined' && ch.root && document.contains(ch.root) && location.hash.startsWith('#/chat')) chLoadSignal().then(() => { if (chq('#chlist')) chDrawList(); }); }
/** A Signal conversation shown in Chat's own conversation pane (so choosing one in the combined list never leaves Chat). */
async function chShowSignal() {
const box = chq('#chm'); if (!box) return;
if (!box.querySelector('#sgm')) { box.innerHTML = '<div id="sgm" class="sgin"></div>'; sg.reply = null; sg.tray = []; }
sg.root = ch.root; sg.dock = false; sg.inChat = true; sg.cur = ch.curSig;
await sgDrawThread(true);
}
+15 -11
View File
@@ -34,19 +34,19 @@ function chEvent(ev) {
clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150);
}
function viewChat(args = []) { ch.cur = args[0] ? decodeURIComponent(args[0]) : null; return chMount($('#view')); }
function viewChat(args = []) { const id = args[0] ? decodeURIComponent(args[0]) : null; ch.curSig = id && id.startsWith('sg:') ? id.slice(3) : null; ch.cur = ch.curSig ? null : id; if (!ch.curSig && typeof sg === 'object') sg.inChat = false; 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>
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty loading" role="status">Loading…</p></div>';
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur || ch.curSig ? '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);
const [r] = await Promise.all([chApi('/convs'), typeof chLoadSignal === 'function' ? chLoadSignal() : null]); 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>';
if (ch.curSig && typeof chShowSignal === 'function') await chShowSignal(); else 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() {
@@ -61,7 +61,8 @@ 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('') : ''}` : '')
const sig = (ch.sig || []).filter(match), rows = [...live.map(c => ({ ts: c.ts || 0, html: chRow(c) })), ...sig.map(c => ({ ts: c.ts || 0, html: chSigRow(c) }))].sort((a, b) => b.ts - a.ts);
box.innerHTML = rows.map(r => r.html).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 || (f.msg.share ? f.msg.share.title : ''))}</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>`;
@@ -79,13 +80,14 @@ function chPoll(m) {
<small>${total} ${total === 1 ? 'person has' : 'people have'} voted</small></div>`;
}
// ---- share cards: a song, film, book... sent from its own screen. Tapping opens it, unless that part of the hub is not turned on for this person.
const CH_SHARE = { song: ['🎵', 'Song'], album: ['💿', 'Album'], artist: ['🎤', 'Artist'], playlist: ['📃', 'Playlist'], movie: ['🎬', 'Movie'], show: ['📺', 'TV show'], episode: ['📺', 'Episode'], book: ['📖', 'Book'], audiobook: ['🎧', 'Audiobook'], podcast: ['🎙️', 'Podcast'], podcastep: ['🎙️', 'Podcast episode'], radio: ['📻', 'Radio'] };
const CH_SHARE = { song: ['🎵', 'Song'], album: ['💿', 'Album'], artist: ['🎤', 'Artist'], playlist: ['📃', 'Playlist'], movie: ['🎬', 'Movie'], show: ['📺', 'TV show'], episode: ['📺', 'Episode'], book: ['📖', 'Book'], audiobook: ['🎧', 'Audiobook'], podcast: ['🎙️', 'Podcast'], podcastep: ['🎙️', 'Podcast episode'], radio: ['📻', 'Radio'], place: ['📍', 'Place'] };
/** The tab a deep link lives in decides whether this person can open it (a child account only gets the tabs a parent allowed). */
const chShareOk = link => { const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); };
const chShareOk = link => { if (/^https:\/\/maps\.apple\.com\/\?ll=/.test(String(link || ''))) return true; const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); };
function chShareCard(m) {
const s = m.share, k = CH_SHARE[s.kind] || ['🔗', 'Shared'], ok = chShareOk(s.link), art = /^\/(plex|api)\//.test(s.art || '') ? s.art : '';
const body = `${art ? `<img class="sgshare-art" alt="" loading="lazy" src="${esc(art)}" onerror="this.replaceWith(Object.assign(document.createElement('i'),{className:'sgshare-art',textContent:'${k[0]}'}))">` : `<i class="sgshare-art">${k[0]}</i>`}
<span class="sgshare-t"><small>${k[0]} ${esc(k[1])}</small><b>${esc(s.title)}</b>${s.sub ? `<em>${esc(s.sub)}</em>` : ''}${ok ? '' : '<small class="sgshare-off">Not available for you</small>'}</span>`;
if (s.kind === 'place') return `<a class="sgshare" href="${esc(s.link)}" target="_blank" rel="noopener noreferrer">${body}<span class="sgshare-go">›</span></a>`;
return ok ? `<a class="sgshare" href="${esc(s.link)}">${body}<span class="sgshare-go">›</span></a>` : `<div class="sgshare off" role="note">${body}</div>`;
}
function chBubble(m, c, prev) {
@@ -162,7 +164,7 @@ function chWireComposer() {
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 }]);
chGifOn(); chq('#chat').onclick = () => actionSheet('Attach', '', [{ label: 'Photo or file', icon: 'clip', run: () => chq('#chfile').click() }, ...(ch.gifOn ? [{ label: 'GIF', icon: 'play', run: chGifPicker }] : []), { label: 'Place or location', icon: 'pin', run: chPlacePicker }, { 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(); };
@@ -187,9 +189,10 @@ async function chPeople() { if (!ch.people) ch.people = await chApi('/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>' : ''}`);
<div class="bar" style="flex-wrap:wrap">${people.length > 1 ? '<button class="btn ghost" id="chg" style="flex:1;justify-content:center">New group…</button>' : ''}${typeof sgNewChat === 'function' && typeof sgInChat === 'function' && sgInChat() ? '<button class="btn ghost" id="chsgn" style="flex:1;justify-content:center">New Signal chat…</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); };
const sn = el.querySelector('#chsgn'); if (sn) sn.onclick = () => { closeSheet(); sg.inChat = true; sg.root = ch.root; sgNewChat(); };
}
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>
@@ -349,10 +352,11 @@ 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>
${typeof allowed === 'function' && allowed('signal') ? `<a class="row" href="#/signal" id="chsig" style="background:var(--panel2);border-radius:12px;margin:10px 0"><span class="grow"><b>Signal</b><small style="white-space:normal">Link or unlink your Signal account, and start a new Signal conversation. Your Signal chats are in this list.</small></span><span style="color:var(--accent)">Open ›</span></a>` : ''}
<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('#chsx').onclick = closeSheet; const sgl = el.querySelector('#chsig'); if (sgl) sgl.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). */
+1 -1
View File
@@ -36,7 +36,7 @@ function crPickGoal(uid) {
const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit());
async function viewChores() {
const v = $('#view'); cr.day = cr.day || crYmd(new Date());
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty loading" role="status">Loading…</p></div>';
try { cr.wk = cr.wk || crYmd(new Date()); [cr.data, cr.week] = await Promise.all([crApi('/?date=' + cr.day), crApi('/week?date=' + cr.wk)]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Chores</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/chores')) return;
const d = cr.data, me = d.me.id, mine = d.board.filter(i => i.user === me), anyone = d.board.filter(i => i.user === null), others = d.people.filter(p => p.id !== me);
+1 -1
View File
@@ -13,7 +13,7 @@ const ctFind = (l, address) => { const a = String(address || '').toLowerCase();
async function viewContacts(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Contacts</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Contacts</h1><p class="empty loading" role="status">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>
+1 -1
View File
@@ -1,7 +1,7 @@
'use strict';
// ---- Family: one start page for the household: the announcement, today's events, chores, the shopping list, shared notes, chats and tasks. Each card loads on its own and
// only shows if the person has that part of the hub, so one slow or missing piece never blanks the page.
const fmCard = (id, title, href, sub) => `<section class="fmcard" id="${id}"><div class="fmh"><h2>${esc(title)}</h2>${href ? `<a href="${href}" class="fmmore">${esc(sub || 'Open')}</a>` : ''}</div><div class="fmb"><p class="empty" style="margin:6px 0">Loading…</p></div></section>`;
const fmCard = (id, title, href, sub) => `<section class="fmcard" id="${id}"><div class="fmh"><h2>${esc(title)}</h2>${href ? `<a href="${href}" class="fmmore">${esc(sub || 'Open')}</a>` : ''}</div><div class="fmb"><p class="empty loading" style="margin:6px 0" role="status">Loading…</p></div></section>`;
const fmBody = id => document.querySelector('#' + id + ' .fmb');
const fmErr = (id, e) => { const b = fmBody(id); if (b) b.innerHTML = `<p class="sub" style="margin:6px 0">${esc(e && e.message || 'Could not load this')}</p>`; };
+1 -1
View File
@@ -2,7 +2,7 @@
// Uses sf, fmtSize, openSheet, confirmSheet, toast, esc and FORM from app.js.
async function fileHistory(repo, path, name, readOnly) {
const base = `/api2/repos/${encodeURIComponent(repo)}/file`;
const el = openSheet('Version history', name, '<p class="empty">Loading…</p>');
const el = openSheet('Version history', name, '<p class="empty loading" role="status">Loading…</p>');
el.querySelector('.sheet').classList.add('wide');
try {
let commits = [], next = '';
+3 -3
View File
@@ -83,7 +83,7 @@ async function offerUndo(repo, dir, items, afterRestore) {
} catch { return false; /* no undo available */ }
}
async function recycleBin(repo, dir) {
const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '<p class="empty">Loading…</p>');
const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '<p class="empty loading" role="status">Loading…</p>');
el.querySelector('.sheet').classList.add('wide');
try {
const list = (await trashList(repo, dir || '/', 100)).sort((a, b) => trashWhen(b.deleted_time) - trashWhen(a.deleted_time));
@@ -143,7 +143,7 @@ async function zipDownload(repo, dir, names) {
// ---- Share links with a password and an expiry, and the ones that already exist
async function shareSheet(repo, path, isDir) {
const p = isDir ? path.replace(/\/?$/, '/') : path, enc = encodeURIComponent, FORMH = { 'Content-Type': 'application/x-www-form-urlencoded' };
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '<p class="empty">Loading…</p>');
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '<p class="empty loading" role="status">Loading…</p>');
const draw = async () => {
let links = []; try { links = (await sf.req(`/api/v2.1/share-links/?repo_id=${enc(repo)}&path=${enc(p)}`)) || []; } catch (e) { if (!document.body.contains(el)) return; }
if (!document.body.contains(el)) return;
@@ -177,7 +177,7 @@ async function shareSheet(repo, path, isDir) {
const TEXT_EXT = new Set('txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore'.split(' '));
async function textPreview(repo, path, name) {
const dir = path.replace(/\/[^/]*$/, '') || '/', ro = libPerm[repo] === 'r', url = (await fileUrl(repo, path)) + '?hubinline=1';
const el = openSheet(name, '', '<p class="empty">Loading…</p>'); el.querySelector('.sheet').classList.add('wide');
const el = openSheet(name, '', '<p class="empty loading" role="status">Loading…</p>'); el.querySelector('.sheet').classList.add('wide');
const dl = `<a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Download</a>`;
try {
const r = await fetch(url); if (!r.ok) throw new Error('Could not open the file (' + r.status + ')');
+17 -6
View File
@@ -6,13 +6,23 @@ async function viewEdit([repo, ...rawParts], opts = {}) {
const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
const v = $('#view');
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b></div>
// On a phone the editor normally uses OnlyOffice's phone layout, which on some OnlyOffice editions only lets you read. "Full editor" asks for the desktop layout instead (scaled to fit the screen),
// and "Edit elsewhere" downloads a copy to edit in another app and uploads it back over this file.
const phone = innerWidth < 768, full = phone && store.get('oo_layout', 'auto') === 'full';
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b>${phone ? `<button class="btn ghost" id="edfull" title="${full ? 'Back to the phone layout' : 'Use the full desktop editor (small, but it can edit)'}">${full ? 'Phone view' : 'Full editor'}</button><button class="ibtn" id="edmore" aria-label="Edit elsewhere" title="Edit in another app">${icon('more')}</button>` : ''}</div>
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
if (phone) {
$('#edfull').onclick = () => { store.set('oo_layout', full ? 'auto' : 'full'); viewEdit([repo, ...rawParts], opts); };
$('#edmore').onclick = () => actionSheet(name, 'Edit in another app', [
{ label: 'Download a copy', icon: 'dl', run: () => downloadItem(repo, path) },
{ label: 'Replace with an edited copy…', icon: 'up', run: () => { const f = document.createElement('input'); f.type = 'file'; f.accept = '.' + name.split('.').pop(); f.onchange = async () => { const file = f.files[0]; if (!file) return; try { const dir = path.slice(0, path.lastIndexOf('/')) || '/', link = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/update-link/?p=${encodeURIComponent(dir)}`), fd = new FormData(); fd.append('target_file', path); fd.append('parent_dir', dir); fd.append('file', file, name); const up = await fetch('/sf' + new URL(link, cfg.seafile).pathname, { method: 'POST', body: fd }); if (!up.ok) throw new Error('Could not replace it (' + up.status + ')'); toast('Replaced with your edited copy'); viewEdit([repo, ...rawParts], opts); } catch (e) { toast(e.message); } }; f.click(); } },
]);
}
if (!sf.token()) return (location.hash = '#/files');
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
try {
const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) });
body: JSON.stringify({ repo, path, mobile: phone && !full }) });
const conf = await r.json();
if (!r.ok) return fail(conf.error || 'Could not open document');
if (!window.DocsAPI) await new Promise((ok, no) => {
@@ -22,6 +32,7 @@ async function viewEdit([repo, ...rawParts], opts = {}) {
if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
$('#oomsg').remove();
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
if (full) { const wrap = $('.edwrap'), W = 1000, k = Math.min(1, wrap.clientWidth / W), box = $('#oo'); wrap.style.overflow = 'hidden'; box.style.cssText = `width:${W}px;height:${Math.round(wrap.clientHeight / k)}px;transform:scale(${k});transform-origin:0 0`; } // (the desktop editor is laid out wide, then shrunk to fit the phone)
editor = new DocsAPI.DocEditor('oo', conf);
} catch (e) { fail(e.message); }
}
@@ -31,7 +42,7 @@ function viewFiles([repo, ...parts]) {
if (!sf.token()) return filesLogin();
const dir = '/' + parts.join('/');
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><div class="files ${repo ? 'open' : ''}" id="files">
<div class="libs" id="libs"><p class="empty">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
<div class="libs" id="libs"><p class="empty loading" role="status">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
libsP = loadLibs(repo);
if (repo) loadDir(repo, dir);
}
@@ -218,7 +229,7 @@ async function prefetchDl(repo, path) {
async function loadDir(repo, dir) {
const main = $('#fmain'); if (!main) return;
main.innerHTML = '<p class="empty">Loading…</p>';
main.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
await libsP.catch(() => {});
if (!document.contains(main)) return;
const enc = encodeURIComponent, ro = libPerm[repo] === 'r';
@@ -232,7 +243,7 @@ async function loadDir(repo, dir) {
const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); };
main.innerHTML = `<div id="ftop"></div><div class="crumbs">${crumbs}</div>
<input type="search" id="ff" placeholder="Filter this folder" autocomplete="off" hidden><div id="items"><p class="empty">Loading…</p></div><p class="sub" id="fsum" style="text-align:center"></p>`;
<input type="search" id="ff" placeholder="Filter this folder" autocomplete="off" hidden><div id="items"><p class="empty loading" role="status">Loading…</p></div><p class="sub" id="fsum" style="text-align:center"></p>`;
const go = p => { location.hash = hash(p); };
const drawTop = () => {
@@ -385,7 +396,7 @@ async function loadDir(repo, dir) {
const restore = () => { if (v.scrolled) return; v.scrolled = true; const y = fScroll.get(location.hash); if (y) requestAnimationFrame(() => { const view = $('#view'); if (view) view.scrollTop = y; }); };
if (!quiet) {
if (!v.items.length && saved) { v.items = saved.items; v.stale = 'old'; v.staleWhy = 'Updating…'; redraw(); restore(); } // the saved list at once, the real one right behind it
else $('#items').innerHTML = '<p class="empty">Loading…</p>';
else $('#items').innerHTML = '<p class="empty loading" role="status">Loading…</p>';
}
try {
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
+29
View File
@@ -0,0 +1,29 @@
'use strict';
// Photos in Signal and Chat open in a full-screen viewer inside the app instead of a new tab.
// Close with the X, a tap outside the photo, Escape, or the phone's Back gesture; the chat is right where it was.
(function () {
let box = null, pushed = false;
function close(fromPop) {
if (!box) return; box.remove(); box = null; document.removeEventListener('keydown', onKey, true);
if (pushed && !fromPop) { pushed = false; history.back(); } else pushed = false;
}
const onKey = e => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); } };
function open(src, name) {
close();
box = document.createElement('div'); box.id = 'imgview'; box.setAttribute('role', 'dialog'); box.setAttribute('aria-modal', 'true'); box.setAttribute('aria-label', name || 'Photo');
box.innerHTML = '<button type="button" class="ibtn ivx" aria-label="Close photo"></button><a class="ivsave" download aria-label="Save photo">Save</a><img alt="">';
const x = box.querySelector('.ivx'); x.innerHTML = typeof icon === 'function' ? icon('x') : '✕';
const im = box.querySelector('img'); im.alt = name || 'Photo'; im.src = src;
const sv = box.querySelector('.ivsave'); sv.href = src; if (name) sv.setAttribute('download', name);
x.onclick = () => close();
box.onclick = e => { if (e.target === box) close(); };
document.body.appendChild(box); document.addEventListener('keydown', onKey, true); x.focus({ preventScroll: true });
try { history.pushState({ imgview: 1 }, ''); pushed = true; } catch { /* no history: the X still closes it */ }
}
addEventListener('popstate', () => { if (box) { pushed = false; close(true); } });
document.addEventListener('click', e => {
const im = e.target.closest && e.target.closest('img.sgimg'); if (!im) return;
const a = im.closest('a[href]'); if (!a) return;
e.preventDefault(); open(a.getAttribute('href'), im.alt);
});
})();
+1 -1
View File
@@ -14,7 +14,7 @@ setInterval(() => { if (!document.hidden && typeof ME !== 'undefined' && ME && d
const inboxAgo = t => { const m = Math.round((Date.now() - t) / 6e4); return m < 1 ? 'now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); };
const INBOX_ICON = { chat: ['chat', '#2aa6a0'], remind: ['bell', '#e0742b'], announce: ['bell', '#5b8def'] };
async function inboxOpen() {
const el = openSheet('Notifications', '', '<div id="inboxl"><p class="empty">Loading…</p></div><div class="bar" id="inboxbar" hidden><button class="btn ghost" id="inboxall" style="flex:1;justify-content:center">Mark all read</button><button class="btn ghost" id="inboxclr" style="flex:1;justify-content:center">Clear all</button></div>');
const el = openSheet('Notifications', '', '<div id="inboxl"><p class="empty loading" role="status">Loading…</p></div><div class="bar" id="inboxbar" hidden><button class="btn ghost" id="inboxall" style="flex:1;justify-content:center">Mark all read</button><button class="btn ghost" id="inboxclr" style="flex:1;justify-content:center">Clear all</button></div>');
let items = [];
const draw = () => {
const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length;
+4
View File
@@ -81,7 +81,9 @@
<script defer src="/radio-view.js"></script>
<script defer src="/store-view.js"></script>
<script defer src="/recipes-view.js"></script>
<script defer src="/pantry-view.js"></script>
<script defer src="/contacts-view.js"></script>
<script defer src="/vault-view.js"></script>
<script defer src="/bible-view.js"></script>
<script defer src="/devotion-view.js"></script>
<script defer src="/save-to-files.js"></script>
@@ -95,6 +97,8 @@
<script defer src="/file-actions.js"></script>
<script defer src="/signal-view.js"></script>
<script defer src="/chat-view.js"></script>
<script defer src="/chat-extras.js"></script>
<script defer src="/image-viewer.js"></script>
<script defer src="/notes-view.js"></script>
<script defer src="/chores-view.js"></script>
<script defer src="/family-view.js"></script>
+2 -2
View File
@@ -47,7 +47,7 @@ function lvPlay(b, at) {
}
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>';
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" role="status">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 (!here()) return;
const S = { q: '', genre: '', page: 1, items: [], more: false, busy: false, seq: 0 }; let genreList = []; lvApi('/genres').then(r => { genreList = r.genres || []; gchips(); }).catch(() => {});
@@ -78,7 +78,7 @@ async function viewFreeBooks(host) { // host: { el, hash } when shown inside the
async function lvSheet(id, after, known = new Map()) {
const sum = known.get(id) || lvShelf.find(x => x.id === id) || { id, title: 'Audiobook', author: '' };
const el = openSheet(sum.title, sum.author || '', '<p class="empty">Loading…</p>');
const el = openSheet(sum.title, sum.author || '', '<p class="empty loading" role="status">Loading…</p>');
let b; try { b = await lvApi('/book/' + id); } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
if (!document.body.contains(el)) return;
const p = lvLeft(id), total = lvTotal(b), onShelf = () => lvShelf.some(x => x.id === id);
+26 -11
View File
@@ -21,7 +21,7 @@ function lyrParseLrc(text) {
}
const lyrSpokenKey = t => !!t && /^(pod|ab):/.test(String(t.key)); // a podcast episode or an audiobook: words from a transcript instead of lyrics
const lyrLabel = () => lyrSpokenKey(typeof cur === 'function' ? cur() : null) ? 'Transcript' : 'Lyrics';
const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
const lyr = { fs: false, open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
// Radio: lyrics for the song that is on now. A station's own clock says when the song began (The Journey FM does; most do not), and what you hear is a few seconds
@@ -77,41 +77,49 @@ async function lyrLoad(t, force) {
return lyrForRadio(t, res);
}
function lyrOpen() {
const t = cur(); if (!t || lyr.open) return;
lyr.open = true; document.body.classList.add('lyropen');
// fs: the full-screen Now Playing view (art on the left, the words on the right, the player along the bottom)
function lyrOpen(fs) {
const t = cur(); if (!t) return;
if (lyr.open) { if (!!fs !== lyr.fs) { lyr.fs = !!fs; lyrShell(); lyrReload(); } return; }
lyr.fs = fs === true; lyr.open = true; document.body.classList.add('lyropen');
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
}
function lyrClose() {
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.idx = -2; document.body.classList.remove('lyropen');
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.fs = false; if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); lyr.idx = -2; document.body.classList.remove('lyropen');
}
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
const lyrCls = () => (lyrPref().karaoke && !lyrSpokenKey(cur()) ? 'kar' : '') + (lyr.fs ? ' fs' : '');
function lyrShell() {
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
lyr.el.className = pref.karaoke && !lyrSpokenKey(t) ? 'kar' : '';
lyr.el.className = lyrCls();
lyr.el.style.setProperty('--lf', pref.size + 'px');
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
<div class="lyrtop"><img class="lyrart" alt="" src="${px.art(t.thumb, 120)}"><div class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small><small id="lyrsrc"></small></div>
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
<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="fsside"><img class="fsimg" alt="" src="${px.art(t.thumb, 800)}"><b>${esc(t.title)}</b><small>${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
<div class="fsbar"><span id="fsc">0:00</span><input type="range" id="fsk" min="0" step="1" value="0" aria-label="Seek"><span id="fsd">0:00</span></div>
<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>`;
<button class="ibtn fsonly" id="fss" aria-label="Shuffle">${icon('shuffle')}</button><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><button class="ibtn fsonly" id="fsr" aria-label="Repeat">${icon('repeat')}</button></div>`;
const $l = q => lyr.el.querySelector(q);
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)
const fk = $l('#fsk'); fk.oninput = () => { fk.dataset.drag = 1; $l('#fsc').textContent = fmtDur(fk.value * 1000); }; fk.onchange = () => { pl.a.currentTime = +fk.value; delete fk.dataset.drag; };
$l('#fss').onclick = () => setShuffle(!pl.shuffle); $l('#fsr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
if (lyr.fs && !document.fullscreenElement && lyr.el.requestFullscreen) lyr.el.requestFullscreen().catch(() => {});
$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; };
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrCls(); $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('#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();
modeUI(); syncUI();
}
async function lyrReload(force) {
const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2;
@@ -129,14 +137,21 @@ function lyrDraw() {
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.why && !d.error ? `<p class="empty"><small>${esc(d.why)}</small></p>` : ''}${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`;
const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return;
}
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return;
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; lyrQueue(body); return;
}
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; lyrQueue(body); return; }
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced') + (lyrIsRadio(t) && d.sync !== 'none' ? ' · live timing is approximate, use - and +' : '');
body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').join('');
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none' || lyrIsRadio(t)) return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
body.scrollTop = 0; lyr.idx = -2;
}
/** With no words to show, the space shows what is coming up instead (tap a song to jump to it). */
function lyrQueue(body) {
if (lyrIsRadio(cur()) || pl.i < 0 || pl.q.length < 2) return;
const rows = pl.q.map((t, i) => ({ t, i })).slice(pl.i, pl.i + 40);
body.insertAdjacentHTML('beforeend', `<div class="lyrq"><h4>Up next</h4>${rows.map(({ t, i }) => `<button class="lyrqrow${i === pl.i ? ' now' : ''}" data-j="${i}"><span class="num">${i === pl.i ? '\u25B6' : i - pl.i}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>`).join('')}</div>`);
body.querySelectorAll('.lyrqrow').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); });
}
function lyrSearchAgain() {
const t = cur(); if (!t) return; const q = lyrQuery(t);
const el = openSheet('Search for the lyrics', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
+2 -2
View File
@@ -165,7 +165,7 @@ async function viewMail(args = []) {
st = saved; mail.st = saved;
jfetch('/api/mail/status?light=1').then(fresh => { if (!fresh.connected || fresh.accounts.map(a => a.id).join() !== saved.accounts.map(a => a.id).join()) { mail.st = null; mcClear(); if (location.hash.startsWith('#/mail')) route(); } else { mail.st = { ...fresh, accounts: fresh.accounts.map(a => ({ ...a, unread: (mail.st.accounts.find(x => x.id === a.id) || {}).unread ?? a.unread })) }; mcPut('st', mail.st); } }).catch(() => {});
} else {
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty loading" role="status">Loading…</p></div>';
try { st = await jfetch('/api/mail/status?light=1'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (st.connected) mcPut('st', { connected: true, accounts: st.accounts, outlook: st.outlook, providers: st.providers });
}
@@ -241,7 +241,7 @@ async function mailLoadList(reset) {
mail.items = []; mail.next = 0; mail.more = false;
const saved = first && mcGet('l:' + folder);
if (saved) { mail.items = saved.items; mail.more = saved.more; mail.next = saved.next; mailDrawList(); } // last time's list, right away
else { const rows = $('#mrows'); if (rows) rows.innerHTML = '<p class="empty">Loading…</p>'; }
else { const rows = $('#mrows'); if (rows) rows.innerHTML = '<p class="empty loading" role="status">Loading…</p>'; }
}
const showing = reset && mail.items.length; // a saved list is on screen: the fresh one replaces it instead of being added to it
try {
+11 -4
View File
@@ -50,9 +50,9 @@ async function mmRandomTracks(sec, n, pls = []) {
}
// ---- 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 }) {
function mmPage(v, { title, sub, thumb, cover, 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>
v.innerHTML = `<div class="wrap">${hrefBack}<div class="mhead">${cover ? cover : 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>
@@ -229,13 +229,20 @@ async function musicStats({ v, st }, range = 'month') {
}
// ================================================================ 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>`;
/** Plex-style cover for a playlist: the art of its songs in a 2x2 grid (one picture if they share one), or a colored tile when no song has art. */
const mmMosaic = (thumbs, name, cls = '') => {
const u = [...new Set((thumbs || []).filter(Boolean))].slice(0, 4);
if (!u.length) return `<span class="mixart ${cls}" style="background:linear-gradient(135deg,hsl(${mmHue(name)} 55% 42%),hsl(${mmHue(name) + 40} 55% 26%))"></span>`;
const img = t => `<img loading="lazy" alt="" src="${px.art(t, 300)}">`;
return `<span class="mosaic ${cls}${u.length < 4 ? ' one' : ''}">${(u.length < 4 ? u.slice(0, 1) : u).map(img).join('')}</span>`;
};
const famCard = p => `<a class="acard" href="#/music/family/${p.id}">${mmMosaic(p.tracks.slice(-40).reverse().map(t => t.thumb), p.name)}<b>${esc(p.name)}</b><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: () => {} });
mmPage(v, { title: p.name, cover: mmMosaic(tracks.map(t => t.thumb), p.name, 'art'), 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); } });
+18 -5
View File
@@ -38,7 +38,7 @@ const mapTrack = (t, mc) => {
async function viewMusic(args) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
const st = await jfetch('/api/plex/status');
if (!location.hash.startsWith('#/music')) return;
@@ -115,13 +115,17 @@ 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 = ['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 tname = t => TAB_NAME[t] || t[0].toUpperCase() + t.slice(1), MAIN = ['featured', 'albums', 'artists', 'playlists'], MORE = tabs.filter(t => !MAIN.includes(t));
// wide screens: every tab in one row. phones: the four main tabs fill one row and the rest are behind "More"
const seg = `<div class="seg segwide">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${tname(t)}</a>`).join('')}</div>
<div class="seg segphone">${MAIN.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${tname(t)}</a>`).join('')}<a href="#" id="segmore" class="${MORE.includes(kind) ? 'on' : ''}">${MORE.includes(kind) ? tname(kind) : 'More'} ▾</a></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>`;
${thumb ? `<img class="art" loading="lazy" alt="" src="${px.art(thumb)}">` : '<span class="mixart"></span>'}<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}${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 sm = $('#segmore'); if (sm) sm.onclick = e => { e.preventDefault(); actionSheet('Music', '', MORE.map(t => ({ label: tname(t) + (t === kind ? ' ✓' : ''), icon: 'music', run: () => { location.hash = '#/music/' + t; } }))); };
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(); } }))); };
@@ -173,7 +177,8 @@ async function musicRoute([kind = 'featured', key, key2], st) {
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>
${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>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
<div class="bar mprime"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button></div>
<div class="bar msecond">${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pmv">Move to NE Hub</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-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>`;
@@ -182,10 +187,18 @@ async function musicRoute([kind = 'featured', key, key2], st) {
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) {
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
$('#pmv').onclick = () => tracks.length ? textSheet('Move to NE Hub', 'Name of the Family playlist', 'Move', async n => {
try {
const meta = tracks.map(mmMeta).filter(t => mmPlex(t.key)); if (!meta.length) return toast('No songs here can be moved');
const r = await mmApi('/family', mmOpt('POST', { name: n, tracks: meta.slice(0, 500) }));
for (let i = 500; i < meta.length; i += 500) await mmApi(`/family/${r.id}/add`, mmOpt('POST', { tracks: meta.slice(i, i + 500) }));
toast('Moved ' + meta.length + ' songs to NE Hub');
confirmSheet('Remove it from Plex too?', '“' + title + '” is now a Family playlist. Cancel keeps the Plex one.', 'Remove from Plex', async () => { try { await plx.del(key); } catch (e) { toast(e.message); } location.hash = '#/music/family/' + r.id; });
} catch (e) { toast(e.message); }
}, title) : toast('This playlist has no songs');
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
}
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
markPlaying();
+2 -2
View File
@@ -17,7 +17,7 @@ async function ntLoad() { const r = await ntApi('/'); nt.notes = r.notes; nt.peo
// ================================================================ the list of notes
async function ntHome() {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Notes</h1><p class="empty">Loading…</p></div>';
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Notes</h1><p class="empty loading" role="status">Loading…</p></div>';
try { await ntLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Notes</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/notes')) return;
const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']];
@@ -45,7 +45,7 @@ function ntNewSheet() {
// ================================================================ one note or list
async function ntEditor(id) {
const v = $('#view'); v.innerHTML = '<div class="wrap"><p class="empty">Loading…</p></div>';
const v = $('#view'); v.innerHTML = '<div class="wrap"><p class="empty loading" role="status">Loading…</p></div>';
let n; try { await ntLoad(); n = nt.notes.find(x => x.id === id); if (!n) throw new Error('That note is not available (it may have been deleted, or is not shared with you).'); } catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a></div><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/notes/')) return;
let saveT, busy = false, dirty = false;
+11 -1
View File
@@ -20,16 +20,26 @@ function ofOpen(repo, path, name) {
}
function ofIcon(name) { const k = OFFICE_KINDS[ofKind(name)]; return `<span class="ic ofic" style="background:${k.color}">${icon('doc')}<em>${esc(ext(name).slice(0, 4).toUpperCase())}</em></span>`; }
/** Ready-made starting points (files in public/templates): the person gets their own copy to fill in. */
const OFFICE_TEMPLATES = [
['letter.docx', 'Letter', 'A formal or friendly letter, laid out', 'docx'], ['resume.docx', 'Resume', 'Summary, experience, education and skills', 'docx'], ['meeting-notes.docx', 'Meeting notes', 'Agenda, notes and action items', 'docx'],
['family-newsletter.docx', 'Family newsletter', 'News, who did what and what is coming up', 'docx'], ['recipe-card.docx', 'Recipe card', 'Ingredients, steps and notes', 'docx'],
['monthly-budget.xlsx', 'Monthly budget', 'Planned against actual, with the math done', 'xlsx'], ['expense-tracker.xlsx', 'Expense tracker', 'One line per purchase, totals by category', 'xlsx'],
['chore-chart.xlsx', 'Chore chart', 'A week of chores with a count for each', 'xlsx'], ['meal-planner.xlsx', 'Weekly meal planner', 'Breakfast, lunch, dinner and a shopping list', 'xlsx'],
['simple-presentation.pptx', 'Simple presentation', 'Title, agenda, key points and a thank-you', 'pptx'],
];
function viewOffice(args = []) {
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
const signed = !!sf.token();
$('#view').innerHTML = `<div class="wrap" id="ofwrap"><div class="ptop"><h1>Office</h1><span class="pspace"></span>${signed ? `<button class="btn ghost" id="ofup">${icon('up')}Upload</button><input type="file" id="ofupf" multiple hidden accept=".docx,.doc,.odt,.rtf,.xlsx,.xls,.ods,.csv,.pptx,.ppt,.odp">` : ''}<a class="btn ghost" href="#/files">${icon('files')}All files</a></div>
<div class="ocreate">${Object.entries(OFFICE_KINDS).map(([k, o]) => `<button class="octile" data-k="${k}" style="--c:${o.color}"><span class="ic" style="background:${o.color}">${icon('plus')}</span><span><b>New ${o.one.toLowerCase()}</b><small>${o.exts[0].toUpperCase()}</small></span></button>`).join('')}</div>
<details class="otplbox" id="otpl"><summary><b>Start from a template</b> <small>${OFFICE_TEMPLATES.length} ready-made documents, spreadsheets and slides</small></summary><div class="otpl">${OFFICE_TEMPLATES.map((t, i) => `<button type="button" class="octile otc" data-t="${i}" style="--c:${OFFICE_KINDS[ofKind('x.' + t[3])].color}">${ofIcon('x.' + t[3])}<span><b>${esc(t[1])}</b><small>${esc(t[2])}</small></span></button>`).join('')}</div></details>
${cfg.editor ? '' : '<p class="sub" style="margin:8px 0 0">Editing inside the app is switched off on this server, so documents open in Seafile instead.</p>'}
<p class="sub" id="olib" style="margin:12px 0 0">${signed ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
<div id="oflt" class="ofilters" hidden><input type="search" id="oq" placeholder="Search documents" autocomplete="off" aria-label="Search documents" value="${esc(ofState.q)}"><div class="sgrp" id="okinds" role="tablist" aria-label="Kind"></div><select id="osort" class="psel" aria-label="Sort"><option value="mtime">Newest first</option><option value="name">Name</option><option value="size">Largest first</option></select></div>
<div id="odocs"></div>${signed ? '' : '<div class="bar"><a class="btn" href="#/files">Sign in</a></div>'}</div>`;
$('#view').querySelectorAll('.octile').forEach(t => t.onclick = () => newOfficeDoc(OFFICE_KINDS[t.dataset.k].exts[0], OFFICE_KINDS[t.dataset.k].one.toLowerCase()));
$('#view').querySelectorAll('.otc').forEach(b => b.onclick = () => { const t = OFFICE_TEMPLATES[+b.dataset.t]; newOfficeDoc(t[3], t[1], t[0], t[1]); });
if (!signed) return;
$('#ofup').onclick = () => $('#ofupf').click();
$('#ofupf').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) ofUpload(files.map(file => ({ file, rel: '' }))); };
@@ -87,7 +97,7 @@ function ofDraw(skeleton) {
const row = d => { const path = ofPath(d), on = pins.has(ofId(loc.repo, path)), i = shown.indexOf(d);
return `<div class="row it orow"><button class="rmain" data-o="${i}">${ofIcon(d.name)}<span class="grow"><b>${esc(d.name)}</b><small>${esc((d.parent_dir === '/' ? '' : d.parent_dir.replace(/^\//, '') + ' · ') + fmtDate(d.mtime) + (d.size ? ' · ' + fmtSize(d.size) : ''))}</small></span></button>
<button class="ibtn" data-pin="${i}" aria-label="${on ? 'Unpin' : 'Pin'} ${esc(d.name)}" title="${on ? 'Unpin' : 'Pin to the top'}" style="${on ? 'color:var(--accent)' : ''}">${icon(on ? 'check' : 'plus')}</button><button class="ibtn" data-m="${i}" aria-label="Actions for ${esc(d.name)}">${icon('more')}</button></div>`; };
box.innerHTML = skeleton ? '<p class="empty">Loading your documents…</p>' : (recents.length ? `<h2>Recently opened</h2><div class="orecent">${recents.map((r, i) => `<button class="octile rc" data-r="${i}" style="--c:${OFFICE_KINDS[ofKind(r.name)].color}">${ofIcon(r.name)}<span><b>${esc(r.name)}</b><small>${esc(new Date(r.t).toLocaleDateString([], { month: 'short', day: 'numeric' }))}</small></span></button>`).join('')}</div>` : '')
box.innerHTML = skeleton ? '<p class="empty loading" role="status">Loading your documents…</p>' : (recents.length ? `<h2>Recently opened</h2><div class="orecent">${recents.map((r, i) => `<button class="octile rc" data-r="${i}" style="--c:${OFFICE_KINDS[ofKind(r.name)].color}">${ofIcon(r.name)}<span><b>${esc(r.name)}</b><small>${esc(new Date(r.t).toLocaleDateString([], { month: 'short', day: 'numeric' }))}</small></span></button>`).join('')}</div>` : '')
+ (pinned.length && !q ? `<h2>Pinned</h2><div class="list">${pinned.map(row).join('')}</div>` : '')
+ (list.length ? `<h2>${q ? 'Results' : ofState.kind === 'all' ? 'All documents' : OFFICE_KINDS[ofState.kind].label} <small style="color:var(--muted);font-weight:400">${list.length}</small></h2><div class="list">${shown.map(row).join('')}</div>${list.length > shown.length ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="ofmore">Show ${Math.min(40, list.length - shown.length)} more (${list.length - shown.length} not shown)</button></div>` : ''}`
: `<p class="empty">${ofState.docs.length ? 'Nothing matches.' : 'No documents here yet. Create one above, or drop files on this page.'}</p>`);
+101
View File
@@ -0,0 +1,101 @@
'use strict';
// ---- Pantry (Recipes > Pantry): what you keep in the kitchen, how many are left, and the level that counts as running low. A phone can scan a barcode to add one or use one;
// the first time a barcode is seen you say which item it is (so Green Giant corn and the store brand are both "Corn"). Low items go on the shopping list in a tap.
const PA_UNITS = [['', 'each'], ['g', 'g'], ['kg', 'kg'], ['oz', 'oz'], ['lb', 'lb'], ['ml', 'ml'], ['l', 'litres'], ['tsp', 'tsp'], ['tbsp', 'tbsp'], ['cup', 'cups'], ['fl oz', 'fl oz'], ['pint', 'pints'], ['quart', 'quarts'], ['gallon', 'gallons']];
const paStep = u => ({ g: 100, ml: 100 }[u] || 1);
const paApi = (p, o) => jfetch('/api/pantry' + p, o), paOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
const paAgo = 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(undefined, { weekday: 'short' }); };
const paRecent = r => r.length ? `<h2>Recently</h2><div class="list">${r.map(x => `<div class="row"><span class="grow"><b>${esc(x.who)}</b> ${x.delta < 0 ? 'took' : 'put back'} ${esc(x.item)}<small>${esc(paAgo(x.t))}${x.note ? ' · ' + esc(x.note) : ''}</small></span></div>`).join('')}</div>` : '';
/** The simple way to use things: a big tile for each item, tap to take one (with Undo). Children see only this. */
async function viewPantryTake() {
let st; const v = $('#view'), kid = !!(ME && ME.kid && ME.kid.on);
try { st = await paApi('/'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Pantry</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/recipes')) return;
const draw = () => {
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>${kid ? 'Pantry' : 'Take one'}</h1><span class="grow"></span>${kid ? '' : `<a class="btn ghost" href="#/recipes/pantry">Manage</a>`}</div>
<p class="sub" style="margin-top:0">Tap what you are taking. It counts one off so the family knows when to buy more.</p>
<div class="paTiles">${st.items.filter(x => !kid || !x.unit).map(x => `<button type="button" class="paTile${x.qty === 0 ? ' out' : ''}${x.isLow && x.qty ? ' low' : ''}" data-u="${esc(x.id)}"${x.qty === 0 ? ' disabled' : ''} aria-label="${x.unit ? 'Use some ' : 'Take one '}${esc(x.name)}${x.qty != null ? ', ' + x.label + ' left' : ''}"><b>${esc(x.name)}</b><small>${x.qty == null ? '' : x.qty === 0 ? 'none left' : x.label + ' left'}</small></button>`).join('') || '<p class="empty">Nothing in the pantry yet.</p>'}</div>
<div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="pasuse">${icon('plus')}Scan a barcode instead</button></div>${paRecent(st.recent || [])}</div>`;
v.querySelectorAll('[data-u]').forEach(b => b.onclick = async () => { const own = st.items.find(x => x.id === b.dataset.u); if (own && own.unit) return paUseSome(own, list => { st.items = list.items; st.recent = list.recent; draw(); }); b.disabled = true; try { const r = await paApi('/use', paOpt({ id: b.dataset.u })); st.items = r.items; st.recent = r.recent; draw(); const it = r.item;
toast(`Took one ${it.name}${it.qty != null ? ': ' + it.qty + ' left' : ''}`, { label: 'Undo', run: async () => { try { const u = await paApi('/use', paOpt({ id: it.id, undo: true })); st.items = u.items; st.recent = u.recent; if ($('.paTiles')) draw(); toast('Put back'); } catch (e) { toast(e.message); } } }); } catch (e) { b.disabled = false; toast(e.message); } });
$('#pasuse').onclick = () => pantryScan('use', list => { st.items = list; draw(); }, () => st.items);
};
draw();
}
async function viewPantry(sub) {
if (sub === 'take' || (ME && ME.kid && ME.kid.on)) return viewPantryTake();
let items = [], recent = []; const v = $('#view');
try { const r = await paApi('/'); items = r.items; recent = r.recent || []; } catch (e) { v.innerHTML = `<div class="wrap"><h1>Pantry</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/recipes/pantry')) return;
const draw = () => {
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1></div>${typeof rcTabs === 'function' ? rcTabs('pantry') : ''}
<div class="bar" style="justify-content:flex-start"><a class="btn" href="#/recipes/pantry/take">Take one</a><button class="btn ghost" id="pasadd">${icon('plus')}Scan to add</button><button class="btn ghost" id="pasuse">Scan to use one</button></div>
<form id="paf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pan" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="paq" placeholder="How many" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="How many are left"><select id="pau" aria-label="Unit" style="flex:1;min-width:0">${PA_UNITS.map(u => `<option value="${esc(u[0])}">${esc(u[1])}</option>`).join('')}</select><input type="number" id="paw" placeholder="Low at" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="Counts as running low at"><button class="btn">Add</button></form>
<div class="list">${items.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.name)}</b>${x.isLow ? ' <small style="color:var(--danger)">running low</small>' : ''}<small>${x.qty == null ? 'not counted' : x.label + ' left'}${x.low != null ? ' · low at ' + x.lowLabel : ''}${x.codes.length ? ' · ' + x.codes.length + ' barcode' + (x.codes.length === 1 ? '' : 's') : ''}</small></span>
${x.isLow && typeof rcApi === 'function' ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.codes.length ? `<button class="ibtn" data-bc="${esc(x.id)}" aria-label="Barcodes for ${esc(x.name)}" title="Barcodes">${icon('list')}</button>` : ''}${x.qty != null && x.unit ? `<button class="btn ghost" data-some="${esc(x.id)}" style="min-height:36px">Use some</button>` : ''}${x.qty != null ? `<button class="ibtn" data-d="${esc(x.id)}:-${paStep(x.unit)}" aria-label="Less ${esc(x.name)}">−</button><button class="ibtn" data-d="${esc(x.id)}:${paStep(x.unit)}" aria-label="More ${esc(x.name)}">+</button>` : ''}<button class="ibtn" data-x="${esc(x.id)}" aria-label="Remove ${esc(x.name)}">${icon('trash')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what you keep in the kitchen, or scan a barcode.</small></span></div>'}</div>
<p class="sub" style="margin-top:12px">Counts are kept by hand (and by scanning); they do not go down when you cook. The meal plan points out ingredients you already have.</p>${paRecent(recent)}</div>`;
const go = async (p, o) => { try { items = (await paApi(p, paOpt(o))).items; draw(); } catch (e) { toast(e.message); } };
$('#paf').onsubmit = e => { e.preventDefault(); go('/item', { name: $('#pan').value, qty: $('#paq').value, unit: $('#pau').value, low: $('#paw').value }); };
v.querySelectorAll('[data-some]').forEach(b => b.onclick = () => paUseSome(items.find(x => x.id === b.dataset.some), r => { items = r.items; recent = r.recent || recent; draw(); }));
v.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const i = b.dataset.d.lastIndexOf(':'); go('/adjust', { id: b.dataset.d.slice(0, i), delta: +b.dataset.d.slice(i + 1) }); });
v.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/delete', { id: b.dataset.x }));
v.querySelectorAll('[data-shop]').forEach(b => b.onclick = async () => { const it = items.find(x => x.id === b.dataset.shop); try { await rcApi('/shopping/add', rcOpt('POST', { text: it.name })); toast(it.name + ' added to the shopping list'); } catch (e) { toast(e.message); } });
v.querySelectorAll('[data-bc]').forEach(b => b.onclick = () => { const it = items.find(x => x.id === b.dataset.bc), el = openSheet('Barcodes for ' + it.name, 'Scanning any of these counts as ' + it.name + '.', `<div class="list">${it.codes.map(c => `<div class="row"><span class="grow"><b>${esc(c)}</b></span><button class="ibtn" data-u="${esc(c)}" aria-label="Unlink ${esc(c)}">${icon('trash')}</button></div>`).join('')}</div>`); el.querySelectorAll('[data-u]').forEach(u => u.onclick = async () => { closeSheet(); go('/unlink', { id: it.id, code: u.dataset.u }); }); });
$('#pasadd').onclick = () => pantryScan('add', list => { items = list; draw(); }, () => items); $('#pasuse').onclick = () => pantryScan('use', list => { items = list; draw(); }, () => items);
};
draw();
}
/** The scanner: the camera looks for barcodes and each one found adds one (or uses one). Keeps going so a bag of shopping can be done in a row. */
let paZx = null;
const paLoadZx = () => paZx || (paZx = new Promise((ok, no) => { if (window.ZXing) return ok(window.ZXing); const s = document.createElement('script'); s.src = '/vendor/zxing/zxing.min.js'; s.onload = () => ok(window.ZXing); s.onerror = () => { paZx = null; no(new Error('Could not load the scanner')); }; document.head.appendChild(s); }));
function pantryScan(mode, onChange, getItems) {
const el = openSheet(mode === 'use' ? 'Scan to use one' : 'Scan to add', mode === 'use' ? 'Each barcode takes one off the count.' : 'Each barcode adds one to the count.', `<video id="pscv" playsinline muted autoplay style="width:100%;border-radius:12px;background:#000;aspect-ratio:4/3;object-fit:cover"></video>
<p class="sub" id="pscs" role="status" style="margin:8px 0">Starting the camera…</p><div id="pscq"></div>
<form id="pscm" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pscn" inputmode="numeric" placeholder="Or type the barcode number" autocomplete="off" aria-label="Barcode number" style="flex:1;min-width:0"><button class="btn ghost">Go</button></form>`);
const video = el.querySelector('#pscv'), status = t => { const s = el.querySelector('#pscs'); if (s) s.textContent = t; };
let stream = null, stopped = false, busy = false, last = '', lastAt = 0, reader = null, timer = 0;
const stop = () => { stopped = true; clearInterval(timer); try { reader && reader.reset(); } catch { /* not started */ } if (stream) stream.getTracks().forEach(t => t.stop()); };
const alive = () => !stopped && el.isConnected;
const resume = () => { busy = false; if (alive()) { el.querySelector('#pscq').innerHTML = ''; status('Point the camera at a barcode'); } };
const finish = (r, said) => { onChange(r.items); status(said); toast(said); try { navigator.vibrate && navigator.vibrate(60); } catch { /* no vibration */ } setTimeout(resume, 1300); };
const found = async code => {
if (busy || !alive() || (code === last && Date.now() - lastAt < 4000)) return; busy = true; last = code; lastAt = Date.now(); status('Looking up ' + code + '…');
try {
const r = await paApi('/lookup?code=' + code); if (!alive()) return;
if (r.linked) { const o = await paApi('/scan', paOpt({ code, mode })); return finish(o, `${o.item.name}: ${o.item.qty} left${o.item.isLow ? ' (running low)' : ''}`); }
const list = (getItems() || []), matches = (r.matches || []).map(m => m.id), sorted = [...list.filter(x => matches.includes(x.id)), ...list.filter(x => !matches.includes(x.id))];
el.querySelector('#pscq').innerHTML = `<form id="pscf"><p style="margin:0 0 6px"><b>${esc(r.product ? r.product.name : 'Barcode ' + code)}</b>${r.product && r.product.brand ? ' <small>· ' + esc(r.product.brand) + '</small>' : ''}</p>
${!r.product ? `<p class="sub" style="margin:0 0 6px">${r.lookedUp ? 'The product database does not know this one.' : 'Could not reach the product database.'} Say what it is once and it is remembered.</p>` : '<p class="sub" style="margin:0 0 6px">First time this barcode is seen. Which item is it?</p>'}
<label for="pscw">Which item</label><select id="pscw">${sorted.map(x => `<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}<option value=""${matches.length ? '' : ' selected'}>A new item…</option></select>
<div id="pscnw"${matches.length ? ' hidden' : ''}><label for="pscnn">Name of the new item</label><input type="text" id="pscnn" value="${esc(r.product ? r.product.short : '')}" autocomplete="off" maxlength="60"></div>
<div class="bar"><button type="button" class="btn ghost" id="pscx" style="flex:1;justify-content:center">Skip</button><button class="btn" style="flex:1;justify-content:center">${mode === 'use' ? 'Use one' : 'Add one'}</button></div><div class="err" id="psce"></div></form>`;
const sel = el.querySelector('#pscw'), nw = el.querySelector('#pscnw'); sel.onchange = () => { nw.hidden = !!sel.value; }; if (sel.value === '' ) nw.hidden = false; status('Which item is this?');
el.querySelector('#pscx').onclick = resume;
el.querySelector('#pscf').onsubmit = async e => { e.preventDefault(); try { const o = await paApi('/scan', paOpt({ code, mode, itemId: sel.value || undefined, name: sel.value ? undefined : el.querySelector('#pscnn').value })); finish(o, `${o.item.name}: ${o.item.qty} left${o.made ? ' (new item)' : ''}`); } catch (er) { el.querySelector('#psce').textContent = er.message; } };
} catch (e) { status(e.message); toast(e.message); setTimeout(resume, 1500); }
};
el.querySelector('#pscm').onsubmit = e => { e.preventDefault(); const c = el.querySelector('#pscn').value.replace(/\D/g, ''); el.querySelector('#pscn').value = ''; if (/^\d{8,14}$/.test(c)) { last = ''; found(c); } else status('A barcode is 8 to 14 digits.'); };
(async () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return status('This browser cannot use the camera here (it needs a secure page). Type the number instead.');
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); } catch (e) { return status(e && e.name === 'NotAllowedError' ? 'The camera is blocked. Allow it for this site, or type the number instead.' : 'No camera could be started. Type the number instead.'); }
if (!alive()) return stop(); video.srcObject = stream; try { await video.play(); } catch { /* autoplay is allowed on a tap */ } status('Point the camera at a barcode');
if ('BarcodeDetector' in window) { // Chrome and Android have a built-in reader
let det; try { det = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e'] }); } catch { det = null; }
if (det) { timer = setInterval(async () => { if (!alive()) return stop(); if (busy || video.readyState < 2) return; try { const r = await det.detect(video); if (r[0] && r[0].rawValue) found(r[0].rawValue); } catch { /* a frame that cannot be read */ } }, 300); return; }
}
try { // everything else (iPhone Safari): a reader written in JavaScript, loaded only now
const Z = await paLoadZx(), hints = new Map(); hints.set(Z.DecodeHintType.POSSIBLE_FORMATS, [Z.BarcodeFormat.EAN_13, Z.BarcodeFormat.EAN_8, Z.BarcodeFormat.UPC_A, Z.BarcodeFormat.UPC_E]); reader = new Z.BrowserMultiFormatReader(hints);
timer = setInterval(() => { if (!alive()) stop(); }, 500);
reader.decodeFromVideoElement(video, (res) => { if (res && alive() && !busy) found(res.getText()); });
} catch (e) { status(e.message + ' Type the number instead.'); }
})();
new MutationObserver((_, ob) => { if (!el.isConnected) { stop(); ob.disconnect(); } }).observe(document.body, { childList: true });
}
/** Use some of an item: an amount in any unit that can be turned into the item's own (4 cups of the flour that is kept in pounds). */
function paUseSome(it, done) {
const el = openSheet('Use some ' + it.name.toLowerCase(), `${it.label} left. Any unit works if it can be turned into ${it.unit || 'a count'} (cups of flour into pounds, for instance).`, `<form id="pusf"><div class="bar" style="flex-wrap:nowrap;gap:6px"><input type="number" id="pusa" min="0" step="any" inputmode="decimal" placeholder="Amount" required style="flex:1;min-width:0"><select id="pusu" aria-label="Unit" style="flex:1;min-width:0">${PA_UNITS.map(u => `<option value="${esc(u[0])}"${u[0] === it.unit ? ' selected' : ''}>${esc(u[1])}</option>`).join('')}</select></div><div class="err" id="puse"></div><div class="bar"><button class="btn" style="flex:1;justify-content:center">Take it out</button></div></form>`);
setTimeout(() => el.querySelector('#pusa').focus(), 80);
el.querySelector('#pusf').onsubmit = async e => { e.preventDefault(); try { const r = await paApi('/take', paOpt({ takes: [{ id: it.id, amount: +el.querySelector('#pusa').value, unit: el.querySelector('#pusu').value }], note: 'used some' })); if (r.skipped.length) { el.querySelector('#puse').textContent = 'That cannot be turned into ' + (it.unit || 'a count') + ' for ' + it.name.toLowerCase() + '. Pick a matching unit.'; return; } closeSheet(); const d = r.done[0]; toast(`${d.name}: ${d.label || 'none'} left${d.isLow ? ' (running low)' : ''}`); done(r); } catch (er) { el.querySelector('#puse').textContent = er.message; } };
}
+94 -39
View File
@@ -22,9 +22,12 @@ const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSear
// so changing songs is instant and a slow or lost connection does not stop the music.
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
const tc = { m: new Map(), ctl: null, busy: false };
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); // (long audio, such as audiobooks, is streamed, not saved whole)
const tcCount = () => Math.min(5, Math.max(3, +store.get('song_cache', 5) || 5)); // songs kept ready, counting the one playing (Settings: 3 to 5)
const TC_BYTES = 120e6; // however many songs, never hold more than this in memory (a lossless album could otherwise fill a phone)
const tcWant = () => { const w = pl.q.slice(Math.max(pl.i, 0), Math.max(pl.i, 0) + tcCount()).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); return w; }; // (the one playing first, so a dropped connection cannot stop it, then the ones coming up; long audio, such as audiobooks, is streamed, not saved whole)
const tcBytes = () => { let n = 0; for (const v of tc.m.values()) n += v.size || 0; return n; };
function tcTrim() {
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), Math.max(pl.i, 0) + tcCount()).map(t => t.src));
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
}
async function tcFetch(src, signal) {
@@ -32,7 +35,7 @@ async function tcFetch(src, signal) {
try {
const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
const b = await r.blob(); // throws if the connection drops part way
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b) });
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b), size: b.size });
return;
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
}
@@ -42,7 +45,8 @@ async function tcPump() {
try {
for (;;) {
tcTrim();
const src = tcWant().find(x => !tc.m.has(x)); if (!src) break;
const want = tcWant(), src = want.find(x => !tc.m.has(x)); if (!src) break;
if (tcBytes() > TC_BYTES && want.indexOf(src) > 1) break; // (the song playing and the next one are always kept; the ones after wait while the cache is full)
tc.ctl = new AbortController(); tc.cur = src;
await tcFetch(src, tc.ctl.signal);
if (!tc.ctl.signal.aborted && !tc.m.has(src)) break; // gave up on this one; try again on the next trigger
@@ -51,25 +55,42 @@ async function tcPump() {
}
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && cur() && !pl.a.paused && (pl.a.readyState < 3 || pl.a.error)) { pl.retries = 0; recover(true); } }); // (back from sleep or another app)
let loadTok = 0, loadTimer = null, stallTimer = null;
// Watchdog: a song that sits on "Buffering…" without moving is not left there. After 10 s it is reloaded, after 20 s it is switched to the copy Plex converts to MP3,
// and after 30 s it is skipped. (This does not rely on the browser sending a "stalled" or "error" event, which a hung request may never do.)
const wd = { tries: 0, at: 0, pos: -1, key: '' };
if (typeof setInterval === 'function') setInterval(() => {
const t = cur(), a = pl.a;
if (!t || !pl.buf || /^radio:/.test(String(t.key)) || !navigator.onLine) { wd.at = Date.now(); wd.pos = -1; return; }
if (wd.key !== t.key) { wd.key = t.key; wd.tries = 0; wd.at = Date.now(); }
if (a.currentTime !== wd.pos) { wd.pos = a.currentTime; wd.at = Date.now(); return; }
if (Date.now() - wd.at < 10000 || loadTimer && a.paused && Date.now() - wd.at < 12000) return;
wd.at = Date.now(); wd.tries++;
if (wd.tries === 1) { pl.forceRecover = true; pl.recovering = true; recover(true); }
else if (wd.tries === 2) { pl.alt.add(t.key); loadCur(0); }
else { wd.tries = 0; toast('"' + t.title + '" would not load. Skipping.'); advance(true); }
}, 2000);
pl.retries = 0; pl.buf = false;
// Show the new track at once, but wait a moment before fetching it so quickly tapping Next several times only loads the last one.
function loadCur(delay = 220) {
const t = cur(); if (!t) return;
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false; pl.recovering = false; wd.tries = 0; wd.at = Date.now();
const tok = ++loadTok; pl.buf = true;
if (delay) pl.a.pause();
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();
try { // (a bug in any screen's refresh must never stop the music from starting)
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();
} catch (e) { console.error('player refresh', e); }
const go = () => {
if (tok !== loadTok) return;
const hit = tc.m.get(t.src);
pl.a.src = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
if (typeof actLog === 'function') actLog(/podcast|audiobook|\/ab\/|radio/.test(String(t.src || '')) ? 'listen' : 'play', (t.artist ? t.artist + ' - ' : '') + (t.title || ''));
pl.a.play().catch(e => { if (e && e.name === 'NotAllowedError') { pl.buf = false; syncUI(); toast('Tap play to start'); } });
pl.a.play().catch(e => { if (tok !== loadTok) return; if (e && e.name === 'NotAllowedError') { pl.buf = false; syncUI(); toast('Tap play to start'); } else if (!(e && e.name === 'AbortError')) { pl.forceRecover = true; pl.recovering = true; recover(true); } });
tcKick();
const n = pl.q[pl.i + 1]; if (n) new Image().src = px.art(n.thumb, 600);
};
@@ -77,14 +98,15 @@ function loadCur(delay = 220) {
}
function setMeta(t) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [96, 128, 256, 384, 512].map(n => ({ src: px.art(t.thumb, n), sizes: n + 'x' + n })) });
}
// If the music stalls, switch to the fully downloaded copy (if there is one) or reload the stream from where it stopped.
function recover(now) {
clearTimeout(stallTimer); const t = cur(), a = pl.a;
if (!t || a.paused) return;
if (!t || (a.paused && !pl.recovering)) return;
stallTimer = setTimeout(() => {
if (a.paused || (a.readyState >= 3 && !a.error)) return;
const forced = pl.forceRecover; pl.forceRecover = false;
if ((a.paused && !pl.recovering) || (!forced && a.readyState >= 3 && !a.error)) return; // (a reload makes the element report "paused" by itself, which must not stop us trying again)
const hit = tc.m.get(t.src), pos = a.currentTime;
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
if (bad && !hit) {
@@ -93,12 +115,14 @@ function recover(now) {
}
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
if (!hit && !navigator.onLine) return; // 'online' will call us again
if (!hit && !bad && pl.retries >= 5) { pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); return; }
if (!hit && !bad && pl.retries >= 5) { // keep trying every 15 s (the 'online' signal is not always sent, e.g. when only the server was unreachable)
if (!pl.gaveUp) { pl.gaveUp = true; pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); }
stallTimer = setTimeout(() => { pl.retries = 0; recover(true); }, 15000); return; }
if (!hit || a.src !== hit.url) pl.retries++;
a.src = want;
pl.recovering = true; a.src = want;
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
recover();
}, now ? 300 : 4000);
}, now ? 300 : a.error ? 1200 : 4000); // (a file that plainly will not load is not waited on for long)
}
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); }
const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); npQueueCount(); toast('Playing next'); };
@@ -126,7 +150,28 @@ function restorePlayer() {
pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
renderMini(); syncUI();
}
// On a wide screen the queue docks down the right side of the page and stays open while you browse; the Queue button turns it on and off.
const qWide = () => typeof matchMedia === 'function' && matchMedia('(min-width: 1100px)').matches;
function qDock() {
if (!qWide() && !document.body?.classList.contains('qdock')) return; // (narrow screens use the sheet)
let el = document.getElementById('qdock'), on = store.get('q_dock', false) && qWide() && pl.q.length && !$('#player').hidden;
if (!on) { if (el) el.remove(); document.body.classList.remove('qdock'); return; }
if (!el) { el = document.createElement('aside'); el.id = 'qdock'; el.setAttribute('aria-label', 'Up next'); $('#main').appendChild(el); }
document.body.classList.add('qdock'); el.style.bottom = ($('#player').offsetHeight || 0) + 'px';
const keep = el.querySelector('.qlist')?.scrollTop || 0;
el.innerHTML = `<div class="qhead"><b>Up next</b><small>${pl.q.length} tracks${pl.radio ? ' · radio' : ''}</small><button class="ibtn" id="qdx" aria-label="Hide the queue">${icon('x')}</button></div>
<div class="qlist">${pl.q.map((t, i) => `<div class="qrow ${i === pl.i ? 'now' : ''}" data-i="${i}"><button class="qmain" 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" title="Remove">${icon('x')}</button>`}</div>`).join('')}</div>
<div class="qfoot"><button class="btn ghost" id="qdc">Clear up next</button><button class="btn ghost" id="qds">Save as playlist</button></div>`;
const list = el.querySelector('.qlist'); list.scrollTop = keep || Math.max(0, (list.querySelector('.now')?.offsetTop || 0) - 60);
el.querySelector('#qdx').onclick = () => { store.set('q_dock', false); qDock(); };
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); });
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(); tcKick(); updateNow(); });
el.querySelector('#qdc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); };
el.querySelector('#qds').onclick = () => addToPlaylist(pl.q.slice(), 'Queue ' + new Date().toLocaleDateString([], { month: 'short', day: 'numeric' }));
}
if (typeof matchMedia === 'function') matchMedia('(min-width: 1100px)').addEventListener('change', () => qDock());
function openQueue() {
if (qWide()) { store.set('q_dock', !store.get('q_dock', false)); return qDock(); }
const upcoming = pl.q.map((t, i) => ({ 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>
@@ -150,7 +195,7 @@ function advance(auto) {
}
const next = () => advance(false);
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else if (pl.i > 0) { pl.i--; loadCur(); } };
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); return pl.a.paused ? pl.a.play() : pl.a.pause(); };
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); pl.recovering = false; return pl.a.paused ? pl.a.play() : pl.a.pause(); };
pl.a.preload = 'auto';
// Tell Plex what is playing, so it shows under Now Playing / Activity in the Plex dashboard (as "NE Hub", under the signed-in Plex account).
let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
@@ -162,7 +207,10 @@ function tlReport(state) {
tlSend(state, tlKey, tlPos, tlDur);
}
function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
pl.a.addEventListener('ended', () => advance(true));
pl.a.addEventListener('ended', () => { // a dropped connection can look like the end of the song: if it stopped well short of the length, carry on from there instead of skipping
const a = pl.a, t = cur(), d = t && t.dur ? t.dur / 1000 : 0;
if (t && d > 20 && !/^radio:/.test(String(t.key)) && a.currentTime < d - 8 && pl.endRetry !== t.key + '|' + Math.floor(a.currentTime / 5)) { pl.endRetry = t.key + '|' + Math.floor(a.currentTime / 5); pl.forceRecover = true; pl.buf = true; syncUI(); recover(true); return; }
advance(true); });
let lastSave = 0, lastPos = 0;
pl.a.addEventListener('timeupdate', () => {
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
@@ -174,14 +222,14 @@ pl.a.addEventListener('timeupdate', () => {
});
addEventListener('pagehide', saveState);
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
pl.a.addEventListener('playing', () => { pl.recovering = false; tlReport('playing'); pl.buf = false; pl.retries = 0; pl.gaveUp = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
pl.a.addEventListener('pause', () => { if (pl.recovering) return; if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
pl.a.addEventListener('canplaythrough', () => tcKick());
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; syncUI(); recover(); });
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; pl.recovering = true; syncUI(); recover(); });
if ('mediaSession' in navigator) {
const ms = navigator.mediaSession;
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => pl.a.pause());
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => { pl.recovering = false; pl.a.pause(); });
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
try { ms.setActionHandler('seekbackward', d => { pl.a.currentTime = Math.max(0, pl.a.currentTime - (d.seekOffset || 10)); }); ms.setActionHandler('seekforward', d => { pl.a.currentTime += d.seekOffset || 10; }); } catch {}
@@ -191,16 +239,16 @@ function markPlaying() {
}
function renderMini() {
if (typeof videoBarOn === 'function' && videoBarOn()) return; // the bottom bar is showing the video's controls
const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
el.hidden = false;
const t = cur(), el = $('#player'); if (!t) { el.hidden = true; return qDock(); }
el.hidden = false; setTimeout(qDock);
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="mv" aria-label="Visuals" title="Visuals">${icon('visuals')}</button><button class="ibtn wide-only" id="mq" aria-label="Queue" title="Queue">${icon('queue')}</button><button class="ibtn wide-only" id="mz" aria-label="Sleep timer" title="Sleep timer">${icon('moon')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen;
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen; $('#mv').onclick = () => vizOpen(); $('#mq').onclick = openQueue; $('#mz').onclick = () => sleepSheet();
// tap or drag along the thin bar to seek
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
@@ -221,8 +269,8 @@ function stopPlayer() {
}
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
function modeUI() {
document.querySelectorAll('#ms, #nps, #cns').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
document.querySelectorAll('#ms, #nps, #cns, #fss').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
document.querySelectorAll('#mr, #npr, #cnr, #fsr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
}
function syncUI() {
const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
@@ -246,15 +294,18 @@ function syncUI() {
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000);
const fk = $('#fsk'); if (fk && !fk.dataset.drag) { fk.max = d || 0; fk.value = liveT ? 0 : a.currentTime; } if (fk) fk.hidden = liveT;
const fc = $('#fsc'); if (fc) fc.textContent = liveT ? '\u25CF LIVE' : fmtDur(a.currentTime * 1000);
const fd = $('#fsd'); if (fd) fd.textContent = liveT ? '' : fmtDur(d * 1000);
const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; }
}
// A Now Playing card with the controls: on the Home dashboard, and at the top of every Music screen (the albums, artists and playlists stay right below it).
// A Now Playing card with the controls on the Home dashboard (the Music screens don't repeat it: the bottom player has the same controls).
function renderNpCard() {
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music');
const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null;
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home');
const host = onHome ? $('#npslot') : null;
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
if (!host.contains(el)) { if (onHome) host.appendChild(el); else host.prepend(el); }
if (!host.contains(el)) host.appendChild(el);
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>
@@ -271,24 +322,28 @@ function renderNpCard() {
}
// The Now Playing sheet stays open while tracks change, so refresh what it shows.
function updateNow() {
qDock();
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next';
const q = $('#npq span'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1);
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
modeUI();
}
function openNow() {
const t = cur(); if (!t) return;
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0" id="npti">${esc(t.title)}</h3>
<p class="sub" id="npsub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
<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="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 chip = (id, ic, label) => `<button class="npchip" id="${id}">${icon(ic)}<span>${label}</span></button>`;
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><div class="npmeta"><h3 id="npti">${esc(t.title)}</h3>
<p class="sub" id="npsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p></div>
<div class="npseek"><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>
<div class="ctl"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><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><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div>
<div class="npchips"><button class="npchip" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}<span>Favorite</span></button>${chip('npl', 'lyrics', lyrLabel())}${chip('npv', 'visuals', 'Visuals')}${chip('npq', 'queue', 'Queue · ' + (pl.q.length - pl.i - 1))}${castOk(pl.a) ? chip('npcast', 'ext', 'Cast') : ''}${chip('npz', 'moon', 'Sleep')}${chip('npfs', 'full', 'Full screen')}</div></div>`);
el.querySelector('.sheet').classList.add('npsheet');
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('#npz').onclick = sleepSheet; el.querySelector('#npfs').onclick = () => { closeSheet(); lyrOpen(true); };
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');
+1 -1
View File
@@ -34,7 +34,7 @@ const pdLeft = e => { const p = pdProg[pdId(e)]; return p && p.pos > 10 && p.dur
async function viewPodcast(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Podcast</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Podcast</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
if (args[0] === 'show' && args[1]) return await pdShow(args[1]);
return await pdHome();
+2 -2
View File
@@ -39,7 +39,7 @@ setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPla
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(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>';
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" role="status">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;
@@ -83,7 +83,7 @@ async function viewRadio(host) { // host: { el, hash } when shown inside the Sto
$('#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>`; } };
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty loading" role="status">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(() => {});
}
+3 -3
View File
@@ -29,7 +29,7 @@ async function viewReader(args = []) {
if (args[0] === 'open' && args[1]) return rdOpen(args[1]);
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>';
v.innerHTML = '<div class="wrap"><h1>Books</h1><p class="empty loading" role="status">Loading…</p></div>';
try { await rdLibrary(); } catch (e) {
if (!location.hash.startsWith('#/read')) return;
v.innerHTML = `<div class="wrap"><h1>Books</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rdretry">Try again</button></div></div>`;
@@ -167,7 +167,7 @@ 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(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>';
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" role="status">Loading…</p></div>';
let sources, mine;
let genreList = []; try { [sources, mine, genreList] = await Promise.all([rdApi('/store/sources'), rdApi('/list'), rdApi('/store/genres').catch(() => [])]); } 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 (!here()) return;
@@ -350,7 +350,7 @@ class RdReader {
async deleteNote(n) { this.A.eraseNote?.(n); await this.pushNote({ ...n, del: true }); this.notes = this.notes.filter(x => !x.del); this.$('#rdbm').classList.toggle('on', !!this.isMarked()); this.refreshNotes?.(); }
// ---- panels
async showToc() {
const el = openSheet('Contents', '', '<p class="empty">Loading…</p>'); let items = [];
const el = openSheet('Contents', '', '<p class="empty loading" role="status">Loading…</p>'); let items = [];
try { items = await this.A.toc(); } catch {}
el.querySelector('.empty').outerHTML = items.length ? `<div class="list rdlist">${items.map((t, i) => `<button class="row" data-i="${i}" style="padding-left:${16 + t.depth * 18}px"><span class="grow"><b>${esc(t.label)}</b></span></button>`).join('')}</div>` : '<p class="empty">This book has no table of contents.</p>';
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(items[b.dataset.i].loc); });
+26 -23
View File
@@ -4,20 +4,22 @@
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 === 'plan' ? '' : ' ghost'}" href="#/recipes/plan">${icon('cal')}Meal plan</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
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 === 'plan' ? '' : ' ghost'}" href="#/recipes/plan">${icon('cal')}Meal plan</a><a class="btn${on === 'pantry' ? '' : ' ghost'}" href="#/recipes/pantry">${icon('list')}Pantry</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>';
v.innerHTML = '<div class="wrap"><h1>Recipes</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
if (args[0] === 'pantry') return await viewPantry(args[1]);
if (ME && ME.kid && ME.kid.on && !args[0]) return await viewPantry('take'); // children see the pantry to take from
if (args[0] === 'shop') return await rcShop();
if (args[0] === 'plan') return await rcPlan(args[1]);
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>`;
v.innerHTML = `<div class="wrap"><h1>Recipes</h1>${rcTabs('')}<p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rcretry">Try again</button></div></div>`; // (the Pantry does not need Mealie, so its tab is here even when recipes are not set up)
$('#rcretry').onclick = () => route();
}
}
@@ -60,7 +62,7 @@ async function rcOne(slug) {
<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><button class="btn ghost" id="rcplan">${icon('cal')}Plan it</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="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><button class="btn ghost" id="rcplan">${icon('cal')}Plan it</button><button class="btn ghost" id="rccook">${icon('check')}Cooked it</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>
@@ -78,7 +80,7 @@ async function rcOne(slug) {
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
paint();
if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
$('#rcplan').onclick = () => rcPlanPick(r.slug, r.name, r.id);
$('#rcplan').onclick = () => rcPlanPick(r.slug, r.name, r.id); $('#rccook').onclick = () => rcCook(r.slug, r.name);
$('#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; }
@@ -117,12 +119,13 @@ async function rcPlan(start) {
<div class="bar" style="justify-content:space-between;align-items:center"><a class="btn ghost" href="${shift(-7)}" aria-label="Previous week">${icon('back')}</a><b>${days[0].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${days[6].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</b><a class="btn ghost" href="${shift(7)}" aria-label="Next week">${icon('chev')}</a></div>
<div class="list">${days.map(d => { const k = rcYmd(d), es = (plan[k] || []).slice().sort((a, b) => RC_MEALS.findIndex(m => m[0] === a.meal) - RC_MEALS.findIndex(m => m[0] === b.meal));
return `<div class="row" style="align-items:flex-start${k === today ? ';outline:2px solid var(--accent);outline-offset:-2px;border-radius:12px' : ''}"><span style="width:52px;flex:none"><b>${d.toLocaleDateString(undefined, { weekday: 'short' })}</b><small style="display:block">${d.getDate()}</small></span>
<span class="grow">${es.map(e => `<span style="display:flex;align-items:center;gap:6px;margin:2px 0"><a href="#/recipes/r/${encodeURIComponent(e.slug)}" class="grow" style="white-space:normal">${esc(e.name)}${e.meal !== 'dinner' ? ` <small>· ${esc(e.meal)}</small>` : ''}</a><button class="ibtn" data-rm="${e.id}" aria-label="Remove ${esc(e.name)}" title="Remove">${icon('x')}</button></span>`).join('') || '<small>Nothing planned</small>'}</span>
<span class="grow">${es.map(e => `<span style="display:flex;align-items:center;gap:6px;margin:2px 0"><a href="#/recipes/r/${encodeURIComponent(e.slug)}" class="grow" style="white-space:normal">${esc(e.name)}${e.meal !== 'dinner' ? ` <small>· ${esc(e.meal)}</small>` : ''}</a><button class="ibtn" data-cook="${esc(e.slug)}|${esc(e.name)}" aria-label="Cooked ${esc(e.name)}" title="Cooked it: take it out of the pantry">${icon('check')}</button><button class="ibtn" data-rm="${e.id}" aria-label="Remove ${esc(e.name)}" title="Remove">${icon('x')}</button></span>`).join('') || '<small>Nothing planned</small>'}</span>
<button class="ibtn" data-add="${k}" aria-label="Plan a meal on ${d.toLocaleDateString(undefined, { weekday: 'long' })}" title="Add a meal">${icon('plus')}</button></div>`; }).join('')}</div>
<div class="bar"><button class="btn" id="rcpshop"${count ? '' : ' disabled'}>${icon('check')}Add this week's ingredients to the shopping list</button><button class="btn ghost" id="rcpantry">Pantry</button></div><div id="rcphave"></div></div>`;
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add));
document.querySelectorAll('[data-cook]').forEach(b => b.onclick = () => { const i = b.dataset.cook.indexOf('|'); rcCook(b.dataset.cook.slice(0, i), b.dataset.cook.slice(i + 1)); });
document.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { try { await rcApi('/plan/remove', rcOpt('POST', { id: b.dataset.rm })); rcPlan(rcYmd(mon)); } catch (e) { toast(e.message); } });
$('#rcpantry').onclick = () => rcPantry(() => rcPlan(rcYmd(mon)));
$('#rcpantry').onclick = () => { location.hash = '#/recipes/pantry'; };
if (count) rcApi(`/plan/pantry?from=${from}&to=${to}`).then(r => { const box = $('#rcphave'); if (!box || !r.items.length) return; const names = [...new Set(r.items.map(i => i.have))]; box.innerHTML = `<p class="sub" style="white-space:normal">You may already have ${esc(names.slice(0, 8).join(', '))}${names.length > 8 ? '…' : ''} (${r.items.length} ingredient${r.items.length === 1 ? '' : 's'} this week). They still go on the shopping list; tick them off there.</p>`; }).catch(() => {});
$('#rcpshop').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/plan/shop', rcOpt('POST', { from, to })); toast(`Ingredients for ${o.recipes} recipe${o.recipes === 1 ? '' : 's'} added`, { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (er) { toast(er.message); } b.disabled = false; };
}
@@ -138,21 +141,21 @@ function rcPlanPick(slug, name, rid, date) {
el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find();
}
/** The pantry: what you keep in stock, how many are left, and the level that counts as running low (low ones go on the shopping list in a tap). */
async function rcPantry(onClose) {
let items = []; try { items = (await rcApi('/pantry')).items; } catch (e) { return toast(e.message); }
const el = openSheet('Pantry', 'What you keep in stock. The meal plan points out ingredients you have, and an item running low can start an automation.', '<div id="pyl"></div>');
const draw = () => {
el.querySelector('#pyl').innerHTML = `<form id="pyf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pyn" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="pyq" placeholder="How many" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="How many are left"><input type="number" id="pyw" placeholder="Low at" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="Counts as running low at"><button class="btn">Add</button></form>
<div class="list">${items.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.name)}</b>${x.isLow ? ' <small style="color:var(--danger)">running low</small>' : ''}<small>${x.qty == null ? 'not counted' : x.qty + ' left'}${x.low != null ? ' · low at ' + x.low : ''}</small></span>
${x.isLow ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.qty != null ? `<button class="ibtn" data-d="${esc(x.id)}:-1" aria-label="One less ${esc(x.name)}">−</button><button class="ibtn" data-d="${esc(x.id)}:1" aria-label="One more ${esc(x.name)}">+</button>` : ''}<button class="ibtn" data-x="${esc(x.id)}" aria-label="Remove ${esc(x.name)}">${icon('trash')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what you keep in the kitchen.</small></span></div>'}</div>
<div class="bar"><button class="btn" id="pydone" style="flex:1;justify-content:center">Done</button></div>`;
const go = async (p, o) => { try { items = (await rcApi(p, rcOpt('POST', o))).items; draw(); } catch (e) { toast(e.message); } };
el.querySelector('#pyf').onsubmit = e => { e.preventDefault(); go('/pantry/item', { name: el.querySelector('#pyn').value, qty: el.querySelector('#pyq').value, low: el.querySelector('#pyw').value }); };
el.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const [id, d] = b.dataset.d.split(':'); go('/pantry/adjust', { id, delta: +d }); });
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/pantry/delete', { id: b.dataset.x }));
el.querySelectorAll('[data-shop]').forEach(b => b.onclick = async () => { const it = items.find(x => x.id === b.dataset.shop); try { await rcApi('/shopping/add', rcOpt('POST', { text: it.name })); toast(it.name + ' added to the shopping list'); } catch (e) { toast(e.message); } });
el.querySelector('#pydone').onclick = () => { closeSheet(); if (onClose) onClose(); };
/** "Cooked it": shows what cooking this recipe would take out of the pantry (an estimate), lets you untick lines, and takes the rest out when you confirm. */
async function rcCook(slug, name, scale = 1) {
const el = openSheet('Cooked ' + name, 'What this takes out of the pantry', '<div id="rcck"><p class="sub">Working it out…</p></div>');
const show = async sc => {
let r; try { r = await rcApi(`/cook-plan?slug=${encodeURIComponent(slug)}&scale=${sc}`); } catch (e) { el.querySelector('.sheet').insertAdjacentHTML('beforeend', `<p class="err">${esc(e.message)}</p>`); return; }
const ok = r.rows.filter(x => x.ok), skip = r.rows.filter(x => !x.ok), body = `<div id="rcck"><div class="seg" role="radiogroup" aria-label="How much did you make">${[[0.5, 'Half'], [1, 'As written'], [2, 'Double'], [3, 'Triple']].map(([v, l]) => `<a href="#" role="radio" aria-checked="${sc === v}" data-sc="${v}" class="${sc === v ? 'on' : ''}">${l}</a>`).join('')}</div>
${ok.length ? `<div class="list">${ok.map((x, i) => `<label class="row" style="cursor:pointer"><input type="checkbox" data-i="${i}" checked><span class="grow" style="white-space:normal"><b>${esc(x.item.name)}</b> −${esc(x.takeLabel)}<small>${esc(x.line)} · ${x.left === 0 ? 'none' : esc(x.leftLabel || x.left)} left${x.why ? ' · ' + esc(x.why) : ''}</small></span></label>`).join('')}</div>` : '<p class="empty">Nothing in your pantry matches this recipe\'s ingredients that can be counted off.</p>'}
${skip.length ? `<h3 class="sub" style="margin:12px 0 4px">Left alone</h3><div class="list">${skip.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.item.name)}</b><small>${esc(x.line)} · ${esc(x.why)}</small></span></div>`).join('')}</div>` : ''}
<p class="sub" style="margin-top:8px">These are estimates (a cup of flour is about 4¼ oz). Untick anything that is not right.</p><div id="rcce" class="err"></div>
<div class="bar"><a class="btn ghost" href="#/recipes/pantry" id="rccp" style="flex:1;justify-content:center">Pantry</a>${ok.length ? '<button class="btn" id="rccgo" style="flex:1;justify-content:center">Take these out</button>' : ''}</div></div>`;
el.querySelector('#rcck').outerHTML = body;
el.querySelectorAll('[data-sc]').forEach(a => a.onclick = e => { e.preventDefault(); show(+a.dataset.sc); });
const go = el.querySelector('#rccgo'); if (go) go.onclick = async () => { const picks = [...el.querySelectorAll('[data-i]:checked')].map(c => ok[+c.dataset.i]); if (!picks.length) return closeSheet();
try { const t = await jfetch('/api/pantry/take', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ takes: picks.map(x => ({ id: x.item.id, amount: x.take, unit: x.unit })), note: 'cooked ' + name }) }); closeSheet(); const low = t.done.filter(d => d.isLow).map(d => d.name); toast(`Took ${t.done.length} item${t.done.length === 1 ? '' : 's'} out of the pantry${low.length ? '. Running low: ' + low.join(', ') : ''}`, low.length ? { label: 'Pantry', run: () => { location.hash = '#/recipes/pantry'; } } : undefined); }
catch (e) { el.querySelector('#rcce').textContent = e.message; } };
};
draw();
show(scale);
}
+6 -3
View File
@@ -11,8 +11,11 @@ async function famSettings(box) {
box.innerHTML = `<h2>You</h2><div class="list">${can('chat') ? famRow('fs-chat', 'chat', 'Chat', 'Quiet hours for phone alerts' + (admin ? ', and the family announcement' : '')) : ''}</div><div id="fs-dev"></div>
${admin && can('chores') ? `<h2>Chores</h2><div class="list">${famRow('fs-chores', 'check', 'Chores and weekly rewards', 'Make chores, choose who does them, set the points and the reward tiers')}</div>` : ''}
${admin ? '<h2>Children</h2><div id="fs-req"></div><p class="sub" style="margin:8px 0">What each child did this week, and Plex.</p><div class="list" id="fs-kids"><div class="row"><span class="grow"><small>Loading…</small></span></div></div><div class="list" style="margin-top:10px">' + famRow('fs-words', 'search', 'Blocked search words', 'A child\'s search only uses Simple English Wikipedia and a built-in list of blocked words. Add your own words here.') + '</div>' : ''}
${admin ? '<h2>Family board</h2><p class="sub" style="margin:0 0 8px">Who has a name tile on the shared family screen and shows in its chore progress.</p><div class="list" id="fs-board"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>' : ''}`;
${admin ? '<h2>Family board</h2><div class="list" style="margin-bottom:10px">' + famRow('fs-fname', 'people', 'Family name', 'What the board says: Good morning, ...') + '</div><p class="sub" style="margin:0 0 8px">Who has a name tile on the shared family screen and shows in its chore progress.</p><div class="list" id="fs-board"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>' : ''}`;
const c = box.querySelector('#fs-chat'); if (c) c.onclick = () => chSettings();
const fn = box.querySelector('#fs-fname'); if (fn) fn.onclick = async () => { let r; try { r = await jfetch('/api/board/family'); } catch (e) { return toast(e.message); }
const el = openSheet('Family name', 'The family board greets everyone with this. Leave it empty to use the last name most of you share.', `<form id="ff"><label for="fnn">Name</label><input type="text" id="fnn" maxlength="40" value="${esc(r.name)}" placeholder="${esc(r.shown)}" autocomplete="off"><div class="bar"><button type="button" class="btn ghost" id="ffx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#ffx').onclick = closeSheet; el.querySelector('#ff').onsubmit = async e => { e.preventDefault(); try { const o = await jfetch('/api/board/family', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#fnn').value }) }); closeSheet(); toast('The board now says: Good morning, ' + o.shown); } catch (x) { toast(x.message); } }; };
const k = box.querySelector('#fs-chores'); if (k) k.onclick = famManageChores;
if (typeof devSettings === 'function') devSettings(box.querySelector('#fs-dev'));
if (admin) { famRequests(box.querySelector('#fs-req')); famKids(box.querySelector('#fs-kids')); const w = box.querySelector('#fs-words'); if (w) w.onclick = famWords; }
@@ -29,7 +32,7 @@ async function famRequests(box) {
const open = r.requests.filter(x => x.status === 'open'); if (!open.length) { box.innerHTML = ''; return; }
const when = t => new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' });
box.innerHTML = `<h2 style="margin-top:0">Asking you (${open.length})</h2><div class="list">${open.map(q => `<div class="row" style="flex-wrap:wrap"><span class="grow" style="min-width:200px"><b>${esc(q.userName)}: ${esc(q.text)}</b><small style="white-space:normal">${when(q.at)}${q.tab ? ' · wants ' + esc((TABS.find(t => t[0] === q.tab) || [0, q.tab])[1]) : ''}${q.minutes ? ' · +' + q.minutes + ' min' : ''}</small></span>${q.time ? [15, 30, 60].map(m => `<button class="btn" data-q="${esc(q.id)}" data-y="1" data-m="${m}">+${m === 60 ? '1 h' : m + ' min'}</button>`).join('') : `<button class="btn" data-q="${esc(q.id)}" data-y="1">Yes</button>`}<button class="btn ghost" data-q="${esc(q.id)}">No</button></div>`).join('')}</div>`;
box.querySelectorAll('[data-q]').forEach(b => b.onclick = async () => { const yes = !!b.dataset.y; try { await jfetch('/api/kid/requests/' + b.dataset.q, { method: 'POST', body: JSON.stringify({ approve: yes, minutes: b.dataset.m ? +b.dataset.m : undefined }) }); toast(yes ? 'Said yes' : 'Said no'); famRequests(box); } catch (e) { toast(e.message); } });
box.querySelectorAll('[data-q]').forEach(b => b.onclick = async () => { const yes = !!b.dataset.y; try { await jfetch('/api/kid/requests/' + b.dataset.q, { method: 'POST', body: JSON.stringify({ approve: yes, minutes: b.dataset.m ? +b.dataset.m : undefined }) }); toast(yes ? 'Said yes' : 'Said no'); famRequests(box); if (typeof askWatch === 'object' && askWatch.tick) askWatch.tick(); } catch (e) { toast(e.message); } });
}
// ---- One row per child: their week (minutes by area, what they played, searched and tried), and Plex locking.
async function famKids(box) {
@@ -39,7 +42,7 @@ async function famKids(box) {
box.querySelectorAll('[data-k]').forEach(b => b.onclick = () => famChild(kids.find(u => u.id === b.dataset.k)));
}
async function famChild(u) {
const el = openSheet(u.name, 'Last 7 days', '<div id="fcb"><p class="empty">Loading…</p></div>'); let a, px;
const el = openSheet(u.name, 'Last 7 days', '<div id="fcb"><p class="empty loading" role="status">Loading…</p></div>'); let a, px;
try { [a, px] = await Promise.all([jfetch('/api/kid/activity?user=' + encodeURIComponent(u.id) + '&days=7'), jfetch('/api/kid/plex-lock?user=' + encodeURIComponent(u.id))]); } catch (e) { el.querySelector('#fcb').innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const h = m => m >= 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : m + ' min', total = a.days.reduce((n, d) => n + d.minutes, 0), by = {}; a.days.forEach(d => Object.entries(d.areas).forEach(([k, m]) => { by[k] = (by[k] || 0) + m; }));
const icn = { play: '🎵', watch: '🎬', listen: '🎧', read: '📖', search: '🔎', signin: '🔑', chore: '✅', add: '➕', blocked: '⛔' }, max = Math.max(1, ...a.days.map(d => d.minutes));
+3
View File
@@ -91,6 +91,7 @@ function viewSettings(args) {
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
${allowed('music') ? '<label class="row"><span class="grow"><b>Songs kept ready</b><small>The playing song and the ones coming up are saved on this device, so a weak signal does not stop the music</small></span><select id="songcache"><option value="3">3</option><option value="4">4</option><option value="5">5</option></select></label>' : ''}
${allowed('music') || allowed('radio') ? '<label class="row" style="cursor:pointer"><span class="grow"><b>Look up lyrics and song covers online</b><small>Sends the artist and title of what is playing to LRCLIB and iTunes. Turn off to keep that private.</small></span><input type="checkbox" id="lookups"></label>' : ''}
</div>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
@@ -146,6 +147,7 @@ function viewSettings(args) {
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server + (st.user ? ' · as ' + st.user.title : '') : 'Signed in · no server chosen') : 'Signed out';
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 sc = $('#songcache'); if (sc) { sc.value = String(Math.min(5, Math.max(3, +store.get('song_cache', 5) || 5))); sc.onchange = () => { store.set('song_cache', +sc.value); if (typeof tcKick === 'function') tcKick(); toast('Saved'); }; }
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(); $('#stbtn').onclick = () => screenTimeSheet(); }
if (isAdmin) jfetch('/api/health').then(h => {
@@ -156,6 +158,7 @@ function viewSettings(args) {
(h.plex.configured ? row('Plex', h.plex.ok ? 'Answering in ' + h.plex.ms + ' ms' + (h.plex.user ? ' · using ' + esc(h.plex.user) : '') : esc(h.plex.error || 'Not answering'), !h.plex.ok) : row('Plex', 'Not set up', false))
+ (h.services || []).map(x => row(esc(x.name), x.ok ? 'Answering in ' + x.ms + ' ms' : esc(x.error || 'Not answering'), !x.ok)).join('')
+ (h.mail.length ? h.mail.map(m => row('Mail: ' + esc(m.address), m.ok ? 'Connected (' + m.ms + ' ms)' : esc(m.error || 'Could not connect'), !m.ok)).join('') : row('Mail', 'No account connected', false))
+ (h.push ? row('Reminders and push', h.push.devices ? h.push.devices + ' device' + (h.push.devices === 1 ? '' : 's') + ' for ' + h.push.people + (h.push.people === 1 ? ' person' : ' people') + ' · ' + h.push.sent + ' sent since the server started' + (h.push.expired ? ' · ' + h.push.expired + ' expired device' + (h.push.expired === 1 ? '' : 's') + ' removed' : '') + (h.push.failed ? ' · ' + h.push.failed + ' failed (last: ' + esc(h.push.lastErr) + ')' : '') : 'No device has turned reminders on yet', h.push.failed > 0 && h.push.lastErrAt > h.push.lastOk) : '')
+ (h.disk ? row('Disk', gb(h.disk.free) + ' free of ' + gb(h.disk.total), h.disk.free / h.disk.total < .1 || h.disk.free < 2e9) : '')
+ row('Last backup download', h.backup ? days(h.backup.t) + ' day' + (days(h.backup.t) === 1 ? '' : 's') + ' ago (by ' + esc(h.backup.who || '?') + ')' : 'Never. Use Download a backup below.', !h.backup || days(h.backup.t) > 30)
+ (h.transcripts && h.transcripts.enabled ? row('Transcripts', `${h.transcripts.saved} saved · ${h.transcripts.working ? 'working on ' + h.transcripts.working : 'idle'}${h.transcripts.failed ? ' · ' + h.transcripts.failed + ' failed recently' : ''} · ${h.transcripts.auto ? 'automatic for the newest ' + h.transcripts.keep + ' of each show' : 'automatic transcribing is off'}`, false) : '')
+13 -7
View File
@@ -7,12 +7,14 @@ const sgTime = ts => { const d = new Date(ts), n = new Date(); return d.toDateSt
const sgInitial = n => (String(n).replace(/^\+/, '').trim()[0] || '?').toUpperCase();
const sgColor = id => `hsl(${[...String(id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7)} 45% 42%)`;
const sgOnView = () => location.hash.startsWith('#/signal');
/** Where a Signal conversation opens: inside Chat when the person has both (the Chat list shows Signal chats too), otherwise the Signal screen. */
const sgHref = id => typeof sgInChat === 'function' && sgInChat() ? '#/chat/' + encodeURIComponent('sg:' + id) : '#/signal/' + encodeURIComponent(id);
const sgMounted = () => !!sg.root && document.contains(sg.root);
async function sgStart() { // keep the unread count and live updates running whatever screen is open
if (!allowed('signal') || sg.es) return;
try { const st = await sgApi('/status'); sg.st = st; sg.unread = st.unread || 0; renderNav(activeTab); } catch {}
sg.es = new EventSource('/api/signal/events');
sg.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } sgEvent(ev); };
sg.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } sgEvent(ev); if (typeof chSigChanged === 'function') chSigChanged(); };
}
function sgStop() { if (sg.es) { sg.es.close(); sg.es = null; } }
// ---- Getting your attention: a chime, a desktop notification when the app is in the background, the count in the tab title and on the app icon.
@@ -38,7 +40,7 @@ function sgAlert(ev) { // a message from someone else arrived and you are not lo
if (a.sound) sgChime();
if (a.notify && 'Notification' in window && Notification.permission === 'granted' && (document.hidden || !document.hasFocus())) {
try { const n = new Notification(ev.name || 'Signal', { body: ev.preview || 'New message', tag: 'sg:' + ev.conv, icon: '/icons/icon-192.png', silent: true });
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView()) location.hash = '#/signal/' + encodeURIComponent(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView() || sg.inChat) location.hash = sgHref(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
}
}
let sgEvT = null;
@@ -49,6 +51,9 @@ function sgEvent(ev) {
if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); }
}
if (ev.t === 'gone') sgGone(ev.conv, true);
if (sg.inChat) { // a Signal conversation is open inside Chat: refresh it and the combined list, never turn the Chat screen into the Signal screen
if (!sgMounted()) return; clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (sg.cur && ev.t !== 'status') sgDrawThread(false); if (typeof chSigChanged === 'function') chSigChanged(); }, 150); return;
}
if (!sgMounted()) return;
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
}
@@ -57,8 +62,9 @@ function sgEvent(ev) {
function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
// ... and the same screens in a panel that floats over every other screen.
async function sgMount(root, dock) {
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : '<h1>Signal</h1>';
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
sg.inChat = false; // (a full Signal screen or the floating panel; Chat sets this itself when it shows a Signal conversation)
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : `<h1>Signal</h1>${typeof sgInChat === 'function' && sgInChat() ? `<p class="sub" style="margin:0 0 8px"><a href="#/chat" style="color:var(--accent)">‹ Back to Chat</a> · your Signal chats are also listed there</p>` : ''}`;
root.innerHTML = `<div class="${cls}">${title}<p class="empty loading" role="status">Loading…</p></div>`;
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
if (sg.root !== root) return; sg.st = st;
if (!st.reachable) { root.innerHTML = `<div class="${cls}">${title}<div class="card" style="max-width:none"><p style="margin-top:0">The Signal service is not answering. Check that its container (<b>signal</b>) is running, then reload.</p></div></div>`; return; }
@@ -114,7 +120,7 @@ async function sgDrawThread(first) {
if (sg.cur !== id || !sgq('#sgm')) return; sg.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">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="#/signal" 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 || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span><button type="button" class="ibtn" id="sgmore" aria-label="Chat options" title="Chat options">${icon('more')}</button></div>
box.innerHTML = `<div class="sgbar">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="${sg.inChat ? '#/chat' : '#/signal'}" 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 || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span><button type="button" class="ibtn" id="sgmore" aria-label="Chat options" title="Chat options">${icon('more')}</button></div>
<div class="sgmsgs" id="sgmsgs"></div><div id="sgrep" class="sgrep" hidden></div><div id="sgtray" class="sgtray" hidden></div>
<form class="sgc" id="sgform"><button type="button" class="ibtn" id="sgat" aria-label="Attach a file">${icon('clip')}</button><input type="file" id="sgfile" multiple hidden>
<textarea id="sgtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button class="ibtn sgsend" id="sgsend" aria-label="Send">${icon('send')}</button></form>`;
@@ -186,7 +192,7 @@ function sgChatMenu(id) {
}
function sgGone(id, fromServer) {
sg.convs = sg.convs.filter(x => x.id !== id);
if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sgOnView()) location.hash = '#/signal'; }
if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sg.inChat) { location.hash = '#/chat'; } else if (sgOnView()) location.hash = '#/signal'; }
if (!fromServer || sgMounted()) sgRefresh();
}
function sgEditContact(c, after) {
@@ -205,7 +211,7 @@ async function sgNewChat() {
const input = el.querySelector('#sgnn'), draw = () => { const q = input.value.toLowerCase().trim(); el.querySelector('#sgcl').innerHTML = contacts.filter(c => !q || (c.name || '').toLowerCase().includes(q) || c.number.includes(q)).slice(0, 60).map(c => `<div class="row it"><button type="button" class="rmain" data-n="${esc(c.number)}"><span class="sgav" style="background:${sgColor(c.number)}">${esc(sgInitial(c.name || c.number))}</span><span class="grow"><b>${esc(c.name || c.number)}</b><small>${esc(c.number)}</small></span></button><button type="button" class="ibtn" data-e="${esc(c.number)}" aria-label="Edit ${esc(c.name || c.number)}" title="Edit contact">${icon('edit')}</button></div>`).join('');
el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n));
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { const k = contacts.find(x => x.number === b.dataset.e); sgEditContact(k, async () => { try { contacts = (await sgApi('/contacts')).contacts; } catch {} }); }); };
const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = '#/signal/' + encodeURIComponent(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = sgHref(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
input.oninput = draw; draw(); el.querySelector('#sgnf').onsubmit = e => { e.preventDefault(); go(input.value); }; setTimeout(() => input.focus(), 60);
}
function sgLinkScreen(root, dock) {
+47 -3
View File
@@ -29,19 +29,27 @@ async function viewStore(args = []) {
}
// ---------------------------------------------------------------- My Store
let stSelecting = false; const stPicked = new Set();
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stSeen = r.seen || {}; stAdmin = r.admin; return r; }
async function stMine(el) {
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const draw = () => {
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>
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 ? `<label class="btn ghost" style="cursor:pointer;margin:0 6px 0 0" title="Pick many EPUB, PDF, MOBI, AZW3 or DOCX files at once. Titles, authors and covers are read from the books.">${icon('plus')}Add many books<input type="file" id="stbulk" hidden multiple accept=".epub,.pdf,.mobi,.azw3,.azw,.docx"></label><button class="btn ghost" id="stsel" style="margin:0 6px 0 0">${stSelecting ? 'Cancel' : 'Select'}</button><button class="btn" id="stadd">${icon('plus')}Add</button>` : ''}</div>
${stSelecting ? `<div class="bar" style="justify-content:flex-start;position:sticky;top:0;z-index:3;background:var(--bg,var(--panel));padding:8px 0;flex-wrap:wrap"><b style="margin-right:8px">${stPicked.size} selected</b><button class="btn ghost" id="stall">Select all shown</button><button class="btn" id="stgive"${stPicked.size ? '' : ' disabled'}>Add to people…</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); });
el.querySelectorAll('[data-it]').forEach(a => a.onclick = e => { e.preventDefault(); const it = stItems.find(x => x.id === a.dataset.it);
if (stSelecting) { if (it.kind !== 'book' || !it.files.length) return toast('Only books can be given to people'); stPicked.has(it.id) ? stPicked.delete(it.id) : stPicked.add(it.id); return draw(); } if (a.dataset.run) location.hash = '#/store/ext/' + it.id; else stDetail(it, draw); });
if ($('#stadd')) $('#stadd').onclick = () => stEdit(null, draw);
if ($('#stsel')) $('#stsel').onclick = () => { stSelecting = !stSelecting; stPicked.clear(); if (stSelecting) stFilter = 'book'; draw(); };
el.querySelectorAll('[data-it]').forEach(a => a.classList.toggle('picked', stPicked.has(a.dataset.it)));
if ($('#stgive')) $('#stgive').onclick = () => stGive([...stPicked], () => { stSelecting = false; stPicked.clear(); draw(); });
if ($('#stall')) $('#stall').onclick = () => { const bs = shown.filter(i => i.kind === 'book' && i.files.length); bs.every(i => stPicked.has(i.id)) ? stPicked.clear() : bs.forEach(i => stPicked.add(i.id)); draw(); };
if ($('#stbulk')) $('#stbulk').onchange = e => { const fs = [...e.target.files]; e.target.value = ''; if (fs.length) stBulk(fs, draw); };
};
draw();
}
@@ -128,7 +136,7 @@ function stPickFile() {
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>';
const box = el.querySelector('#stpk'); box.innerHTML = '<p class="empty loading" role="status">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>`;
@@ -246,3 +254,39 @@ async function stHomeExt(el) {
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>`;
}
// Bulk add books: each file goes up on its own and the server reads the title, author and cover out of the book.
async function stBulk(files, redraw) {
let stop = false, done = 0; const added = [], dups = [], failed = [], noCover = [], noAuthor = [];
const el = openSheet('Adding books', '', '<p class="sub" id="sbn"></p><div style="height:6px;border-radius:3px;background:var(--line);overflow:hidden"><div id="sbp" style="height:100%;width:0;background:var(--accent)"></div></div><p class="sub" id="sbl" style="margin-top:10px;word-break:break-word"></p><div id="sbr"></div><div class="bar"><button class="btn ghost" id="sbx" style="flex:1;justify-content:center">Stop</button></div>');
const q = s => el.querySelector(s); q('#sbx').onclick = () => { if (done >= files.length || stop) closeSheet(); else { stop = true; q('#sbx').textContent = 'Stopping…'; } };
for (const f of files) {
if (stop) break;
q('#sbn').textContent = `Book ${done + 1} of ${files.length}`; q('#sbl').textContent = f.name;
try {
const r = await stApi('/import?name=' + encodeURIComponent(f.name), { method: 'POST', body: f, headers: { 'content-type': 'application/octet-stream' } });
if (r.duplicate) dups.push(f.name); else { added.push(r.item); if (r.noCover) noCover.push(r.item.title); if (r.noAuthor) noAuthor.push(r.item.title); }
} catch (x) { failed.push(f.name + ': ' + x.message); }
q('#sbp').style.width = (++done / files.length * 100) + '%';
}
const list = (t, a) => a.length ? `<details style="margin:6px 0"><summary>${t} (${a.length})</summary><div class="sub" style="max-height:25dvh;overflow:auto;word-break:break-word">${a.map(esc).join('<br>')}</div></details>` : '';
q('#sbn').textContent = `${stop ? 'Stopped. ' : ''}Added ${added.length} of ${files.length} books`; q('#sbl').textContent = '';
q('#sbr').innerHTML = list('Already in the store, skipped', dups) + list('No cover found', noCover) + list('No author found', noAuthor) + list('Could not be added', failed) + ((noCover.length || noAuthor.length) ? '<p class="sub">Open a book and tap Edit to fix its author or cover.</p>' : '');
q('#sbx').textContent = 'Done'; done = files.length;
try { await stLoad(); } catch {} redraw();
}
// Give the chosen books to people: they land on each person's shelf (a person without the Books tab is skipped, and a book they already have is left alone).
async function stGive(ids, after) {
let people; try { people = await stApi('/people'); } catch (e) { return toast(e.message); }
const el = openSheet('Add to people', ids.length + (ids.length === 1 ? ' book' : ' books'), `<div class="list" style="max-height:50dvh;overflow:auto">${people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-u="${esc(p.id)}"${p.books ? '' : ' disabled'}><span class="grow"><b>${esc(p.name)}</b>${p.books ? '' : '<small>No Books tab</small>'}</span></label>`).join('')}</div>
<div class="err" id="sterr"></div><div class="bar"><button class="btn ghost" id="sgx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="sgok" style="flex:1;justify-content:center">Add</button></div>`);
el.querySelector('#sgx').onclick = closeSheet;
el.querySelector('#sgok').onclick = async e => {
const us = [...el.querySelectorAll('[data-u]:checked')].map(c => c.dataset.u), err = m => { el.querySelector('#sterr').textContent = m; }; if (!us.length) return err('Choose at least one person');
e.currentTarget.disabled = true; e.currentTarget.textContent = 'Adding…';
try { const r = await stApi('/assign', stOpt('POST', { items: ids, users: us })); closeSheet(); const n = r.people.reduce((a, p) => a + p.added, 0), had = r.people.reduce((a, p) => a + p.had, 0), bad = r.people.reduce((a, p) => a + p.failed, 0);
toast(`Added ${n} ${n === 1 ? 'book' : 'books'} to ${r.people.filter(p => p.added).length} ${r.people.filter(p => p.added).length === 1 ? 'person' : 'people'}` + (had ? `, ${had} already had it` : '') + (bad ? `, ${bad} failed` : '')); after();
} catch (x) { err(x.message); e.currentTarget.disabled = false; e.currentTarget.textContent = 'Add'; }
};
}
+122 -8
View File
@@ -146,8 +146,8 @@ body.editing #view { padding: 0; display: flex; flex-direction: column; }
/* Music */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.seg { display: flex; gap: 6px; margin: 12px 0; overflow-x: auto; }
.seg a { padding: 8px 16px; border-radius: 999px; background: var(--panel2); color: var(--muted); white-space: nowrap; min-height: 40px; display: flex; align-items: center; }
.seg { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
.seg a { padding: 8px 14px; border-radius: 999px; background: var(--panel2); color: var(--muted); white-space: nowrap; min-height: 40px; display: flex; align-items: center; }
.seg a.on { background: var(--accent); color: var(--accent-t); font-weight: 600; }
.agrid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.acard { display: block; min-width: 0; }
@@ -659,8 +659,9 @@ body.nav-mini .ngrp span { display: none; }
.vspin i { display: block; width: 44px; height: 44px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .3); border-top-color: #fff; animation: vspin 1s linear infinite; } @keyframes vspin { to { transform: rotate(360deg); } }
#vnextbox { position: absolute; right: 12px; bottom: 96px; z-index: 5; max-width: calc(100% - 24px); }
#vnextbox .vnext { background: rgba(20, 22, 28, .92); color: #fff; margin: 0; }
.vstage.vfull { position: fixed; inset: 0; z-index: 90; border-radius: 0; }
.vstage.vfull video, .vstage:fullscreen video { height: 100dvh; max-height: none; object-fit: contain; }
.vstage.vfull { position: fixed; inset: 0 0 var(--playerh, 0px) 0; z-index: 90; border-radius: 0; }
body.vfullwin #player { position: fixed; left: 0; right: 0; bottom: 0; z-index: 91; }
.vstage.vfull video, .vstage:fullscreen video { height: calc(100dvh - var(--playerh, 0px)); max-height: none; object-fit: contain; }
.vstage:fullscreen { border-radius: 0; background: #000; width: 100%; height: 100%; }
.vstage:fullscreen video { height: 100%; }
@media (max-width: 560px) { .vright input[type=range], #vmute { display: none; } .vb { min-width: 40px; } .vb.txt { font-size: 13px; padding: 0 6px; } }
@@ -689,7 +690,7 @@ body.nav-mini .ngrp span { display: none; }
.ofilters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 0; }
.ofilters input[type=search] { flex: 1; min-width: 180px; margin: 0; }
.orecent { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
.octile.rc { padding: 10px 12px; }
.octile.rc { padding: 10px 12px; } .octile .ic.ofic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; flex: none; } .octile .ic.ofic svg { width: 20px; height: 20px; margin-top: -5px; } .octile .ic.ofic em { bottom: 3px; }
.orow .rmain { min-width: 0; }
#ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; }
@@ -705,7 +706,6 @@ body.nav-mini .ngrp span { display: none; }
.vmb .vskip em { font-size: 8px; top: 6px; }
/* The video's controls live in the bottom bar (like Music); the overlay strip is only for full screen */
.vstage:not(:fullscreen):not(.vfull) .vctl { display: none; }
.vtop .vright { display: flex; align-items: center; gap: 2px; margin-left: auto; }
.vtop .vtitle { flex: 0 1 auto; max-width: 38%; }
.vtop .vtc { flex: 0 1 auto; }
@@ -922,10 +922,13 @@ body.mselecting .mrow { user-select: none; -webkit-user-select: none; -webkit-to
.rdonow { min-height: 40px; } .rdonow small { display: block; color: var(--muted); } .rdonow b { font-size: 17px; }
/* Menu: Listen/Read sub-buttons, the Apps sheet behind More, choosing the bottom bar */
#subnav { display: flex; gap: 6px; overflow-x: auto; margin: 0; padding: 6px calc(var(--sar) + 12px) 2px calc(var(--sal) + 12px); scrollbar-width: none; flex: none; }
#subnav { display: flex; flex-wrap: nowrap; gap: 6px; overflow-x: auto; margin: 0 0 0 6px; padding: 0 2px; scrollbar-width: none; flex: 1; min-width: 0; }
#subnav::-webkit-scrollbar { display: none; } #subnav[hidden], body.editing #subnav { display: none; }
#subnav a { min-height: 36px; padding: 6px 14px; font-size: 14px; }
@media (min-width: 768px) { #subnav { padding-left: 24px; } }
#navbar:has(#subnav:not([hidden])) #nvt { display: none; } /* the pills say where you are */
@media (max-width: 767px) { body.subtabs #view .wrap > h1:first-child, body.subtabs #view .ptop > h1:first-child { display: none; } body.subtabs .ptop { margin-top: 4px; } body.subtabs .ptop .btn { margin-left: 0; flex: 1 1 auto; justify-content: center; } body.subtabs #navbar #nvf { display: none; } #subnav a { padding: 6px 12px; } }
/* a page's own tab row stays on one line and scrolls sideways instead of wrapping into several rows */
@media (max-width: 767px) { #view .seg:not([style*="wrap"]):not(.segphone) { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin: 8px 0; } #view .seg:not([style*="wrap"]) a { min-height: 36px; flex: none; } #view .seg::-webkit-scrollbar { display: none; } }
#sheet .sheet.launcher { height: 92dvh; max-height: 92dvh; display: flex; flex-direction: column; }
#sheet .sheet.launcher #lnb { flex: 1; overflow-y: auto; margin: 4px -4px 0; padding: 0 4px; }
#sheet .sheet.launcher #lnq { width: 100%; margin-top: 4px; }
@@ -1106,3 +1109,114 @@ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simpleho
html.rm *, html.rm *::before, html.rm *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-decoration: underline; } html.hc .btn.ghost, html.hc select, html.hc input[type=text], html.hc input[type=search], html.hc textarea { border: 2px solid var(--line); } html.hc :focus-visible { outline-width: 3px; }
#offnote { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--sab) + 70px); z-index: 90; max-width: min(92vw, 460px); padding: 10px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0, 0, 0, .3); font-size: 14px; text-align: center; } #offnote[hidden] { display: none; }
.paTiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin: 8px 0 16px; } .paTile { min-height: 84px; padding: 12px; border-radius: 16px; border: 1px solid var(--line); background: var(--panel); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 17px; } .paTile small { color: var(--muted); font-size: 14px; } .paTile:active { transform: scale(.97); } .paTile.low { border-color: var(--danger); } .paTile.out { opacity: .45; }
.gifgrid { columns: 3 110px; column-gap: 6px; margin: 8px 0; } .gifc { display: block; width: 100%; margin: 0 0 6px; padding: 0; border: 0; border-radius: 10px; overflow: hidden; background: var(--panel2); break-inside: avoid; min-height: 0; cursor: pointer; } .gifc img { display: block; width: 100%; height: auto; } .gifc:focus-visible { outline: 3px solid var(--accent); }
.srcbadge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; background: var(--panel2); color: var(--muted); font-size: 11px; vertical-align: middle; }
.otplbox { margin: 12px 0 0; } .otplbox summary { cursor: pointer; padding: 10px 2px; list-style: none; } .otplbox summary::-webkit-details-marker { display: none; } .otplbox summary::before { content: '▸ '; color: var(--accent); } .otplbox[open] summary::before { content: '▾ '; } .otplbox summary small { color: var(--muted); margin-left: 6px; }
.otpl { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .octile.otc small { white-space: normal; }
.sgin { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; }
/* Now Playing sheet: cover, title, seek, controls, and a row of extras */
#sheet .sheet.npsheet h3:empty { display: none; }
#sheet .sheet.npsheet { padding-top: 14px; }
.np .art { width: min(68vw, 320px); border-radius: 14px; margin: 4px auto 18px; box-shadow: 0 18px 40px rgba(0, 0, 0, .45), 0 2px 6px rgba(0, 0, 0, .3); }
.np .npmeta { padding: 0 8px; }
.np .npmeta h3 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: -.01em; word-break: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.np .npmeta .sub { margin: 4px 0 0; font-size: 14px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.np .npseek { margin: 16px 0 0; }
.np .ctl { gap: 8px; margin: 6px 0 14px; }
.np .ctl .ibtn.big { width: 64px; height: 64px; background: var(--accent); color: var(--accent-t); border-radius: 50%; }
.np .npchips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; padding-top: 14px; border-top: 1px solid var(--line); }
.npchip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: var(--text, inherit); font: inherit; font-size: 13px; cursor: pointer; transition: background .15s; }
.npchip:hover { background: var(--panel2); } .npchip svg { width: 16px; height: 16px; } .npchip[hidden] { display: none; }
.npchip.on { color: var(--danger); border-color: currentColor; }
/* full screen: art and words side by side, the player along the bottom */
#lyr .fsside, #lyr .fsbar, #lyr .fsonly { display: none; }
#lyr.fs { display: grid; grid-template-columns: minmax(300px, 42%) 1fr; grid-template-rows: auto 1fr auto auto; }
#lyr.fs .lyrtop { grid-column: 1 / -1; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: hidden; }
#lyr.fs .fsside { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; padding: 0 3vw; min-height: 0; text-align: center; grid-column: 1; grid-row: 2; }
#lyr.fs .fsimg { width: min(100%, 46vh, 460px); aspect-ratio: 1; object-fit: cover; border-radius: 16px; background: var(--panel2); box-shadow: 0 24px 60px rgba(0, 0, 0, .55); margin-bottom: 18px; }
#lyr.fs .fsside b { font-size: clamp(20px, 2.4vw, 30px); letter-spacing: -.01em; } #lyr.fs .fsside small { color: var(--muted); font-size: 15px; }
#lyr.fs .lyrbody { grid-column: 2; grid-row: 2; padding: 20vh 4vw 26vh 2vw; text-align: left; } #lyr.fs.kar .lyrbody { padding: 8vh 4vw 8vh 2vw; }
#lyr.fs .fsbar { display: flex; grid-column: 1 / -1; align-items: center; gap: 12px; padding: 8px max(4vw, 20px) 0; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
#lyr.fs .fsbar input { flex: 1; accent-color: var(--accent); }
#lyr.fs .lyrfoot { grid-column: 1 / -1; border-top: 0; background: transparent; justify-content: center; padding-top: 4px; }
#lyr.fs .lyrfoot .grow { flex: 0; } #lyr.fs .fsonly { display: inline-grid; }
@media (max-width: 760px) { #lyr.fs { display: flex; } #lyr.fs .fsside { display: none; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: visible; } #lyr.fs .lyrbody { padding: 24vh 20px 30vh; text-align: center; } #lyr.fs .fsbar { display: none; } }
/* no lyrics: the queue fills the space */
.lyrq { max-width: 560px; margin: 18px auto 0; text-align: left; }
.lyrq h4 { margin: 0 0 8px; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.lyrqrow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.lyrqrow:hover { background: color-mix(in srgb, var(--panel2) 70%, transparent); } .lyrqrow.now { background: var(--panel2); }
.lyrqrow .num { width: 22px; text-align: center; color: var(--muted); font-size: 13px; flex: none; } .lyrqrow.now .num { color: var(--accent); }
.lyrqrow .grow { min-width: 0; } .lyrqrow b, .lyrqrow small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lyrqrow small { color: var(--muted); font-size: 12px; }
#lyr .lyrbody:has(.lyrq) { padding-top: 10vh; padding-bottom: 16vh; } #lyr .lyrbody:has(.lyrq) .empty { padding-top: 0; text-align: center; }
.mini .vbvol { width: 84px; accent-color: var(--accent); } .mini .ibtn.txt { width: auto; min-width: 40px; font-size: 13px; font-weight: 600; padding: 0 6px; }
/* full screen: the bottom bar slides away with the other controls after a few idle seconds */
body.vfullwin #player { transition: transform .25s ease, opacity .25s; }
body.vfullwin:has(.vstage.vhide) #player { transform: translateY(100%); opacity: 0; pointer-events: none; }
body.vfullwin:has(.vstage.vhide) .vstage.vfull { bottom: 0; } body.vfullwin:has(.vstage.vhide) .vstage.vfull video { height: 100dvh; }
/* album and playlist pages on a phone: Play and Shuffle are the two big buttons, the rest sit under them */
.mprime .btn { flex: 1; justify-content: center; min-height: 48px; font-size: 16px; }
.msecond { margin-top: -4px; } .msecond:empty { display: none; }
.msecond .btn { min-height: 40px; font-size: 14px; }
/* playlist cover: the songs' art in a grid, like Plex */
.mosaic { display: grid; grid-template-columns: 1fr 1fr; width: 100%; aspect-ratio: 1; border-radius: 12px; overflow: hidden; background: var(--panel2); }
.mosaic img { width: 100%; height: 100%; object-fit: cover; display: block; } .mosaic.one { grid-template-columns: 1fr; }
.mhead .mosaic { width: 38vw; max-width: 200px; flex: none; border-radius: 14px; } .acard .mixart { display: block; }
/* Now Playing sheet: the close button sits over the corner instead of pushing the art aside */
#sheet .sheet.npsheet .sheetx { float: none; display: grid; place-items: center; margin: 0 0 -36px auto; background: rgba(0, 0, 0, .45); backdrop-filter: blur(6px); color: #fff; }
.seg.segphone { display: none; }
@media (max-width: 640px) { .seg.segwide { display: none; } .seg.segphone { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; } .seg.segphone a { padding: 0 2px; justify-content: center; font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; } }
/* the bottom bars (music and video): a playhead you can see and grab, at the end of the played part */
.mini .bar2 { overflow: visible; }
.mini .bar2 i::after { content: ''; position: absolute; right: -8px; top: 50%; width: 16px; height: 16px; margin-top: -8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent), 0 1px 4px rgba(0, 0, 0, .5); transition: transform .12s; pointer-events: none; }
.mini .bar2:active i::after { transform: scale(1.35); }
.mini .bar2::before { top: -14px; bottom: -12px; }
.mini .bar2[hidden] i::after { display: none; }
/* the queue docked down the right side on wide screens */
#qdock { position: fixed; right: 0; top: 0; width: 340px; display: flex; flex-direction: column; background: var(--panel); border-left: 1px solid var(--line); z-index: 40; }
body.qdock #view { padding-right: 340px; }
#qdock .qhead { display: flex; align-items: center; gap: 8px; padding: 14px 8px 10px 16px; } #qdock .qhead b { font-size: 16px; } #qdock .qhead small { color: var(--muted); flex: 1; }
#qdock .qlist { flex: 1; overflow-y: auto; padding: 0 8px; }
#qdock .qrow { display: flex; align-items: center; border-radius: 10px; } #qdock .qrow:hover { background: var(--panel2); } #qdock .qrow.now { background: var(--panel2); }
#qdock .qmain { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 8px 6px; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; }
#qdock .num { width: 22px; text-align: center; color: var(--muted); font-size: 13px; flex: none; } #qdock .qrow.now .num, #qdock .qrow.now b { color: var(--accent); }
#qdock .grow { min-width: 0; } #qdock b, #qdock small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #qdock .qmain small { color: var(--muted); font-size: 12px; }
#qdock .qfoot { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); } #qdock .qfoot .btn { flex: 1; justify-content: center; }
/* touch screens (iPad): pressing and holding does not select text or open the image menu, and a double tap does not zoom.
Text you would want to copy (mail, notes, messages, the page text in the reader) and every text field stay selectable. */
@media (pointer: coarse) {
html, body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
input, textarea, select, [contenteditable], .mbody, .mbody *, .ntbody, .ntbody *, .sgmsgs, .sgmsgs *, .textLayer, .textLayer *, .selectable, .selectable * { -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; }
img, svg, a, button { -webkit-touch-callout: none; -webkit-user-drag: none; }
input, textarea, select { font-size: max(16px, 1em); } /* (iPadOS zooms the page when a smaller field is focused) */
}
/* My Store: books ticked for "Add to people" */
.vcard.picked { outline: 3px solid var(--accent); outline-offset: 2px; border-radius: 12px; }
/* "Loading…" placeholders: a soft shimmer so a slow tab reads as working, not stuck (still text for screen readers; static when motion is reduced) */
.empty.loading { background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--muted) 16%, transparent) 50%, transparent 100%) 0 0 / 200% 100% no-repeat; animation: loadsweep 1.4s ease-in-out infinite; border-radius: 12px; }
@keyframes loadsweep { from { background-position: 150% 0; } to { background-position: -50% 0; } }
@media (prefers-reduced-motion: reduce) { .empty.loading { animation: none; } }
/* Photo viewer for Signal and Chat images (image-viewer.js) */
#imgview { position: fixed; inset: 0; z-index: 400; background: rgba(0, 0, 0, .92); display: flex; align-items: center; justify-content: center; padding: calc(var(--sat, 0px) + 56px) 12px calc(var(--sab, 0px) + 12px); }
#imgview img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; touch-action: pinch-zoom; -webkit-touch-callout: default; user-select: auto; }
#imgview .ivx { position: absolute; top: calc(var(--sat, 0px) + 8px); right: 12px; background: rgba(255, 255, 255, .16); color: #fff; }
#imgview .ivsave { position: absolute; top: calc(var(--sat, 0px) + 16px); left: 16px; color: #fff; font-size: 16px; padding: 6px 12px; border-radius: 16px; background: rgba(255, 255, 255, .16); text-decoration: none; cursor: pointer; }
#askpill { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 10px); left: 50%; transform: translateX(-50%); z-index: 70; max-width: calc(100vw - 24px); padding: 9px 18px; border-radius: 99px; border: 0; background: var(--danger); color: #fff; font: inherit; font-weight: 700; font-size: 14px; box-shadow: 0 4px 16px rgba(0,0,0,.35); cursor: pointer; animation: askpulse 1.8s ease-in-out infinite; }
@keyframes askpulse { 50% { box-shadow: 0 4px 22px rgba(217,69,58,.85); } }
@media (prefers-reduced-motion: reduce) { #askpill { animation: none; } }
body:has(#sheet) #askpill { display: none; }
.abnew { position: absolute; top: 6px; left: 6px; font-style: normal; font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 99px; background: var(--accent); color: #fff; }
.abf.on { background: var(--accent); color: #fff; border-color: var(--accent); }
+4 -2
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v122';
const CACHE = 'hub-v145';
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
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'];
@@ -86,7 +86,7 @@ self.addEventListener('fetch', e => {
// Push reminders (see push.js on the server)
self.addEventListener('push', e => {
let d = {}; try { d = e.data.json(); } catch {}
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } }));
e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, requireInteraction: !!d.urgent, renotify: !!d.urgent, vibrate: d.urgent ? [200, 100, 200, 100, 200] : undefined, actions: (d.actions || []).slice(0, 2), data: { url: d.url || '#/home', req: d.req } }));
});
// The push service can replace a device's subscription: make a new one and give it to the server so reminders keep arriving.
self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => {
@@ -98,6 +98,8 @@ self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => {
})()));
self.addEventListener('notificationclick', e => {
e.notification.close();
const rid = e.notification.data && e.notification.data.req; // "Yes" / "Not now" on a child's request answers it without opening the app
if (rid && (e.action === 'yes' || e.action === 'no')) return e.waitUntil(fetch('/api/kid/requests/' + rid, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ approve: e.action === 'yes' }) }).then(r => r.ok ? self.registration.showNotification(e.action === 'yes' ? 'Approved' : 'Declined', { body: 'The child has been told.', icon: '/icons/icon-192.png', tag: 'ans-done', silent: true }) : self.clients.openWindow('/#/settings/family')).catch(() => self.clients.openWindow('/#/settings/family')));
const url = '/' + ((e.notification.data && e.notification.data.url) || '');
e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(cs => {
const c = cs.find(x => new URL(x.url).origin === self.location.origin);
+10 -10
View File
@@ -7,7 +7,7 @@ let taskLists = []; // the lists (NE Hub and iCloud), so a task seen in "All" op
async function viewTasks() {
const v = $('#view');
v.innerHTML = '<div class="wrap">' + plannerHead('tasks') + '<p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap">' + plannerHead('tasks') + '<p class="empty loading" role="status">Loading…</p></div>';
try {
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)
@@ -18,7 +18,7 @@ async function viewTasks() {
v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
<div class="seg"><a href="#" data-l="today" class="${cur === 'today' ? 'on' : ''}">Today</a><a href="#" data-l="upcoming" class="${cur === 'upcoming' ? 'on' : ''}">Upcoming</a>${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${['all', 'today', 'upcoming'].includes(cur) ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
<div id="tl"><p class="empty">Loading…</p></div>
<div id="tl"><p class="empty loading" role="status">Loading…</p></div>
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
: icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : '';
@@ -159,7 +159,7 @@ function chooseFolder(title, start, opts = {}) {
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
const finish = v => { done = true; closeSheet(); resolve(v); };
const draw = async () => {
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty">Loading…</p>';
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
try {
if (!repo) {
const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name));
@@ -193,26 +193,26 @@ function chooseFolder(title, start, opts = {}) {
}
// Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor).
async function createFromTemplate(repo, dir, name, x) {
async function createFromTemplate(repo, dir, name, x, tpl) {
const enc = encodeURIComponent;
if (!['docx', 'xlsx', 'pptx'].includes(x)) {
await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) });
return name;
}
const tpl = await fetch('/templates/blank.' + x); if (!tpl.ok) throw new Error('Template missing');
const tplRes = await fetch('/templates/' + (tpl || 'blank.' + x)); if (!tplRes.ok) throw new Error('Template missing');
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tpl.blob(), name);
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tplRes.blob(), name);
const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Could not create ' + name);
return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken
}
async function newOfficeDoc(x, label) {
async function newOfficeDoc(x, label, tpl, defName) {
if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; }
let loc;
try { loc = await officeLocation(true); } catch (e) { return toast(e.message); }
if (!loc) return;
const el = openSheet('New ' + label, '', `<form><label for="nn">File name</label><input type="text" id="nn" value="Untitled" autocomplete="off" autocapitalize="off" required>
const el = openSheet(tpl ? 'New from template' : 'New ' + label, tpl ? label : '', `<form><label for="nn">File name</label><input type="text" id="nn" value="${esc(defName || 'Untitled')}" autocomplete="off" autocapitalize="off" required>
<label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="ne"></div></form>`);
const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); };
@@ -221,7 +221,7 @@ async function newOfficeDoc(x, label) {
el.querySelector('#nl').onclick = async () => {
// Sheets don't stack, so close this one, choose, then reopen with the new location.
const picked = await chooseFolder('Save in…', loc);
if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label);
if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label, tpl, defName);
};
setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50);
el.querySelector('form').onsubmit = async e => {
@@ -230,7 +230,7 @@ async function newOfficeDoc(x, label) {
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
try {
const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name);
const name = await createFromTemplate(loc.repo, loc.dir, want, x, tpl), path = joinP(loc.dir, name);
addRecent(loc.repo, path, name); closeSheet();
if (cfg.editor) location.hash = officeHash(loc.repo, path);
else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+174
View File
@@ -0,0 +1,174 @@
'use strict';
// ---- Passwords: your Vaultwarden vault. The vault stays end-to-end encrypted; unlocking asks for your master password, which goes to the server's
// Bitwarden tool only to open it for a few idle minutes. Lists never carry passwords: a password, code or note is fetched only when you open that entry.
const vtReq = async (p, body) => {
const r = await fetch('/api/vault' + p, body === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), j, { status: r.status });
return j;
};
const vtState = { q: '', folder: '', items: null, folders: [] };
const vtType = { 1: 'Login', 2: 'Note', 3: 'Card', 4: 'Identity' };
let vtClip = 0;
/** Copy something private, and clear the clipboard again after half a minute if it is still ours. */
async function vtCopy(text, what) {
try { await navigator.clipboard.writeText(text); toast(what + ' copied (cleared in 30 seconds)'); clearTimeout(vtClip); vtClip = setTimeout(async () => { try { if ((await navigator.clipboard.readText()) === text) await navigator.clipboard.writeText(''); } catch { try { await navigator.clipboard.writeText(''); } catch {} } }, 30000); }
catch { toast('Could not copy'); }
}
const vtHost = u => { try { return new URL(/^[a-z]+:\/\//i.test(u) ? u : 'https://' + u).hostname.replace(/^www\./, ''); } catch { return ''; } };
const vtSafeUrl = u => { try { const x = new URL(/^[a-z]+:\/\//i.test(u) ? u : 'https://' + u); return /^https?:$/.test(x.protocol) ? x.href : ''; } catch { return ''; } };
async function viewVault() {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Passwords</h1><p class="empty loading" role="status">Loading…</p></div>';
let st; try { st = await vtReq('/status'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Passwords</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/vault')) return;
st.unlocked ? vtList(st) : vtUnlock(st);
}
function vtUnlock(st, need2fa) {
const v = $('#view'), host = vtHost((typeof cfg !== 'undefined' && cfg.vault) || st.url);
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Passwords</h1></div>
<form id="vtf" class="card" style="max-width:420px;display:grid;gap:10px" autocomplete="off">
<p class="sub" style="margin:0">Unlock your vault on <b>${esc(host)}</b>. Your master password is only used to open it and is never saved.</p>
${st.loggedIn ? `<p class="sub" style="margin:0">Signed in as <b>${esc(st.email)}</b></p>` : '<label for="vte">Email address</label><input type="email" id="vte" autocomplete="username" autocapitalize="off" required>'}
<label for="vtp">Master password</label><input type="password" id="vtp" autocomplete="current-password" required>
${need2fa ? '<label for="vtc">Authenticator code</label><input type="text" id="vtc" inputmode="numeric" autocomplete="one-time-code" maxlength="8" required>' : ''}
<p class="err" id="vterr" role="alert"></p>
<div class="bar" style="margin:0"><button class="btn" id="vtgo">Unlock</button>${st.loggedIn ? '<button type="button" class="btn ghost" id="vtout">Use a different account</button>' : ''}</div>
<p class="sub" style="margin:0">No account yet? <a href="${esc(vtSafeUrl(st.url) ? st.url.replace(/\/+$/, '') + '/#/signup' : '#')}" target="_blank" rel="noopener" style="color:var(--accent)">Create one on ${esc(host)}</a>, then come back here to unlock it.</p>
</form></div>`;
const f = $('#vtf'); setTimeout(() => (f.querySelector(need2fa ? '#vtc' : st.loggedIn ? '#vtp' : '#vte') || {}).focus?.(), 0);
if ($('#vtout')) $('#vtout').onclick = () => confirmSheet('Use a different account?', 'This signs the hub out of the vault. Nothing is deleted from the vault itself.', 'Sign out of vault', async () => { try { await vtReq('/forget', {}); viewVault(); } catch (e) { toast(e.message); } });
f.onsubmit = async e => {
e.preventDefault(); $('#vterr').textContent = ''; $('#vtgo').disabled = true;
try { const body = { password: $('#vtp').value }; if ($('#vte')) body.email = $('#vte').value; if ($('#vtc')) body.code = $('#vtc').value; const r = await vtReq('/unlock', body); $('#vtp').value = ''; vtState.items = null; vtList(r); }
catch (err) { if (err.need2fa) return vtUnlock({ ...st, email: st.email || ($('#vte') && $('#vte').value) }, true) || void 0; $('#vterr').textContent = err.message; $('#vtgo').disabled = false; }
};
}
async function vtList(st) {
const v = $('#view');
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Passwords</h1><span class="pspace grow"></span><button class="btn" id="vtnew">${icon('plus')}New</button><button class="btn ghost" id="vtgen">Generate</button><button class="btn ghost" id="vtsend">Send</button><button class="btn ghost" id="vtsync" aria-label="Refresh from the server" title="Refresh">${icon('refresh')}</button><button class="btn ghost" id="vtlock">Lock</button></div>
<input type="search" id="vtq" placeholder="Search your vault" value="${esc(vtState.q)}" autocomplete="off" aria-label="Search your vault" style="width:100%;margin-bottom:8px"><div id="vtfo"></div><div id="vtl"><p class="empty loading" role="status">Loading…</p></div>
<p class="sub">Locks itself after a few idle minutes. <a href="${esc(vtSafeUrl(st.url) || '#')}" target="_blank" rel="noopener" style="color:var(--accent)">Open the full vault</a> for cards, identities and the trash.</p></div>`;
const lockedOut = () => { vtState.items = null; vtUnlock({ ...st, unlocked: false }); };
$('#vtnew').onclick = () => vtEdit(null, () => load(), lockedOut);
$('#vtgen').onclick = () => vtGenerate();
$('#vtsend').onclick = () => vtSend(lockedOut);
$('#vtlock').onclick = async () => { try { await vtReq('/lock', {}); } catch {} lockedOut(); toast('Vault locked'); };
$('#vtsync').onclick = async () => { try { await vtReq('/sync', {}); await load(); toast('Refreshed'); } catch (e) { e.locked ? lockedOut() : toast(e.message); } };
let timer; $('#vtq').oninput = e => { vtState.q = e.target.value; clearTimeout(timer); timer = setTimeout(load, 250); };
async function load() {
try { const r = await vtReq('/items?q=' + encodeURIComponent(vtState.q.trim())); if (!location.hash.startsWith('#/vault')) return; vtState.items = r.items; vtState.folders = r.folders; draw(); }
catch (e) { if (e.locked) return lockedOut(); $('#vtl').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
}
function draw() {
const fo = vtState.folders; $('#vtfo').innerHTML = fo.length ? `<div class="seg" role="tablist"><a href="#" data-f="" class="${vtState.folder ? '' : 'on'}">All</a>${fo.map(f => `<a href="#" data-f="${esc(f.id)}" class="${vtState.folder === f.id ? 'on' : ''}">${esc(f.name)}</a>`).join('')}</div>` : '';
$('#vtfo').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); vtState.folder = a.dataset.f; draw(); });
const rows = vtState.items.filter(i => !vtState.folder || i.folderId === vtState.folder);
$('#vtl').innerHTML = rows.length ? `<div class="list">${rows.map(i => `<button class="row" data-id="${esc(i.id)}"><span class="ic" style="background:${i.type === 1 ? '#1f6fb2' : i.type === 3 ? '#b25a1f' : '#6a7280'}">${icon(i.type === 1 ? 'key' : i.type === 3 ? 'store' : 'doc')}</span><span class="grow"><b>${esc(i.name)}</b><small>${esc(i.username || vtHost(i.uri) || vtType[i.type] || '')}</small></span>${i.favorite ? '<span aria-label="Favorite">★</span>' : ''}</button>`).join('')}</div>` : `<p class="empty">${vtState.q ? 'Nothing matches.' : 'Your vault is empty.'}</p>`;
$('#vtl').querySelectorAll('.row').forEach(b => b.onclick = () => vtOpen(b.dataset.id, lockedOut, load));
}
if (vtState.items) draw(); load();
}
async function vtOpen(id, lockedOut, reload) {
let it; try { it = await vtReq('/item/' + encodeURIComponent(id)); } catch (e) { return e.locked ? lockedOut() : toast(e.message); }
const field = (label, value, key, secret) => value ? `<div class="vtf" style="margin:10px 0"><small style="color:var(--muted)">${esc(label)}</small><div style="display:flex;gap:8px;align-items:center"><span class="grow vtv" data-k="${key}" style="word-break:break-all;font-family:${secret ? 'ui-monospace,monospace' : 'inherit'}">${secret ? '••••••••••' : esc(value)}</span>${secret ? `<button class="btn ghost vtshow" data-k="${key}">Show</button>` : ''}<button class="btn ghost vtcp" data-k="${key}" data-w="${esc(label)}">Copy</button></div></div>` : '';
const vals = { user: it.username, pass: it.password, totp: it.totp };
const extra = []; if (it.card) { vals.cnum = it.card.number; vals.ccode = it.card.code; }
(it.fields || []).forEach((f, n) => { vals['f' + n] = f.value; });
const links = it.uris.map(vtSafeUrl).filter(Boolean);
const el = openSheet(it.name || 'Entry', vtType[it.type] || '', `${field('Username', it.username, 'user')}${field('Password', it.password, 'pass', true)}${field('One-time code', it.totp, 'totp')}
${it.card ? field('Cardholder', it.card.holder, 'chold') + field('Card number', it.card.number, 'cnum', true) + field('Security code', it.card.code, 'ccode', true) + field('Expires', [it.card.expMonth, it.card.expYear].filter(Boolean).join('/'), 'cexp') : ''}
${(it.fields || []).map((f, n) => field(f.name || 'Field', f.value, 'f' + n, f.hidden)).join('')}
${links.map(u => `<div class="vtf" style="margin:10px 0"><small style="color:var(--muted)">Website</small><div><a href="${esc(u)}" target="_blank" rel="noopener" style="color:var(--accent);word-break:break-all">${esc(u)}</a></div></div>`).join('')}
${it.notes ? `<div class="vtf" style="margin:10px 0"><small style="color:var(--muted)">Notes</small><div style="white-space:pre-wrap">${esc(it.notes)}</div></div>` : ''}`);
if (it.type === 1 || it.type === 2) el.insertAdjacentHTML('beforeend', '<div class="bar"><button class="btn ghost" id="vted" style="flex:1;justify-content:center">Edit</button></div>');
if ($('#vted')) $('#vted').onclick = () => { closeSheet(); vtEdit(it, reload, lockedOut); };
Object.assign(vals, { chold: it.card && it.card.holder, cexp: it.card && [it.card.expMonth, it.card.expYear].filter(Boolean).join('/') });
el.querySelectorAll('.vtcp').forEach(b => b.onclick = () => vtCopy(vals[b.dataset.k], b.dataset.w));
el.querySelectorAll('.vtshow').forEach(b => b.onclick = () => { const s = el.querySelector(`.vtv[data-k="${b.dataset.k}"]`), on = b.textContent === 'Show'; s.textContent = on ? vals[b.dataset.k] : '••••••••••'; b.textContent = on ? 'Hide' : 'Show'; });
}
/** Make a random password or passphrase. */
function vtGenerate() {
const o = { type: 'password', length: 16, upper: 1, lower: 1, number: 1, special: 1, words: 4, capitalize: 1, inum: 0 };
const el = openSheet('Generate a password', 'Made on the server by the Bitwarden tool. Nothing is saved.', `<div class="seg"><a href="#" data-t="password" class="on">Password</a><a href="#" data-t="passphrase">Passphrase</a></div>
<div id="vgv" style="font-family:ui-monospace,monospace;word-break:break-all;font-size:18px;padding:12px;border-radius:10px;background:var(--panel2);margin:8px 0;min-height:28px"></div>
<div class="bar" style="margin:0 0 8px"><button class="btn" id="vgc" style="flex:1;justify-content:center">Copy</button><button class="btn ghost" id="vgr" style="flex:1;justify-content:center">New one</button></div>
<div id="vgo"></div><p class="err" id="vge" role="alert"></p>`);
let value = '';
const gen = async () => {
const q = new URLSearchParams(o.type === 'password' ? { type: 'password', length: o.length, upper: o.upper, lower: o.lower, number: o.number, special: o.special } : { type: 'passphrase', words: o.words, capitalize: o.capitalize, number: o.inum });
try { value = (await vtReq('/generate?' + q)).value; el.querySelector('#vgv').textContent = value; el.querySelector('#vge').textContent = ''; } catch (e) { el.querySelector('#vge').textContent = e.message; }
};
const opts = () => {
el.querySelector('#vgo').innerHTML = o.type === 'password'
? `<label for="vgl">Length: <b>${o.length}</b></label><input type="range" id="vgl" min="8" max="64" value="${o.length}" style="width:100%">${[['upper', 'A-Z'], ['lower', 'a-z'], ['number', '0-9'], ['special', '!@#$']].map(([k, l]) => `<label style="display:inline-flex;gap:6px;align-items:center;margin:8px 14px 0 0"><input type="checkbox" data-k="${k}" ${o[k] ? 'checked' : ''}>${l}</label>`).join('')}`
: `<label for="vgw">Words: <b>${o.words}</b></label><input type="range" id="vgw" min="3" max="10" value="${o.words}" style="width:100%"><label style="display:inline-flex;gap:6px;align-items:center;margin:8px 14px 0 0"><input type="checkbox" data-k="capitalize" ${o.capitalize ? 'checked' : ''}>Capitalize</label><label style="display:inline-flex;gap:6px;align-items:center;margin:8px 0 0"><input type="checkbox" data-k="inum" ${o.inum ? 'checked' : ''}>Add a number</label>`;
const r = el.querySelector('#vgl') || el.querySelector('#vgw'); r.oninput = () => { o[o.type === 'password' ? 'length' : 'words'] = +r.value; r.previousElementSibling.querySelector('b').textContent = r.value; }; r.onchange = gen;
el.querySelectorAll('#vgo input[type=checkbox]').forEach(c => c.onchange = () => { o[c.dataset.k] = c.checked ? 1 : 0; gen(); });
};
el.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); o.type = a.dataset.t; el.querySelectorAll('.seg a').forEach(x => x.classList.toggle('on', x === a)); opts(); gen(); });
el.querySelector('#vgc').onclick = () => value && vtCopy(value, 'Password'); el.querySelector('#vgr').onclick = gen;
opts(); gen();
}
/** Bitwarden Send: a link that shows some text once (or a few times) and then goes away. */
async function vtSend(lockedOut) {
const el = openSheet('Send', 'Share some text through a private link that deletes itself.', `<form id="vsf" style="display:grid;gap:8px" autocomplete="off">
<label for="vsn">Name (only you see it)</label><input type="text" id="vsn" maxlength="100" placeholder="Wi-Fi password for the sitter">
<label for="vst">Text to share</label><textarea id="vst" rows="4" maxlength="10000" required></textarea>
<div class="bar" style="margin:0"><label style="flex:1">Deletes after<select id="vsd" style="width:100%"><option value="1">1 day</option><option value="7" selected>7 days</option><option value="31">31 days</option></select></label>
<label style="flex:1">Max views<input type="number" id="vsm" min="0" max="9999" value="1" style="width:100%" aria-describedby="vsmh"></label></div><small id="vsmh" style="color:var(--muted)">0 for no limit on views.</small>
<label for="vsp">Password to open it (optional)</label><input type="text" id="vsp" autocomplete="off" maxlength="200">
<p class="err" id="vse" role="alert"></p><button class="btn" id="vsg" style="justify-content:center">Make link</button></form><div id="vsr"></div><h2 style="margin-top:18px">Your sends</h2><div id="vsl"><p class="empty loading">Loading…</p></div>`);
const drawList = async () => {
try { const r = await vtReq('/sends'); el.querySelector('#vsl').innerHTML = r.sends.length ? `<div class="list">${r.sends.map(x => `<div class="row"><span class="grow"><b>${esc(x.name)}</b><small>${x.max ? x.views + ' of ' + x.max + ' views' : x.views + ' views'}${x.expires ? ' · deletes ' + esc(new Date(x.expires).toLocaleDateString()) : ''}</small></span><button class="btn ghost" data-c="${esc(x.url)}">Copy link</button><button class="btn ghost" data-d="${esc(x.id)}" aria-label="Delete ${esc(x.name)}">Delete</button></div>`).join('')}</div>` : '<p class="empty">No sends yet.</p>';
el.querySelectorAll('[data-c]').forEach(b => b.onclick = () => vtCopy(b.dataset.c, 'Link'));
el.querySelectorAll('[data-d]').forEach(b => b.onclick = async () => { try { await vtReq('/sends/delete', { id: b.dataset.d }); drawList(); } catch (e) { toast(e.message); } }); }
catch (e) { if (e.locked) { closeSheet(); return lockedOut(); } el.querySelector('#vsl').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
el.querySelector('#vsf').onsubmit = async e => {
e.preventDefault(); el.querySelector('#vse').textContent = ''; el.querySelector('#vsg').disabled = true;
try { const r = await vtReq('/sends', { name: el.querySelector('#vsn').value, text: el.querySelector('#vst').value, days: el.querySelector('#vsd').value, max: el.querySelector('#vsm').value, password: el.querySelector('#vsp').value });
el.querySelector('#vst').value = ''; el.querySelector('#vsp').value = '';
el.querySelector('#vsr').innerHTML = `<div class="card" style="margin-top:10px"><small style="color:var(--muted)">Your link</small><div style="word-break:break-all;margin:4px 0 8px">${esc(r.url)}</div><button class="btn" id="vscp" style="justify-content:center">Copy link</button></div>`;
el.querySelector('#vscp').onclick = () => vtCopy(r.url, 'Link'); vtCopy(r.url, 'Link'); drawList(); }
catch (err) { if (err.locked) { closeSheet(); return lockedOut(); } el.querySelector('#vse').textContent = err.message; }
el.querySelector('#vsg').disabled = false;
};
drawList();
}
/** Add an entry (it === null) or change one. Logins and secure notes; cards and identities are changed on the Vaultwarden site. */
function vtEdit(it, reload, lockedOut) {
const isNew = !it, type = it ? it.type : 1;
const el = openSheet(isNew ? 'New entry' : 'Edit ' + (it.name || 'entry'), '', `<form id="vef" style="display:grid;gap:8px" autocomplete="off">
${isNew ? '<div class="seg" id="vet"><a href="#" data-t="1" class="on">Login</a><a href="#" data-t="2">Secure note</a></div>' : ''}
<label for="ven">Name</label><input type="text" id="ven" maxlength="1000" required value="${esc(it ? it.name : '')}">
<div id="vel">
<label for="veu">Username</label><input type="text" id="veu" autocapitalize="off" autocomplete="off" value="${esc(it ? it.username : '')}">
<label for="vep">Password</label><div style="display:flex;gap:8px"><input type="text" id="vep" class="grow" autocapitalize="off" autocomplete="new-password" style="font-family:ui-monospace,monospace" value="${esc(it ? it.password : '')}"><button type="button" class="btn ghost" id="veg">Generate</button></div>
<label for="vew">Website</label><input type="text" id="vew" autocapitalize="off" placeholder="https://" value="${esc(it ? it.uris[0] || '' : '')}">
<label for="vek">Authenticator key (optional)</label><input type="text" id="vek" autocapitalize="off" autocomplete="off" value="${esc(it ? it.totpSeed : '')}">
</div>
<label for="vex">Notes</label><textarea id="vex" rows="3" maxlength="10000">${esc(it ? it.notes : '')}</textarea>
<label for="vef2">Folder</label><select id="vef2"><option value="">No folder</option>${vtState.folders.map(f => `<option value="${esc(f.id)}" ${it && it.folderId === f.id ? 'selected' : ''}>${esc(f.name)}</option>`).join('')}</select>
<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" id="vefv" ${it && it.favorite ? 'checked' : ''}>Favorite</label>
<p class="err" id="vee" role="alert"></p>
<div class="bar" style="margin:0"><button class="btn" id="ves" style="flex:1;justify-content:center">Save</button>${isNew ? '' : '<button type="button" class="btn ghost" id="ved" style="flex:1;justify-content:center">Delete</button>'}</div></form>`);
let t = type; const sync = () => { el.querySelector('#vel').hidden = t === 2; };
el.querySelectorAll('#vet a').forEach(a => a.onclick = e => { e.preventDefault(); t = +a.dataset.t; el.querySelectorAll('#vet a').forEach(x => x.classList.toggle('on', x === a)); sync(); }); sync();
if (it && it.type !== 1) el.querySelector('#vel').hidden = true;
el.querySelector('#veg').onclick = async () => { try { el.querySelector('#vep').value = (await vtReq('/generate?type=password&length=20&upper=1&lower=1&number=1&special=1')).value; } catch (e) { toast(e.message); } };
el.querySelector('#vef').onsubmit = async e => {
e.preventDefault(); el.querySelector('#vee').textContent = ''; el.querySelector('#ves').disabled = true;
const q = id => el.querySelector(id).value;
try { await vtReq('/item', { id: it && it.id, type: t, name: q('#ven'), username: q('#veu'), password: q('#vep'), uris: [q('#vew')], totp: q('#vek'), notes: q('#vex'), folderId: q('#vef2'), favorite: el.querySelector('#vefv').checked });
closeSheet(); toast(isNew ? 'Added' : 'Saved'); reload && reload(); }
catch (err) { if (err.locked) { closeSheet(); return lockedOut(); } el.querySelector('#vee').textContent = err.message; el.querySelector('#ves').disabled = false; }
};
const del = el.querySelector('#ved'); if (del) del.onclick = () => confirmSheet('Delete this entry?', 'It moves to the vault\'s trash on the Vaultwarden site, where it can be restored.', 'Delete', async () => { try { await vtReq('/item/delete', { id: it.id }); toast('Deleted'); reload && reload(); } catch (err) { toast(err.message); } });
}
+1 -1
View File
@@ -20,7 +20,7 @@ const vbDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLoca
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>';
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Verse By Verse</h1><p class="empty loading" role="status">Loading…</p></div>';
try { await vbLoadProg(); if (args[0] === 'study' && args[1]) return await vbStudy(args[1]); return await vbHome(); }
catch (e) { if (!location.hash.startsWith('#/podcast/vbv')) return; v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/podcast">${icon('back')}Podcast</a><h1>Verse By Verse</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="vbretry">Try again</button><a class="btn ghost" href="https://versebyverseministry.org" target="_blank" rel="noopener">Open the website</a></div></div>`; $('#vbretry').onclick = () => route(); }
}
+245
View File
@@ -0,0 +1,245 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
========================================================================
jai-imageio
========================================================================
Copyright (c) 2005 Sun Microsystems, Inc.
Copyright © 2010-2014 University of Manchester
Copyright © 2010-2015 Stian Soiland-Reyes
Copyright © 2015 Peter Hull
All Rights Reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistribution of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistribution in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
Neither the name of Sun Microsystems, Inc. or the names of
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
This software is provided "AS IS," without a warranty of any
kind. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS AND
WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE OR NON-INFRINGEMENT, ARE HEREBY
EXCLUDED. SUN MIDROSYSTEMS, INC. ("SUN") AND ITS LICENSORS SHALL
NOT BE LIABLE FOR ANY DAMAGES SUFFERED BY LICENSEE AS A RESULT OF
USING, MODIFYING OR DISTRIBUTING THIS SOFTWARE OR ITS
DERIVATIVES. IN NO EVENT WILL SUN OR ITS LICENSORS BE LIABLE FOR
ANY LOST REVENUE, PROFIT OR DATA, OR FOR DIRECT, INDIRECT, SPECIAL,
CONSEQUENTIAL, INCIDENTAL OR PUNITIVE DAMAGES, HOWEVER CAUSED AND
REGARDLESS OF THE THEORY OF LIABILITY, ARISING OUT OF THE USE OF OR
INABILITY TO USE THIS SOFTWARE, EVEN IF SUN HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
You acknowledge that this software is not designed or intended for
use in the design, construction, operation or maintenance of any
nuclear facility.
+1
View File
@@ -0,0 +1 @@
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).ZXing={})}(this,(function(t){"use strict";var e,r,n=(e=function(t,r){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},e(t,r)},function(t,r){if("function"!=typeof r&&null!==r)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");function n(){this.constructor=t}e(t,r),t.prototype=null===r?Object.create(r):(n.prototype=r.prototype,new n)}),i=function(t){function e(e,r){var n,i,s,o=this.constructor,a=t.call(this,e,r)||this;return Object.defineProperty(a,"name",{value:o.name,enumerable:!1,configurable:!0}),n=a,i=o.prototype,(s=Object.setPrototypeOf)?s(n,i):n.__proto__=i,function(t,e){void 0===e&&(e=t.constructor);var r=Error.captureStackTrace;r&&r(t,e)}(a),a}return n(e,t),e}(Error);class s extends i{constructor(t=void 0){super(t),this.message=t}getKind(){return this.constructor.kind}}s.kind="Exception";class o extends s{}o.kind="ArgumentException";class a extends s{}a.kind="IllegalArgumentException";class h{constructor(t){if(this.binarizer=t,null===t)throw new a("Binarizer must be non-null.")}getWidth(){return this.binarizer.getWidth()}getHeight(){return this.binarizer.getHeight()}getBlackRow(t,e){return this.binarizer.getBlackRow(t,e)}getBlackMatrix(){return null!==this.matrix&&void 0!==this.matrix||(this.matrix=this.binarizer.getBlackMatrix()),this.matrix}isCropSupported(){return this.binarizer.getLuminanceSource().isCropSupported()}crop(t,e,r,n){const i=this.binarizer.getLuminanceSource().crop(t,e,r,n);return new h(this.binarizer.createBinarizer(i))}isRotateSupported(){return this.binarizer.getLuminanceSource().isRotateSupported()}rotateCounterClockwise(){const t=this.binarizer.getLuminanceSource().rotateCounterClockwise();return new h(this.binarizer.createBinarizer(t))}rotateCounterClockwise45(){const t=this.binarizer.getLuminanceSource().rotateCounterClockwise45();return new h(this.binarizer.createBinarizer(t))}toString(){try{return this.getBlackMatrix().toString()}catch(t){return""}}}class l extends s{static getChecksumInstance(){return new l}}l.kind="ChecksumException";class c{constructor(t){this.source=t}getLuminanceSource(){return this.source}getWidth(){return this.source.getWidth()}getHeight(){return this.source.getHeight()}}class d{static arraycopy(t,e,r,n,i){for(;i--;)r[n++]=t[e++]}static currentTimeMillis(){return Date.now()}}class u extends s{}u.kind="IndexOutOfBoundsException";class g extends u{constructor(t=void 0,e=void 0){super(e),this.index=t,this.message=e}}g.kind="ArrayIndexOutOfBoundsException";class f{static fill(t,e){for(let r=0,n=t.length;r<n;r++)t[r]=e}static fillWithin(t,e,r,n){f.rangeCheck(t.length,e,r);for(let i=e;i<r;i++)t[i]=n}static rangeCheck(t,e,r){if(e>r)throw new a("fromIndex("+e+") > toIndex("+r+")");if(e<0)throw new g(e);if(r>t)throw new g(r)}static asList(...t){return t}static create(t,e,r){return Array.from({length:t}).map((t=>Array.from({length:e}).fill(r)))}static createInt32Array(t,e,r){return Array.from({length:t}).map((t=>Int32Array.from({length:e}).fill(r)))}static equals(t,e){if(!t)return!1;if(!e)return!1;if(!t.length)return!1;if(!e.length)return!1;if(t.length!==e.length)return!1;for(let r=0,n=t.length;r<n;r++)if(t[r]!==e[r])return!1;return!0}static hashCode(t){if(null===t)return 0;let e=1;for(const r of t)e=31*e+r;return e}static fillUint8Array(t,e){for(let r=0;r!==t.length;r++)t[r]=e}static copyOf(t,e){return t.slice(0,e)}static copyOfUint8Array(t,e){if(t.length<=e){const r=new Uint8Array(e);return r.set(t),r}return t.slice(0,e)}static copyOfRange(t,e,r){const n=r-e,i=new Int32Array(n);return d.arraycopy(t,e,i,0,n),i}static binarySearch(t,e,r){void 0===r&&(r=f.numberComparator);let n=0,i=t.length-1;for(;n<=i;){const s=i+n>>1,o=r(e,t[s]);if(o>0)n=s+1;else{if(!(o<0))return s;i=s-1}}return-n-1}static numberComparator(t,e){return t-e}}class w{static numberOfTrailingZeros(t){let e;if(0===t)return 32;let r=31;return e=t<<16,0!==e&&(r-=16,t=e),e=t<<8,0!==e&&(r-=8,t=e),e=t<<4,0!==e&&(r-=4,t=e),e=t<<2,0!==e&&(r-=2,t=e),r-(t<<1>>>31)}static numberOfLeadingZeros(t){if(0===t)return 32;let e=1;return t>>>16==0&&(e+=16,t<<=16),t>>>24==0&&(e+=8,t<<=8),t>>>28==0&&(e+=4,t<<=4),t>>>30==0&&(e+=2,t<<=2),e-=t>>>31,e}static toHexString(t){return t.toString(16)}static toBinaryString(t){return String(parseInt(String(t),2))}static bitCount(t){return t=(t=(858993459&(t-=t>>>1&1431655765))+(t>>>2&858993459))+(t>>>4)&252645135,t+=t>>>8,63&(t+=t>>>16)}static truncDivision(t,e){return Math.trunc(t/e)}static parseInt(t,e=void 0){return parseInt(t,e)}}w.MIN_VALUE_32_BITS=-2147483648,w.MAX_VALUE=Number.MAX_SAFE_INTEGER;class C{constructor(t,e){void 0===t?(this.size=0,this.bits=new Int32Array(1)):(this.size=t,this.bits=null==e?C.makeArray(t):e)}gLine truncated
+31 -36
View File
@@ -29,7 +29,7 @@ async function vdNav(m, back) {
}
async function viewVideo(args = []) {
const v = $('#view'), [kind, key] = args;
v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
const st = await jfetch('/api/plex/status');
if (!location.hash.startsWith('#/video')) return;
@@ -118,7 +118,7 @@ async function videoHome(st, libKey) {
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
<input type="search" id="vq" placeholder="Search titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
<div id="vhero"></div><div id="vdeck"></div><div id="vmix" class="seg"></div><div id="vnight"></div><div id="vwlr"></div><div id="vfavr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty loading" role="status">Loading…</p></div><div id="vmore"></div></div>`;
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
@@ -310,38 +310,23 @@ function videoMount() {
const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage">
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b>
<div class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', vp.speed + '×', 'Speed', ' txt')}${castOk(vp.el) ? b('vcast', 'Cast', 'Play on a TV or speaker', ' txt') : ''}${b('vset', 'Quality & tracks', 'Quality and tracks', ' txt')}${document.pictureInPictureEnabled ? b('vpip', icon('pip'), 'Picture in picture') : ''}${b('vfs', icon('full'), 'Full screen')}${b('vclose', icon('x'), 'Close the video')}</div></div>
</div>
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
<div class="vctl"><div class="vseek"><span id="vcur">0:00</span><input type="range" id="vrange" min="0" max="1000" step="1" value="0" aria-label="Seek"><span id="vrem">-0:00</span></div>
<div class="vrow2"><div class="vleft">${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `<span class="vskip">${icon('rew')}<em>10</em></span>`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `<span class="vskip">${icon('fwd')}<em>10</em></span>`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}</div></div></div>
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
const stage = $('#vstage'); stage.prepend(el); el.controls = false;
$('#vvol').value = Math.round(el.volume * 100);
const total = () => (vp.m.duration || 0) / 1000;
let drag = false, hideT = null;
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => stage.classList.add('vhide'), 3000); };
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => { if (document.querySelector('#player:hover, #player:focus-within')) wake(); else stage.classList.add('vhide'); }, 3000); }; // (in full screen the bottom bar hides with the rest, but not while you are on it)
vp.wake = wake; vp.drag = () => drag;
vp.flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
$('#vpp').onclick = () => { el.paused ? el.play().catch(() => {}) : el.pause(); };
$('#vb10').onclick = () => videoSkip(-10); $('#vf10').onclick = () => videoSkip(10);
if ($('#vprev')) $('#vprev').onclick = () => videoGo(-1); if ($('#vnextep')) $('#vnextep').onclick = () => videoGo(1);
if ($('#vcast')) $('#vcast').onclick = () => castTo(vp && vp.el);
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
$('#vmute').onclick = () => { el.muted = !el.muted; };
$('#vclose').onclick = () => { const back = $('#vback').getAttribute('href'); videoStop(); location.hash = back; };
$('#vvol').oninput = e => { el.volume = e.target.value / 100; el.muted = el.volume === 0; store.set('video_vol', el.volume); };
$('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el.playbackRate = r; $('#vspd').textContent = r + '×'; } })));
const range = $('#vrange');
range.oninput = () => { drag = true; vdUi(); wake(); }; range.onchange = () => { const t = total() || el.duration || 0; videoSeek(range.value / 1000 * t); drag = false; wake(); };
let lastTap = 0, lastDbl = 0;
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
stage.addEventListener('pointerup', e => {
if (e.target.closest('.vctl, .vtop, #vnextbox, .vmark')) return;
if (e.target.closest('.vtop, #vnextbox, .vmark')) return;
if (e.pointerType === 'touch') {
const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width;
if (now - lastTap < 320 && (x < .35 || x > .65)) { videoSkip(x < .5 ? -10 : 10); lastTap = 0; wake(); return; }
lastTap = now; if (stage.classList.contains('vhide')) wake(); else { stage.classList.add('vhide'); clearTimeout(hideT); }
const wasHidden = vp.hidAtDown !== undefined ? vp.hidAtDown : stage.classList.contains('vhide'); vp.hidAtDown = undefined; // (the touch itself already woke the bar in full-window mode, so ask what it was when the finger went down)
lastTap = now; if (wasHidden) wake(); else { stage.classList.add('vhide'); clearTimeout(hideT); }
} else { if (e.detail === 2) { videoFullscreen(); return; } setTimeout(() => { if (Date.now() - lastDbl > 350) { el.paused ? el.play().catch(() => {}) : el.pause(); } }, 0); wake(); }
});
stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake);
@@ -414,11 +399,21 @@ function videoBar() {
el.innerHTML = `<div class="mini vbar"><div class="bar2" id="vbseek" role="slider" aria-label="Seek"><i id="vbp"></i></div><img class="art" alt="" src="${vdArt(m.thumb || m.parentThumb || m.grandparentThumb, 100, 150)}" onerror="this.style.visibility='hidden'">
<button class="meta" id="vbo" aria-label="Open the player"><b>${esc(ep ? m.title : m.title)}</b><small>${esc(ep ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index}` : [m.year, 'Movie'].filter(Boolean).join(' · '))}</small></button><span class="mtime" id="vbtm"></span>
${ep ? `<button class="ibtn wide-only" id="vbprev" aria-label="Previous episode" title="Previous episode">${icon('prev')}</button>` : ''}<button class="ibtn" id="vbb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="ibtn" id="vbpp" aria-label="Play or pause"></button><button class="ibtn" id="vbf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button>${ep ? `<button class="ibtn wide-only" id="vbnext" aria-label="Next episode" title="Next episode">${icon('next')}</button>` : ''}
<button class="ibtn wide-only" id="vmute" aria-label="Mute" title="Mute">${icon(vp.el.muted || vp.el.volume === 0 ? 'mute' : 'vol')}</button><input class="wide-only vbvol" type="range" id="vvol" min="0" max="100" value="${Math.round(vp.el.volume * 100)}" aria-label="Volume">
<button class="ibtn wide-only txt" id="vspd" aria-label="Playback speed" title="Playback speed">${vp.speed}×</button>${castOk(vp.el) ? `<button class="ibtn wide-only" id="vcast" aria-label="Play on a TV or speaker" title="Play on a TV or speaker">${icon('ext')}</button>` : ''}
<button class="ibtn" id="vset" aria-label="Quality and tracks" title="Quality and tracks">${icon('cog')}</button>${document.pictureInPictureEnabled ? `<button class="ibtn wide-only" id="vpip" aria-label="Picture in picture" title="Picture in picture">${icon('pip')}</button>` : ''}<button class="ibtn" id="vfs" aria-label="Full screen" title="Full screen">${icon('full')}</button>
<button class="ibtn wide-only" id="vbx" aria-label="Close the video" title="Close the video">${icon('x')}</button></div>`;
const q = s => el.querySelector(s);
q('#vbpp').onclick = () => { const v = vp && vp.el; if (v) v.paused ? v.play().catch(() => {}) : v.pause(); };
q('#vbb10').onclick = () => videoSkip(-10); q('#vbf10').onclick = () => videoSkip(10);
if (q('#vbprev')) q('#vbprev').onclick = () => videoGo(-1); if (q('#vbnext')) q('#vbnext').onclick = () => videoGo(1);
const el0 = vp.el;
q('#vmute').onclick = () => { el0.muted = !el0.muted; };
q('#vvol').oninput = e => { el0.volume = e.target.value / 100; el0.muted = el0.volume === 0; store.set('video_vol', el0.volume); };
q('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el0.playbackRate = r; q('#vspd').textContent = r + '×'; } })));
if (q('#vcast')) q('#vcast').onclick = () => castTo(vp && vp.el);
q('#vset').onclick = videoSettings; q('#vfs').onclick = videoFullscreen;
if (q('#vpip')) q('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el0.requestPictureInPicture(); } catch {} };
q('#vbo').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
q('#vbx').onclick = () => { const onPlayer = location.hash.startsWith('#/video/watch/'); const back = onPlayer && $('#vback') ? $('#vback').getAttribute('href') : null; videoStop(); if (back) location.hash = back; };
// drag along the line: the position and time follow your finger at once, and the video jumps once when you let go (a seek on every move would make a video stutter)
@@ -467,19 +462,19 @@ function videoGo(dir) {
/** Full screen: the browser's own full screen when it allows it (the player and its buttons stay), the phone's video full screen on iPhone,
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
function videoFullscreen() {
if (!vp) return; const box = document.querySelector('.vstage'), el = vp.el; if (!box) return;
const native = document.fullscreenElement || document.webkitFullscreenElement;
if (native) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); return; }
if (box.classList.contains('vfull')) { box.classList.remove('vfull'); document.body.classList.remove('vfullwin'); return; }
const req = box.requestFullscreen || box.webkitRequestFullscreen;
const fallback = () => { if (el.webkitEnterFullscreen && /iPhone|iPad|iPod/.test(navigator.userAgent)) { try { el.webkitEnterFullscreen(); return; } catch {} } box.classList.add('vfull'); document.body.classList.add('vfullwin'); };
if (!req) return fallback();
try { // some embedded browsers accept the request and then do nothing: if full screen has not started a moment later, use the full-window mode
const p = req.call(box); if (p && p.catch) p.catch(fallback);
setTimeout(() => { if (!document.fullscreenElement && !document.webkitFullscreenElement && !box.classList.contains('vfull') && vp) fallback(); }, 450);
} catch { fallback(); }
if (!vp) return; const box = document.querySelector('.vstage'); if (!box) return;
if (box.classList.contains('vfull')) return videoFsExit();
box.classList.add('vfull'); document.body.classList.add('vfullwin'); // (the picture fills the window; the bottom bar stays under it with the controls)
const root = document.documentElement, req = root.requestFullscreen || root.webkitRequestFullscreen;
if (req) { try { const p = req.call(root); if (p && p.catch) p.catch(() => {}); } catch {} } // (if the browser refuses, the full-window mode above is what you get)
}
document.addEventListener('fullscreenchange', () => { const b = $('#vfs'); if (b) b.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen'; });
function videoFsExit() {
const box = document.querySelector('.vstage.vfull'); if (box) box.classList.remove('vfull'); document.body.classList.remove('vfullwin');
if (document.fullscreenElement || document.webkitFullscreenElement) { try { (document.exitFullscreen || document.webkitExitFullscreen).call(document); } catch {} }
}
// in full screen, touching or moving over the picture or the bar brings the hidden bar back
['pointermove', 'pointerdown', 'keydown'].forEach(ev => addEventListener(ev, () => { if (vp && vp.wake && document.body.classList.contains('vfullwin')) { if (ev === 'pointerdown') vp.hidAtDown = !!document.querySelector('.vstage.vhide'); vp.wake(); } }, true));
document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && document.body.classList.contains('vfullwin')) videoFsExit(); });
/** The picture stopped on its own: start again from where it was, at most 4 times in a row, and say so. */
function videoRescue(why, at) {
if (!vp) return false; const now = Date.now(); if (now - (vp.rescueAt || 0) > 90000) vp.rescues = 0; if ((vp.rescues || 0) >= 4) return false;
@@ -568,7 +563,7 @@ addEventListener('keydown', e => {
else if (k === 'ArrowUp') { e.preventDefault(); el.volume = Math.min(1, el.volume + .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
else if (k === 'ArrowDown') { e.preventDefault(); el.volume = Math.max(0, el.volume - .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
else if (k === 'f') { e.preventDefault(); videoFullscreen(); }
else if (k === 'Escape') { const b = document.querySelector('.vstage.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } }
else if (k === 'Escape') videoFsExit();
else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1);
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
}, true);
@@ -591,7 +586,7 @@ async function videoEdit(m) {
el.querySelector('#sx').onclick = closeSheet;
const loaded = {};
const images = async (tab, what) => { // posters / backgrounds: pick one, or add from an image link
const box = el.querySelector('#vtab-' + tab); box.innerHTML = '<p class="empty">Loading…</p>';
const box = el.querySelector('#vtab-' + tab); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
try {
const mc = await px.get(`/library/metadata/${m.ratingKey}/${what}`), list = mc.Metadata || [];
box.innerHTML = `<div class="vimgs ${what}">${list.map((x, i) => `<button type="button" class="vpick ${x.selected ? 'on' : ''}" data-i="${i}" aria-label="Use this image"><img alt="" loading="lazy" src="${/^https?:/.test(x.thumb || '') ? esc(x.thumb) : vdArt(x.thumb, 200, 300)}"></button>`).join('') || '<p class="empty">No images yet.</p>'}</div>
+17 -3
View File
@@ -36,13 +36,23 @@ function localNow(tz, at = new Date()) {
return { day: `${p.year}-${p.month}-${p.day}`, hm: `${p.hour}:${p.minute}` };
}
// Since the server started: how many pushes went out, and what the push service last refused (shown to the admin in Settings > Admin)
const stats = { sent: 0, failed: 0, expired: 0, lastOk: 0, lastErr: '', lastErrAt: 0 };
function health() {
let devices = 0, people = 0;
for (const u of users.all()) { if (u.disabled) continue; const n = load(users.dirOf(u.id)).subs.length; devices += n; if (n) people++; }
return { ...stats, devices, people };
}
async function send(dir, st, uid, payload) {
if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag, ref: payload.ref }); // (reminders also land in the Notifications list)
keys(); const { ref, ...wire } = payload;
let changed = false;
await Promise.all(st.subs.map(async s => {
try { await webpush.sendNotification(s.sub, JSON.stringify(wire), { TTL: 3600 }); }
catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; } else console.warn('[push] send failed:', e.statusCode || e.message); }
try { await webpush.sendNotification(s.sub, JSON.stringify(wire), payload.urgent ? { TTL: 900, urgency: 'high' } : { TTL: 3600 }); stats.sent++; stats.lastOk = Date.now(); }
catch (e) {
if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; stats.expired++; }
else { stats.failed++; stats.lastErr = String(e.statusCode || e.message).slice(0, 120); stats.lastErrAt = Date.now(); console.warn('[push] send failed:', e.statusCode || e.message); }
}
}));
if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); }
}
@@ -181,5 +191,9 @@ async function handle(req, res) {
json(res, 404, { error: 'Not found' });
}
/** A notification for one person's devices (a chat message, say), unless they turned that kind off. */
/** Time-sensitive (a child asking for time, a parent's answer): goes to every device at once, ignores the chat on/off switch and quiet hours, stays on screen until seen, and is kept in Notifications too. */
async function notifyUrgent(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length) return 0; await send(dir, st, null, { ...payload, urgent: true }); return st.subs.length; }
/** Remember a person's time zone (the app reports it), so bedtime and reminders follow their own clock. */
function setTz(uid, tz) { if (!tz || !validTz(tz)) return; const dir = users.dirOf(uid), st = load(dir); if (st.tz === tz) return; st.tz = tz; try { save(dir, st); } catch {} }
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, null, payload); }
module.exports = { handle, start, tick, localNow, notify };
module.exports = { handle, start, tick, localNow, notify, notifyUrgent, setTz, health };
+167
View File
@@ -0,0 +1,167 @@
// Passwords (not vault.js, which encrypts stored tokens): each person's Vaultwarden / Bitwarden passwords, through the Bitwarden command-line tool (`bw`).
// The vault is end-to-end encrypted, so the hub only ever holds what a person types in to unlock it: the master password goes to `bw` through its
// environment (never the command line, never disk), and the session key `bw` hands back lives in memory only and is dropped after a few idle minutes.
// Each person gets their own `bw` data folder, so one person's unlocked vault is never reachable from another's session.
const fs = require('fs'), path = require('path'), { execFile } = require('child_process');
const users = require('./users');
const { readBody } = require('./body');
const URL_ = (process.env.VAULT_URL || '').replace(/\/+$/, '');
const BW = process.env.BW_BIN || 'bw';
const IDLE_MS = Math.max(1, +process.env.VAULT_IDLE_MIN || 10) * 60e3;
const enabled = () => !!URL_;
const sessions = new Map(); // user id -> { key, last }
const chains = new Map(); // user id -> the last bw call, so one person's calls run one at a time
const fails = new Map(); // user id -> { n, until }, slows down password guessing
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message, extra) => Object.assign(new Error(message), { code, extra });
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const appdata = uid => { const d = path.join(users.dirOf(uid), 'bw'); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; };
/** Run `bw` as one person. Secrets travel in the environment only. */
function bw(uid, args, { session, password, input, timeout = 45e3 } = {}) {
const env = { PATH: process.env.PATH, HOME: process.env.HOME || '/tmp', BITWARDENCLI_APPDATA_DIR: appdata(uid), BW_NOINTERACTION: 'true', NODE_NO_WARNINGS: '1' };
if (session) env.BW_SESSION = session;
if (password != null) env.BW_PASSWORD = password;
const run = () => new Promise((resolve, reject) => {
const child = execFile(BW, args, { env, timeout, maxBuffer: 16e6 }, (err, stdout, stderr) => {
if (err) { const e = new Error(String(stderr || err.message).trim().split('\n').pop() || 'The vault did not answer'); e.stdout = stdout; return reject(e); }
resolve(String(stdout));
});
if (input != null) child.stdin.end(input); // (a Send's text goes in on stdin, so it never shows in the process list)
});
const prev = chains.get(uid) || Promise.resolve(), p = prev.then(run, run);
chains.set(uid, p.catch(() => {}));
return p;
}
const parse = s => { try { return JSON.parse(s); } catch { throw fail(502, 'The vault gave an unexpected answer'); } };
const lock = uid => { const s = sessions.get(uid); sessions.delete(uid); if (s) bw(uid, ['lock']).catch(() => {}); };
setInterval(() => { const now = Date.now(); for (const [uid, s] of sessions) if (now - s.last > IDLE_MS) lock(uid); }, 30e3).unref();
const keyOf = uid => { const s = sessions.get(uid); if (!s) throw fail(401, 'The vault is locked', { locked: true }); s.last = Date.now(); return s.key; };
/** A bw failure for something that needs an unlocked vault: if bw says the session is bad, treat it as locked. */
const asLocked = e => { if (!e.code && /session|locked|not logged in|unauthenticated/i.test(e.message)) return fail(401, 'The vault is locked', { locked: true }); return e; };
const brief = i => ({ id: i.id, name: i.name || '', type: i.type, favorite: !!i.favorite, folderId: i.folderId || null,
username: i.login ? i.login.username || '' : '', uri: i.login && i.login.uris && i.login.uris[0] ? i.login.uris[0].uri || '' : '', hasTotp: !!(i.login && i.login.totp) });
const int = (v, lo, hi, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
/** A new random password or passphrase from bw (needs no unlocked vault). */
async function generate(uid, q) {
const args = ['generate'];
if (q.get('type') === 'passphrase') {
args.push('--passphrase', '--words', String(int(q.get('words'), 3, 20, 4)), '--separator', String(q.get('separator') || '-').slice(0, 1));
if (q.get('capitalize') === '1') args.push('--capitalize'); if (q.get('number') === '1') args.push('--includeNumber');
} else {
const flags = ['upper', 'lower', 'number', 'special'].filter(k => q.get(k) !== '0').map(k => ({ upper: 'u', lower: 'l', number: 'n', special: 's' })[k]);
args.push('-' + (flags.join('') || 'l'), '--length', String(int(q.get('length'), 5, 128, 16)));
}
return { value: (await bw(uid, args, { timeout: 15e3 })).trim() };
}
const enc = o => Buffer.from(JSON.stringify(o)).toString('base64');
const clip = (v, n) => String(v == null ? '' : v).slice(0, n);
/** Make or change a login or secure note. A change starts from the entry as it is now, so custom fields and anything not shown here are kept. */
async function saveItem(uid, b, key) {
const name = clip(b.name, 1000).trim(); if (!name) throw fail(400, 'Give the entry a name');
const folderId = b.folderId && ID.test(b.folderId) ? b.folderId : null;
const uris = (Array.isArray(b.uris) ? b.uris : []).map(u => clip(u, 1000).trim()).filter(Boolean).slice(0, 10);
let item;
if (b.id) { if (!ID.test(b.id)) throw fail(400, 'Bad item'); item = parse(await bw(uid, ['get', 'item', b.id], { session: key })); }
else { const type = +b.type === 2 ? 2 : 1; item = { organizationId: null, collectionIds: null, type, fields: [], reprompt: 0, login: type === 1 ? {} : undefined, secureNote: type === 2 ? { type: 0 } : undefined }; }
item.name = name; item.notes = clip(b.notes, 10000) || null; item.folderId = folderId; item.favorite = !!b.favorite;
if (item.type === 1) {
const l = item.login = item.login || {}; l.username = clip(b.username, 1000) || null; l.password = clip(b.password, 1000) || null; l.totp = clip(b.totp, 1000).trim() || null;
const old = l.uris || []; l.uris = uris.map(u => ({ ...(old.find(o => o.uri === u) || { match: null }), uri: u }));
}
const out = parse(await bw(uid, b.id ? ['edit', 'item', b.id] : ['create', 'item'], { session: key, input: enc(item) }));
bw(uid, ['sync'], { session: key }).catch(() => {});
return { id: out.id };
}
const sendBrief = x => ({ id: x.id, name: x.name || '', url: x.accessUrl || '', expires: x.deletionDate || '', views: x.accessCount || 0, max: x.maxAccessCount || 0, hasPassword: !!x.password || !!x.hasPassword, disabled: !!x.disabled });
/** A text Send: a link that shows the text once (or a few times) and then goes away. */
async function createSend(uid, b, key) {
const text = String(b.text || '').slice(0, 10000); if (!text.trim()) throw fail(400, 'Type what you want to send');
const days = int(b.days, 1, 31, 7), max = int(b.max, 0, 9999, 0);
const body = { type: 0, name: String(b.name || '').trim().slice(0, 100) || 'Text from NE Hub', notes: null, text: { text, hidden: !!b.hidden }, file: null, key: null,
maxAccessCount: max || null, deletionDate: new Date(Date.now() + days * 864e5).toISOString(), expirationDate: null, password: b.password ? String(b.password).slice(0, 200) : null, disabled: false, hideEmail: false };
const out = parse(await bw(uid, ['send', 'create'], { session: key, input: Buffer.from(JSON.stringify(body)).toString('base64') }));
return sendBrief(out);
}
async function status(uid) {
let st = { status: 'unauthenticated' }; try { st = parse(await bw(uid, ['status'], { timeout: 15e3 })); } catch {}
const s = sessions.get(uid);
return { enabled: true, url: URL_, unlocked: !!s && st.status === 'unlocked', email: st.userEmail || '', loggedIn: st.status !== 'unauthenticated' };
}
async function unlock(uid, b) {
const f = fails.get(uid); if (f && f.until > Date.now()) throw fail(429, 'Too many tries. Wait a few minutes and try again.');
const password = String(b.password || ''); if (!password) throw fail(400, 'Enter your master password');
const st = await status(uid); let out;
try {
if (!st.loggedIn) {
const email = String(b.email || '').trim(); if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw fail(400, 'Enter the email address you use for the vault');
await bw(uid, ['config', 'server', URL_]);
const args = ['login', email, '--passwordenv', 'BW_PASSWORD', '--raw'];
if (b.code) args.push('--method', '0', '--code', String(b.code).replace(/\s/g, '').slice(0, 12)); // 0 = authenticator app
out = await bw(uid, args, { password });
} else out = await bw(uid, ['unlock', '--passwordenv', 'BW_PASSWORD', '--raw'], { password });
} catch (e) {
if (e.code) throw e;
if (/two-?step|two-?factor|2fa|code/i.test(e.message) && !b.code) throw fail(400, 'Enter the 6-digit code from your authenticator app', { need2fa: true });
const n = ((fails.get(uid) || {}).n || 0) + 1; fails.set(uid, { n, until: n >= 5 ? Date.now() + 10 * 60e3 : 0 }); if (n >= 5) fails.get(uid).n = 0;
throw fail(401, /invalid|incorrect|password|username/i.test(e.message) ? 'That password did not work' : 'Could not unlock: ' + e.message);
}
fails.delete(uid);
const key = out.trim(); if (!key) throw fail(502, 'The vault did not give a session');
sessions.set(uid, { key, last: Date.now() });
await bw(uid, ['sync'], { session: key }).catch(() => {});
return status(uid);
}
async function handle(req, res) {
try {
const u = users.current(); if (!u) throw fail(401, 'Sign in first');
if (!enabled()) throw fail(404, 'The vault is not set up (VAULT_URL)');
const uid = u.id, p = new URL(req.url, 'http://x').pathname.replace(/^\/api\/vault/, '') || '/', q = new URL(req.url, 'http://x').searchParams;
if (p === '/status' && req.method === 'GET') return json(res, 200, await status(uid));
if (p === '/unlock' && req.method === 'POST') return json(res, 200, await unlock(uid, JSON.parse(await readBody(req, 1e4) || '{}')));
if (p === '/lock' && req.method === 'POST') { lock(uid); return json(res, 200, { ok: true }); }
if (p === '/forget' && req.method === 'POST') { lock(uid); await bw(uid, ['logout']).catch(() => {}); return json(res, 200, { ok: true }); } // sign this account out of the hub completely
if (p === '/sync' && req.method === 'POST') { try { await bw(uid, ['sync'], { session: keyOf(uid) }); } catch (e) { throw asLocked(e); } return json(res, 200, { ok: true }); }
if (p === '/item' && req.method === 'POST') { const key = keyOf(uid); try { return json(res, 200, await saveItem(uid, JSON.parse(await readBody(req, 5e4) || '{}'), key)); } catch (e) { throw asLocked(e); } }
if (p === '/item/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); if (!ID.test(id || '')) throw fail(400, 'Bad item'); const key = keyOf(uid); try { await bw(uid, ['delete', 'item', id], { session: key }); } catch (e) { throw asLocked(e); } bw(uid, ['sync'], { session: key }).catch(() => {}); return json(res, 200, { ok: true }); } // goes to the vault's trash, where it can still be restored
if (p === '/generate' && req.method === 'GET') return json(res, 200, await generate(uid, q));
if (p === '/sends' && req.method === 'GET') { try { return json(res, 200, { sends: parse(await bw(uid, ['send', 'list'], { session: keyOf(uid) })).filter(x => x.type === 0).map(sendBrief) }); } catch (e) { throw asLocked(e); } }
if (p === '/sends' && req.method === 'POST') { const key = keyOf(uid); try { return json(res, 200, await createSend(uid, JSON.parse(await readBody(req, 5e4) || '{}'), key)); } catch (e) { throw asLocked(e); } }
if (p === '/sends/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); if (!ID.test(id || '')) throw fail(400, 'Bad item'); try { await bw(uid, ['send', 'delete', id], { session: keyOf(uid) }); } catch (e) { throw asLocked(e); } return json(res, 200, { ok: true }); }
if (p === '/items' && req.method === 'GET') {
const key = keyOf(uid), search = String(q.get('q') || '').slice(0, 100).trim(), args = ['list', 'items'];
if (search) args.push('--search=' + search);
try { const items = parse(await bw(uid, args, { session: key })).filter(i => i.type === 1 || i.type === 2 || i.type === 3 || i.type === 4); // login, note, card, identity
let folders = []; try { folders = parse(await bw(uid, ['list', 'folders'], { session: key })).filter(f => f.id).map(f => ({ id: f.id, name: f.name })); } catch {}
return json(res, 200, { items: items.map(brief).sort((a, b) => (b.favorite - a.favorite) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })).slice(0, 1000), folders }); }
catch (e) { throw asLocked(e); }
}
let m;
if ((m = /^\/item\/([^/]+)$/.exec(p)) && req.method === 'GET') { // one entry in full, only when a person opens it
if (!ID.test(m[1])) throw fail(400, 'Bad item');
const key = keyOf(uid);
try {
const i = parse(await bw(uid, ['get', 'item', m[1]], { session: key })), l = i.login || {};
let totp = ''; if (l.totp) { try { totp = (await bw(uid, ['get', 'totp', m[1]], { session: key })).trim(); } catch {} }
const c = i.card || {};
return json(res, 200, { id: i.id, name: i.name || '', type: i.type, notes: i.notes || '', username: l.username || '', password: l.password || '', totp, totpSeed: l.totp || '', folderId: i.folderId || '', favorite: !!i.favorite,
uris: (l.uris || []).map(x => x.uri).filter(Boolean),
card: i.type === 3 ? { holder: c.cardholderName || '', brand: c.brand || '', number: c.number || '', code: c.code || '', expMonth: c.expMonth || '', expYear: c.expYear || '' } : null,
fields: (i.fields || []).filter(f => f.type !== 3).map(f => ({ name: f.name || '', value: f.value || '', hidden: f.type === 1 })) });
} catch (e) { throw asLocked(e); }
}
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message, ...(e.extra || {}) }); }
}
module.exports = { handle, enabled, url: () => URL_ };
+12 -26
View File
@@ -104,18 +104,7 @@ const DAY = /^\d{4}-\d{2}-\d{2}$/, MEALS = ['breakfast', 'lunch', 'dinner'], pla
const readPlan = () => { try { const d = JSON.parse(fs.readFileSync(planFile(), 'utf8')); return { days: d && d.days && typeof d.days === 'object' ? d.days : {} }; } catch { return { days: {} }; } };
const writePlan = d => atomicWrite(planFile(), JSON.stringify(d), { mode: 0o600 });
const planBetween = (from, to) => { const out = {}; for (const [k, v] of Object.entries(readPlan().days)) if (k >= from && k <= to) out[k] = v; return out; };
// The pantry: what the family keeps in stock, one shared list. Each item has a name, how many are left (blank when you do not count it) and the level it counts as
// running low at (blank: never). The meal plan points out planned ingredients you have; an item going low can start an automation and goes on the shopping list in a tap.
const pantryFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'pantry.json');
const num = v => v === '' || v == null || !isFinite(+v) || +v < 0 ? null : Math.min(100000, Math.round(+v * 100) / 100);
const readPantry = () => { try { const a = JSON.parse(fs.readFileSync(pantryFile(), 'utf8')); return (Array.isArray(a) ? a : []).map(x => typeof x === 'string' ? { id: crypto.randomBytes(4).toString('hex'), name: x, qty: null, low: null } : x).filter(x => x && x.name); } catch { return []; } };
const writePantry = l => atomicWrite(pantryFile(), JSON.stringify(l), { mode: 0o600 });
const isLow = x => x.qty != null && x.low != null && x.qty <= x.low;
const pantryView = l => l.map(x => ({ id: x.id, name: x.name, qty: x.qty, low: x.low, isLow: isLow(x) })).sort((a, b) => b.isLow - a.isLow || a.name.localeCompare(b.name));
const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, '');
const inPantry = (text, items) => { items = items.map(x => typeof x === 'string' ? x : x.name); const words = new Set(String(text).toLowerCase().match(/[a-z]+/g) || []), stems = new Set([...words].map(stem)); return items.filter(it => { const ws = (String(it).toLowerCase().match(/[a-z]+/g) || []).map(stem); return ws.length && ws.every(w => stems.has(w)); }); };
/** An item has just gone low: tell the automations of everyone who asked to hear about it. */
function pantryLow(it) { try { require('./automations').fireAll('pantry.low', { title: it.name }); } catch (e) { console.warn('[recipes] pantry rules failed:', e.message); } }
const pantry = require('./pantry'), U = require('./units'); // what is in stock (the plan checks its ingredients against it)
/** A recipe's ingredients onto Mealie's shopping list, `times` over (Mealie adds up what is already there). */
async function addRecipe(slug, times) {
const r = await mealie('/api/recipes/' + (slugOk(slug) ? slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +times || 1));
@@ -164,22 +153,19 @@ async function handle(req, res) {
if (d.days[b.date].length > 12) throw fail(400, 'That day is full'); writePlan(d); return json(res, 200, { ok: true, id });
}
if (p === '/plan/remove' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), d = readPlan(); for (const k of Object.keys(d.days)) { d.days[k] = d.days[k].filter(e => e.id !== b.id); if (!d.days[k].length) delete d.days[k]; } writePlan(d); return json(res, 200, { ok: true }); }
if (p === '/pantry' && req.method === 'GET') return json(res, 200, { items: pantryView(readPantry()) });
if (p === '/pantry/item' && req.method === 'POST') { // add an item, or change one
const b = JSON.parse(await readBody(req, 2e3) || '{}'), l = readPantry(), name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the item a name');
const i = b.id ? l.findIndex(x => x.id === b.id) : l.findIndex(x => x.name.toLowerCase() === name.toLowerCase()); if (b.id && i < 0) throw fail(404, 'That item is gone'); if (i < 0 && l.length >= 400) throw fail(400, 'The pantry is full');
const before = i >= 0 ? isLow(l[i]) : false, it = { id: i >= 0 ? l[i].id : crypto.randomBytes(4).toString('hex'), name: i >= 0 && !b.id ? l[i].name : name, qty: num(b.qty), low: num(b.low) }; if (i >= 0) l[i] = it; else l.push(it); writePantry(l);
if (isLow(it) && !before) pantryLow(it);
return json(res, 200, { items: pantryView(l) });
if (p === '/cook-plan' && req.method === 'GET') { // what cooking this recipe would take out of the pantry (nothing is changed here; the person confirms in the pantry)
const slug = q.get('slug') || '', scale = Math.max(0.25, Math.min(20, +q.get('scale') || 1)); if (!slugOk(slug)) throw fail(400, 'Pick a recipe');
const r = await mealie('/api/recipes/' + slug), ings = (r.recipeIngredient || []).filter(i => i && !(i.title && !i.display && !i.originalText && !i.food)).map(i => {
const line = ingredientText(i), qn = +i.quantity, food = i.food && (i.food.name || i.food), uname = i.unit && (i.unit.name || i.unit);
if (qn > 0 && food) return { amount: qn, unit: U.unitOf(uname) || '', name: [food, i.note].filter(Boolean).join(' '), line }; // (Mealie already split it up)
return { ...U.parseIngredient(line), line };
});
const rows = U.plan(ings, pantry.read(), scale).map(x => ({ ...x, takeLabel: U.fmt(x.take, x.unit), leftLabel: x.left == null ? '' : U.fmt(x.left, x.unit) }));
return json(res, 200, { name: r.name, servings: r.recipeServings || r.recipeYield || '', scale, rows, lines: ings.length });
}
if (p === '/pantry/adjust' && req.method === 'POST') { // one more or one less
const b = JSON.parse(await readBody(req, 1e3) || '{}'), l = readPantry(), it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone');
const before = isLow(it); it.qty = Math.max(0, Math.round(((it.qty || 0) + (+b.delta || 0)) * 100) / 100); writePantry(l); if (isLow(it) && !before) pantryLow(it); return json(res, 200, { items: pantryView(l) });
}
if (p === '/pantry/delete' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e3) || '{}'); const l = readPantry().filter(x => x.id !== b.id); writePantry(l); return json(res, 200, { items: pantryView(l) }); }
if (p === '/plan/pantry' && req.method === 'GET') { // which planned ingredients the pantry already covers
const from = q.get('from') || '', to = q.get('to') || ''; if (!DAY.test(from) || !DAY.test(to)) throw fail(400, 'Bad dates'); const items = readPantry().filter(x => x.qty !== 0), out = []; if (!items.length) return json(res, 200, { items: [] });
for (const sl of [...new Set(Object.values(planBetween(from, to)).flat().map(e => e.slug))]) { try { const r = full(await mealie('/api/recipes/' + sl)); for (const i of r.ingredients) { const hit = i.text && inPantry(i.text, items); if (hit && hit.length) out.push({ recipe: r.name, text: i.text, have: hit[0] }); } } catch (e) { if (e.code !== 404 && e.status !== 404) throw e; } }
const from = q.get('from') || '', to = q.get('to') || ''; if (!DAY.test(from) || !DAY.test(to)) throw fail(400, 'Bad dates'); const items = pantry.read().filter(x => x.qty !== 0), out = []; if (!items.length) return json(res, 200, { items: [] });
for (const sl of [...new Set(Object.values(planBetween(from, to)).flat().map(e => e.slug))]) { try { const r = full(await mealie('/api/recipes/' + sl)); for (const i of r.ingredients) { const hit = i.text && pantry.inPantry(i.text, items); if (hit && hit.length) out.push({ recipe: r.name, text: i.text, have: hit[0] }); } } catch (e) { if (e.code !== 404 && e.status !== 404) throw e; } }
return json(res, 200, { items: out });
}
if (p === '/plan/shop' && req.method === 'POST') { // every planned meal in the range goes on the shopping list (a recipe planned twice counts twice)
+16 -2
View File
@@ -15,11 +15,17 @@ const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'applicat
const fail = (code, message) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, n);
/** Phone notification to every parent right away: the buttons answer it without opening the app (Yes with the minutes asked for, or Not now). */
function tellParents(r, title) {
const mins = r.minutes || 0, actions = r.time || mins ? [{ action: 'yes', title: 'Yes' + (mins ? ', ' + mins + ' min' : '') }, { action: 'no', title: 'Not now' }] : [];
for (const a of users.all()) if (!a.disabled && a.role === 'admin') { try { require('./push').notifyUrgent(a.id, { title, body: r.text, url: '#/settings/family', tag: 'req:' + r.id, req: r.id, actions }).catch(() => {}); } catch {} }
}
/** A request made from the lock screen ("Can I have a little more time?"): one open time request at a time, so asking twice does not pile them up. */
function askTime(me, text, minutes) {
const old = db().find(r => r.user === me.id && r.status === 'open' && r.time); if (old) return old;
const r = { id: crypto.randomBytes(5).toString('hex'), user: me.id, userName: me.name, text: str(text, 200) || 'Can I have a little more time, please?', tab: '', minutes: minutes || 30, time: true, status: 'open', at: Date.now() }; db().push(r); save();
for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: me.name + ' is asking for time', body: r.text, url: '#/settings/family', tag: 'req:' + r.id });
tellParents(r, '⏰ ' + me.name + ' is asking for more time');
return r;
}
async function handle(req, res, me) {
@@ -33,16 +39,24 @@ async function handle(req, res, me) {
const tab = users.TABS.includes(b.tab) && !users.can(me, b.tab) ? b.tab : '', open = db().filter(r => r.user === me.id && r.status === 'open'); if (open.length >= 8) throw fail(400, 'You already have a lot waiting. Ask a parent about those first.');
const r = { id: crypto.randomBytes(5).toString('hex'), user: me.id, userName: me.name, text, tab, minutes: Math.max(0, Math.min(120, Math.round(+b.minutes) || 0)), status: 'open', at: Date.now() }; db().push(r); save();
for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: me.name + ' is asking', body: text, url: '#/settings/family', tag: 'req:' + r.id });
tellParents(r, me.name + ' is asking' + (r.minutes ? ' for ' + r.minutes + ' more min' : ''));
return json(res, 200, r);
}
let m; if ((m = /^\/([0-9a-f]{10})$/.exec(p)) && req.method === 'POST') { // a parent answers
if (!admin) throw fail(403, 'Only a parent (an admin) can answer'); const r = db().find(x => x.id === m[1]); if (!r) throw fail(404, 'Request not found'); if (r.status !== 'open') throw fail(400, 'That one is already answered');
const b = JSON.parse(await readBody(req, 4e3) || '{}'), yes = !!b.approve, kid = users.get(r.user); r.status = yes ? 'approved' : 'denied'; r.note = str(b.note, 200); r.by = me.name; r.decidedAt = Date.now();
if (yes && kid) { if (r.tab && !users.can(kid, r.tab)) users.update(kid.id, { tabs: [...new Set([...(kid.tabs || []), r.tab])] }); const mins = b.minutes !== undefined ? Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)) : r.minutes; if (mins) { r.minutes = mins; try { require('./kidlimits').give(kid.id, mins); } catch {} } }
if (yes && kid) { if (r.tab && !users.can(kid, r.tab)) users.update(kid.id, { tabs: [...new Set([...(kid.tabs || []), r.tab])] }); const mins = b.minutes !== undefined ? Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)) : r.minutes; if (mins) { r.minutes = mins; try { require('./kidlimits').give(kid.id, mins, true); } catch {} } }
save(); if (kid) inbox.add(kid.id, { kind: 'request', title: (yes ? 'Yes: ' : 'Not this time: ') + r.text, body: r.note || (yes ? me.name + ' said yes' : me.name + ' said no'), url: '#/home', tag: 'ans:' + r.id });
if (kid) { try { require('./push').notifyUrgent(kid.id, { title: (yes ? '✅ Yes' + (r.minutes && r.time ? ': ' + r.minutes + ' more minutes' : '') : 'Not this time'), body: r.note || r.text, url: '#/home', tag: 'ans:' + r.id }).catch(() => {}); } catch {} }
return json(res, 200, r);
}
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e instanceof SyntaxError ? 400 : 500, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, askTime };
/** A request nobody has answered keeps nudging the parents: again after 5 minutes, then every 10, up to 4 more times. */
function nag(now = Date.now()) {
let n = 0; for (const r of db()) if (r.status === 'open' && (r.nags || 0) < 4 && now - (r.nagAt || r.at) >= ((r.nags || 0) ? 10 : 5) * 60e3) { r.nags = (r.nags || 0) + 1; r.nagAt = now; n++; tellParents(r, '⏰ Still waiting: ' + r.userName + (r.time ? ' is asking for time' : ' is asking')); }
if (n) save(); return n;
}
const nagTimer = setInterval(() => { try { nag(); } catch {} }, 60e3); nagTimer.unref();
module.exports = { handle, askTime, nag };
+9 -2
View File
@@ -44,10 +44,12 @@ const bible = require('./bible'); // Bible: each person's place, highlights
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 pwvault = require('./pwvault'); // each person's Vaultwarden passwords, through the Bitwarden CLI (VAULT_URL)
const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export)
const activity = require('./activity'), alertParents = activity.alertParents;
const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time
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 pantry = require('./pantry'); // the family pantry, with barcode scanning
const automations = require('./automations'); // when this happens, do that (per person)
const moviefam = require('./moviefam'); // Movies & TV: favorites and the family's movie night list
const chores = require('./chores'); // chores for the family, with points
@@ -586,6 +588,7 @@ const ACCESS = [
[/^\/api\/bible\//, ['bible']],
[/^\/api\/read\//, ['read']],
[/^\/api\/radio\//, ['radio']],
[/^\/api\/pantry(\/|$)/, ['recipes']],
[/^\/api\/moviefam(\/|$)/, ['video']],
[/^\/api\/chores(\/|$)/, ['chores']],
[/^\/api\/notes(\/|$)/, ['notes']],
@@ -594,6 +597,7 @@ const ACCESS = [
[/^\/api\/store\/my(\/|$)/, ['store']],
[/^\/api\/recipes(\/|$)/, ['recipes']],
[/^\/api\/contacts(\/|$)/, ['contacts']],
[/^\/api\/vault(\/|$)/, ['vault']],
[/^\/api\/signal\//, ['signal']],
[/^\/api\/lyrics$/, ['music', 'radio']],
[/^\/api\/transcript(\/search)?$/, ['podcast', 'audio']],
@@ -612,7 +616,7 @@ function securityHeaders(res) {
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=()');
res.setHeader('Permissions-Policy', 'camera=(self), geolocation=(self), payment=()');
if (process.env.PUBLIC_URL && process.env.PUBLIC_URL.startsWith('https://')) res.setHeader('Strict-Transport-Security', 'max-age=15552000');
}
/** Cross-site request check: a browser request that changes something must come from this same site. */
@@ -661,7 +665,7 @@ async function handleRequest(req, res) {
const c = t => users.can(user, t);
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', vault: c('vault') && pwvault.enabled() ? pwvault.url() : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
}
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
if (pathname === '/api/listening/family') { if (req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').setFamilyGoal(b.goal))); } res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(require('./progress').familyGoal())); }
@@ -685,6 +689,7 @@ async function handleRequest(req, res) {
const checks = [probe('Seafile (Files)', SEAFILE_URL + '/api2/ping/'), probe('OnlyOffice', ONLYOFFICE_URL + '/healthcheck'), probe('Audiobooks', (process.env.OPENAUDIBLE_URL || AUDIOBOOKS_URL).replace(/\/$/, '') + '/'), probe('Podcasts (Podgrab)', PODCAST_URL)];
if (recipes.enabled()) checks.push(probe('Recipes (Mealie)', (process.env.MEALIE_URL || 'https://mealie.necloud.us').replace(/\/+$/, '') + '/api/app/about'));
out.services = await Promise.all(checks);
try { out.push = require('./push').health(); } catch { out.push = null; }
out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); out.transcripts = transcripts.status();
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out));
}
@@ -716,6 +721,7 @@ async function handleRequest(req, res) {
if (pathname === '/api/kid/requests' || pathname.startsWith('/api/kid/requests/')) return require('./requests').handle(req, res, user);
if (pathname.startsWith('/api/kid/')) return kidlimits.handle(req, res, user, { askParents: chat.askParents });
if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user);
if (pathname === '/api/pantry' || pathname.startsWith('/api/pantry/')) return pantry.handle(req, res);
if (pathname === '/api/auto' || pathname.startsWith('/api/auto/')) return automations.handle(req, res);
if (pathname === '/api/moviefam' || pathname.startsWith('/api/moviefam/')) return moviefam.handle(req, res);
if (pathname === '/api/chores' || pathname.startsWith('/api/chores/')) return chores.handle(req, res);
@@ -724,6 +730,7 @@ async function handleRequest(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/vault' || pathname.startsWith('/api/vault/')) return pwvault.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);
+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', 'music_feat_pls', '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', 'lookups', 'song_cache']);
const SECRET = new Set(['ab_creds', 'sf_token']);
const MAX_VALUE = 20000; // bytes of JSON per key
+2 -2
View File
@@ -180,7 +180,7 @@ async function handle(req, res) {
const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 };
const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; });
count(load().tasks);
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) count(parseTodos(r.ics)); } catch {} }
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) count(parseTodos(r.ics)); } catch (e) { console.warn('[tasks] could not read the iCloud list ' + l.name + ':', e.message); } }
return json(res, 200, out);
}
json(res, 404, { error: 'Not found' });
@@ -191,7 +191,7 @@ async function handle(req, res) {
async function allTasks() {
const d = load(), out = [];
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', dueTime: t.dueTime || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); }
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} }
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch (e) { console.warn('[tasks] could not read the iCloud list ' + l.name + ':', e.message); } }
return out;
}
+3
View File
@@ -24,6 +24,9 @@ const hub = http.createServer((req, res) => req.url.startsWith('/api/chores') ?
assert.equal((await act(kid.id, 'claim')).status, 200, 'Kai takes it with one tap'); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.claimed, true); assert.equal(cb.userName, 'Kai');
assert.equal((await act(bob.id, 'done')).status, 403, 'Bob has no PIN, so he cannot act as himself yet'); assert.equal((await act(kid.id, 'done')).status, 200); cb = (await call('/api/board?day=' + today, {}, dev)).json.chores.find(c => c.chore === ch.id); assert.equal(cb.done, true); assert.equal(cb.doneBy, 'Kai');
assert.equal((await act(kid.id, 'undo')).status, 200); assert.equal((await post('/api/board/chore', { uid: kid.id, chore: ch.id, action: 'done' }, '')).status, 403, 'only a family device can do this'); assert.equal((await act(kid.id, 'nope')).status, 400);
const rw = (await post('/api/chores/reward', { title: 'Movie night', points: 20 }, 'hub_session=' + session(ann).slice(12))).json; assert.ok(rw && rw.id, 'a reward exists'); const gl = (b, who = dev) => post('/api/board/goal', { uid: kid.id, date: today, ...b }, who);
assert.equal((await gl({ reward: rw.id }, '')).status, 403, 'only a family device can choose'); assert.equal((await gl({ reward: rw.id })).status, 200, 'Kai chooses a goal from the family screen'); assert.equal(((await call('/api/board?day=' + today, {}, dev)).json.week.people.find(p => p.id === kid.id) || {}).goal, rw.id, 'and the board shows it as the goal'); assert.equal((await gl({ reward: 'nope' })).status, 400);
const fam = 'hub_session=' + session(ann).slice(12); assert.equal(typeof (await call('/api/board?day=' + today, {}, dev)).json.familyName, 'string'); assert.equal((await post('/api/board/family', { name: 'Emmert Family' }, dev)).status, 403, 'only a signed-in admin sets the family name'); assert.equal((await post('/api/board/family', { name: 'Emmert Family' }, fam)).status, 200); assert.equal((await call('/api/board?day=' + today, {}, dev)).json.familyName, 'Emmert Family', 'the board greets the family by name');
const add = b => post('/api/board/add', { uid: kid.id, ...b }, dev), day0 = new Date().toISOString().slice(0, 10);
assert.equal((await add({ kind: 'task', text: 'Water the plants', due: day0 })).status, 200, 'Kai adds a family task'); assert.ok((await call('/api/tasks?list=local:family', {}, 'hub_session=' + session(ann).slice(12))).status !== 500); assert.ok(JSON.stringify((await call('/api/board?day=' + day0, {}, dev)).json.tasks).includes('Water the plants'), 'and it shows on the board');
assert.equal((await add({ kind: 'event', text: 'Movie night', date: day0, time: '19:00', tz: 'UTC' })).status, 200); assert.ok(JSON.stringify((await call('/api/board?day=' + day0, {}, dev)).json.events).includes('Movie night'), 'a timed event lands on the family calendar');
+57
View File
@@ -0,0 +1,57 @@
// Family Chat extras: GIFs (kept family-friendly, fetched through the hub, kept in the chat) and sharing a place (a search, and coordinates that are checked).
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-chatx-'));
const GIF = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'); let commonsPages = null, asked = [], tenorQ = [], giphyQ = [], wikiQ = [];
const media = http.createServer((req, res) => { asked.push(req.url); if (req.url.startsWith('/big')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(Buffer.alloc(9 * 1024 * 1024)); } if (req.url.startsWith('/html')) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<b>x</b>'); } res.writeHead(200, { 'content-type': 'image/gif' }); res.end(GIF); }).listen(0);
const svc = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'), M = 'http://127.0.0.1:' + media.address().port; res.setHeader('content-type', 'application/json');
if (u.pathname.startsWith('/v2/')) { tenorQ.push(u.pathname + u.search); return res.end(JSON.stringify({ next: 'n2', results: [
{ content_description: 'happy dance', tags: ['dance'], media_formats: { tinygif: { url: M + '/t1.gif', dims: [100, 80] }, gif: { url: M + '/g1.gif', dims: [300, 240] } } },
{ content_description: 'sexy dance', tags: ['dance'], media_formats: { tinygif: { url: M + '/t2.gif', dims: [100, 80] }, gif: { url: M + '/g2.gif', dims: [300, 240] } } },
{ content_description: 'party', tags: ['topless', 'fun'], media_formats: { tinygif: { url: M + '/t3.gif', dims: [100, 80] }, gif: { url: M + '/g3.gif', dims: [300, 240] } } },
{ content_description: 'elsewhere', tags: [], media_formats: { tinygif: { url: 'https://evil.example/t4.gif', dims: [1, 1] }, gif: { url: 'https://evil.example/g4.gif', dims: [1, 1] } } }] })); }
if (u.pathname.startsWith('/v1/gifs/')) { giphyQ.push(u.pathname + u.search); return res.end(JSON.stringify({ data: [{ title: 'cat', rating: 'g', images: { fixed_width_small: { url: M + '/s1.gif', width: '100', height: '80' }, fixed_width: { url: M + '/f1.gif' } } }, { title: 'cat 2', rating: 'pg-13', images: { fixed_width_small: { url: M + '/s2.gif', width: '100', height: '80' }, fixed_width: { url: M + '/f2.gif' } } }] })); }
if (u.pathname === '/w/api.php') { wikiQ.push(u.search); const M2 = M, mk = (i, title, mime, desc, cats, thumb) => ({ pageid: i, index: i, title: 'File:' + title, imageinfo: [{ mime, thumburl: thumb === null ? '' : (thumb || M2 + '/commons-' + i + '.gif'), thumbwidth: 320, thumbheight: 240, extmetadata: { ImageDescription: { value: desc }, Categories: { value: cats } } }] });
const pages = [mk(1, 'Cat walking.gif', 'image/gif', 'A <b>cat</b> walking', 'Cats|Animation'), mk(2, 'Nude study.gif', 'image/gif', 'Study', 'Nudity in art'), mk(3, 'Cat.png', 'image/png', 'still', 'Cats'), mk(4, 'Dancing robot.gif', 'image/gif', 'A robot', 'Robots'), mk(5, 'Clinic.gif', 'image/gif', 'Medical animation of the female genitalia', 'Medicine'), mk(6, 'No thumb.gif', 'image/gif', 'x', 'Cats', null)];
commonsPages = pages; const cont = pages.length ? { continue: { gsroffset: 24 } } : {}; return res.end(JSON.stringify({ ...cont, query: { pages: Object.fromEntries(pages.map(p => [p.pageid, p])) } })); }
if (u.pathname === '/search') { return res.end(JSON.stringify([{ name: 'Central Park', display_name: 'Central Park, Manhattan, New York, United States', lat: '40.78', lon: '-73.96' }, { name: 'Bad', display_name: 'Bad', lat: 'x', lon: 'y' }])); }
res.statusCode = 404; res.end('{}'); }).listen(0, async () => {
process.env.GIF_MEDIA_HOSTS = '127.0.0.1:' + media.address().port; process.env.WIKIMEDIA_URL = process.env.TENOR_URL = process.env.GIPHY_URL = process.env.NOMINATIM_URL = 'http://127.0.0.1:' + svc.address().port;
const users = require('../users'), chat = require('../chat'), gifs = require('../gifs');
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' }) };
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', j = r => r.json(), 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);
try {
// the filter's words
for (const bad of ['sexy dance', 'Boobs', 'naked man', 'p0rn'.replace('0', 'o'), 'bikini beach', 'F-U-C-K'.replace(/-/g, '')]) assert.equal(gifs.unsafe(bad), true, bad + ' is refused'); for (const ok of ['happy dance', 'class', 'assistant', 'Scunthorpe', 'cat', 'thanks', 'high five', 'book club', 'pass the butter']) assert.equal(gifs.unsafe(ok), false, ok + ' is fine');
// not set up
process.env.GIF_PROVIDER = 'off'; let r = await call('/gif/status'); assert.deepEqual(await j(r), { enabled: false, provider: '' }); assert.equal((await call('/gif?q=cat')).status, 503); delete process.env.GIF_PROVIDER;
// Tenor: always the strictest setting, and what comes back is filtered by words and by where it is hosted
process.env.TENOR_API_KEY = 'k1'; assert.deepEqual(await j(await call('/gif/status')), { enabled: true, provider: 'tenor' });
r = await j(await call('/gif?q=dance')); assert.match(tenorQ[0], /contentfilter=high/); assert.match(tenorQ[0], /q=dance/); assert.deepEqual(r.items.map(x => x.title), ['happy dance'], 'a title or a tag with a bad word, and a picture from another site, are dropped'); assert.equal(r.next, 'n2');
assert.equal((await call('/gif?q=sexy')).status, 400, 'a bad search is refused before it is sent'); assert.equal(tenorQ.length, 1, 'and never reaches the service'); await call('/gif'); assert.match(tenorQ[1], /featured/, 'with no words: what is popular');
// previews come through the hub (only ones it listed), never the phone's own request
const prev = r.items[0].preview; assert.equal((await call('/gifimg?u=' + encodeURIComponent(prev))).status, 200); assert.equal((await call('/gifimg?u=' + encodeURIComponent('http://127.0.0.1:' + media.address().port + '/other.gif'))).status, 400, 'an address the hub did not list is refused'); assert.equal((await call('/gifimg?u=' + encodeURIComponent('https://evil.example/x.gif'))).status, 400);
// sending: downloaded and kept in the chat like a photo
const dm = (await j(await post('/new', { type: 'direct', with: P.b.id }))).conv; r = await post('/gif/send', { conv: dm.id, url: r.items[0].url, title: 'happy dance' }); assert.equal(r.status, 200); const m = (await j(r)).msg; assert.equal(m.att[0].type, 'image/gif'); assert.equal(m.att[0].name, 'happy dance.gif');
const f = await call('/file/' + m.att[0].id, {}, 'b'); assert.equal(f.status, 200); assert.deepEqual(Buffer.from(await f.arrayBuffer()), GIF, 'Bob sees the very same picture, from the hub'); assert.equal((await post('/gif/send', { conv: dm.id, url: 'http://127.0.0.1:' + media.address().port + '/g1.gif' }, 'c')).status, 404, 'a stranger cannot send into the chat');
assert.equal((await post('/gif/send', { conv: dm.id, url: 'http://127.0.0.1:' + media.address().port + '/never-listed.gif' })).status, 400, 'only listed GIFs can be sent');
// size and type limits
{ const M = 'http://127.0.0.1:' + media.address().port; gifs.remember([M + '/big.gif', M + '/html.gif']); assert.equal((await post('/gif/send', { conv: dm.id, url: M + '/big.gif' })).status, 413, 'a huge GIF is refused'); assert.equal((await post('/gif/send', { conv: dm.id, url: M + '/html.gif' })).status, 502, 'something that is not a picture is refused'); }
// Wikimedia Commons (MediaWiki's search): no key needed, the default when there is no Tenor or GIPHY key; animated GIFs only, and files with risky titles, descriptions or categories are dropped
delete process.env.TENOR_API_KEY; delete process.env.GIPHY_API_KEY; assert.deepEqual(await j(await call('/gif/status')), { enabled: true, provider: 'wikimedia' }, 'with no key, Commons is used');
r = await j(await call('/gif?q=cats')); assert.match(wikiQ[0], /filemime%3Aimage%2Fgif/); assert.match(wikiQ[0], /gsrsearch=cats/); assert.deepEqual(r.items.map(x => x.title), ['Cat walking', 'Dancing robot'], 'a risky category, a risky description, a still picture and one with no thumbnail are dropped, in search order'); assert.equal(r.next, '24'); assert.ok(!r.items.some(x => /Bikini|Nude/i.test(x.title)));
await call('/gif'); assert.match(wikiQ[1], /gsrsearch=animal/, 'with no words, something to look at'); r = await j(await call('/gif?q=cats&pos=24')); assert.match(wikiQ[2], /gsroffset=24/); assert.equal((await call('/gif?q=naked')).status, 400, 'the same word list applies');
{ const M = 'http://127.0.0.1:' + media.address().port; assert.equal((await post('/gif/send', { conv: (await j(await post('/new', { type: 'direct', with: P.b.id }))).conv.id, url: M + '/commons-1.gif', title: 'Cat walking' })).status, 200, 'a Commons GIF is kept in the chat too'); }
process.env.TENOR_API_KEY = 'k1'; process.env.GIF_PROVIDER = 'wikimedia'; assert.equal((await j(await call('/gif/status'))).provider, 'wikimedia', 'GIF_PROVIDER picks one even when keys exist'); delete process.env.GIF_PROVIDER; delete process.env.TENOR_API_KEY;
// Giphy: G only, and nothing above it slips through
delete process.env.TENOR_API_KEY; process.env.GIPHY_API_KEY = 'k2'; r = await j(await call('/gif?q=cat')); assert.match(giphyQ[0], /rating=g/); assert.match(giphyQ[0], /api_key=k2/); assert.deepEqual(r.items.map(x => x.title), ['cat'], 'a result rated above G is dropped even if the service sent it');
// a place: search through the hub, and a share with checked coordinates
assert.equal((await call('/places?q=ab')).status, 400); r = await j(await call('/places?q=central park')); assert.deepEqual(r.items, [{ title: 'Central Park', sub: 'Manhattan, New York, United States', lat: 40.78, lng: -73.96 }], 'bad rows are dropped');
r = await post('/send', { conv: dm.id, share: { kind: 'place', title: 'Central Park', sub: 'New York', lat: 40.78, lng: -73.96, link: 'https://evil.example/' } }); assert.equal(r.status, 200); const sh = (await j(r)).msg.share; assert.equal(sh.link, 'https://maps.apple.com/?ll=40.78,-73.96&q=Central%20Park', 'the Apple Maps link is made by the hub, not taken from the sender'); assert.equal(sh.kind, 'place');
for (const bad of [{ lat: 91, lng: 0 }, { lat: 0, lng: 181 }, { lat: 'x', lng: 1 }, { lat: '', lng: '' }, {}]) assert.equal((await post('/send', { conv: dm.id, share: { kind: 'place', title: 'x', ...bad } })).status, 400, JSON.stringify(bad) + ' is not a place');
console.log('chat extras ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); svc.close(); media.close(); setTimeout(() => process.exit(process.exitCode || 0), 80);
});
});
+1 -1
View File
@@ -32,7 +32,7 @@ for (const f of listed) {
}
assert.deepEqual(failures, [], 'every script loads:\n ' + failures.join('\n '));
// the pieces the app calls by name across files are there
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalendar', 'viewBible', 'viewMail', 'viewRecipes', 'viewVideo', 'themeApply', 'autoSettings', 'bbToday', 'homeListening', 'saveToFiles', 'downloadText', 'viewPantry', 'pantryScan', 'chGifPicker', 'chPlacePicker', 'chSigRow']) assert.equal(typeof vm.runInContext(`typeof ${name}`, ctx), 'string') && assert.equal(vm.runInContext(`typeof ${name}`, ctx), 'function', name + ' is defined');
// the watch-stats counting (a pure function)
{ const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx);
const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now);
+3 -2
View File
@@ -15,6 +15,8 @@ assert.equal(kid.lockedFor(users.get(sam.id), '/api/kid/status'), null, 'except
assert.throws(() => kid.give(pat.id, 15), /not a child/); st = kid.give(sam.id, 20); assert.equal(st.locked, null, 'more time unlocks it'); assert.equal(st.limit, 35);
// bedtime
users.update(sam.id, { kid: { on: true, limitMin: 0, bedtime: { on: true, from: '00:00', to: '23:59' } } }); assert.equal(kid.status(users.get(sam.id)).locked, 'bedtime'); users.update(sam.id, { kid: { on: true, limitMin: 0, bedtime: { on: false } } }); assert.equal(kid.status(users.get(sam.id)).locked, null, 'no limit, no bedtime: never locked'); assert.equal(kid.status(users.get(sam.id)).left, null);
// a yes at bedtime opens the app for the minutes given, then it locks again (and the lift survives a change of day)
users.update(sam.id, { kid: { on: true, limitMin: 0, bedtime: { on: true, from: '00:00', to: '23:59' } } }); st = kid.give(sam.id, 15, true); assert.equal(st.locked, null, 'a yes at bedtime lets them in'); t += 14 * 60e3; assert.equal(kid.status(users.get(sam.id)).locked, null, 'still open inside the 15 minutes'); t += 2 * 60e3; assert.equal(kid.status(users.get(sam.id)).locked, 'bedtime', 'locked again afterwards'); assert.equal(kid.give(sam.id, 15).locked, 'bedtime', 'extra minutes alone do not lift bedtime (only a parent\'s yes does)');
Date.now = real;
// over HTTP: who may do what, and asking a parent
const hub = http.createServer((req, res) => { const who = { sam, ann, pat }[req.headers['x-user'] || 'sam']; users.runAs(users.get(who.id), () => kid.handle(req, res, users.get(who.id), { askParents: chat.askParents })); }).listen(0, async () => {
@@ -23,8 +25,7 @@ const hub = http.createServer((req, res) => { const who = { sam, ann, pat }[req.
assert.equal((await j(await call('/status'))).on, true); assert.equal((await post('/ping', {})).status, 200); assert.equal((await post('/extra', { user: sam.id, minutes: 10 }, 'sam')).status, 403, 'a child cannot give themselves time'); assert.equal((await post('/extra', { user: sam.id, minutes: 10 }, 'ann')).status, 200);
assert.equal((await call('/usage?user=' + sam.id, {}, 'sam')).status, 403); assert.equal((await j(await call('/usage?user=' + sam.id, {}, 'ann'))).on, true);
assert.equal((await post('/ask', { text: 'More time please?' }, 'pat')).status, 400, 'only child accounts ask'); const r = await j(await post('/ask', { text: 'Can I have 15 more minutes? I am nearly done.' })); assert.equal(r.told, 1, 'the one admin is told');
const dm = 'd:' + [sam.id, ann.id].sort().join(':'); const conv = await asAdmin(users.get(ann.id), async () => { const out = {}; await chat.handle({ url: '/api/chat/conv/' + encodeURIComponent(dm), method: 'GET', headers: {} }, { writeHead() {}, end(b) { out.j = JSON.parse(b); }, headersSent: false }); return out.j; });
assert.equal(conv.msgs[0].text, 'Can I have 15 more minutes? I am nearly done.'); assert.equal(conv.msgs[0].name, 'Sam', 'it arrives as a chat message from the child');
const open = JSON.parse(fs.readFileSync(path.join(process.env.DATA_DIR, 'kid-requests.json'), 'utf8')).filter(q => q.time && q.status === 'open'); assert.equal(open.length, 1, 'the ask is one open request for the parents (and one urgent notification, not a chat message too)'); assert.equal(open[0].text, 'Can I have 15 more minutes? I am nearly done.');
console.log('kidlimits ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50);
+168
View File
@@ -0,0 +1,168 @@
# Makes the ready-made Office templates in public/templates. Needs: pip install python-docx openpyxl python-pptx. Run: python3 tools/make-office-templates.py public/templates
import sys, os
from docx import Document
from docx.shared import Pt, RGBColor, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from pptx import Presentation
from pptx.util import Inches as PI, Pt as PP
from pptx.dml.color import RGBColor as PC
OUT = sys.argv[1]; os.makedirs(OUT, exist_ok=True)
BLUE = '3D6BF0'
# ---------------- Word
def base(doc, size=11):
st = doc.styles['Normal']; st.font.name = 'Calibri'; st.font.size = Pt(size)
for s in doc.sections: s.left_margin = s.right_margin = Inches(1); s.top_margin = s.bottom_margin = Inches(0.9)
def para(doc, text='', bold=False, size=None, color=None, align=None, after=6, italic=False):
p = doc.add_paragraph(); r = p.add_run(text); r.bold = bold; r.italic = italic
if size: r.font.size = Pt(size)
if color: r.font.color.rgb = RGBColor.from_string(color)
if align: p.alignment = align
p.paragraph_format.space_after = Pt(after); return p
def heading(doc, text, size=13):
p = para(doc, text.upper(), bold=True, size=size - 2, color=BLUE, after=3); p.paragraph_format.space_before = Pt(10)
return p
d = Document(); base(d, 11)
para(d, '[Your name]', bold=True, size=14, after=0); para(d, '[Street address]', after=0); para(d, '[City, State ZIP]', after=0); para(d, '[Phone] · [Email]', after=14)
para(d, '[Date]', after=14); para(d, '[Recipient name]', after=0); para(d, '[Title or company]', after=0); para(d, '[Street address]', after=0); para(d, '[City, State ZIP]', after=14)
para(d, 'Dear [Name],', after=10)
para(d, 'Start with the reason you are writing, in a sentence or two. Keep it friendly and direct.', after=10)
para(d, 'Use the middle paragraph for the details: what you need, what happened, or what you are offering. Short paragraphs are easier to read.', after=10)
para(d, 'Close by saying what happens next, and thank them for their time.', after=14)
para(d, 'Sincerely,', after=30); para(d, '[Your name]', after=0)
d.save(f'{OUT}/letter.docx')
d = Document(); base(d, 10.5)
para(d, '[Your Name]', bold=True, size=24, after=0); para(d, '[City, State] · [Phone] · [Email]', color='666666', after=8)
heading(d, 'Summary'); para(d, 'Two or three sentences about who you are, what you do well, and what you are looking for.')
heading(d, 'Experience')
for i in range(2):
para(d, '[Job title], [Employer]', bold=True, after=0); para(d, '[Month Year] – [Month Year]', italic=True, color='666666', after=3)
for t in ['What you did and the result it had (use numbers when you can)', 'Something you improved, built or led', 'A skill you used every day']:
p = d.add_paragraph(t, style='List Bullet'); p.paragraph_format.space_after = Pt(1)
para(d, '', after=4)
heading(d, 'Education'); para(d, '[School], [Degree or program]', bold=True, after=0); para(d, '[Year]', italic=True, color='666666')
heading(d, 'Skills'); para(d, '[Skill] · [Skill] · [Skill] · [Skill] · [Skill]')
d.save(f'{OUT}/resume.docx')
d = Document(); base(d, 11)
para(d, 'Meeting notes', bold=True, size=22, color=BLUE, after=2); para(d, '[Meeting name]', size=13, after=10)
t = d.add_table(rows=4, cols=2); t.style = 'Light Grid Accent 1'
for i, (a, b) in enumerate([('Date', '[Date and time]'), ('Where', '[Place or link]'), ('Who was there', '[Names]'), ('Taking notes', '[Name]')]): t.cell(i, 0).text = a; t.cell(i, 1).text = b
para(d, '', after=4); heading(d, 'Agenda')
for x in ['[First topic]', '[Second topic]', '[Third topic]']: d.add_paragraph(x, style='List Number')
heading(d, 'Notes'); para(d, 'What was said and what was decided.', italic=True, color='666666'); para(d, '', after=30)
heading(d, 'Action items')
t = d.add_table(rows=4, cols=3); t.style = 'Light Grid Accent 1'
for i, h in enumerate(['What needs doing', 'Who', 'By when']): t.cell(0, i).text = h
d.save(f'{OUT}/meeting-notes.docx')
d = Document(); base(d, 11)
para(d, '[Family Name] News', bold=True, size=28, color=BLUE, align=WD_ALIGN_PARAGRAPH.CENTER, after=0); para(d, '[Month Year]', align=WD_ALIGN_PARAGRAPH.CENTER, color='666666', after=14)
heading(d, 'The big news'); para(d, 'Share the headline: a trip, a birthday, a new job, a new pet. A few friendly sentences is plenty.')
heading(d, 'What everyone has been up to')
for n in ['[Name]', '[Name]', '[Name]']: para(d, n, bold=True, after=0); para(d, 'A line or two about school, work, hobbies or something funny.', after=8)
heading(d, 'Coming up'); t = d.add_table(rows=4, cols=2); t.style = 'Light List Accent 1'
t.cell(0, 0).text = 'Date'; t.cell(0, 1).text = 'What'
heading(d, 'A favorite moment'); para(d, 'Add a photo here (Insert → Image) and a sentence about it.', italic=True, color='666666')
d.save(f'{OUT}/family-newsletter.docx')
d = Document(); base(d, 11)
para(d, '[Recipe name]', bold=True, size=24, color=BLUE, after=2); para(d, 'Serves [4] · Prep [15 min] · Cook [30 min]', color='666666', after=10)
heading(d, 'Ingredients')
for x in ['[2 cups flour]', '[1 tsp salt]', '[1 cup milk]', '[2 eggs]']: d.add_paragraph(x, style='List Bullet')
heading(d, 'Steps')
for x in ['[First step]', '[Second step]', '[Third step]']: d.add_paragraph(x, style='List Number')
heading(d, 'Notes'); para(d, 'Where it came from, what to change next time, what goes well with it.', italic=True, color='666666')
d.save(f'{OUT}/recipe-card.docx')
# ---------------- Excel
thin = Side(style='thin', color='D0D5DD'); BOX = Border(left=thin, right=thin, top=thin, bottom=thin)
HEAD = PatternFill('solid', fgColor=BLUE); SOFT = PatternFill('solid', fgColor='EEF2FF')
def hdr(ws, row, values, widths=None):
for i, v in enumerate(values, 1):
c = ws.cell(row=row, column=i, value=v); c.font = Font(bold=True, color='FFFFFF'); c.fill = HEAD; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True); c.border = BOX
if widths:
for i, w in enumerate(widths, 1): ws.column_dimensions[get_column_letter(i)].width = w
def title(ws, text, sub=None):
ws['A1'] = text; ws['A1'].font = Font(bold=True, size=18, color=BLUE)
if sub: ws['A2'] = sub; ws['A2'].font = Font(italic=True, color='666666')
wb = Workbook(); ws = wb.active; ws.title = 'Budget'; title(ws, 'Monthly budget', 'Fill in the planned and actual amounts. The last column does the math.')
ws['A4'] = 'Income'; ws['A4'].font = Font(bold=True, size=12)
hdr(ws, 5, ['Item', 'Planned', 'Actual', 'Difference'], [30, 14, 14, 14])
inc = ['Paycheck', 'Second income', 'Other']
for i, n in enumerate(inc):
r = 6 + i; ws.cell(r, 1, n); ws.cell(r, 2, 0); ws.cell(r, 3, 0); ws.cell(r, 4, f'=C{r}-B{r}')
r = 6 + len(inc); ws.cell(r, 1, 'Total income').font = Font(bold=True); ws.cell(r, 2, f'=SUM(B6:B{r-1})'); ws.cell(r, 3, f'=SUM(C6:C{r-1})'); ws.cell(r, 4, f'=C{r}-B{r}'); inc_total = r
for c in range(1, 5): ws.cell(r, c).fill = SOFT; ws.cell(r, c).font = Font(bold=True)
start = r + 2; ws.cell(start, 1, 'Expenses').font = Font(bold=True, size=12); hdr(ws, start + 1, ['Item', 'Planned', 'Actual', 'Difference'])
exp = ['Rent or mortgage', 'Utilities', 'Groceries', 'Transportation', 'Insurance', 'Phone and internet', 'Eating out', 'Kids and school', 'Health', 'Giving', 'Savings', 'Fun', 'Other']
first = start + 2
for i, n in enumerate(exp):
r = first + i; ws.cell(r, 1, n); ws.cell(r, 2, 0); ws.cell(r, 3, 0); ws.cell(r, 4, f'=B{r}-C{r}')
last = first + len(exp) - 1; r = last + 1; ws.cell(r, 1, 'Total expenses'); ws.cell(r, 2, f'=SUM(B{first}:B{last})'); ws.cell(r, 3, f'=SUM(C{first}:C{last})'); ws.cell(r, 4, f'=B{r}-C{r}'); exp_total = r
for c in range(1, 5): ws.cell(r, c).fill = SOFT; ws.cell(r, c).font = Font(bold=True)
r += 2; ws.cell(r, 1, 'Left over (income − expenses)').font = Font(bold=True, size=12); ws.cell(r, 2, f'=B{inc_total}-B{exp_total}'); ws.cell(r, 3, f'=C{inc_total}-C{exp_total}')
for row in ws.iter_rows(min_row=6, max_row=r):
for c in row[1:4]: c.number_format = '$#,##0.00;[Red]-$#,##0.00'
for c in row[:4]:
if c.row not in (start, start + 1, r) and c.value is not None: c.border = BOX
ws.freeze_panes = 'A6'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/monthly-budget.xlsx')
wb = Workbook(); ws = wb.active; ws.title = 'Chores'; title(ws, 'Weekly chore chart', 'Put a name in each box, or a check mark when it is done.')
days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; hdr(ws, 4, ['Chore'] + days + ['Points'], [26] + [10] * 7 + [10])
chores = ['Make bed', 'Set the table', 'Clear the table', 'Dishes / dishwasher', 'Feed the pets', 'Take out trash', 'Tidy bedroom', 'Laundry', 'Vacuum', 'Water plants']
for i, n in enumerate(chores):
r = 5 + i; ws.cell(r, 1, n)
for c in range(1, 10): ws.cell(r, c).border = BOX; ws.cell(r, c).alignment = Alignment(horizontal='center' if c > 1 else 'left')
ws.cell(r, 9, f'=COUNTA(B{r}:H{r})')
r = 5 + len(chores); ws.cell(r, 1, 'Total').font = Font(bold=True); ws.cell(r, 9, f'=SUM(I5:I{r-1})'); ws.cell(r, 9).font = Font(bold=True)
for c in range(1, 10): ws.cell(r, c).fill = SOFT; ws.cell(r, c).border = BOX
ws.freeze_panes = 'B5'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/chore-chart.xlsx')
wb = Workbook(); ws = wb.active; ws.title = 'Meals'; title(ws, 'Weekly meal planner', 'Plan the week, then build the shopping list from it.')
hdr(ws, 4, ['', 'Breakfast', 'Lunch', 'Dinner', 'Snacks'], [14, 24, 24, 28, 22])
for i, d_ in enumerate(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']):
r = 5 + i; ws.cell(r, 1, d_).font = Font(bold=True); ws.cell(r, 1).fill = SOFT
for c in range(1, 6): ws.cell(r, c).border = BOX; ws.cell(r, c).alignment = Alignment(vertical='top', wrap_text=True)
ws.row_dimensions[r].height = 34
ws.cell(13, 1, 'Shopping list').font = Font(bold=True, size=12); ws.cell(14, 1, 'Write what you need to buy under here.').font = Font(italic=True, color='666666')
ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/meal-planner.xlsx')
wb = Workbook(); ws = wb.active; ws.title = 'Expenses'; title(ws, 'Expense tracker', 'One line per purchase. The totals below add themselves up.')
hdr(ws, 4, ['Date', 'What', 'Category', 'Paid with', 'Amount'], [14, 32, 18, 16, 14])
for r in range(5, 45):
for c in range(1, 6): ws.cell(r, c).border = BOX
ws.cell(r, 1).number_format = 'mmm d, yyyy'; ws.cell(r, 5).number_format = '$#,##0.00'
ws['D46'] = 'Total'; ws['D46'].font = Font(bold=True); ws['E46'] = '=SUM(E5:E44)'; ws['E46'].number_format = '$#,##0.00'; ws['E46'].font = Font(bold=True)
ws['G4'] = 'By category'; ws['G4'].font = Font(bold=True, color='FFFFFF'); ws['G4'].fill = HEAD; ws['H4'] = 'Total'; ws['H4'].font = Font(bold=True, color='FFFFFF'); ws['H4'].fill = HEAD; ws.column_dimensions['G'].width = 18; ws.column_dimensions['H'].width = 14
for i, n in enumerate(['Groceries', 'Gas', 'Eating out', 'Home', 'Kids', 'Health', 'Fun', 'Other']):
r = 5 + i; ws.cell(r, 7, n); ws.cell(r, 8, f'=SUMIF($C$5:$C$44,G{r},$E$5:$E$44)'); ws.cell(r, 8).number_format = '$#,##0.00'
ws.freeze_panes = 'A5'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/expense-tracker.xlsx')
# ---------------- PowerPoint
prs = Presentation(); prs.slide_width = PI(13.333); prs.slide_height = PI(7.5)
def slide(title_text, lines=None, layout=1, sub=None):
s = prs.slides.add_slide(prs.slide_layouts[layout]); s.shapes.title.text = title_text
for p in s.shapes.title.text_frame.paragraphs:
for r in p.runs: r.font.color.rgb = PC.from_string(BLUE); r.font.bold = True; r.font.name = 'Calibri'
if layout == 0 and sub is not None: s.placeholders[1].text = sub
elif lines is not None:
tf = s.placeholders[1].text_frame; tf.clear()
for i, t in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph(); p.text = t; p.font.size = PP(26); p.font.name = 'Calibri'
return s
slide('[Presentation title]', layout=0, sub='[Your name] · [Date]')
slide('Agenda', ['[First topic]', '[Second topic]', '[Third topic]', 'Questions'])
slide('[A key point]', ['Say it in one clear sentence', 'Back it up with two or three short points', 'Add a picture or a chart on the right (Insert → Image)'])
slide('[Another key point]', ['Keep to a few words per line', 'Let the pictures do the talking', 'Practice saying the rest out loud'])
slide('Summary', ['What we covered', 'What happens next', 'Who does what'])
slide('Thank you', layout=0, sub='[Contact or question prompt]')
prs.save(f'{OUT}/simple-presentation.pptx')
print(sorted(os.listdir(OUT)))
+18
View File
@@ -24,6 +24,24 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
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);
// bulk import: title, author and cover are read out of the EPUB, a repeat is skipped, a plain file falls back to its name
const mk = (dir, name) => { cp.execFileSync('python3', ['-c', `import zipfile,sys;z=zipfile.ZipFile(sys.argv[1],'w');z.writestr('mimetype','application/epub+zip',zipfile.ZIP_STORED);z.writestr('META-INF/container.xml','<container><rootfiles><rootfile full-path="OEBPS/c.opf" media-type="application/oebps-package+xml"/></rootfiles></container>');z.writestr('OEBPS/c.opf','<package><metadata xmlns:dc="x"><dc:title>Caf&#233; &amp; Cream</dc:title><dc:creator>Ann Author</dc:creator><dc:creator>Bo Two</dc:creator><meta name="cover" content="img"/></metadata><manifest><item id="img" href="img/cv.png" media-type="image/png"/></manifest></package>');z.writestr('OEBPS/img/cv.png',b'\\x89PNG'+b'0'*900);z.close()`, path.join(dir, name)]); return fs.readFileSync(path.join(dir, name)); };
const withCover = mk(fx, 'cover.epub'), imp = (n, b, h = {}) => put('/import?name=' + encodeURIComponent(n), b, h);
assert.equal((await imp('a.epub', withCover, bob)).status, 403, 'only the admin imports');
assert.equal((await imp('a.exe', withCover)).status, 400);
let ir = await (await imp('whatever_file.epub', withCover)).json(); assert.equal(ir.item.title, 'Café & Cream'); assert.equal(ir.item.creator, 'Ann Author, Bo Two'); assert.equal(ir.item.cover, true); assert.equal(ir.noCover, false);
assert.equal((await fetch(`${B}/items/${ir.item.id}/cover`)).headers.get('content-type'), 'image/png');
assert.equal((await (await imp('again.epub', withCover)).json()).duplicate, true, 'the same book is not added twice');
ir = await (await imp('Plain_Name.epub', epub)).json(); assert.equal(ir.item.title, 'Rabbit Tales'); assert.equal(ir.noCover, true); assert.equal(ir.item.creator, 'A. Writer');
ir = await (await imp('Some_Pdf_Book.pdf', Buffer.from('%PDF-1.4 hello'))).json(); assert.equal(ir.item.title, 'Some Pdf Book'); assert.equal(ir.noAuthor, true);
// the admin gives chosen books to chosen people
const impd = (await (await fetch(B + '/items')).json()).items.filter(x => x.id !== book.id && x.kind === 'book').map(x => x.id);
assert.equal((await fetch(B + '/people', { headers: bob })).status, 403); const ppl = await (await fetch(B + '/people')).json(); assert.ok(ppl.some(x => x.id === people.b.id && x.books));
assert.equal((await post('/assign', { items: impd, users: [people.b.id] }, bob)).status, 403); assert.equal((await post('/assign', { items: [], users: [people.b.id] })).status, 400); assert.equal((await post('/assign', { items: impd, users: [] })).status, 400);
let as = await (await post('/assign', { items: impd, users: [people.b.id] })).json(); assert.equal(as.people[0].added + as.people[0].had, impd.length, 'one of them is the book Bob already has'); assert.ok(as.people[0].added >= 2); assert.equal(as.people[0].failed, 0);
as = await (await post('/assign', { items: impd, users: [people.b.id] })).json(); assert.equal(as.people[0].added, 0); assert.equal(as.people[0].had, impd.length, 'a repeat is left alone');
assert.ok((await (await fetch(base + '/api/read/list', { headers: bob })).json()).some(x => x.title === 'Café & Cream'), 'the book is on Bob\'s shelf');
for (const it of (await (await fetch(B + '/items')).json()).items.filter(x => x.id !== book.id && x.kind === 'book')) await fetch(`${B}/items/${it.id}`, { method: 'DELETE' });
// 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);
+72
View File
@@ -0,0 +1,72 @@
// The pantry: items with counts and low levels, several barcodes per item (corn from different brands is one item), product lookup (a stand-in for Open Food Facts), and what happens when a count goes low.
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-pantry-'));
let asked = [], down = false;
const off = http.createServer((req, res) => { const code = /product\/(\d+)\.json/.exec(req.url)[1]; asked.push(code); if (down) { res.destroy(); return; }
const P = { '0016000275287': { product_name: 'Green Giant Sweet Corn 15.25 oz', brands: 'Green Giant', quantity: '15.25 oz' }, '0024000163275': { product_name: "Libby's Whole Kernel Corn 425 g", brands: "Libby's" }, '0070470003344': { product_name: 'Greek Yogurt', brands: 'Chobani' } };
res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(P[code] ? { status: 1, product: P[code] } : { status: 0 })); }).listen(0, () => {
process.env.OPENFOODFACTS_URL = 'http://127.0.0.1:' + off.address().port;
const users = require('../users'), pantry = require('../pantry'), automations = require('../automations');
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['recipes'], handle: 'ZP' }), kid = users.create({ name: 'Kit', role: 'user', tabs: ['recipes'], handle: 'KD' }); kid.kid = { on: true };
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : req.headers['x-user'] === 'kid' ? kid : ann, () => pantry.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port + '/api/pantry', j = r => r.json(), get = p => fetch(base + p), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} });
const fired = []; const realFireAll = automations.fireAll; automations.fireAll = async (t, c) => { fired.push(t + ':' + c.title); };
try {
// names are cleaned from products
assert.equal(pantry.shortName('Heinz Tomato Ketchup 500 ml', 'Heinz'), 'Tomato Ketchup'); assert.equal(pantry.shortName('Green Giant Sweet Corn 15.25 oz', 'Green Giant'), 'Sweet Corn'); assert.equal(pantry.shortName('', ''), '');
// items: add, count, change by the same name, adjust, low levels
assert.equal((await post('/item', { name: ' ' })).status, 400);
let r = await j(await post('/item', { name: 'Corn', qty: 2, low: 1 })); assert.deepEqual(r.items.map(x => [x.name, x.qty, x.low, x.isLow, x.codes.length]), [['Corn', 2, 1, false, 0]]); const corn = r.items[0].id;
r = await j(await post('/item', { name: 'corn', qty: 2, low: 1 })); assert.equal(r.items.length, 1, 'the same name updates the item'); assert.equal(r.items[0].name, 'Corn');
r = await j(await post('/adjust', { id: corn, delta: -1 })); assert.equal(r.items[0].isLow, true); assert.deepEqual(fired, ['pantry.low:Corn'], 'going low starts the rules, once'); await post('/adjust', { id: corn, delta: -1 }); assert.equal(fired.length, 1, 'and not again while it stays low');
assert.equal((await j(await post('/adjust', { id: corn, delta: -9 }))).items[0].qty, 0, 'never below none'); await post('/adjust', { id: corn, delta: 5 });
// a barcode we have not seen: the product is looked up, and the items it might be are offered
assert.equal((await get('/lookup?code=abc')).status, 400);
r = await j(await get('/lookup?code=0016000275287')); assert.equal(r.linked, null); assert.equal(r.product.short, 'Sweet Corn'); assert.deepEqual(r.matches.map(m => m.name), ['Corn'], 'Corn is offered, since the product has the word corn');
assert.equal((await post('/scan', { code: '0016000275287' })).status, 400, 'a new barcode must say which item it is');
// scanning links it to the chosen item and adds one
r = await j(await post('/scan', { code: '0016000275287', itemId: corn })); assert.equal(r.item.name, 'Corn'); assert.equal(r.item.qty, 6); assert.equal(r.made, false);
asked = []; r = await j(await get('/lookup?code=0016000275287')); assert.deepEqual(r.linked, { id: corn, name: 'Corn' }, 'next time it goes straight to Corn'); assert.deepEqual(asked, [], 'without asking the product database again');
r = await j(await post('/scan', { code: '0016000275287' })); assert.equal(r.item.qty, 7);
// a second brand is the same item, with one count
r = await j(await post('/scan', { code: '0024000163275', itemId: corn })); assert.equal(r.items.length, 1, 'still one item'); assert.equal(r.items[0].qty, 8); assert.equal(r.items[0].codes.length, 2, 'with two barcodes');
// use one
r = await j(await post('/scan', { code: '0024000163275', mode: 'use' })); assert.equal(r.item.qty, 7);
// a new item made from a scan; the same name later finds it; products the database does not know are named by hand
r = await j(await post('/scan', { code: '0070470003344', name: 'Yogurt' })); assert.equal(r.made, true); assert.equal(r.item.qty, 1); r = await j(await get('/lookup?code=1234567890128')); assert.equal(r.product, null); assert.equal(r.lookedUp, true);
r = await j(await post('/scan', { code: '1234567890128', name: 'yogurt' })); assert.equal(r.made, false, 'a name that exists links to it'); assert.equal(r.item.qty, 2);
// using one that takes it to its low level fires the rules
fired.length = 0; await post('/item', { id: corn, name: 'Corn', qty: 3, low: 2 }); await post('/scan', { code: '0016000275287', mode: 'use' }); assert.deepEqual(fired, ['pantry.low:Corn']);
// the database being down is not an error for the person: they just name it
down = true; r = await j(await get('/lookup?code=5000112637922')); assert.equal(r.lookedUp, false); assert.equal(r.product, null); down = false;
// moving a barcode to another item takes it off the first; unlinking
const yog = (await j(await get('/'))).items.find(x => x.name === 'Yogurt').id; r = await j(await post('/scan', { code: '0016000275287', itemId: yog })); assert.equal((await j(await get('/lookup?code=0016000275287'))).linked.name, 'Corn', 'a linked barcode stays with its item');
r = await j(await post('/unlink', { id: corn, code: '0016000275287' })); assert.equal(r.items.find(x => x.id === corn).codes.length, 1);
assert.equal((await post('/delete', { id: yog })).status, 200); assert.ok(!(await j(await get('/'))).items.some(x => x.name === 'Yogurt'));
// the simple way: take one (children too), with undo, and a note of who took what
r = await j(await post('/item', { name: 'Granola bars', qty: 2, low: 1 })); const gb = r.items.find(x => x.name === 'Granola bars').id; fired.length = 0;
r = await j(await post('/use', { id: gb }, 'kid')); assert.equal(r.item.qty, 1); assert.equal(r.item.isLow, true); assert.deepEqual(fired, ['pantry.low:Granola bars'], 'taking the second-to-last one starts the low rule'); assert.equal(r.recent[0].who, 'Kit'); assert.equal(r.recent[0].item, 'Granola bars');
r = await j(await post('/use', { id: gb, undo: true }, 'kid')); assert.equal(r.item.qty, 2, 'undo puts it back'); await post('/use', { id: gb }, 'kid'); await post('/use', { id: gb }, 'kid'); assert.equal((await post('/use', { id: gb }, 'kid')).status, 409, 'none left to take');
const note = (await j(await post('/item', { name: 'Napkins' }))).items.find(x => x.name === 'Napkins').id; r = await j(await post('/use', { id: note })); assert.equal(r.item.qty, null, 'an item that is not counted is only noted');
// a child can look and take, and nothing else
for (const [p, o] of [['/item', { name: 'Candy', qty: 9 }], ['/adjust', { id: gb, delta: 5 }], ['/delete', { id: gb }], ['/unlink', { id: gb, code: '1' }], ['/scan', { code: '0099999999999', mode: 'add', name: 'Candy' }]]) assert.equal((await post(p, o, 'kid')).status, 403, p + ' is for grown-ups');
assert.equal((await get('/')).status, 200); assert.equal((await j(await fetch(base + '/', { headers: { 'x-user': 'kid' } }))).kid, true);
r = await j(await post('/scan', { code: '0016000275287', itemId: corn })); const before = r.item.qty; r = await j(await post('/scan', { code: '0016000275287', mode: 'use' }, 'kid')); assert.equal(r.item.qty, before - 1, 'a child can scan a known barcode to take one'); assert.equal((await post('/scan', { code: '0555555555555', mode: 'use', name: 'Mystery' }, 'kid')).status, 403, 'but not make new items');
// units: an item in pounds, amounts in cups, taken out together (grown-ups only), and the shown amount
r = await j(await post('/item', { name: 'Flour', qty: 10, unit: 'lb', low: 2 })); let fl = r.items.find(x => x.name === 'Flour'); assert.deepEqual([fl.unit, fl.label, fl.lowLabel], ['lb', '10 lb', '2 lb']); assert.equal((await j(await post('/item', { name: 'Bad', qty: 1, unit: 'furlongs' }))).items.find(x => x.name === 'Bad').unit, '', 'an unknown unit is a count');
r = await j(await post('/take', { takes: [{ id: fl.id, amount: 4, unit: 'cup' }], note: 'cooked Pancakes' })); assert.equal(r.done.length, 1); assert.ok(Math.abs(r.done[0].took - 4 * 120 / 453.592) < 0.01); assert.equal(r.items.find(x => x.name === 'Flour').label, '8 lb 15 oz', '10 lb less 4 cups'); assert.equal(r.recent[0].note, 'cooked Pancakes');
r = await j(await post('/take', { takes: [{ id: fl.id, amount: 8, unit: 'oz' }, { id: fl.id, amount: 3, unit: 'furlongs' }, { id: 'nope', amount: 1 }, { id: note, amount: 1 }] })); assert.equal(r.done.length, 1); assert.equal(r.skipped.length, 3, 'what cannot be turned into the item\'s unit, is missing or is not counted is skipped, with a reason'); assert.match(r.skipped.find(x => x.name === 'Napkins').why, /not counted/);
r = await j(await post('/take', { takes: [{ id: fl.id, amount: 99, unit: 'lb' }] })); assert.equal(r.items.find(x => x.name === 'Flour').qty, 0, 'never below none'); assert.equal((await post('/take', { takes: [{ id: fl.id, amount: 1 }] }, 'kid')).status, 403, 'children only take one');
assert.equal((await post('/use', { id: fl.id }, 'kid')).status, 409, 'tapping take-one on a pound of flour is not allowed: use an amount');
// scanning goes by the size of the package for an item in a unit
r = await j(await post('/item', { name: 'Tomatoes', qty: 0, unit: 'oz' })); const tom = r.items.find(x => x.name === 'Tomatoes').id; const tcache = path.join(process.env.DATA_DIR, 'barcode-cache.json'); const cc = JSON.parse(fs.readFileSync(tcache, 'utf8')); cc['0041331000123'] = { found: true, t: Date.now(), name: 'Diced Tomatoes', brand: 'Muir', quantity: '14.5 oz' }; cc['0041331000456'] = { found: true, t: Date.now(), name: 'Tomato Paste', brand: 'Muir', quantity: '1 lb' }; fs.writeFileSync(tcache, JSON.stringify(cc));
r = await j(await post('/scan', { code: '0041331000123', itemId: tom })); assert.equal(r.step, 14.5); assert.equal(r.item.qty, 14.5, 'a 14.5 oz can adds 14.5 oz'); r = await j(await post('/scan', { code: '0041331000456', itemId: tom })); assert.equal(r.step, 16, 'a 1 lb tin is 16 oz'); assert.equal(r.item.qty, 30.5); r = await j(await post('/scan', { code: '0041331000123', mode: 'use' })); assert.equal(r.item.qty, 16, 'using one takes the package size back off');
r = await j(await post('/scan', { code: '0070470003344', itemId: gb, mode: 'add' })); assert.equal(r.step, 1, 'a counted item goes up by one');
// the pantry is shared, and old pantry files (a list of names) still load
assert.equal((await j(await get('/', 'bo'))).items.length >= 1, true); fs.writeFileSync(path.join(process.env.DATA_DIR, 'pantry.json'), JSON.stringify(['Flour', 'Salt'])); r = await j(await get('/')); assert.deepEqual(r.items.map(x => x.name), ['Flour', 'Salt']); assert.equal(r.items[0].qty, null);
assert.deepEqual(pantry.inPantry('3 cups all-purpose flour', ['Flour', 'Salt']), ['Flour']);
console.log('pantry test ok');
} catch (e) { console.error(e); process.exitCode = 1; }
automations.fireAll = realFireAll; hub.close(); off.close();
});
});
+2
View File
@@ -78,6 +78,8 @@ const users = require('../users'), push = require('../push');
dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] }));
await push.tick(new Date());
ok('a device that has unsubscribed (410) is removed', JSON.parse(fs.readFileSync(path.join(d, 'push.json'), 'utf8')).subs.length === 0, fs.readFileSync(path.join(d, 'push.json'), 'utf8'));
const h = push.health();
ok('the admin health report counts the pushes sent and the expired device removed', h.sent > 0 && h.expired >= 1 && typeof h.devices === 'number' && typeof h.people === 'number', h);
fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll push checks passed'); process.exit(failed ? 1 : 0);
+10 -9
View File
@@ -62,16 +62,17 @@ const fake = http.createServer((req, res) => {
j = await (await post('/plan/shop', { from: '2027-03-02', to: '2027-03-08' })).json(); assert.equal(j.ok, true); assert.equal(j.recipes, 1, 'a recipe planned twice is added once, twice over');
assert.equal((await post('/plan/shop', { from: '2027-04-01', to: '2027-04-07' })).status, 400, 'nothing planned, nothing to add');
await post('/plan/remove', { id: (await (await get('/plan?from=2027-03-02&to=2027-03-02')).json())['2027-03-02'][0].id }); assert.deepEqual(await (await get('/plan?from=2027-03-02&to=2027-03-02')).json(), {});
// the pantry: items with a count and a low level; the plan flags planned ingredients you have ("3 cups flour" is covered by "flour"; butter is not in the pantry)
await post('/plan/add', { date: '2027-05-03', slug: 'bread', name: 'Bread' });
assert.equal((await post('/pantry/item', { name: ' ' })).status, 400, 'an item needs a name');
j = await (await post('/pantry/item', { name: 'Flour', qty: 3, low: 1 })).json(); assert.deepEqual(j.items.map(x => [x.name, x.qty, x.low, x.isLow]), [['Flour', 3, 1, false]]); const fid = j.items[0].id;
j = await (await post('/pantry/item', { name: 'salt', qty: '', low: '' })).json(); assert.equal(j.items.find(x => x.name === 'salt').qty, null, 'an item need not be counted'); assert.equal(j.items.find(x => x.name === 'salt').isLow, false);
j = await (await post('/pantry/item', { name: 'FLOUR', qty: 3, low: 1 })).json(); assert.equal(j.items.filter(x => x.name.toLowerCase() === 'flour').length, 1, 'the same name updates the item');
// the pantry check: a planned ingredient the pantry has ("3 cups flour" is covered by "flour"; butter is not in the pantry, and an empty one is not "had")
await post('/plan/add', { date: '2027-05-03', slug: 'bread', name: 'Bread' }); const pf = path.join(process.env.DATA_DIR, 'pantry.json');
fs.writeFileSync(pf, JSON.stringify([{ id: 'a1', name: 'Flour', qty: 3, low: 1, codes: [] }, { id: 'a2', name: 'Salt', qty: null, low: null, codes: [] }]));
j = await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json(); assert.deepEqual(j.items.map(i => i.text), ['3 cups flour']); assert.equal(j.items[0].have.toLowerCase(), 'flour');
j = await (await post('/pantry/adjust', { id: fid, delta: -2 })).json(); assert.equal(j.items[0].qty, 1); assert.equal(j.items[0].isLow, true, 'at the low level counts as running low'); assert.equal(j.items[0].name, 'Flour', 'low ones come first');
j = await (await post('/pantry/adjust', { id: fid, delta: -5 })).json(); assert.equal(j.items[0].qty, 0, 'never below none'); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty one is not "had"');
await post('/pantry/delete', { id: fid }); j = await (await get('/pantry')).json(); assert.deepEqual(j.items.map(x => x.name), ['salt']);
fs.writeFileSync(pf, JSON.stringify([{ id: 'a1', name: 'Flour', qty: 0, low: 1, codes: [] }])); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty one is not "had"');
fs.writeFileSync(pf, '[]'); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty pantry flags nothing');
// what cooking a recipe would take out of the pantry (shown first; nothing changes until it is confirmed in the pantry)
fs.writeFileSync(pf, JSON.stringify([{ id: 'fl', name: 'Flour', qty: 10, unit: 'lb', low: 2, codes: [] }, { id: 'bu', name: 'Butter', qty: 1, unit: 'lb', low: null, codes: [] }, { id: 'ca', name: 'Cocoa', qty: null, unit: '', low: null, codes: [] }]));
j = await (await get('/cook-plan?slug=bread')).json(); assert.equal(j.name, 'Bread'); assert.deepEqual(j.rows.map(r => [r.item.name, r.ok]), [['Flour', true], ['Butter', true]], 'the glaze heading and anything not in the pantry are left out');
assert.ok(Math.abs(j.rows[0].take - 3 * 120 / 453.592) < 0.01, '3 cups of flour in pounds'); assert.equal(j.rows[0].unit, 'lb'); assert.match(j.rows[0].takeLabel, /^(12|13) oz$/); assert.ok(Math.abs(j.rows[1].take - 0.0326) < 0.002, '1 tbsp of butter in pounds (Mealie gave it already split up)');
j = await (await get('/cook-plan?slug=bread&scale=2')).json(); assert.ok(Math.abs(j.rows[0].take - 6 * 120 / 453.592) < 0.01, 'doubled'); assert.equal((await get('/cook-plan?slug=..%2Fx')).status, 400);
// 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);
+16
View File
@@ -0,0 +1,16 @@
// Runs every tools/*-test.js (or only the names given), one after another, and lists the ones that fail.
// A new test file is picked up automatically; `smoke` and `frontend` run first because they fail fastest.
const { spawnSync } = require('child_process');
const fs = require('fs'), path = require('path');
const FIRST = ['smoke', 'frontend'];
const all = fs.readdirSync(__dirname).filter(f => f.endsWith('-test.js')).map(f => f.slice(0, -'-test.js'.length));
const want = process.argv.slice(2);
const names = (want.length ? want : all).sort((a, b) => (FIRST.indexOf(a) + 1 || 99) - (FIRST.indexOf(b) + 1 || 99) || a.localeCompare(b));
const failed = [];
for (const n of names) {
if (!all.includes(n)) { console.error('No test named ' + n); process.exit(2); }
console.log('\n== ' + n);
if (spawnSync(process.execPath, [path.join(__dirname, n + '-test.js')], { stdio: 'inherit' }).status !== 0) failed.push(n);
}
console.log(failed.length ? '\nFAILED: ' + failed.join(', ') : '\nAll ' + names.length + ' test files passed');
process.exit(failed.length ? 1 : 0);
+7
View File
@@ -0,0 +1,7 @@
// Every setting the app syncs must be one the server accepts, or the whole save fails.
const fs = require('fs'), assert = require('assert');
const client = fs.readFileSync(__dirname + '/../public/app.js', 'utf8'), server = fs.readFileSync(__dirname + '/../settings.js', 'utf8');
const list = s => [...s.matchAll(/'([a-z_]+)'/g)].map(m => m[1]);
const c = list(/const SYNCED = new Set\(\[([^\]]*)\]/.exec(client)[1]), s = new Set(list(/const KEYS = new Set\(\[([^\]]*)\]/.exec(server)[1]));
const missing = c.filter(k => !s.has(k)); assert.deepStrictEqual(missing, [], 'the server would refuse: ' + missing.join(', '));
console.log('settings keys: ' + c.length + ' synced settings, all accepted by the server');
+29
View File
@@ -0,0 +1,29 @@
// Units: reading recipe lines, converting (cups of flour to pounds), showing amounts, and working out what a recipe takes out of the pantry.
const assert = require('assert'), U = require('../units');
const P = U.parseIngredient, near = (a, b, tol = 0.02) => assert.ok(Math.abs(a - b) <= tol * Math.max(1, Math.abs(b)), `${a} is not about ${b}`);
// reading lines
assert.deepEqual(P('3 cups all-purpose flour'), { amount: 3, unit: 'cup', name: 'all-purpose flour' });
assert.deepEqual(P('1 1/2 tsp salt'), { amount: 1.5, unit: 'tsp', name: 'salt' }); assert.deepEqual(P('½ cup sugar'), { amount: 0.5, unit: 'cup', name: 'sugar' }); assert.deepEqual(P('1½ cups milk'), { amount: 1.5, unit: 'cup', name: 'milk' });
assert.deepEqual(P('2-3 tbsp olive oil'), { amount: 2.5, unit: 'tbsp', name: 'olive oil' }, 'a range is its middle'); assert.deepEqual(P('1 lb. ground beef, browned'), { amount: 1, unit: 'lb', name: 'ground beef' });
assert.deepEqual(P('2 large eggs'), { amount: 2, unit: '', name: 'large eggs' }); assert.deepEqual(P('1 onion, diced'), { amount: 1, unit: '', name: 'onion' }); assert.deepEqual(P('2 (14 oz) cans diced tomatoes'), { amount: 28, unit: 'oz', name: 'diced tomatoes' });
assert.deepEqual(P('Salt and pepper to taste'), { amount: null, unit: '', name: 'Salt and pepper to taste' }); assert.deepEqual(P('1 stick butter'), { amount: 1, unit: 'stick', name: 'butter' }); assert.deepEqual(P('8 fl oz cream'), { amount: 8, unit: 'fl oz', name: 'cream' }); assert.deepEqual(P('1 can corn'), { amount: 1, unit: '', name: 'can corn' });
assert.deepEqual(P('4 c. flour'), { amount: 4, unit: 'cup', name: 'flour' }); assert.deepEqual(P('2 T sugar'), { amount: 2, unit: 'tbsp', name: 'sugar' }); assert.deepEqual(P('2 cloves garlic'), { amount: 2, unit: '', name: 'cloves garlic' });
// converting
near(U.convert(1, 'lb', 'oz'), 16); near(U.convert(1, 'cup', 'tbsp'), 16); near(U.convert(1, 'gallon', 'quart'), 4); near(U.convert(1000, 'g', 'kg'), 1); assert.equal(U.convert(2, 'cup', 'lb', 'bananas'), null, 'cups to pounds needs to know the ingredient');
near(U.convert(4, 'cup', 'lb', 'all-purpose flour'), 4 * 120 / 453.592, 0.01); near(U.convert(1, 'lb', 'cup', 'flour'), 453.592 / 120, 0.01); near(U.convert(1, 'cup', 'oz', 'brown sugar'), 213 / 28.3495, 0.01); near(U.convert(1, 'stick', 'oz', 'butter'), 4, 0.02); assert.equal(U.convert(1, 'cup', 'lb', 'unobtainium'), null); assert.equal(U.convert(2, '', 'lb', 'flour'), null, 'a count does not become a weight');
assert.equal(U.density('light brown sugar, packed'), 213, 'the longest match wins'); assert.equal(U.density('sugar'), 200); assert.equal(U.density('bananas'), 0);
// showing amounts
assert.equal(U.fmt(8.94, 'lb'), '8 lb 15 oz'); assert.equal(U.fmt(10, 'lb'), '10 lb'); assert.equal(U.fmt(0.94, 'lb'), '15 oz'); assert.equal(U.fmt(2.5, 'cup'), '2 1/2 cups'); assert.equal(U.fmt(1, 'cup'), '1 cup'); assert.equal(U.fmt(12, ''), '12'); assert.equal(U.fmt(null, 'lb'), ''); assert.equal(U.fmt(0.99999, 'lb'), '1 lb'); assert.equal(U.fmt(0.91, 'gallon'), '3 qt 2 1/2 cups'); assert.equal(U.fmt(0.09, 'gallon'), '1 1/2 cups'); assert.equal(U.fmt(1, 'gallon'), '1 gallon'); assert.equal(U.fmt(0.5, 'quart'), '2 cups'); assert.equal(U.fmt(2.25, 'quart'), '2 qt 1 cup');
// what cooking takes out of the pantry
const items = [{ id: 'f', name: 'Flour', qty: 10, unit: 'lb' }, { id: 'e', name: 'Eggs', qty: 12, unit: '' }, { id: 's', name: 'Salt', qty: null, unit: '' }, { id: 'b', name: 'Butter', qty: 2, unit: 'lb' }, { id: 'm', name: 'Milk', qty: 1, unit: 'gallon' }, { id: 'o', name: 'Onions', qty: 3, unit: 'lb' }, { id: 'bs', name: 'Brown sugar', qty: 2, unit: 'lb' }, { id: 'su', name: 'Sugar', qty: 4, unit: 'lb' }];
const pl = U.plan(['4 cups all-purpose flour', '2 large eggs', '1 tsp salt', '1 stick butter', '2 cups milk', '1 onion, diced', '1 cup packed brown sugar', 'pinch of nutmeg', 'Glaze'], items, 1);
const by = id => pl.find(r => r.item.id === id);
assert.ok(by('f').ok); near(by('f').take, 4 * 120 / 453.592, 0.01); near(by('f').left, 10 - 4 * 120 / 453.592, 0.01, 'flour: 4 cups is about 1 lb 1 oz of the 10 lb');
assert.deepEqual([by('e').ok, by('e').take, by('e').left], [true, 2, 10], 'eggs are counted'); assert.equal(by('s').ok, false); assert.match(by('s').why, /not counted/);
near(by('b').take, 0.25, 0.02); near(by('m').take, 0.125, 0.01); assert.equal(by('o').ok, false); assert.match(by('o').why, /recipe counts onions/, 'a whole onion cannot come off pounds');
near(by('bs').take, 213 / 453.592, 0.01); assert.equal(pl.find(r => r.item.id === 'su'), undefined, '"brown sugar" is not plain sugar... and sugar is not listed for it'); assert.equal(pl.length, 7, 'lines that match nothing are left out');
// scaling, and not going below none
near(U.plan(['4 cups flour'], items, 2)[0].take, 8 * 120 / 453.592, 0.01); const tiny = U.plan(['4 cups flour'], [{ id: 'f', name: 'Flour', qty: 0.5, unit: 'lb' }])[0]; assert.equal(tiny.left, 0); assert.match(tiny.why, /more than the pantry has/);
{ const two = U.plan(['2 cups flour', '1 cup flour'], [{ id: 'f', name: 'Flour', qty: 1, unit: 'lb' }]); near(two[1].left, 1 - 3 * 120 / 453.592, 0.02); /* two lines of the same item add up */ }
assert.equal(U.plan(['3 cups flour'], [{ id: 'f', name: 'Flour', qty: 5, unit: 'cup' }])[0].take, 3, 'same unit: no conversion'); near(U.plan(['1 tsp vanilla'], [{ id: 'v', name: 'Vanilla', qty: 4, unit: 'tbsp' }])[0].left, 3.6667, 0.001);
console.log('units test ok');
+1 -1
View File
@@ -10,6 +10,6 @@ for (const k of ['12345', 'radio:journeyfm']) ok(ctx.vizKind({ key: k }) === 'mu
ok(ctx.VIZ_MODES.length === 6 && ctx.VIZ_MODES[0][0] === 'classic', 'six looks, Classic first');
const root = __dirname + '/../public/', rd = f => fs.readFileSync(root + f, 'utf8');
ok(rd('index.html').includes('/visualizer.js'), 'script is loaded');
ok(rd('player.js').includes("id=\"npv\"") && rd('player.js').includes("id=\"cnv\""), 'buttons in the player and the Music card');
ok(/id=\\"npv\\"|chip\('npv'/.test(rd('player.js')) && rd('player.js').includes("id=\"cnv\""), 'buttons in the player and the Music card');
ok(rd('player.js').includes('vizTrackChanged') && rd('radio-view.js').includes('vizTrackChanged'), 'follows track changes');
console.log('visualizer: ' + n + ' checks passed');
+81
View File
@@ -0,0 +1,81 @@
// Amounts and units for the pantry and recipes: read "1 1/2 cups all-purpose flour" into an amount, a unit and a name; convert between units (cups to pounds needs to know the
// ingredient, so there is a table of common ones); show an amount nicely ("8 lb 15 oz"); and work out what cooking a recipe takes out of the pantry. Everything here is an estimate:
// it is for keeping count of the flour, not for the lab.
const VOL = { ml: 1, l: 1000, tsp: 4.92892, tbsp: 14.7868, 'fl oz': 29.5735, cup: 236.588, pint: 473.176, quart: 946.353, gallon: 3785.41, stick: 118.294 }; // to millilitres (a stick of butter is half a cup)
const WT = { g: 1, kg: 1000, oz: 28.3495, lb: 453.592 }; // to grams
const ALIAS = { c: 'cup', cups: 'cup', tablespoon: 'tbsp', tablespoons: 'tbsp', tbs: 'tbsp', tbl: 'tbsp', tbsps: 'tbsp', t: 'tsp', teaspoon: 'tsp', teaspoons: 'tsp', tsps: 'tsp', ounce: 'oz', ounces: 'oz', ozs: 'oz', pound: 'lb', pounds: 'lb', lbs: 'lb', gram: 'g', grams: 'g', gr: 'g', kilogram: 'kg', kilograms: 'kg', kgs: 'kg', milliliter: 'ml', milliliters: 'ml', millilitre: 'ml', millilitres: 'ml', liter: 'l', liters: 'l', litre: 'l', litres: 'l', pt: 'pint', pints: 'pint', qt: 'quart', quarts: 'quart', gal: 'gallon', gallons: 'gallon', floz: 'fl oz', 'fl. oz': 'fl oz', 'fl oz.': 'fl oz', sticks: 'stick' };
/** The canonical unit for a word ("Cups" -> "cup", "lbs." -> "lb"), or '' when it is not a unit this knows. */
function unitOf(w) { const s = String(w || '').toLowerCase().trim().replace(/\.$/, ''); if (VOL[s] || WT[s]) return s; return ALIAS[s] || ''; }
const family = u => WT[u] ? 'weight' : VOL[u] ? 'volume' : 'count';
const UNITS = ['', 'g', 'kg', 'oz', 'lb', 'ml', 'l', 'tsp', 'tbsp', 'cup', 'fl oz', 'pint', 'quart', 'gallon'];
// grams in one cup, for the things people bake and cook with (King Arthur, USDA). The longest matching name wins ("brown sugar" before "sugar").
const DENSITY = { flour: 120, 'bread flour': 120, 'whole wheat flour': 120, 'almond flour': 96, 'coconut flour': 112, 'rice flour': 158, sugar: 200, 'brown sugar': 213, 'powdered sugar': 120, 'confectioners sugar': 120, 'icing sugar': 120, butter: 227, margarine: 227, shortening: 190, milk: 245, buttermilk: 245, cream: 238, 'heavy cream': 238, 'half and half': 242, water: 237, juice: 248, oil: 218, 'olive oil': 216, 'vegetable oil': 218, 'coconut oil': 218, honey: 340, molasses: 340, 'maple syrup': 322, 'corn syrup': 328, syrup: 322, rice: 185, 'brown rice': 190, quinoa: 170, oats: 90, oatmeal: 90, cornmeal: 157, cornstarch: 128, cocoa: 85, 'cocoa powder': 85, salt: 288, 'baking powder': 230, 'baking soda': 230, yeast: 144, 'peanut butter': 258, 'almond butter': 250, 'cream cheese': 227, 'sour cream': 240, yogurt: 245, 'chocolate chips': 170, raisins: 145, nuts: 120, almonds: 140, walnuts: 117, pecans: 109, cheese: 113, parmesan: 100, breadcrumbs: 108, lentils: 192, beans: 190, pasta: 100, mayonnaise: 220, ketchup: 240, 'tomato sauce': 245, 'chicken broth': 240, broth: 240, stock: 240, vinegar: 239, 'soy sauce': 255, coconut: 80, 'cottage cheese': 226, ricotta: 246, 'peanuts': 146, 'sunflower seeds': 140, 'chia seeds': 170, flaxseed: 168 };
const stem = w => w.replace(/(ies)$/, 'y').replace(/(ves)$/, 'f').replace(/(es|s)$/, '');
const words = t => (String(t).toLowerCase().replace(/&/g, ' and ').match(/[a-z]+/g) || []).map(stem);
/** Grams per cup for a named ingredient, or 0 when it is not in the table. */
function density(name) {
const have = new Set(words(name)); let best = 0, bestLen = 0;
for (const [k, g] of Object.entries(DENSITY)) { const ks = words(k); if (ks.length >= bestLen && ks.every(w => have.has(w)) && (ks.length > bestLen || !best)) { best = g; bestLen = ks.length; } }
return best;
}
const FR = { '¼': 0.25, '½': 0.5, '¾': 0.75, '⅓': 1 / 3, '⅔': 2 / 3, '⅛': 0.125, '⅜': 0.375, '⅝': 0.625, '⅞': 0.875, '⅕': 0.2, '⅖': 0.4, '⅗': 0.6, '⅘': 0.8, '⅙': 1 / 6, '⅚': 5 / 6 };
/** A leading amount ("1 1/2", "½", "1½", "2-3", "2 to 3", "0.5"): { n, rest } or null. A range counts as its middle. */
function leadingNumber(text) {
let s = String(text || '').trim().replace(/[¼½¾⅓⅔⅛⅜⅝⅞⅕⅖⅗⅘⅙⅚]/g, m => ' ' + FR[m] + ' ').replace(/\s+/g, ' ').trim();
const one = /^(\d+\s+\d+\/\d+|\d+\/\d+|\d+\.\d+|\d+\s+0?\.\d+|\d+|0?\.\d+)/.exec(s); if (!one) return null;
const val = t => { t = t.trim(); let m; if ((m = /^(\d+)\s+(\d+)\/(\d+)$/.exec(t))) return +m[1] + +m[2] / +m[3]; if ((m = /^(\d+)\/(\d+)$/.exec(t))) return +m[1] / +m[2]; if ((m = /^(\d+)\s+(0?\.\d+)$/.exec(t))) return +m[1] + +m[2]; return parseFloat(t); };
let n = val(one[1]), rest = s.slice(one[0].length).trim(); const range = /^(?:-|–|to)\s*(\d+\s+\d+\/\d+|\d+\/\d+|\d+\.\d+|\d+|0?\.\d+)/i.exec(rest); if (range) { n = (n + val(range[1])) / 2; rest = rest.slice(range[0].length).trim(); }
return isFinite(n) ? { n, rest } : null;
}
/** One recipe line -> { amount (number or null), unit ('' for a count), name }. "2 (14 oz) cans tomatoes" is 28 oz of tomatoes. */
function parseIngredient(text) {
const t = String(text || '').trim(), q = leadingNumber(t); if (!q) return { amount: null, unit: '', name: t };
let amount = q.n, rest = q.rest, unit = '', m;
if ((m = /^\(\s*([\d./\s]+)\s*([a-z. ]+?)\s*\)\s*/i.exec(rest)) && unitOf(m[2])) { const inner = leadingNumber(m[1]); if (inner) { amount = q.n * inner.n; unit = unitOf(m[2]); rest = rest.slice(m[0].length).replace(/^(cans?|jars?|packages?|pkgs?|bags?|boxes|box|bottles?|containers?)\b\s*/i, '').trim(); } }
else { const two = /^(fl\.?\s?oz\.?)\b/i.exec(rest); if (two) { unit = 'fl oz'; rest = rest.slice(two[0].length).trim(); } else if ((m = /^T(\s|$)/.exec(rest))) { unit = 'tbsp'; rest = rest.slice(m[0].length).trim(); } // (a capital T is a tablespoon, a small t a teaspoon)
else if ((m = /^([a-zA-Z]+\.?)(\s|$)/.exec(rest)) && unitOf(m[1]) && !(m[1].length === 1 && /^[a-z]$/.test(m[1]) && !/^[ctT]$/.test(m[1]))) { unit = unitOf(m[1]); rest = rest.slice(m[0].length).trim(); } }
rest = rest.replace(/^of\s+/i, '').replace(/\(.*?\)/g, ' ').replace(/,.*$/, '').replace(/\s+/g, ' ').trim();
return { amount, unit, name: rest || t };
}
/** Convert an amount between units. Weight and volume need the ingredient (a name, or grams per cup); a count converts only to a count. Returns null when it cannot. */
function convert(amount, from, to, name, gPerCup) {
if (from === to) return amount; const ff = family(from), tf = family(to);
if (ff === 'count' || tf === 'count') return null;
if (ff === tf) { const t = ff === 'weight' ? WT : VOL; return amount * t[from] / t[to]; }
const g = gPerCup || density(name); if (!g) return null; const gPerMl = g / VOL.cup;
return ff === 'volume' ? amount * VOL[from] * gPerMl / WT[to] : amount * WT[from] / gPerMl / VOL[to];
}
const r2 = n => Math.round(n * 100) / 100, r4 = n => Math.round(n * 10000) / 10000; // (amounts keep four decimals: a spoonful of butter is a few hundredths of a pound)
/** An amount to show: "8 lb 15 oz", "2 1/2 cups", "12". */
function fmt(qty, unit) {
if (qty == null) return ''; const q = Math.round(qty * 100) / 100;
if (unit === 'lb' && q % 1) { let lb = Math.floor(q), oz = Math.round((q - lb) * 16); if (oz === 16) { lb++; oz = 0; } return oz ? `${lb ? lb + ' lb ' : ''}${oz} oz` : lb + ' lb'; }
if ((unit === 'gallon' || unit === 'quart') && q % 1) { // a fraction of a gallon or quart is easier to picture in smaller measures: "3 qt 2 1/2 cups"
let cups = Math.round(q * (unit === 'gallon' ? 16 : 4) * 4) / 4; const parts = []; if (unit === 'gallon') { const g = Math.floor(cups / 16); if (g) parts.push(g + ' gal'); cups -= g * 16; } const qt = Math.floor(cups / 4); if (qt) parts.push(qt + ' qt'); cups -= qt * 4;
if (cups) { const w = Math.floor(cups), f = { 0.25: '1/4', 0.5: '1/2', 0.75: '3/4' }[cups - w]; parts.push(`${w ? w + ' ' : ''}${f || ''}`.trim() + (cups > 1 ? ' cups' : ' cup')); } return parts.join(' ') || '0';
}
const frac = q % 1, whole = Math.floor(q), nice = { 0.25: '1/4', 0.33: '1/3', 0.5: '1/2', 0.67: '2/3', 0.75: '3/4' }[r2(frac)];
const num = ['cup', 'tbsp', 'tsp', 'quart', 'pint', 'gallon'].includes(unit) && nice ? `${whole ? whole + ' ' : ''}${nice}` : String(q);
return unit ? `${num} ${unit === 'cup' && q > 1 ? 'cups' : unit}` : num;
}
const aboutWords = (a, b) => { const ws = new Set(words(a)); return words(b).length > 0 && words(b).every(w => ws.has(w)); };
/** What cooking `ingredients` (lines, or { amount, unit, name }) takes out of the pantry `items` (each { id, name, qty, unit }), times `scale`.
* Returns [{ line, item, take (in the item's own unit), unit, left, ok, why }]. Lines that match nothing in the pantry are not listed. */
function plan(ingredients, items, scale = 1) {
const out = [], left = new Map(items.map(x => [x.id, x.qty]));
for (const raw of ingredients) {
const ing = typeof raw === 'string' ? { ...parseIngredient(raw), line: raw } : { ...raw, line: raw.line || [raw.amount, raw.unit, raw.name].filter(x => x != null && x !== '').join(' ') };
let best = null; for (const it of items) if (aboutWords(ing.name, it.name) && (!best || words(it.name).length > words(best.name).length)) best = it; if (!best) continue;
const row = { line: ing.line, item: { id: best.id, name: best.name }, unit: best.unit || '', ok: false, take: 0, left: null, why: '' };
if (best.qty == null) { row.why = 'not counted'; out.push(row); continue; }
if (ing.amount == null) { row.why = 'no amount in the recipe (“to taste”)'; out.push(row); continue; }
const a = ing.amount * (scale || 1), fromU = ing.unit, toU = best.unit || '';
let take = fromU === '' && toU === '' ? a : fromU !== '' && toU !== '' ? convert(a, fromU, toU, best.name + ' ' + ing.name) : null;
if (take == null) row.why = fromU === '' ? `the recipe counts ${best.name.toLowerCase()} but the pantry has it in ${toU}` : toU === '' ? `the recipe uses ${fromU} but the pantry counts ${best.name.toLowerCase()}` : `cannot turn ${fromU} into ${toU} for ${best.name.toLowerCase()}`;
else { take = r4(take); const now = left.get(best.id); row.ok = true; row.take = take; row.left = Math.max(0, r4(now - take)); left.set(best.id, row.left); if (take > now) row.why = 'more than the pantry has'; }
out.push(row);
}
return out;
}
module.exports = { UNITS, unitOf, family, density, leadingNumber, parseIngredient, convert, fmt, plan, words };
+2 -2
View File
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
fs.mkdirSync(DATA_DIR, { recursive: true });
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', 'notes', 'chores', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', 'notes', 'chores', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links', 'vault'];
const ALWAYS = ['home', 'family', 'settings'];
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', notes: 'Notes', chores: 'Chores', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', notes: 'Notes', chores: 'Chores', 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', vault: 'Passwords' };
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; };