117 Commits
Author SHA1 Message Date
Claude b204f968c6 Push test: build the event's end from the real time so it passes just before midnight UTC
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-26 22:59:16 +00:00
nmemmertandClaude Sonnet 5.5 d9a59b393d Movies & TV: better search and filters
build-container / build (push) Successful in 4m1s
- Search titles, actors and directors; results grouped into Movies, TV shows, Actors, Directors and Episodes
- Tap an actor or director to see everything they are in
- Filter the library by genre, decade, rating and watched or unwatched, alongside sorting
- README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 17:07:20 -04:00
nmemmertandClaude Sonnet 5.5 b1c720c74f Time requests from the lock screen: one request with +15/+30/+1 h buttons, lock lifts quickly
build-container / build (push) Successful in 4m9s
- Ask a parent on the lock screen also makes a request parents answer in Settings > Family
- Parents choose how many minutes; the child's lock screen rechecks every few seconds
- Notification links open Settings > Family
- Test updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 17:03:43 -04:00
nmemmertandClaude Sonnet 5.5 301e8017d9 Parent controls for children, name-tile sign-in, restricted Learn search, simple mode
build-container / build (push) Successful in 3m52s
- Sign-in page shows name tiles on remembered devices; tap a name, then PIN or passkey
- Music and Movies are separate on the server; Plex can be locked to a child's managed profile
- Activity log, weekly summary and alerts to parents; per-area and weekend limits
- Requests from children (Ask button) answered in Settings; bonus screen time from rewards
- Restricted Learn search (Simple English Wikipedia, blocked words) replaces web search for children
- Simple mode (big-button home) for young children or grandparents
- Child usage kept in memory instead of read from disk on every request
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:49:48 -04:00
nmemmertandClaude Sonnet 5.5 aac6f6efc2 Fix Store tab stuck on Loading; adjustable idle time for family devices; videos count as in use
build-container / build (push) Successful in 3m35s
- A bare #/store now opens at #/store/books so the Book store page draws
- Admins choose how long a family device waits before returning to the board (1 to 30 minutes)
- Any playing video or audio keeps a family device signed in
- Test updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:24:06 -04:00
nmemmertandClaude Sonnet 5.5 4c6b1a8458 Board shows list items and note text; fix white bar under read chat messages
build-container / build (push) Successful in 3m34s
- Family notes card lists each shared checklist's items (with who added them) and each note's text
- The read tick in Chat and Signal had the same class name as the book reader's page style, drawing a white bar; renamed

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:59:41 -04:00
nmemmertandClaude Sonnet 5.5 e88b9dedf0 Chores: weekly points and rewards are for child accounts only
build-container / build (push) Successful in 5m0s
- The weekly board, reward tiers and the board's This week card only include children
- Rewards can be for all children or chosen children, not adults
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:33:28 -04:00
nmemmertandClaude Sonnet 5.5 15ddc68cf0 Board: keep the pop-up above the sticky card headers
build-container / build (push) Successful in 4m2s
- Card titles were drawing over the name picker and add forms, cutting off the title and buttons

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:27:58 -04:00
nmemmertandClaude Sonnet 5.5 a45fc1b89c Board: all people visible in the name picker, PIN enters itself, no number before shopping items
build-container / build (push) Successful in 3m35s
- Name picker wraps in a wider box so five or more people fit
- The board knows each PIN's length and submits when the last digit is typed
- Shopping items typed in are added with no quantity and shown without Mealie's leading 1
- Test updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:12:22 -04:00
nmemmertandClaude Sonnet 5.5 96b00108bb Fix the Family tab in Settings being removed as empty
build-container / build (push) Successful in 4m17s
- Give the section a Loading line so the tab cleanup keeps it until its content fills in

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:49:34 -04:00
nmemmertandClaude Sonnet 5.5 f4c01c2656 Fix Chores page stuck on Loading; add a test that draws the page
build-container / build (push) Successful in 6m0s
- Remove a leftover reference to the old points summary that threw after load
- New chores-view test renders the page from real server data

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:17:48 -04:00
nmemmertandClaude Sonnet 5.5 900e900e53 Settings: a Family tab gathering chat, PIN, chores and rewards, family board people and shared screens
build-container / build (push) Successful in 5m27s
- New Family tab in Settings; each row opens the existing sheet
- Admin switches for who appears on the family board
- Shared-screen controls moved here from Security
- Chore manager can be reopened from Settings after a save

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:13:23 -04:00
nmemmertandClaude Sonnet 5.5 3c721ef6a5 Family board: per-person chore columns, touch scrolling in every box, no list caps
build-container / build (push) Successful in 4m43s
- Chores box spans the width with a column per person (fits five or more)
- Each box and each person's chore column scrolls by touch on its own, with no scroll bars
- Shopping, tasks and shared notes show everything (they scroll)
- Card width no longer capped by the app's shared card style

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:10:46 -04:00
nmemmertandClaude Sonnet 5.5 f87a218eea Family board: add to shopping, tasks, checklists and the Family calendar; record who added each
build-container / build (push) Successful in 4m27s
- + button on each board card: write it, tap your name (PIN unless a child), it is added as you
- Tasks, events, checklist items and shopping items remember who added them
- Names shown on the board and in Tasks, Notes and Calendar
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:01:40 -04:00
nmemmertandClaude Sonnet 5.5 472e7bec7e Family board: fit a 12.9-inch iPad without scrolling; See all for the chore pool
build-container / build (push) Successful in 5m40s
- Fixed-height grid for tablets (3x2 landscape, 2x3 portrait), larger text on big screens
- Compact chores card with tappable pills and +N overflow
- "See all" opens the full list of up-for-grabs chores to pick from

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:49:56 -04:00
nmemmertandClaude Sonnet 5.5 17a282054a Chores: weekly goals with custom reward tiers (everyone or chosen people)
build-container / build (push) Successful in 3m53s
- Admin-set reward tiers; points count Monday to Sunday and restart each week
- Per-person weekly totals, chores done, progress bar toward the next reward, week history
- Mark given / undo when a reward is handed over
- Rewards can be for everyone or only chosen people
- "This week" card on the family board
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:46:44 -04:00
nmemmertandClaude Sonnet 5.5 0df1ff5705 Family board: tap chores to take, finish or undo them; style number inputs
build-container / build (push) Successful in 4m42s
- Chores on the board are tappable; pick your name (PIN unless a child) and it happens as you
- Up-for-grabs chores offer "I did it" or "Take it for later"
- Number inputs (chore points, time limit) use the dark input style so they are readable
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:39:05 -04:00
nmemmertandClaude Sonnet 5.5 870c7129b9 Chores: claim from the pool; admin picks who appears on the family board
build-container / build (push) Successful in 4m21s
- "Anyone" chores get Take it / Put back; a claimed chore is that person's for the day
- Per-person "Show on the family board" switch controls board tiles and family chore progress
- Tests and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:23:59 -04:00
nmemmertandClaude Sonnet 5.5 eef1adaa73 Family devices: shared-screen family board with a who-is-this profile picker
build-container / build (push) Successful in 4m30s
- Admin can make a screen a family device; it opens on a family board
- Name tiles with KID labels; kids tap, others enter a PIN (lockout after 5 wrong tries)
- Name label with Switch user, auto-return to the board after 5 idle minutes
- Short 30-minute sessions on family devices
- Settings section for PIN and turning a device on or off
- Test, README and roadmap updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:58:23 -04:00
nmemmertandClaude Sonnet 5.5 058f410a58 Shared Family calendar and task list; notes default to Family; phone chat and Family card fixes
build-container / build (push) Successful in 4m14s
- Built-in Family calendar everyone with Calendar can edit (data/family)
- Family task list shared by everyone with Tasks
- New notes and lists start shared with the family
- Phone chat sized above the bottom bar and mini player so the message box is visible
- Family page cards no longer overflow on long chat previews
- Tests added and updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:33:23 -04:00
nmemmertandClaude Sonnet 5.5 0383da87f5 Chores, Family page, genre search, child accounts, wider Search and Notifications center
build-container / build (push) Successful in 4m6s
- Chores tab with repeats, points and a week/month board
- Family page: calendar, chores, shopping, notes, chat and tasks on one start page
- Genre search in the Store, Free Books and LibriVox
- Child accounts: safe content, daily limit and bedtime, ask a parent
- Search now covers notes, chat, contacts, chores, recipes and your books
- Notifications center: bell with a per-person list of chat, reminders and announcements
- Roadmap, README and tests updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:48:55 -04:00
nmemmertandClaude Sonnet 5.5 cf292e3bc7 Notes tab: shared and private notes and checklists; icon-only menu rail on short windows
build-container / build (push) Successful in 6m21s
- notes.js and the Notes tab: private, family or chosen-people sharing; checklist items change one at a time (no overwrites), with who ticked them; open lists refresh live; stale text edits are refused
- Pin, colour, archive, search, filters, ready-made lists, Share to Chat, send open items to Tasks
- The narrow menu rail shows icons only (names on hover) when the window is too short for labels
- Roadmap and README updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:28:13 -04:00
nmemmertandClaude Sonnet 5.5 41aa4e676d Family Chat phases 2 and 3, family extras; genre search on the roadmap
build-container / build (push) Successful in 8m12s
- Ticks (sent, delivered, read), typing, presence, disappearing messages, @mentions, pins, forwarding
- Voice messages with a waveform, link previews, polls, emoji picker
- Message to task, shopping item or calendar event; Share to Chat from recipes and songs; quiet hours; family announcement on Home
- Roadmap: chat items ticked off, and Browse and search by genre added

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:55:32 -04:00
Claude 0617664d97 Bump the app cache after merging
build-container / build (push) Successful in 6m34s
2026-10-26 12:17:43 +00:00
Claude ce76f4fa8d Merge suggestions 2026-10-26 12:16:47 +00:00
Claude 5fc741ab02 Merge mailscroll 2026-10-26 12:16:47 +00:00
Claude dc72665610 Merge hardening 2026-10-26 12:16:46 +00:00
Claude 57edf974fd Offline podcast episodes, Continue reading on Home, switch to turn off online lookups
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-26 11:52:16 +00:00
Claude 1d345e072e Mail: on a phone the message is as tall as its text, so there is one scroll instead of a scrolling box inside a scrolling page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-26 11:46:38 +00:00
Claude d606e11de4 Limit request sizes, answer 413, stop cleanly on SIGTERM
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-26 11:40:31 +00:00
Claude 95c9127e8e Merge visualizer
build-container / build (push) Successful in 4m58s
2026-10-25 19:54:11 +00:00
Claude 36cbdcf200 Visualizer: add the classic iTunes-style swirl and make it the music default
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 19:44:41 +00:00
Claude 67e8f7ed5d Add music visualizer for music, radio, podcasts and audiobooks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 19:43:19 +00:00
Claude 661a15162c Merge radio-art: cover art, LIVE display and a simpler player for radio
build-container / build (push) Successful in 3m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 16:52:39 +00:00
Claude 2d975baea2 Radio: cover art for the song that is on (iTunes), else the station logo or a tile; LIVE instead of a length; no seek bar or queue for a live station
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 16:51:49 +00:00
Claude 424d797ecf Merge radio-lyrics: artist and lyrics for radio, LRCLIB lyrics
build-container / build (push) Successful in 3m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 16:36:51 +00:00
Claude 387736fb56 Radio: artist and song in the player, lyrics for the song that is on (LRCLIB), synced for The Journey FM
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 16:32:21 +00:00
Claude 1e46aad9ea Merge menu-bible: Listen and Read entries, custom bottom bar, Apps sheet, and the Bible tab (BSB)
build-container / build (push) Successful in 4m54s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 15:51:38 +00:00
Claude 7fee97497d Menu: Listen and Read entries, a bottom bar you choose, an Apps sheet with search and recents. Bible tab with the Berean Standard Bible (search, highlights, notes, synced place)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 15:44:44 +00:00
Claude 026e74641e Merge store-fix: Book store reads Project Gutenberg and Standard Ebooks the way they actually work
build-container / build (push) Successful in 3m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 03:11:52 +00:00
Claude 239730fd3b Book store: Project Gutenberg through its own catalogue feed (Gutendex timed out), Standard Ebooks through its public pages (its OPDS feed needs a paid sign-in)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 03:11:35 +00:00
Claude 047ac32b19 Merge save-to-files: Save to Files, and the Verse By Verse handout fix
build-container / build (push) Successful in 2m50s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 02:34:56 +00:00
Claude 60291c0e21 Save to Files: copy books, free audiobooks and Verse By Verse lessons and handouts into Seafile (background job, progress, skips what is there)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 02:34:35 +00:00
Claude af51b37ae4 Verse By Verse: handouts open as a plain link (no pop-up blocking), friendly page when a lesson has none; studies with an empty or partial feed are read from their page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 02:27:59 +00:00
Claude 1c3df5c70f Merge vbv: Radio (The Journey FM) and Verse By Verse Ministry studies
build-container / build (push) Successful in 4m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 02:14:11 +00:00
Claude eed07bb1ee Podcast: Verse By Verse Ministry studies (browse, play lesson after lesson, handouts, synced place)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 02:11:17 +00:00
Claude 04b16b6432 Radio: internet radio with The Journey FM featured, Radio Browser search, saved stations and now playing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 02:06:26 +00:00
Claude 9436ac8d4d Merge free-in-list: free audiobooks show in the Audiobooks list
build-container / build (push) Successful in 3m57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 01:28:50 +00:00
Claude 27df8031ca Audiobooks: free audiobooks you saved or started now show in the main Audiobooks list (and under Continue listening), even when the audiobook library cannot be reached
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 01:28:49 +00:00
Claude 6a4edd442f Merge librivox: free audiobooks from LibriVox
build-container / build (push) Successful in 4m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 01:15:46 +00:00
Claude c59ff51361 Audiobooks: free audiobooks from LibriVox (search, shelf, chapters, synced listening position)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 01:15:31 +00:00
Claude 207f8dcc2a Merge book-store: Book store in Books (Gutenberg, Standard Ebooks, Internet Archive, OPDS)
build-container / build (push) Successful in 4m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 01:10:29 +00:00
Claude 84ba0000b8 Books: book store with Project Gutenberg, Standard Ebooks, Internet Archive and OPDS catalogues
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 01:07:39 +00:00
Claude ca47dc46e4 Merge mail-select: swipe and multi-select in Mail, page flip fix in Books
build-container / build (push) Successful in 4m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-25 01:01:14 +00:00
Claude b6944b4ee1 Mail: select several messages (long press, Ctrl/Cmd or Shift click, or the select button) to mark read or unread, flag, archive, move or delete
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-24 20:22:08 +00:00
Claude 8acf9418dc Mail: swipe a message left to delete, right to archive. Books: page flip by tap and swipe works on touch screens
The page frame is wider than the screen (pages sit side by side inside it), so tap positions
were measured against the wrong width and taps on touch devices never turned the page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-24 20:15:53 +00:00
Claude 34a619b235 Merge ebook-reader: Books tab (EPUB, PDF, DOCX, MOBI, AZW3)
build-container / build (push) Successful in 3m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-24 02:55:41 +00:00
Claude 2e99893c8b Books: read DRM-free MOBI, AZW and AZW3; refuse Kindle files with DRM
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-24 02:54:34 +00:00
Claude 07c16ddc25 Books: EPUB, PDF and DOCX reader with synced position, bookmarks, notes and highlights
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-24 02:40:36 +00:00
nmemmertandClaude Sonnet 5.5 1c089c4a10 Transcripts: new and existing episodes are transcribed in the background (published transcript first, else whisper), with a status line in Health
build-container / build (push) Successful in 3m29s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 11:46:15 -04:00
nmemmert e7bd24bbaa Transcripts: a podcast episode gets its transcript going as soon as you play it (published, else made on your server)
build-container / build (push) Successful in 4m6s
2026-10-23 11:43:34 -04:00
nmemmert 747002924d Transcripts: say why a show's own transcript was not found; read Podgrab fields whatever their capitals
build-container / build (push) Successful in 4m2s
2026-10-23 11:42:43 -04:00
nmemmert ae00f61711 CI: build the Lyrica image only when ./lyrica changes (its own workflow), so a Docker Hub timeout cannot fail the main build
build-lyrics-container / build (push) Successful in 6m56s
build-container / build (push) Successful in 3m10s
2026-10-23 11:37:39 -04:00
nmemmert c82bd7cae9 Transcripts: check the whisper model is installed, download it (trying each form the service has used), and say what came back if it will not
build-container / build (push) Failing after 6m18s
2026-10-23 11:25:50 -04:00
nmemmert 05e6b19bfa Transcripts: ask the whisper service to download its model first
build-container / build (push) Failing after 6m42s
2026-10-23 11:20:31 -04:00
nmemmert 40a74e6cbe Transcripts: errors say which step failed and why
build-container / build (push) Successful in 12m23s
2026-10-23 11:17:49 -04:00
nmemmertandClaude Sonnet 5.5 0534bf7c4b Transcripts for podcasts and audiobooks: the show's own, or speech-to-text on your server (whisper service)
build-container / build (push) Successful in 13m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 10:52:59 -04:00
nmemmertandClaude Sonnet 5.5 e984f11a94 Mail: a button per account with its own unread count (kept fresh), instead of a dropdown
build-container / build (push) Successful in 14m15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 10:31:58 -04:00
nmemmertandClaude Sonnet 5.5 7198efaccd Movies & TV: arrows and drag to slide rows (no scrollbar needed), Trailer button (Plex extras, else a YouTube search)
build-container / build (push) Successful in 11m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 10:30:24 -04:00
nmemmert d598501393 Home: the Tasks card lists what is overdue or due this week from every list, with a tick
build-container / build (push) Successful in 10m51s
2026-10-23 09:55:23 -04:00
nmemmertandClaude Sonnet 5.5 2b79160f95 Tasks: an All view with every list, NE Hub and iCloud, together
build-container / build (push) Successful in 12m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:54:33 -04:00
nmemmertandClaude Sonnet 5.5 732906bc7a Movies & TV: posters in a row are all the same size
build-container / build (push) Successful in 12m23s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:47:32 -04:00
nmemmertandClaude Sonnet 5.5 3166a7b1ab Mail in Search, Pick up where you left off on Home (evening first), Cast/AirPlay buttons
build-container / build (push) Successful in 13m46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:34:51 -04:00
nmemmert 1981e66b8a Admin health report (Plex, mail accounts, disk, last backup) 2026-10-23 09:33:33 -04:00
nmemmert 2c8afdef79 Keyboard shortcuts for mail and a shortcut list, offline help and refresh on return, push alerts for new mail 2026-10-23 09:32:56 -04:00
nmemmert 93ae8f8840 Movies & TV: Watch later (a Plex playlist), Collections, who-is-watching switch 2026-10-23 09:32:00 -04:00
nmemmertandClaude Sonnet 5.5 92cf86bf5c Movies & TV: Resume banner, poster-tinted detail page, friendlier empty state
build-container / build (push) Successful in 9m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:23:13 -04:00
nmemmertandClaude Sonnet 5.5 2de5e343b2 Polish: card hover lift, fade-in posters, skeleton loading, snap rows, glowing progress, stronger detail backdrop, lyrics glow
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:22:02 -04:00
nmemmertandClaude Sonnet 5.5 c49693bac6 Movies & TV: remove or clear Continue watching, time left, Recently added and Unwatched rows kept on device, sort/genre/unwatched filters, hold-for-actions, Skip intro/credits
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 09:21:03 -04:00
nmemmertandClaude Sonnet 5.5 bbbb8ef5d5 Mail: faster open/delete (instant delete and archive, separate background IMAP connection, one-fetch message read, bodies saved on device with prefetch); Lyrics: no sweep across line-synced lines
build-container / build (push) Successful in 10m28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 08:40:17 -04:00
nmemmertandClaude Sonnet 5.5 3f57e4bde1 Audiobooks: each person's listening positions are their own (the whole library stays shared)
build-container / build (push) Successful in 9m2s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:52:38 -04:00
nmemmertandClaude Sonnet 5.5 2635e4615d Fix: Podcast screen failed to load (its script ran before the player's); load it after the player
build-container / build (push) Successful in 12m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:25:55 -04:00
nmemmertandClaude Sonnet 5.5 eb64e4964d Signal: a sent message appears at once (dimmed while Signal takes it), in order, and comes back to the box if it fails
build-container / build (push) Successful in 9m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:22:05 -04:00
nmemmertandClaude Sonnet 5.5 4468c2e025 Podcast: each person has their own list of shows
build-container / build (push) Successful in 7m29s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:12:04 -04:00
nmemmertandClaude Sonnet 5.5 b3494b9720 Podcast: resume where you left off, synced between devices (shared progress store with audiobooks)
build-container / build (push) Successful in 9m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:37:51 -04:00
nmemmertandClaude Sonnet 5.5 aa2133ed9a Audiobooks: talk to OpenAudible directly with its own username and password (OPENAUDIBLE_URL)
build-container / build (push) Successful in 9m46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 15:12:34 -04:00
nmemmertandClaude Sonnet 5.5 9231315186 Build: x86 (amd64) only, and run the podcast and audiobook tests
build-container / build (push) Successful in 9m17s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:52:44 -04:00
nmemmertandClaude Sonnet 5.5 62ff4d815d Audiobooks: built-in library, playback and synced progress through the audiobook player (classic frame kept)
build-container / build (push) Canceled after 2m57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:48:05 -04:00
nmemmertandClaude Sonnet 5.5 66a376d22c Podcast: search for new shows and add them
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:42:28 -04:00
nmemmertandClaude Sonnet 5.5 675df5710b Podcast: built-in screen for Podgrab (shows, episodes, playback, search)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:41:59 -04:00
nmemmertandClaude Sonnet 5.5 7f38ac5366 Home: no sideways scrolling on phones
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:41:56 -04:00
nmemmertandClaude Sonnet 5.5 fa4b562fcc Reminders: Clear all notifications on this device
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:41:56 -04:00
nmemmertandClaude Sonnet 5.5 05e2a2ec7b Search: movies and TV from Plex
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:41:56 -04:00
nmemmertandClaude Sonnet 5.5 bf22aba9dd Maintenance: mail libraries to latest patch versions, badge polling only while the app is visible
build-container / build (push) Successful in 21m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:50:17 -04:00
nmemmertandClaude Sonnet 5.5 06300578be Home: tiles stack title over subtitle, phone header keeps Quick add beside the greeting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:50:17 -04:00
nmemmertandClaude Sonnet 5.5 668c7241a1 Settings: General, Connections, Security and Admin tabs (last one remembered); Reminders no longer hangs when the service worker does not start
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:50:17 -04:00
nmemmertandClaude Sonnet 5.5 a3010299d0 Mail: folder unread counts refresh at once after a change, read state survives a late list, no wrong message after quick clicks, mark unread keeps the message in the list
build-container / build (push) Successful in 21m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:19:45 -04:00
nmemmertandClaude Sonnet 5.5 89fd595809 Home: header with date and quick actions, compact app launcher, two-column cards on wide screens, apps first on phones
build-container / build (push) Successful in 20m46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:11:46 -04:00
nmemmertandClaude Sonnet 5.5 924a024b0b PWA: install button, one icon badge for Signal, mail and tasks due, share target for files, manifest launch handler
build-container / build (push) Successful in 22m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:06:19 -04:00
nmemmertandClaude Sonnet 5.5 5c5a7b5155 Plex: edit a title's details (fields locked on save) and choose its poster or background, owner only, through a narrow write rule in the proxy
build-container / build (push) Successful in 19m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:42:32 -04:00
nmemmertandClaude Sonnet 5.5 b171f35be6 Plex Home: the owner's PIN is asked for and checked by plex.tv when switching back, wrong guesses are slowed down
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:39:24 -04:00
nmemmertandClaude Sonnet 5.5 d813884074 Calendar: several NE Hub calendars with colors, per-person view/edit sharing and per-calendar subscribe links; Movies & TV: converted video keeps the right time and seeks inside what the stream has
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:38:15 -04:00
nmemmertandClaude Sonnet 5.5 d25eae5920 Movies & TV: scrubbing the bottom-bar line previews and seeks once on release, Space on a focused button no longer toggles twice, skip updates the bar at once
build-container / build (push) Successful in 20m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:26:39 -04:00
nmemmertandClaude Sonnet 5.5 79dcd5cdf3 Movies & TV: the video controls live in the bottom bar like Music; the floating picture has no controls; tools along the top of the player page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:24:05 -04:00
nmemmertandClaude Sonnet 5.5 2bde697955 Office: search, kind filter, sorting, pinned and recent documents, saved list, upload and drag-and-drop, document menu; Movies & TV mini player keeps its controls visible
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:18:48 -04:00
nmemmertandClaude Sonnet 5.5 1f08443c38 Mail: moving between messages no longer rebuilds the whole page (only the reader and the selected row change)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:12:59 -04:00
156 changed files with 10910 additions and 524 deletions

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
name: build-lyrics-container
# The lyrics container (Lyrica, pinned to one commit) only changes when ./lyrica does, so it is built only then (or by hand with "Run workflow").
# Building it on every push meant a needless restart of the lyrics service each time, and a failed pull of its Python base image failed the whole run.
on:
push:
branches: [main]
paths: ['lyrica/**', '.gitea/workflows/build-lyrica.yml']
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: git.necloud.us
username: ${{ secrets.REGISTRY_USER || gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- id: meta_ly
uses: docker/metadata-action@v5
with:
images: git.necloud.us/nate/hub-lyrica
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
- uses: docker/build-push-action@v6
with:
context: ./lyrica
platforms: linux/amd64
push: true
tags: ${{ steps.meta_ly.outputs.tags }}
labels: ${{ steps.meta_ly.outputs.labels }}
cache-from: type=registry,ref=git.necloud.us/nate/hub-lyrica:buildcache
cache-to: type=registry,ref=git.necloud.us/nate/hub-lyrica:buildcache,mode=max
+6 -20
View File
@@ -22,15 +22,19 @@ jobs:
- run: node tools/tasks-test.js # repeating tasks and subtasks
- run: node tools/push-test.js # reminders: when they are sent, never twice
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration
- run: node tools/cal-test.js # calendars: several per person, colors, sharing view/edit, subscribe links
- run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself
- run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery
- run: node tools/lyrics-test.js # lyrics: formats, wrong matches refused, remembered
- run: node tools/transcript-test.js # transcripts: SRT/VTT/JSON, a show's own transcript link, private addresses refused, speech-to-text
- run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy
- run: node tools/mail-imap-test.js # Gmail and iCloud mail over IMAP/SMTP: connect, read, send, reply, privacy
- run: node tools/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
- run: node tools/abdirect-test.js # audiobooks straight from OpenAudible: its login, byte ranges, positions kept by the hub
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
# Repo secrets needed (Settings -> Actions -> Secrets):
@@ -54,27 +58,9 @@ jobs:
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=git.necloud.us/nate/hub:buildcache
cache-to: type=registry,ref=git.necloud.us/nate/hub:buildcache,mode=max
# The lyrics container (Lyrica, pinned)
- id: meta_ly
uses: docker/metadata-action@v5
with:
images: git.necloud.us/nate/hub-lyrica
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
type=ref,event=tag
- uses: docker/build-push-action@v6
with:
context: ./lyrica
platforms: linux/amd64
push: true
tags: ${{ steps.meta_ly.outputs.tags }}
labels: ${{ steps.meta_ly.outputs.labels }}
+117 -9
View File
@@ -14,6 +14,8 @@ Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12
docker compose up -d --build # http://localhost:8080
# or: node server.js
**Install extras**: Settings has an Install button where the browser offers one (Chrome, Edge, Android). The installed app's icon badge counts unread Signal chats, unread mail and tasks that are overdue or due today. On Android and desktop Chrome/Edge, NE Hub appears in the Share menu: share files to it, pick a Seafile folder, and they upload.
Put it behind your reverse proxy with HTTPS (needed for "Add to Home Screen" and the service worker),
e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docker-compose.yml.
@@ -24,6 +26,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
Office files open in Seafile's own OnlyOffice integration.
- **Edit here**: opens docs inside the app via OnlyOffice. The server signs each editor config with the JWT secret and saves edits back to Seafile.
- **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume).
- **Settings** is split into General, Connections, Security and (for the admin) Admin tabs; the last one used is remembered on each device.
- **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audiobooks, Podcast, Podcast Admin, Mail,
Calendar, Tasks, Search, Links; Home and Settings are always on), and give them a one-time invite code to create their own passkey. Everyone's tasks, NE Hub events,
settings, iCloud connection, calendar links and Plex sign-in are private to them (`/data/users/<id>/`). Limits are enforced by the server, not just hidden.
@@ -36,24 +39,34 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
breadcrumb** to move them (hold Alt/Option to copy). **Drag a single file out to your desktop** to download it (Chrome and Edge only; browsers cannot drag out folders
or several files). Also share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation.
- **Calendar**: month grid and agenda. Add, edit and delete events:
- **NE Hub calendar**: events you create are stored in `/data/events.json` and published as a private subscribe link
(Manage calendars -> copy link) so Outlook, Google or Apple Calendar can show them (read-only there).
- **iCloud (two-way)**: connect with an Apple ID and an *app-specific password* (appleid.apple.com). Credentials are
encrypted in `/data/icloud.json`; only `*.icloud.com` is contacted. Edits keep alarms and attendees intact.
- **NE Hub calendars**: make as many as you like (Manage calendars -> New calendar), each with its own name and color (a picker; iCloud, linked and shared calendars can be recolored too). Events carry their calendar's color everywhere and you pick the calendar when adding one. Older events stay in the first calendar.
- **Sharing**: a calendar is private until you share it. In its Edit sheet, choose per person whether they can **view** or **edit**; the server enforces it. Each calendar also has its own private subscribe link (copy or make a new one), and there is still one link for all of yours, so Outlook, Google or Apple Calendar can show them (read-only there).
- **ICS links** (Google secret address, Outlook/M365 published calendar) are read-only.
- **Tasks**: to-do lists stored in NE Hub (`/data/tasks.json`), with due dates, notes and completion. If the connected iCloud
account exposes reminder lists over CalDAV they appear too and sync both ways. Apple hides Reminders lists that were upgraded
for iOS 13+, so for most accounts only NE Hub lists are available.
- **Plex PINs and editing**: a Plex Home profile that has a PIN in Plex (the owner's included) asks for it every time you switch to it; plex.tv checks it and five wrong guesses make the hub wait ten minutes. The **Edit** button on a movie, show, season or episode (only when you are the Plex server's owner) opens the fields Plex's own Edit dialog has: title, sort title, original title, release date, content rating, studio, tagline, summary and genres, plus choosing a poster or background (or adding one from an image link). Only changed fields are saved, and each is locked in Plex so a refresh does not undo it. The hub's Plex proxy allows just those edit requests (nothing else on the server can be changed).
- **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. The Seafile sign-in token is saved there too (encrypted), so Settings shows Seafile as signed in on every device. Only the music queue stays in the browser.
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as <name>" with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal.
- **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. **Mini player**: leave the player page (to any tab) and the video keeps playing in a small window at the bottom right, with play/pause, back to the full player and close; music and video pause each other. Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting.
- **Audiobooks**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons.
Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks
- **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. **Bottom bar and mini picture**: the video's controls live in the same bottom bar Music uses, on every tab: poster, title, time, a seek line you can tap or drag, back and forward 10 s, play/pause, and on wider screens previous and next episode and close. Tap the title to open the full player. Leave the player page and the picture keeps playing in a small window at the bottom right (click it to go back). On the player page, volume, speed, Quality & tracks, picture in picture, full screen and close are along the top; the on-video controls only appear in full screen. Music and video end each other (starting one stops the other). Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting.
- **Audiobooks** is a built-in screen for the [audiobook player](https://git.necloud.us/nate/audiobook-player) at `AUDIOBOOKS_URL`: your library with covers, Continue listening, search, chapters, and playback in the same player as Music. The hub signs in to the player as you with the audiobook sign-in saved in Settings (or, if you have none, the shared `AUDIOBOOKS_LOGIN_LINK`), so no password reaches the browser. Everyone sees the whole library, but where you got to in each book is your own: the hub keeps each person's positions on the server (so they follow you between devices), and with a personal audiobook-player sign-in they are also kept in step with the player's own page. Search finds your books too. Set `OPENAUDIBLE_URL` to talk to OpenAudible directly instead: then the username and password in Settings -> Audiobooks sign-in are OpenAudible's own login (or `OPENAUDIBLE_USER` / `OPENAUDIBLE_PASSWORD` on the server), and the hub keeps each person's listening position itself (as it does in both modes). The old frame is still there as **Classic player** (`#/audio/classic`).
(Classic player) Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks
the person may use the Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page,
script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET`
(and optionally `AUDIOBOOKS_SSO_URL`, default `<AUDIOBOOKS_URL>/sso`) for short-lived signed links: `?token=<body>.<sig>`, where `body` is base64url JSON
`{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied.
- **Books**: an e-book reader for **EPUB, PDF, DOCX, MOBI, AZW and AZW3** (Media group; give it to people in Settings -> People as *Books*). Add files with the button or by dropping them on the page (up to 300 MB each; the server checks a file really is what its name says and does not store the same book twice). Titles, authors and covers are read out of the books in the browser; long-press or right-click a book to edit, download or delete it. Each person has their own shelf in `/data/users/<id>/books/`.
- **Reading**: EPUB uses epub.js (pages or scroll, two-page spreads on wide screens), PDF uses pdf.js (continuous scroll, zoom, selectable text, dark and sepia tints) DOCX is converted to HTML by mammoth, and MOBI, AZW and AZW3 are read by `@lingo-reader/mobi-parser`; both are shown in a frame that cannot run scripts (scroll only, with contents from the book or its headings). Contents, search inside the book, a progress slider, tap the edges or use the arrow keys, swipe on touch screens, tap the middle to hide the bars. Appearance (theme, text size, font, line spacing, margins, layout, zoom) is remembered on each device.
- **Synced**: where you got to, bookmarks, highlights (four colours) and notes are kept on the server, so a book opens where you left off on any device; two devices adding notes at once keep both (the newest edit of the same note wins). Continue reading on the library page, sorting and search, and *Copy as text* exports a book's notes.
- **Book store** (Books -> Book store): search free, DRM-free books from **Project Gutenberg** (its own catalogue feed), **Standard Ebooks** (its public website: its OPDS feed is for paying members only, so the hub reads the public search pages and downloads the free EPUB), the **Internet Archive** (public-domain EPUBs only; borrow-only items are refused) and any **OPDS catalogue** an admin adds under *Catalogues* (Calibre-Web, Kavita, Komga and so on; an optional username and password are kept encrypted on the server and never sent to the browser). One search covers every source, results show their cover and source, and *Add to library* downloads the book on the server straight onto your shelf. The server only ever contacts a source's own addresses (redirects to other sites are refused), covers come through the hub, and a catalogue book must live on that catalogue's own address. Settings: `GUTENBERG_URL`, `STANDARD_EBOOKS_URL` and `ARCHIVE_URL` can point at mirrors. `node tools/store-test.js` tests it against fake catalogues; it was also tried against the live Project Gutenberg, Standard Ebooks and Internet Archive (searches, and real EPUBs downloaded); if one of them changes its pages or feed, the line under the search box names the source that failed.
- The reader scripts are in `public/vendor/reader/` (epub.js, JSZip, pdf.js, mammoth, the MOBI parser) and load only when a book is opened or added. `node tools/books-test.js` tests the server; `python3 tools/mkbooks.py <dir>` makes a small EPUB, PDF and MOBI to try. **DRM-protected books are refused** (a Kindle file with copy protection is detected on upload and the reason is shown); only DRM-free books work. The MOBI reader was tried on a generated MOBI 6 file, not on real Kindle files, so tell me about any book that opens oddly. Not included: text-to-speech, offline reading.
**Free audiobooks** (Audiobooks -> Free audiobooks; works without any audiobook sign-in): search **LibriVox**, thousands of public-domain books read aloud by volunteers, by title or author. A book shows its length, language, description and chapters; *Play* (or *Resume*) queues every chapter in the same player as Music so the next one follows by itself, and starting a book puts it on your shelf (*My free audiobooks*, with a progress bar; *Remove from shelf* in the book's sheet). Where you got to is kept on the server per person, so it follows you between devices. The hub searches LibriVox and hands the browser each chapter's address, but only addresses on LibriVox or the Internet Archive, which hosts the recordings; covers come through the hub. `LIBRIVOX_URL` points at a mirror. `node tools/librivox-test.js` tests it against a fake LibriVox; it has not been run against the live site from the build environment.
- **Radio**: internet radio, with **The Journey FM** (Liberty University, Lynchburg, Virginia; MP3 192 kbps, `http://ic.liberty.edu:8000/WRVL`) always on top, showing what is playing now and the last songs played from the station's own feed. Below it, search thousands of stations from the community [Radio Browser](https://www.radio-browser.info/) directory (free, no key) by name or with quick topic chips (Christian, Worship, Gospel, Talk, News, Classical, Jazz...). The heart saves a station to *My stations* (your own list). Stations play in the same player as Music, so they keep playing as you move between tabs. The hub fetches each stream itself and passes it on, because many stations only offer plain http, which a browser refuses to play inside an https page; it also reads the song title carried in the stream (the player shows it) and fetches station logos. The hub never connects to private, local or link-local addresses for a station. Give it to people in Settings -> People (Radio). `JOURNEYFM_STREAM_URL`, `JOURNEYFM_NOW_URL` and `RADIO_BROWSER_URL` can override the addresses. Lyrics come from the Lyrica service if you run one (`LYRICS_API_URL`), and otherwise (or when Lyrica finds nothing) from [LRCLIB](https://lrclib.net), a free lyrics database that needs no key: the artist and song title are sent to it, and set `LYRICS_LRCLIB=0` to never ask it. `node tools/radio-test.js` and `node tools/lrclib-test.js` test it against fakes; the real Journey stream could not be reached from the build environment, so if it will not play, open the station's own *Website* button and tell me what changed. **Song and artist**: the player shows the song as the title with the artist under it (and the station as the album), also on the mini player, the Now Playing sheet, the Radio page and the phone's lock screen; it follows the station every 10 seconds. The Journey FM's own feed gives the artist and when each song began; other stations send `Artist - Title` inside the stream, which the hub splits. **Cover art**: the cover of the song that is on (from Apple's free iTunes search, no key; the song's artist and title are sent to it), else the station's logo, else a coloured tile with the station's initials, so there is never an empty picture. A live station shows *LIVE* in place of a length, and has no seek bar, queue or skip buttons. **Lyrics** (the Lyrics button on the player or the Radio page) are looked up for the song that is on now and change when the song does. A station that says when a song began (The Journey FM) gets synced lyrics that follow along; because what you hear is a few seconds behind the station's clock, it starts 5 seconds early and the - and + buttons move it a second at a time (remembered for that station). Other stations show the words without following along. Not included: HLS-only stations, recording, alarms.
- **Verse By Verse** (Podcast -> *Verse By Verse*; needs only the Podcast tab, not Podgrab): free verse-by-verse Bible teaching from [Verse By Verse Ministry International](https://versebyverseministry.org). Browse the ministry's studies (about 80: Old and New Testament books, topical series, single teachings, women's and youth), search them, and open a study to see its lessons with dates and summaries. *Start* / *Continue* plays the study lesson after lesson in the same player as Music, a lesson resumes where you left off (kept on the server per person, so it follows you between devices), and each lesson's handout (PDF) opens from *Notes*. The hub reads the study list from the ministry's category pages and the lessons from each study's own podcast feed (`/bible-studies/<study>/podcast.xml`), keeps what it read for 30 to 60 minutes so the ministry's site is not asked on every visit, and sends the browser straight to the ministry's media server for the audio. Only the ministry's own addresses are ever used. `VBV_URL` can point at a mirror. `node tools/vbv-test.js` tests it against a fake copy of the site; it was also tried against the live site (80 studies, a study's lessons, audio and handout links). If the ministry redesigns its pages, the study list is the first thing that would break, and the screen would say so.
- **Save to Files**: copy things into your own Seafile from where you find them. **Books** (long-press or right-click a book -> *Save to Files*), **free audiobooks** (a book's sheet -> *Save to Files*, one numbered MP3 per chapter in a folder) and **Verse By Verse** (a study's *Save to Files* button saves every lesson and handout, or just the audio or just the handouts; the download button on a lesson saves that one). You pick the library and folder (it remembers the last one); the hub makes a tidy subfolder (`Books`, `Audiobooks/<title>`, `Verse By Verse/<study>`), names files safely and in order, **skips anything already there** (so running it again fills in what is missing) and counts lessons that have no handout instead of failing. The hub does the downloading and uploading itself with the Seafile sign-in already saved for you, so a whole study (several GB) does not pass through your phone and keeps going if you tap *Hide*; a sheet shows progress, *Stop* cancels, and *Open folder* jumps to the result. It needs the Files tab and the tab the content comes from (Books, Audiobooks or Podcast); each person can have two saves running at once; files up to 500 MB. `node tools/savefiles-test.js` tests it against a fake Seafile.
- **Bible**: the **Berean Standard Bible** (BSB, public domain: https://berean.bible/), all 66 books, with its section headings, poetry layout and footnotes. The whole text is one file (`public/vendor/bible/bsb.json`, about 1.5 MB compressed, made by `python3 tools/make-bsb.py` from the free Hello AO Bible API) that the browser loads once and keeps, so reading is instant and works offline. **Choose** a book and chapter, **search** for words (every word must be in the verse) or type a place (`John 3:16`, `1 cor 13:4-7`, `ps 23`), swipe or use the arrow keys to turn chapters, and tap verses to **highlight** (four colours), add a **note**, or **copy** them with their reference. Tap a small letter for a footnote. *My highlights and notes* lists everything. Your place, highlights and notes are kept on the server per person, so they follow you between devices; text size and font are remembered per device. Give it to people in Settings -> People (Bible). `node tools/bible-test.js` tests the saving and checks all 31,086 verses are in the file.
- **Menu on a phone**: related tabs share one entry (**Listen**: Music, Radio, Podcast, Audiobooks; **Read**: Bible, Books; each part still needs its own permission), with a row of buttons under the top bar to switch between the parts (it remembers the last one you used). The **bottom bar** is yours to choose: *More* -> *Edit bottom bar* (up to five apps, in the order you tap them; it is saved to your account). **More** opens the **Apps** sheet: every app as a big icon, grouped, with the ones you used lately on top, a search box (typing *rad* finds Radio even though it lives under Listen) and unread badges. Home still shows one tile for each part. On a tablet or computer the side menu uses the same merged entries, and Ctrl/Cmd-K finds every page by name.
- **Signal**: your Signal chats inside the app, **on desktop-class screens only** (a window at least 900 px wide with a mouse or trackpad; phones and touch tablets don't
see it). Besides the Signal tab there is a chat button on **every** screen that opens a panel over whatever you are doing (close it with the x; the
expand button opens the full page). New messages get your attention with a toast, a chime, the unread count in the browser tab title and on the installed app's icon, and
@@ -78,14 +91,27 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Recovery codes and activity log**: Settings -> Recovery codes makes 8 one-time codes; if you lose every device, the sign-in page's "Use an invite or recovery code" adds a new passkey with one (codes are stored only as hashes). Admins see an **Activity** list in Settings (sign-ins, failed sign-ins, passkeys added or removed, invites, recovery code use, people changes, backups); it is `/data/audit.log` and never contains codes or passwords.
- **File version history**: on any Seafile file, "Version history" lists earlier versions with who and when, and can download or restore one (restoring keeps the current version in the history). This uses Seafile's history API and has not been tried against a live Seafile server yet.
- **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`.
- **Podcast** opens `PODCAST_URL` (a home-network address) in its own tab. **Podcast Admin** is no longer a menu entry: it is a built-in link on the **Links** page (opens `VERSE_URL` in a new tab, cannot be edited or deleted) for people who have the Podcast Admin permission in Settings -> People; the old `#/verse` address goes to Links.
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
- **Podcast** is a built-in screen for [Podgrab](https://github.com/akhilrex/podgrab) at `PODCAST_URL`: your shows, their episodes (played state, download all, pause, remove), adding a show by feed address, and playback in the same player as Music. The hub signs in to Podgrab for you with `PODGRAB_PASSWORD` (Podgrab's `PASSWORD` setting; the user name is `podgrab`), so that password never reaches the browser. Each person has their own list of shows (the admin starts with everything already in Podgrab, everyone else starts empty); adding a show someone already follows just puts it on your list, and removing one only takes it off yours (Podgrab drops it when nobody follows it). Where you got to in each episode is kept per person on the server, so it follows you between devices. Search finds your shows too. Add podcast also searches Apple's public podcast directory for new shows (the words you type go to Apple). Not here: resume position.
- **Podcast Admin** is no longer a menu entry: it is a built-in link on the **Links** page (opens `VERSE_URL` in a new tab, cannot be edited or deleted) for people who have the Podcast Admin permission in Settings -> People; the old `#/verse` address goes to Links.
- **Office**: make, find and open Word, Excel and PowerPoint files kept in Seafile (they open in OnlyOffice inside the app). New document / spreadsheet / presentation buttons, search, a filter by kind, sorting (newest, name, size), **Pinned** and **Recently opened** at the top, an Upload button and drop-files-on-the-page (with the same Replace / Keep both / Skip choice as Files), a three-dots menu on every document (share link, download, version history, rename, move, delete), and a saved copy of the list that shows at once while the real one loads (and stays if Seafile cannot be reached). Pins are saved with your settings and follow you across devices. The Ctrl/Cmd-K palette can create a new document, spreadsheet or presentation.
- **Mail**: several mail accounts per person in one place: **Gmail**, **iCloud Mail** and (optionally) **Outlook.com** (personal). Folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB) and a From choice, reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab (all accounts added up) and a Home card with the latest unread across accounts. Each person adds their own accounts (Mail tab, or Settings -> Mail -> Add an account); everything is private to them and stored encrypted in their own folder.
- **Gmail and iCloud** use IMAP/SMTP with an **app password**, so there is nothing to set up on the server. Gmail: turn on 2-Step Verification, then make an app password at myaccount.google.com/apppasswords. iCloud: appleid.apple.com -> Sign-In and Security -> App-Specific Passwords. The password is checked with the provider before it is saved, and wrong ones are explained in plain words. iCloud does not keep a copy of mail sent over SMTP, so NE Hub files one in Sent itself (not yet confirmed against a real iCloud account).
- **Outlook.com** goes through Microsoft Graph (Outlook.com no longer allows IMAP passwords), which needs the one-time Microsoft app below; without it the Outlook.com button simply is not offered.
- Message text is shown in a sandboxed frame with no scripts, and pictures from the internet stay hidden until you press Show pictures. Not included: a combined inbox across accounts (switch account at the top), message previews in the list for Gmail/iCloud, drafts autosave, contacts, Microsoft 365 work accounts, other IMAP providers (easy to add the same way).
**One-time Outlook.com setup** (step by step, with troubleshooting, in [docs/mail-setup.md](docs/mail-setup.md); needs a free Microsoft account and takes five minutes): go to entra.microsoft.com -> App registrations -> New registration. Name it NE Hub; supported account types **Personal Microsoft accounts only**; Redirect URI: platform **Web**, value `<your hub address>/api/mail/callback` (for example `https://hub.necloud.us/api/mail/callback`; it must match `PUBLIC_URL` exactly). Then API permissions -> Add -> Microsoft Graph -> Delegated: `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Copy the **Application (client) ID** and paste it, with the secret, into **Settings -> Microsoft app (admin)** (or set `MS_CLIENT_ID` / `MS_CLIENT_SECRET` in the environment, which win). If you registered it as a Web app, also make a client secret (Certificates & secrets) and put its value in `MS_CLIENT_SECRET`; if you used platform "Mobile and desktop applications" instead, no secret is needed. Leave `MS_CLIENT_ID` empty to switch Mail off. (The old bundled Cypht webmail and its `nehub-cypht` volume are gone; you can delete that volume.) Connecting from the installed iPhone/iPad app may open Microsoft's page in Safari; if the app does not notice, reopen it.
## Visualizer
Tap **Visuals** on the Now Playing sheet, the Music card or the Journey FM card for a full-screen picture that moves with the sound: Classic (the swirling, colour-cycling look iTunes had, and the default for music), Rings (cover art in the middle), Bars, Waves, Aurora and Sparks. It works for music, radio, podcasts and audiobooks, picks a calm look for speech, and takes its colours from the cover. Tap the picture to hide the buttons; Esc closes it. The screen stays awake while it is open.
It listens to the sound the browser is playing. Where it cannot (iPhone Safari, or audio that comes from another site such as Verse By Verse and LibriVox), it falls back to an "ambient" animation that still keeps time with play and pause, and the top of the screen says which mode is on.
## Offline podcasts, Continue reading and privacy
- **Save for offline:** on a podcast show, the download button beside an episode keeps it on that device so it plays with no signal. Tap it again to remove it. Saved episodes live in the browser's own storage (not synced between devices) and the browser may clear them if space runs low.
- **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.
## 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
@@ -106,3 +132,85 @@ On the server:
cp .env.example .env # fill in secrets, PUBLIC_URL, MS_CLIENT_ID (for Mail), etc.
docker compose pull && docker compose up -d
docker compose logs hub | grep "Setup code" # first sign-in: create your passkey
## Transcripts for podcasts and audiobooks
The lyrics screen shows a **Transcript** for podcast episodes and audiobooks. If the show publishes its own (Podcast 2.0 `<podcast:transcript>`: SRT, VTT or JSON) it is used
straight away. Otherwise a **Transcribe on this server** button makes one with speech-to-text: the bundled `whisper` service in `docker-compose.yml` (faster-whisper behind an
OpenAI-compatible API) is reached at `TRANSCRIBE_API_URL` (default `http://127.0.0.1:8000`), model `TRANSCRIBE_MODEL` (default `Systran/faster-whisper-small`, downloaded on first use).
CPU only: about a quarter of the running time or more. One job at a time; results are kept in `/data/transcripts`. Remove the service and the variable to switch the button off.
In the background the server also keeps transcripts up to date by itself: every ten minutes it looks at the newest `TRANSCRIBE_KEEP` (default 5) episodes of every show in Podgrab, uses the show's
published transcript when there is one, and otherwise transcribes the episode (one at a time, at most two new ones per round, a failing one retried a few times). `TRANSCRIBE_AUTO=0` switches this off;
playing an episode or pressing the button still works. Settings -> Health shows how many are saved and what it is working on.
## Podcast search, transcript search, Recipes and Contacts
- **Podcast discovery**: *Add podcast* searches Apple's free directory and, when `PODCASTINDEX_KEY` and `PODCASTINDEX_SECRET` are set (a free key from podcastindex.org), Podcast Index too, with topic buttons (News, History, Science...) that list trending shows. A feed in both appears once.
- **Search what's said** (Podcast screen): finds a word or phrase in every transcript on the server and plays that episode from that moment. Only podcasts on your own list (and audiobooks) are searched; older transcripts are matched to their episode the first time.
- **Recipes** (needs `MEALIE_TOKEN`; `MEALIE_URL` defaults to `https://mealie.necloud.us`): browse and search your Mealie recipes, read one (ingredients, steps, times), import a recipe from a web link, and add its ingredients to the Mealie shopping list (Mealie adds up repeats). The shopping list is also kept in **Tasks** as a "Shopping" list: ticking, adding or deleting an item in either place is carried over when either screen opens or an item changes. In a recipe, tick ingredients (and tap steps) as you go; this is remembered on the device, with *Uncheck all* to start over. On wide screens the ingredients sit beside the steps. Turn the tab on per person in the admin screen.
- **Contacts**: an address book per person (name, company, emails, phones, address, birthday, notes). Mail suggests contacts while you type a recipient, a message you are reading can be saved as a contact (or opens the one you have), birthdays appear in the Calendar, and .vcf files from iCloud, Google or a phone can be imported and exported.
## Store
One **Store** tab (turn it on per person) with sections for **Books** (the free book sources, Open Library and any OPDS catalogue), **Audiobooks** (LibriVox) and **Radio** (the station finder), each shown only to people who have that tab, plus **My Store**. The old Book store and Free audiobooks buttons now open the Store. **Radio** under Listen starts empty: find stations in the Store's Radio section (The Journey FM is listed there too) and tap *Add to Radio*; only what you add appears under Listen, and you can remove it again. Someone without the Store tab keeps the station finder in Radio itself.
**My Store** is your own catalogue, for everyone who has the Store tab. The admin adds items with **Add**: upload files, or copy a file from their Seafile (**From Files**), with an optional cover picture.
- **Books** (EPUB, PDF, MOBI, AZW3, DOCX): *Add to my library* puts a copy on that person's shelf, or download it.
- **Music** (MP3, M4A, AAC, FLAC, OGG, OPUS, WAV): an album can have many tracks; they play in the hub's player and can be downloaded.
- **Apps**: any files to download (an APK, an installer...), with an optional link and version. The hub does not run them.
- **Sites**: any website as a card. It opens in a new tab, or inside the hub if you tick *Open inside the hub* (some sites refuse to be shown that way).
- **Extensions**: one self-contained `.html` file that adds a feature to the hub. A person installs it from My Store and it opens inside the Store. It runs in a sandbox with no access to the hub's sign-in or data (and `connect-src https:` only), and talks to the hub through a small `hub` object: `hub.storage.get/set/all/remove` (its own settings for that person, up to 256 KB), `hub.user()` (the person's name), `hub.toast(text)` and `hub.open(url)`. It can also ask for hub data, which the person approves the first time (and which is forgotten when the extension is removed): `hub.tasks.lists() / list(listId) / add({ title, notes, due, list }) / complete(listId, taskId)`, `hub.contacts.list() / add({ name, email, phone, org })` and `hub.calendar.events(from, to) / add({ title, start, end, allDay })` (dates as `YYYY-MM-DD`, times as `YYYY-MM-DDTHH:MM`). A refused request rejects with an error. Ready-made examples are in `docs/extensions/`: **notes.html** (notes and checklists, can send items to Tasks), **tracker.html** (habit and Bible-reading streaks), **kitchen.html** (unit converter and recipe scaler that can add to your Shopping list), **weather.html** (forecast plus a verse of the day) and **notepad.html** (the smallest one).
Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions up to 2 MB). Only an admin can add, change or delete items.
**Smashwords** is a book source in the Store (Books). It lists Smashwords' most downloaded free EPUBs through its own shelf feed, filtered for a family: anything Smashwords flags as adult or erotica, anything in LGBTQ+, anything whose categories, tags or description say so, and any romance that does not say it is clean (sweet, Christian, inspirational...). The filter is automatic and cannot be perfect, so a book can occasionally slip through. Smashwords has no public search, so searching looks through a remembered set of about 400 of its popular free books. Downloading needs a free Smashwords account: an admin enters it once under Books, *Catalogues* (kept encrypted on the server, used only to sign in at smashwords.com). `SMASHWORDS_LANG` (default `1`, English) picks the language.
**My apps on Home:** the extensions a person installed from My Store, and any Sites in My Store, appear under *My apps* on Home and in the command palette.
**Music, Featured:** the first tab of Music shows a featured artist and a featured album, picked at random. They stay the same for the day on that device; *Pick again* rolls new ones. *Based on* chooses which playlists they come from (nothing chosen means the whole library), and that choice follows you between devices. *Play mix* shuffles the artist's songs; *Play* plays the album in order.
**Music, more** (Plex music; what is about a person is kept by the hub in `music-*.json` in their folder, and `music-family.json` for Family playlists):
- **Mixes tab:** a Daily mix (what you play, your favorites and a few surprises), Most played this month, Forgotten favorites, Moods (calm, upbeat, focus, worship, built from your genres), On this day (albums released today in past years), Decades, Genres (each with a featured album and a shuffle), *Library radio* (an endless shuffle that keeps adding songs; it uses the playlists chosen on Featured), and **Smart playlists**: your own rules (genres, years, added recently, not played in a while, played at least N times, favorites only, order, size) that pick songs each time you open them.
- **Favorites:** a heart on songs (in lists, the full player and the song menu); the Favorites page plays them. Kept by the hub, so they follow you between devices.
- **Stats tab:** songs played, time listened, artists, day streak, minutes per day or month, top artists, songs and albums, and recently played; for a week, month, year or all time. A song counts when it has been played for half its length. *Clear my history* starts over.
- **Family playlists** (Playlists tab, and *Add to playlist* on any song): shared by everyone who has Music; anyone can add or remove songs, only the owner or an admin renames or deletes one.
- **Also:** reorder the queue with the arrows; *More like this* on an artist or song; an About note on albums and artists (Plex's own summary); **Save offline** on an album (plays with no signal); the player takes its colors from the album cover; **Share a line** in the lyrics makes a picture of the lyric over the cover; Featured shows a verse of the day when you have the Bible tab.
- Not included: crossfade or gapless (the player already keeps the next songs downloaded for quick changes), searching lyrics by a line, and a speaker picker.
**iPad and Screen Time:** Settings, Admin, *iPad and Screen Time* explains the simple setup (Screen Time, Content Restrictions, Web Content: **Limit Adult Websites**, and add only the hub's address under *Always Allow*), lists the web addresses the app needs (the hub, plus the document editor for the Office tab) for anyone using **Allowed Websites Only**, shows other addresses devices were actually seen reaching, and has a *Check this device* button to run on the iPad. The hub sends a report-only Content-Security-Policy (nothing is blocked) so devices report any other address a page loads, and pictures from other sites (podcast search covers, Plex Home pictures, a Verse By Verse cover, search answers) are passed through the hub (`/api/img`) so the browser only talks to the hub for them. Extensions and Sites you add may still reach their own addresses. If the document editor is put behind the same address as the hub (a reverse proxy path), even that one drops off the list.
**Chat** (turn it on per person in the admin screen): the family's own messenger, with the same look as the Signal tab. Direct chats and groups between people who have an account here, text with links and emoji, attachments (up to 100 MB each; pictures, audio and video show in the chat), replies, reactions, edit and delete for everyone, unread counts on the menu and the tab title, search (chats and messages), drafts that survive closing the app, mute and archive, live delivery while the app is open and a phone push when it is not (the same notification devices as reminders, and muted chats stay quiet). Messages and files are kept readable on this server in `data/chat`; end-to-end encryption is not built. Also: sent, delivered and read ticks (everyone, in a group), typing and who is online, disappearing messages (a timer per chat), @mentions (a mentioned person is alerted even in a muted group), pinned messages, forwarding, voice messages with a waveform, link previews (the server reads the page, so devices never contact the site), polls (single or multiple choice, with who voted), an emoji picker, turn a message into a task, a shopping item or a calendar event, *Share to Chat* from a recipe or a song, quiet hours for phone alerts, and a family announcement on Home that an admin posts from Chat settings. Not built: a GIF picker, sharing a location (the hub blocks location access), and a parent setting a child's quiet hours (see `docs/v2-roadmap.md`).
**Notes** (turn it on per person): notes and checklists that are private, shared with the whole family, or shared with chosen people. Anyone it is shared with can edit it. Checklist items change one at a time (tick, add, edit, remove), so two people ticking the same list never overwrite each other, ticked items say who ticked them, and an open list follows other people's changes every few seconds. Text edits are checked against a revision, so a stale edit is refused instead of overwriting someone else's. Pin, colour, archive, search and filters (lists, notes, mine, shared, archived), ready-made lists (grocery, packing, weekend to-do), *Share to Chat*, and *Send the open items to Tasks*. Only the person who made a note (or an admin) changes who it is shared with or deletes it.
**Chores** (turn it on per person): jobs that repeat daily, on weekdays, on chosen days of the week, weekly, monthly or happen once, given to one or more people or to anyone. Each person ticks off their own (an admin can tick for someone), finished chores earn points, and the board adds up the week and month. **Weekly goals** (for child accounts only: adults can do chores but are not scored): an admin makes reward tiers in Manage (for example 20 points: pick the movie, 40 points: ice cream). Points count Monday to Sunday and start again every Monday, with each person's total, the chores they did that week and a bar toward the next reward (also on the family board). Step back through earlier weeks with the arrows; nothing is deleted. When someone reaches a tier, an admin taps **Mark given** once the reward is handed over. Only an admin adds or changes chores. A chore given to *anyone* is a pool: a person taps **Take it** to claim it for the day (it moves under their name, and nobody else can tick it; **Put back** returns it to the pool). Kept in `data/chores.json`.
**Family** (the page every account has next to Home): today and tomorrow's calendar, your chores and everyone's progress, the shopping list, shared notes and lists, recent chats and the tasks due this week. Each card only shows if the person has that part of the hub.
**Child accounts** (admin screen, per person): the Store, Free Books and podcasts show only child-safe books and non-explicit shows, LibriVox opens on children's books, and an optional daily time limit and bedtime are enforced by the server. A child who runs out of time sees a lock screen and can ask a parent, which sends a message in Chat; an admin can give extra minutes.
**More parent controls** (all under Settings → Family, and in a child's settings under Admin → People):
- **Per-part and weekend limits:** besides the daily limit, a child can have a different weekend time and a limit on one part of the app (Movies and TV, Music, Podcasts/audiobooks/radio, Books, Chat). When one part's time is up only that part closes.
- **Movies and Music are separate on the server:** a person with only Music cannot open Plex movies or shows (and the other way round), including by typing the address.
- **Plex lock:** a parent can lock a child's Plex to the managed profile it is on, which forgets the owner sign-in the hub kept for switching, so they cannot change profile or server. Plex's own limits for that profile (ratings, libraries, labels) still apply.
- **Restricted search for children:** the open-web search and its links are gone for a child account. In Search they get *Learn*: answers from Simple English Wikipedia only (written for young readers), shown inside the hub so they never land on another site. A built-in list of blocked words, plus any words a parent adds (Settings → Family → Blocked search words), stops a search before it is made and hides any result that mentions them; a blocked search tells the parents. It lowers the risk but is not perfect, so every search also shows in the activity view.
- **Activity:** each child's week: minutes per part of the app, what they played, watched, read and searched, sign-ins, and blocked attempts. Chat messages are never logged. A weekly summary for each child arrives in the parents' Notifications on Monday morning, and parents are told when time runs out, when something is blocked, or when a child asks for something.
- **Asking:** a child's *Ask* button sends a request (a movie, a book, a part of the app, more minutes); a parent answers Yes or No and the child is told. A yes can open that part of the app and add the minutes.
- **Bonus screen time:** a weekly reward can come with extra minutes, added to the day when the parent marks it given.
- **Simple mode:** for young children or grandparents: no menus, a Home screen of big buttons and a big Home button.
**Genre search**: in the Store, Free Books and LibriVox you can browse and search books and audiobooks by genre (Fiction, Mystery, History, Children's, Christian and more). LibriVox genres use an index the server builds in the background the first time.
**Search** covers mail, tasks, events, notes, chat messages, contacts, chores, recipes, your books, files, music, video, podcasts and the web, and only shows what the person has access to.
**Notifications** (the bell on Home and Family): a list of what you have not seen yet: chat messages (one line per conversation, with a count), reminders for tasks, events and mail, and family announcements. Tap one to open it; mark all read or clear. Kept per person in `inbox.json`.
**Movies & TV search:** search by title, actor or director (results grouped into Movies, TV shows, Actors, Directors and Episodes); tap an actor or director to see everything they are in; filter the library by genre, decade, rating (G, PG, PG-13, R, TV ratings) and watched or unwatched, together with the sort.
**Name tiles on the sign-in page:** on a device you have remembered (Settings → Family → *Remember this device*, or any family device) the sign-in page shows everyone's name, children marked KID. Tap yours, then enter your PIN (it signs in as soon as the last digit is typed) or use your passkey; a child with no PIN just taps. Any other browser still shows only *Sign in with passkey*, so strangers see no names and cannot guess PINs. A remembered personal device gets a normal long session; a family device keeps its short sessions. *Forget* switches it off again.
**Family devices** (Settings, under Passkeys): for a kitchen tablet or the family computer. An admin turns on *Make this a family device* while signed in on it; from then on it opens on the **family board** (announcement, today's family calendar, chores with progress, shopping list, family tasks and notes) and a row of name tiles, children marked **KID**. Tap your name: a child signs in with one tap, everyone else enters a PIN they set in Settings (five wrong tries lock that person for five minutes; there is also a passkey link). A label with your name and **Switch user** stays on screen, and the device goes back to the board after five idle minutes (music playing keeps it signed in). Sessions on a family device last 30 minutes at a time, not months. Turn it off from the same place. Each board card has a **+** to add to the shopping list, the family tasks, a shared checklist (pick which) or the Family calendar (all day, or at a time): write it, tap your name (PIN unless you are a child) and it is added as you, if that part of the hub is turned on for you. Chores on the board are tappable: tap one (up-for-grabs chores ask *I did it* or *Take it for later*), tap your name, and enter your PIN if you are not a child; the chore is then done or claimed as you, with all the usual chore rules, without signing in. The admin picks who appears on the board (a **Show on the family board** switch in each person's settings; people left off do not get a tile or show in family chore progress). The Family calendar and Family task list are shared by everyone and appear on the board.
On a tablet or with the menu collapsed, the menu rail shows icons only (the name appears on hover) when the window is not tall enough for a label under every icon.
+103
View File
@@ -0,0 +1,103 @@
// Audiobooks: the library, covers, chapters, the audio itself and each person's listening position. Two ways to reach them:
// - OPENAUDIBLE_URL set: the hub talks to OpenAudible directly, signing in with the username and password saved in Settings (OpenAudible's own login;
// OPENAUDIBLE_USER / OPENAUDIBLE_PASSWORD are used when none are saved). The hub keeps each person's listening position itself.
// - otherwise: through the audiobook player (https://git.necloud.us/nate/audiobook-player) at AUDIOBOOKS_URL, signing in to it as the person
// (the account in Settings), or with the shared secret link (AUDIOBOOKS_LOGIN_LINK). The player keeps the positions.
// No password or link ever reaches the browser.
const { Readable } = require('stream');
const settings = require('./settings');
const users = require('./users');
const OA = (process.env.OPENAUDIBLE_URL || '').replace(/\/$/, ''); // direct mode
const BASE = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
const LINK = process.env.AUDIOBOOKS_LOGIN_LINK || '';
const COOKIE = 'oa_session';
const sessions = new Map(); // person id -> the player's session cookie
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const cookieOf = r => { const c = (r.headers.getSetCookie ? r.headers.getSetCookie() : []).map(x => x.split(';')[0]).find(x => x.startsWith(COOKIE + '=')); return c || ''; };
// Hub path -> player path (the player itself only serves read-only calls for these)
const ROUTES = [
[/^\/books$/, () => '/api/books'],
[/^\/books\/([^/]+)\/(chapters\/flat|cover)$/, m => `/api/books/${m[1]}/${m[2]}`],
[/^\/books\/([^/]+)\/filelist$/, m => `/admin/api/books/${m[1]}/files`],
[/^\/books\/([^/]+)\/files\/([^/]+)$/, m => `/api/books/${m[1]}/files/${m[2]}`],
];
async function signIn(me) {
const c = settings.get('ab_creds') || {};
let r;
if (c.u && c.p) r = await fetch(BASE + '/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: c.u, password: c.p }), redirect: 'manual', signal: AbortSignal.timeout(15000) });
else if (LINK) r = await fetch(LINK, { redirect: 'manual', signal: AbortSignal.timeout(15000) });
else throw Object.assign(new Error('Add your audiobook sign-in in Settings first'), { code: 503 });
const ck = cookieOf(r);
if (!ck) throw Object.assign(new Error(c.u ? 'The audiobook site did not accept your saved sign-in' : 'The audiobook site did not accept the shared sign-in'), { code: 502 });
sessions.set(me.id, ck); return ck;
}
// Direct to OpenAudible: Basic auth with the saved login (or the server's), and a clear message when it is refused.
async function direct(path, init = {}) {
const c = settings.get('ab_creds') || {}, u = c.u || process.env.OPENAUDIBLE_USER || '', p = c.u ? c.p : process.env.OPENAUDIBLE_PASSWORD || '';
const headers = { ...init.headers }; if (u) headers.authorization = 'Basic ' + Buffer.from(u + ':' + p).toString('base64');
else throw Object.assign(new Error('Add your OpenAudible username and password in Settings first'), { code: 503 });
const r = await fetch(OA + path, { ...init, headers, redirect: 'manual' });
if (r.status === 401) { await r.body?.cancel().catch(() => {}); throw Object.assign(new Error('OpenAudible did not accept the username and password saved in Settings'), { code: 502 }); }
return r;
}
// Listening positions kept by the hub (direct mode)
const prog = require('./progress')('audiobook-progress.json'), progLoad = prog.load, progMerge = prog.merge;
async function upstream(me, path, init = {}, retry = true) {
if (OA) return direct(path, init);
const ck = sessions.get(me.id) || await signIn(me);
const r = await fetch(BASE + path, { ...init, headers: { ...init.headers, cookie: ck }, redirect: 'manual' });
if ((r.status === 401 || r.status === 302) && retry) { sessions.delete(me.id); await r.body?.cancel().catch(() => {}); return upstream(me, path, init, false); }
return r;
}
async function handle(req, res) {
const me = users.current();
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab'.length) || '/';
const ctl = new AbortController(); res.on('close', () => ctl.abort());
try {
if (p === '/progress') { // each person's own positions are kept by the hub; with a personal sign-in on the audiobook player they are also kept in step with it
const personal = !OA && (c => !!(c && c.u && c.p))(settings.get('ab_creds'));
if (req.method === 'POST') {
const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e6) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
const merged = progMerge(body);
if (personal) upstream(me, '/sync/progress', { method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }).then(r => r.body?.cancel()).catch(() => {});
return json(res, 200, merged);
}
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
if (personal) { try { const r = await upstream(me, '/sync/progress', { signal: ctl.signal }); if (r.ok) progMerge(await r.json()); } catch { /* the hub's own copy is enough */ } }
return json(res, 200, progLoad());
}
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
let to = null; for (const [re, f] of ROUTES) { const m = re.exec(p); if (m) { to = f(m); break; } }
if (!to) return json(res, 404, { error: 'Not found' });
const headers = req.headers.range ? { range: req.headers.range } : {};
const r = await upstream(me, to, { headers, signal: ctl.signal });
const out = { 'cache-control': p.endsWith('/cover') ? 'private, max-age=86400' : 'no-store' };
for (const k of ['content-type', 'content-length', 'content-range', 'accept-ranges']) if (r.headers.get(k)) out[k] = r.headers.get(k);
const want = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || ''), total = +r.headers.get('content-length') || 0;
if (OA && want && r.status === 200 && total && (want[1] || want[2])) { // OpenAudible sent the whole file: phones need byte ranges, so cut the piece out here
const a = want[1] === '' ? Math.max(0, total - +want[2]) : +want[1], b = want[1] === '' || want[2] === '' ? total - 1 : Math.min(+want[2], total - 1);
if (a > b || a >= total) { await r.body.cancel().catch(() => {}); res.writeHead(416, { 'content-range': 'bytes */' + total }); return res.end(); }
res.writeHead(206, { ...out, 'content-range': `bytes ${a}-${b}/${total}`, 'content-length': String(b - a + 1), 'accept-ranges': 'bytes' });
let at = 0; const rd = r.body.getReader();
try { for (;;) { const { done, value } = await rd.read(); if (done) break; const end = at + value.length; if (end > a && at <= b) { if (!res.write(value.subarray(Math.max(0, a - at), Math.min(value.length, b + 1 - at)))) await new Promise(ok => res.once('drain', ok)); } at = end; if (at > b) break; } }
finally { rd.cancel().catch(() => {}); res.end(); }
return;
}
res.writeHead(r.status, out);
if (!r.body) return res.end();
Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res);
} catch (e) {
if (res.headersSent) return res.destroy();
json(res, e.code || 502, { error: e.code ? e.message : 'The audiobook site did not answer' });
}
}
module.exports = { handle };
+46
View File
@@ -0,0 +1,46 @@
// What a child does, for the parents: a short log of what they played, read, searched and when they signed in, plus alerts to the parents when something needs them
// (the time is up, a request, something blocked). Kept in DATA_DIR/activity.json. Chat messages are never logged here.
const fs = require('fs'), path = require('path');
const atomicWrite = require('./atomic');
const users = require('./users');
const inbox = require('./inbox');
const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'activity.json');
const MAX_PER_CHILD = 800, KEEP_DAYS = 60;
let cache = null;
const db = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return j && typeof j === 'object' ? j : {}; } catch { return {}; } })());
const save = () => { try { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); } catch (e) { console.warn('[activity]', e.message); } };
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, n);
const isKid = u => !!(u && u.kid && u.kid.on && u.role !== 'admin');
const KINDS = ['play', 'watch', 'listen', 'read', 'search', 'signin', 'chore', 'add', 'blocked', 'other'];
/** Note something a child did. The same thing twice within ten minutes counts once (a song restarting, a page reloading). */
function log(uid, kind, title) {
const u = users.get(uid); if (!isKid(u)) return; kind = KINDS.includes(kind) ? kind : 'other'; title = str(title, 160); if (!title && kind !== 'signin') return;
const l = db()[uid] || (db()[uid] = []), now = Date.now(), last = l.slice(-6).find(e => e.kind === kind && e.title === title && now - e.t < 10 * 60e3); if (last) return;
l.push({ t: now, kind, title }); const cut = now - KEEP_DAYS * 864e5; while (l.length && (l.length > MAX_PER_CHILD || l[0].t < cut)) l.shift(); save();
}
const recent = (uid, days = 7) => (db()[uid] || []).filter(e => e.t >= Date.now() - days * 864e5).sort((a, b) => b.t - a.t);
const alerted = new Map(); // child + kind -> when a parent was last told, so a repeated attempt is one alert, not twenty
/** Tell every parent (admin) in their Notifications list: `kind` is 'limit', 'request' or 'blocked'. */
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 });
}
/** Once a week (Monday morning) every parent gets a short summary of each child's week in their Notifications. */
function weeklyDigest(now = new Date()) {
const kids = users.all().filter(u => !u.disabled && isKid(u)); if (!kids.length) return false; const wk = now.toISOString().slice(0, 10), monday = new Date(now); monday.setUTCDate(monday.getUTCDate() - ((monday.getUTCDay() + 6) % 7));
const key = monday.toISOString().slice(0, 10); if (now.getUTCDay() !== 1 || now.getUTCHours() < 8 || db()._digest === key) return false; db()._digest = key; save();
const { usageByDay, AREA_LABEL } = require('./kidlimits');
for (const k of kids) {
const days = usageByDay(k.id, 7), mins = days.reduce((n, d) => n + d.minutes, 0), by = {}; for (const d of days) for (const [a, m] of Object.entries(d.areas)) by[a] = (by[a] || 0) + m;
const ev = recent(k.id, 7), count = kind => ev.filter(e => e.kind === kind).length, h = m => m >= 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : m + ' min';
const parts = [h(mins) + ' in the app', ...Object.entries(by).filter(([a, m]) => m >= 5 && AREA_LABEL[a]).map(([a, m]) => AREA_LABEL[a] + ' ' + h(m)), count('play') + count('watch') + count('listen') ? (count('play') + count('watch') + count('listen')) + ' things played' : '', count('search') ? count('search') + ' searches' : '', count('blocked') ? count('blocked') + ' blocked attempts' : ''].filter(Boolean);
for (const a of users.all()) if (!a.disabled && a.role === 'admin') inbox.add(a.id, { kind: 'kid', title: k.name + ': last week', body: parts.join(' · '), url: '#/settings', tag: 'digest:' + k.id + ':' + key });
}
return true;
}
module.exports = { log, recent, alertParents, isKid, weeklyDigest };
+3 -3
View File
@@ -4,9 +4,9 @@ const users = require('./users');
const auth = require('./auth');
const audit = require('./audit');
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const { readBody } = require('./body');
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const row = u => ({ ...users.publicView(u), tabsChosen: u.tabs || [], passkeys: auth.passkeyCount(u.id) });
const row = u => ({ ...users.publicView(u), kid: u.kid || null, tabsChosen: u.tabs || [], passkeys: auth.passkeyCount(u.id) });
async function handle(req, res, me) {
try {
@@ -26,7 +26,7 @@ async function handle(req, res, me) {
const b = JSON.parse(await readBody(req) || '{}');
if (u.id === me.id && b.disabled) return json(res, 400, { error: 'You cannot turn off your own account' });
audit.log('person changed', { who: me.name, detail: u.name + (b.disabled !== undefined ? (b.disabled ? ' (turned off)' : ' (turned on)') : '') + (b.signOutEverywhere ? ' (signed out everywhere)' : '') + (b.tabs ? ' (tabs)' : '') + (b.role ? ' (role)' : '') }, req);
users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere });
users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere, kid: b.kid, board: b.board, simple: b.simple });
return json(res, 200, { user: row(users.get(id)) });
}
if (!action && req.method === 'DELETE') {
+20 -7
View File
@@ -72,13 +72,23 @@ function currentUser(req) {
}
const isAuthed = req => !!currentUser(req);
const sessionCookie = (req, user) => { const exp = Date.now() + LIFETIME;
return `hub_session=${user.id}.${user.epoch}.${exp}.${mac(`s${user.id}.${user.epoch}.${exp}`)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_DAYS * 86400}${isHttps(req) ? '; Secure' : ''}`; };
// A family device (a shared screen) is signed in for only a short while after each tap, so whoever used it last is not left signed in.
const KIOSK = 30 * 60e3;
const devices = require('./devices');
const deviceOf = req => { const [id, sig] = String(cookies(req).hub_device || '').split('.'); return id && sig && safeEq(sig, mac('d' + id)) && devices.has(id) ? id : null; };
const deviceKind = req => { const id = deviceOf(req), d = id && devices.get(id); return d ? d.kind || 'family' : null; };
const lifeFor = req => deviceKind(req) === 'family' ? KIOSK : LIFETIME;
const sessionCookie = (req, user, life = lifeFor(req)) => { const exp = Date.now() + life;
return `hub_session=${user.id}.${user.epoch}.${exp}.${mac(`s${user.id}.${user.epoch}.${exp}`)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.round(life / 1000)}${isHttps(req) ? '; Secure' : ''}`; };
const deviceCookie = (req, id) => `hub_device=${id}.${mac('d' + id)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${3650 * 86400}${isHttps(req) ? '; Secure' : ''}`;
const enrollDevice = (req, res, name, by, kind, byId) => { const d = devices.add(name, by, kind, byId); addCookie(res, deviceCookie(req, d.id)); return d; };
const forgetDevice = (req, res) => { const id = deviceOf(req); if (id) devices.remove(id); addCookie(res, 'hub_device=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'); };
// Add a Set-Cookie without clobbering ones other code already set on this response.
const addCookie = (res, c) => { const cur = res.getHeader('Set-Cookie'); res.setHeader('Set-Cookie', cur ? [].concat(cur, c) : c); };
function startSession(req, res, user) { addCookie(res, sessionCookie(req, user)); }
function startSession(req, res, user) { addCookie(res, sessionCookie(req, user)); try { require('./activity').log(user.id, 'signin', deviceKind(req) === 'family' ? 'on a family device' : deviceKind(req) === 'trusted' ? 'on a remembered device' : 'on a device'); } catch {} }
/** Renew the cookie when it is more than a day old (and upgrade old-style cookies), so an active session never runs out. */
function refresh(req, res, user) {
if (deviceKind(req) === 'family') { const e = Number(String(cookies(req).hub_session || '').split('.')[2]); if (!(e - Date.now() > KIOSK - 60e3)) addCookie(res, sessionCookie(req, user, KIOSK)); return; }
const parts = String(cookies(req).hub_session || '').split('.');
const exp = Number(parts.length === 4 ? parts[2] : parts[0]);
if (parts.length !== 4 || exp - Date.now() < LIFETIME - 864e5) addCookie(res, sessionCookie(req, user));
@@ -144,7 +154,7 @@ const rp = req => {
};
const equalStr = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && crypto.timingSafeEqual(x, y); };
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const { readBody } = require('./body');
const send = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', ...(res.getHeader('Set-Cookie') ? { 'Set-Cookie': res.getHeader('Set-Cookie') } : {}) }); res.end(JSON.stringify(obj)); };
const credsOf = uid => store.creds.filter(c => c.uid === uid);
@@ -204,8 +214,10 @@ async function handle(req, res) {
}
if (p === '/api/auth/login/options' && req.method === 'POST') {
if (!store.creds.length) return send(res, 400, { error: 'No passkeys set up yet' });
const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred' });
return send(res, 200, { cid: newChallenge(options.challenge, 'login'), options });
const only = body.uid && users.get(String(body.uid)), mine = only ? credsOf(only.id) : [];
if (only && !mine.length) return send(res, 400, { error: only.name + ' has no passkey yet. Use the PIN, or add a passkey in Settings.' });
const options = await generateAuthenticationOptions({ rpID, userVerification: 'preferred', ...(only ? { allowCredentials: mine.map(c => ({ id: c.id, transports: c.transports })) } : {}) }); // (a person picked from the name tiles: only their own passkeys are offered)
return send(res, 200, { cid: newChallenge(options.challenge, 'login', only ? { uid: only.id } : undefined), options });
}
if (p === '/api/auth/login/verify' && req.method === 'POST') {
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
@@ -214,6 +226,7 @@ async function handle(req, res) {
const user = cred && users.get(cred.uid);
if (!rec || rec.kind !== 'login' || !cred || !user) { audit.log('sign-in failed', { detail: 'passkey not recognised' }, req); return send(res, 400, { error: 'Passkey not recognised' }); }
if (user.disabled) return send(res, 403, { error: 'This account has been turned off' });
if (rec.extra && rec.extra.uid && rec.extra.uid !== user.id) return send(res, 400, { error: 'That passkey belongs to someone else' });
const v = await verifyAuthenticationResponse({
response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID,
credential: { id: cred.id, publicKey: Buffer.from(cred.publicKey, 'base64url'), counter: cred.counter, transports: cred.transports },
@@ -248,4 +261,4 @@ async function handle(req, res) {
const passkeyCount = uid => credsOf(uid).length;
function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); delete recovery[uid]; saveRecovery(); }
module.exports = { handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf };
module.exports = { deviceKind, deviceOf, enrollDevice, forgetDevice, startSession, handle, currentUser, isAuthed, refresh, addCookie, secret, SESSION_DAYS, newInvite, INVITE_MINUTES, passkeyCount, removeCredsOf };
+39
View File
@@ -0,0 +1,39 @@
// Bible: the text itself (the Berean Standard Bible, public domain) is a static file the browser loads once (public/vendor/bible/bsb.json); this keeps each person's
// place, highlights, notes and bookmarks on the server so they follow them between devices.
const fs = require('fs'), path = require('path');
const users = require('./users');
const file = () => path.join(users.dir(), 'bible.json');
const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [] }; } catch { return { pos: null, marks: [] }; } };
const save = o => { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const BOOK = /^[A-Z0-9]{3}$/;
/** Merge by id; for each id the newest edit (or deletion) wins, so two devices can add highlights without losing each other's. */
function mergeMarks(have, sent) {
const by = new Map((have || []).map(m => [m.id, m]));
for (const m of Array.isArray(sent) ? sent.slice(0, 3000) : []) {
if (!m || !/^[\w-]{1,40}$/.test(m.id || '') || !BOOK.test(m.b || '')) continue;
const v = Math.max(1, Math.round(+m.v) || 1), clean = { id: m.id, t: +m.t || 0, type: ['hl', 'note', 'mark'].includes(m.type) ? m.type : 'hl', b: m.b, c: Math.max(1, Math.round(+m.c) || 1), v, ve: Math.max(v, Math.round(+m.ve) || v),
color: /^[a-z]{3,12}$/.test(m.color || '') ? m.color : 'yellow', note: clip(m.note, 5000), text: clip(m.text, 1500), del: !!m.del };
const old = by.get(m.id); if (!old || clean.t >= old.t) by.set(m.id, clean);
}
return [...by.values()].filter(m => !m.del || Date.now() - m.t < 30 * 864e5);
}
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 2e6) return null; c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { return undefined; } };
async function handle(req, res) {
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del) }); }
if (p === '/pos' && req.method === 'POST') { // where you are reading; the newest report wins
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load();
if (BOOK.test(b.b || '') && (+b.t || 0) >= ((s.pos || {}).t || 0)) s.pos = { b: b.b, c: Math.max(1, Math.round(+b.c) || 1), v: Math.max(0, Math.round(+b.v) || 0), t: +b.t || Date.now() };
save(s); return json(res, 200, s.pos);
}
if (p === '/marks' && req.method === 'POST') {
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load(); s.marks = mergeMarks(s.marks, b); save(s); return json(res, 200, s.marks.filter(m => !m.del));
}
return json(res, 404, { error: 'Not found' });
}
module.exports = { handle, mergeMarks };
+114
View File
@@ -0,0 +1,114 @@
// The family board and "who is this?": what a family device (see devices.js) shows before anyone signs in, and the way a person picks their name there.
// Children tap their name; everyone else enters a PIN they set for themselves (or signs in with a passkey). The board only ever shows the family's shared things.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users'), auth = require('./auth'), audit = require('./audit'), devices = require('./devices');
const cal = require('./cal'), chores = require('./chores'), notes = require('./notes'), tasks = require('./tasks'), recipes = require('./recipes'), chat = require('./chat');
const { readBody } = require('./body');
const { Readable } = require('stream');
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 FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'pins.json');
let pins = null;
const db = () => pins || (pins = (() => { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch { return {}; } })());
const savePins = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify(db()), { mode: 0o600 }); };
const hash = (pin, salt) => crypto.scryptSync(String(pin), salt, 32).toString('hex');
const hasPin = uid => !!db()[uid];
const MAX_FAILS = 5, LOCK_MS = 5 * 60e3;
// (a PIN's length is kept as `n`, so a shared screen can enter it for you as soon as you have typed all the digits)
function setPin(uid, pin) { if (!/^\d{4,8}$/.test(String(pin || ''))) throw fail(400, 'A PIN is 4 to 8 digits'); const salt = crypto.randomBytes(12).toString('hex'); db()[uid] = { s: salt, h: hash(pin, salt), n: String(pin).length, fails: 0, until: 0 }; savePins(); }
function checkPin(uid, pin) {
const r = db()[uid]; if (!r) return false; if (r.until > Date.now()) throw fail(429, 'Too many wrong tries. Wait a few minutes.');
const ok = /^\d{4,8}$/.test(String(pin || '')) && crypto.timingSafeEqual(Buffer.from(hash(pin, r.s), 'hex'), Buffer.from(r.h, 'hex'));
if (ok) { if (r.fails) { r.fails = 0; savePins(); } return true; }
r.fails = (r.fails || 0) + 1; if (r.fails >= MAX_FAILS) { r.fails = 0; r.until = Date.now() + LOCK_MS; } savePins(); return false;
}
// how long a family device sits idle before it goes back to the board (minutes; a signed-in session on a family device lasts 30)
const SET_FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'board-settings.json');
const IDLE_CHOICES = [1, 2, 3, 5, 10, 15, 30];
const idleMin = () => { try { const n = +JSON.parse(fs.readFileSync(SET_FILE(), 'utf8')).idleMin; return IDLE_CHOICES.includes(n) ? n : 5; } catch { return 5; } };
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 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. */
function identify(t, pin, req) {
if (isKid(t) && !hasPin(t.id)) return;
if (!hasPin(t.id)) throw fail(403, 'Set a PIN first (Settings, on your own device), or sign in with your passkey');
if (!checkPin(t.id, pin)) { audit.log('family device: wrong PIN', { who: t.name }, req); throw fail(401, 'That PIN is not right'); }
}
function eventFields(title, b) {
const day = /^\d{4}-\d{2}-\d{2}$/.test(b.date || '') ? b.date : new Date().toISOString().slice(0, 10), time = /^\d{2}:\d{2}$/.test(b.time || '') ? b.time : '';
if (!time) return { title, allDay: true, start: day, end: day };
const end = String((+time.slice(0, 2) + 1) % 24).padStart(2, '0') + time.slice(2); return { title, allDay: false, start: day + 'T' + time, end: +time.slice(0, 2) === 23 ? day + 'T23:59' : day + 'T' + end, tz: b.tz || 'UTC' };
}
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 })) }))(chores.week(day)), shopping: await recipes.shoppingOpen(), notes: notes.familyNotes(), tasks: tasks.familyOpen() };
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname, me = auth.currentUser(req), anyDev = auth.deviceOf(req), dev = auth.deviceKind(req) === 'family' ? anyDev : null; // (dev: a family device; anyDev: any remembered device)
try {
if (p === '/api/board' && req.method === 'GET') { if (!dev) throw fail(403, 'This is not a family device'); const day = u.searchParams.get('day'); return json(res, 200, await board(ymdOk(day) ? day : new Date().toISOString().slice(0, 10))); }
if (p === '/api/board/chore' && req.method === 'POST') { // a person on the shared screen takes a chore, ticks it off, or undoes it, without signing in
if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), 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 path = { claim: '/api/chores/claim', unclaim: '/api/chores/claim', done: '/api/chores/done', undo: '/api/chores/done' }[b.action]; if (!path) throw fail(400, 'Unknown action');
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/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');
const plan = { shopping: ['recipes', recipes, '/api/recipes/shopping/add', { text }],
task: ['tasks', tasks, '/api/tasks', { list: 'local:family', fields: { title: text, due: /^\d{4}-\d{2}-\d{2}$/.test(b.due || '') ? b.due : '' } }],
item: ['notes', notes, '/api/notes/' + String(b.note || '') + '/items', { text }],
event: ['calendar', cal, '/api/cal/event', { cal: 'family~local', fields: eventFields(text, b) }] }[b.kind];
if (!plan) throw fail(400, 'Unknown list'); if (!users.can(t, plan[0])) throw fail(403, 'That part of the hub is not turned on for ' + t.name);
if (b.kind === 'item') { const n = notes.familyNotes().find(x => x.id === String(b.note) && x.type === 'list'); if (!n) throw fail(404, 'That list is not shared with the family'); }
identify(t, b.pin, req); const r = Readable.from([Buffer.from(JSON.stringify(plan[3]))]); r.url = plan[2]; r.method = 'POST'; r.headers = {};
return users.runAs(t, () => plan[1].handle(r, res)); // (as that person, so that person's own rules apply)
}
if (p === '/api/login/people' && req.method === 'GET') { // the sign-in page's name tiles: only on a remembered device (family or a person's own)
if (!anyDev) throw fail(403, 'This device is not remembered'); return json(res, 200, { people: users.all().filter(u => !u.disabled).map((u, i) => ({ id: u.id, name: u.name, kid: isKid(u), pin: hasPin(u.id), pinLen: (db()[u.id] || {}).n || 0, passkey: auth.passkeyCount(u.id) > 0, color: palette[i % palette.length] })) });
}
if (p === '/api/login/pin' && req.method === 'POST') { // sign in from a name tile with a PIN (a child with no PIN just taps)
if (!anyDev) throw fail(403, 'This device is not remembered'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), t = users.get(String(b.uid));
if (!t || t.disabled) throw fail(404, 'Person not found'); identify(t, b.pin, req); auth.startSession(req, res, t); audit.log('sign-in', { who: t.name, detail: 'name tile' }, req); return json(res, 200, { ok: true });
}
if (p === '/api/board/switch' && req.method === 'POST') {
if (!dev) throw fail(403, 'This is not a family device'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), t = users.get(String(b.uid));
if (!t || !onBoard(t)) throw fail(404, 'Person not found');
identify(t, b.pin, req);
auth.startSession(req, res, t); audit.log('family device: picked a profile', { who: t.name }, req); return json(res, 200, { ok: true });
}
if (p === '/api/device' && req.method === 'GET') { if (!me) throw fail(401, 'Not signed in'); return json(res, 200, { device: !!dev, trusted: auth.deviceKind(req) === 'trusted', idleMin: idleMin(), idleChoices: IDLE_CHOICES, pin: hasPin(me.id), devices: me.role === 'admin' ? devices.load() : undefined }); }
if (p === '/api/device/pin' && (req.method === 'POST' || req.method === 'DELETE')) {
if (!me) throw fail(401, 'Not signed in'); if (req.method === 'DELETE') { delete db()[me.id]; savePins(); return json(res, 200, { pin: false }); }
const b = JSON.parse(await readBody(req, 5e3) || '{}'); setPin(me.id, b.pin); return json(res, 200, { pin: true });
}
if (p === '/api/device/idle' && req.method === 'POST') { // an admin sets how long a family device waits before going back to the board
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 2e3) || '{}'), n = Math.round(+b.minutes);
if (!IDLE_CHOICES.includes(n)) throw fail(400, 'Choose between 1 and 30 minutes'); fs.mkdirSync(path.dirname(SET_FILE()), { recursive: true }); atomicWrite(SET_FILE(), JSON.stringify({ idleMin: n }), { mode: 0o600 }); audit.log('family device idle time set', { who: me.name, detail: n + ' min' }, req); return json(res, 200, { idleMin: n });
}
if (p === '/api/device/enable' && req.method === 'POST') { // an admin makes this screen a family device (and it goes back to the board)
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can do this'); const b = JSON.parse(await readBody(req, 5e3) || '{}'); auth.enrollDevice(req, res, b.name, me.name); audit.log('family device added', { who: me.name, detail: String(b.name || '').slice(0, 40) }, req);
res.setHeader('Set-Cookie', [].concat(res.getHeader('Set-Cookie') || [], 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0')); return json(res, 200, { ok: true });
}
if (p === '/api/device/trust' && req.method === 'POST') { // a person remembers their own device, so the sign-in page shows name tiles on it
if (!me) throw fail(401, 'Not signed in'); if (anyDev) return json(res, 200, { ok: true }); auth.enrollDevice(req, res, '', me.name, 'trusted', me.id); audit.log('device remembered', { who: me.name }, req); return json(res, 200, { ok: true });
}
if (p === '/api/device/remove' && req.method === 'POST') { // switch one off: this screen, or another from the list
if (!me) throw fail(401, 'Not signed in'); const b = JSON.parse(await readBody(req, 5e3) || '{}'), here = anyDev && devices.get(anyDev);
if (me.role !== 'admin' && !(here && here.kind === 'trusted' && here.byId === me.id && !b.id)) throw fail(403, 'Only an admin can do this');
if (b.id) devices.remove(String(b.id)); else auth.forgetDevice(req, res); audit.log('family device removed', { who: me.name }, req); return json(res, 200, { ok: true });
}
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 }); }
}
module.exports = { handle, board, setPin, hasPin };
+11
View File
@@ -0,0 +1,11 @@
'use strict';
// Reads a request body as text, and refuses one that is too big, so a huge upload cannot use up the server's memory.
function readBody(req, max = 2e6) {
return new Promise((ok, no) => {
const c = []; let n = 0, done = false;
req.on('data', d => { if (done) return; n += d.length; if (n > max) { done = true; c.length = 0; const e = new Error('That is too large'); e.status = 413; no(e); return; } c.push(d); });
req.on('end', () => { if (!done) ok(Buffer.concat(c).toString()); });
req.on('error', e => { if (!done) no(e); });
});
}
module.exports = { readBody };
+150
View File
@@ -0,0 +1,150 @@
// E-book library: EPUB, PDF and DOCX files kept per person under /data/users/<id>/books/. The server stores the files, covers, where each person got to in each
// book, and their bookmarks, notes and highlights; the browser does the reading (epub.js, pdf.js and mammoth, in public/vendor/reader).
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const users = require('./users');
const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_JSON = 2e6;
const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw: 'application/vnd.amazon.ebook', azw3: 'application/vnd.amazon.mobi8-ebook', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' };
const COVERS = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp' };
const dir = () => { const d = path.join(users.dir(), 'books'); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; };
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const readJson = (f, dflt) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return dflt; } };
const writeJson = (f, o) => { const tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
const index = () => { const l = readJson(path.join(dir(), 'index.json'), []); return Array.isArray(l) ? l : []; };
const saveIndex = l => writeJson(path.join(dir(), 'index.json'), l);
const state = () => readJson(path.join(dir(), 'state.json'), {});
const saveState = s => writeJson(path.join(dir(), 'state.json'), s);
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const coverFile = id => fs.readdirSync(dir()).find(f => f.startsWith(id + '.cover.'));
async function body(req, max) {
const chunks = []; let n = 0;
for await (const c of req) { n += c.length; if (n > max) return null; chunks.push(c); }
return Buffer.concat(chunks);
}
const MOBI = new Set(['mobi', 'azw', 'azw3']);
/** Kindle files with DRM have a non-zero encryption type in their first record (the PalmDOC header, 12 bytes in). Those cannot be read and are refused. */
function mobiLocked(buf) { try { const rec0 = buf.readUInt32BE(78); return buf.readUInt16BE(rec0 + 12) !== 0; } catch { return true; } }
/** A file must look like what its name says: PDFs start with %PDF, EPUB and DOCX are zip archives (and an EPUB says so in its first entry). */
function looksLike(ext, buf) {
if (ext === 'pdf') return buf.subarray(0, 1024).includes('%PDF-');
if (MOBI.has(ext)) return buf.length > 100 && buf.toString('latin1', 60, 68) === 'BOOKMOBI';
if (buf.length < 4 || buf[0] !== 0x50 || buf[1] !== 0x4b) return false;
return ext === 'epub' ? buf.subarray(0, 200).includes('mimetypeapplication/epub+zip') || buf.includes('META-INF/container.xml') : buf.includes('word/document.xml');
}
const withState = (b, st) => ({ ...b, loc: (st[b.id] || {}).loc || null, pct: (st[b.id] || {}).pct || 0, read: (st[b.id] || {}).t || 0 });
/** Merge notes by id; for each id the newest edit (or deletion) wins, so two devices can add notes without losing each other's. */
function mergeNotes(have, sent) {
const by = new Map((have || []).map(n => [n.id, n]));
for (const n of Array.isArray(sent) ? sent.slice(0, 2000) : []) {
if (!n || !/^[\w-]{1,40}$/.test(n.id || '')) continue;
const clean = { id: n.id, t: +n.t || 0, type: ['mark', 'hl', 'note'].includes(n.type) ? n.type : 'mark', loc: clip(n.loc, 3000), pct: Math.min(1, Math.max(0, +n.pct || 0)),
text: clip(n.text, 2000), note: clip(n.note, 5000), color: /^[a-z]{3,12}$/.test(n.color || '') ? n.color : 'yellow', page: Math.max(0, Math.round(+n.page || 0)), del: !!n.del };
const old = by.get(n.id);
if (!old || clean.t >= old.t) by.set(n.id, clean);
}
return [...by.values()].filter(n => !n.del || Date.now() - n.t < 30 * 864e5);
}
/** Put a book on the signed-in person's shelf (the same checks for an upload and for a download from the store). Returns the book; `duplicate` is set when it was already there. */
function addBook(buf, ext, meta = {}, cover) {
if (!TYPES[ext]) throw new Error('Only EPUB, PDF, DOCX, MOBI, AZW and AZW3 books can be added');
if (buf.length > MAX_BOOK) throw new Error('That file is larger than 300 MB');
if (!looksLike(ext, buf)) throw new Error('That does not look like a real ' + ext.toUpperCase() + ' file');
if (MOBI.has(ext) && mobiLocked(buf)) throw new Error('This book has DRM (copy protection), which NE Hub cannot open. Only DRM-free books work.');
const hash = crypto.createHash('sha1').update(buf).digest('hex'), list = index(), dup = list.find(b => b.hash === hash);
if (dup) return { ...dup, duplicate: true };
const id = crypto.randomBytes(8).toString('hex');
fs.writeFileSync(path.join(dir(), id + '.' + ext), buf, { mode: 0o600 });
const book = { id, ext, hash, size: buf.length, added: Date.now(), title: clip(meta.title, 200) || 'Untitled', author: clip(meta.author, 200), cover: false };
if (cover && COVERS[cover.type] && cover.data.length <= MAX_COVER) { fs.writeFileSync(path.join(dir(), `${id}.cover.${COVERS[cover.type]}`), cover.data, { mode: 0o600 }); book.cover = true; }
list.push(book); saveIndex(list); return book;
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/read'.length) || '/';
let m;
if (p.startsWith('/store/')) return require('./store').handle(req, res, p.slice('/store'.length));
if (p === '/list' && req.method === 'GET') { const st = state(); return json(res, 200, index().map(b => withState(b, st))); }
if (p === '/upload' && req.method === 'POST') {
const name = clip(u.searchParams.get('name'), 200), ext = path.extname(name).slice(1).toLowerCase();
if (!TYPES[ext]) return json(res, 400, { error: 'Only EPUB, PDF, DOCX, MOBI, AZW and AZW3 books can be added' });
const buf = await body(req, MAX_BOOK); if (!buf) return json(res, 413, { error: 'That file is larger than 300 MB' });
try { return json(res, 200, addBook(buf, ext, { title: path.basename(name, '.' + ext).replace(/[_]+/g, ' ') })); } catch (e) { return json(res, 400, { error: e.message }); }
}
if (p === '/state' && req.method === 'GET') return json(res, 200, state());
if ((m = /^\/book\/(\w{16})(?:\/(file|cover|pos|notes))?$/.exec(p))) {
const id = m[1], part = m[2], list = index(), book = list.find(b => b.id === id);
if (!book) return json(res, 404, { error: 'Book not found' });
if (part === 'file' && req.method === 'GET') { // the book itself, with range support so PDFs can load page by page
const f = path.join(dir(), id + '.' + book.ext), size = fs.statSync(f).size, range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
const h = { 'content-type': TYPES[book.ext], 'accept-ranges': 'bytes', 'cache-control': 'private, max-age=3600', 'content-disposition': 'attachment; filename*=UTF-8\'\'' + encodeURIComponent(book.title + '.' + book.ext) };
if (u.searchParams.get('inline') === '1') h['content-disposition'] = 'inline';
let a = 0, b = size - 1, code = 200;
if (range && (range[1] || range[2])) {
a = range[1] ? +range[1] : Math.max(0, size - +range[2]); b = range[1] && range[2] ? Math.min(+range[2], size - 1) : size - 1;
if (a > b || a >= size) { res.writeHead(416, { 'content-range': 'bytes */' + size }); return res.end(); }
code = 206; h['content-range'] = `bytes ${a}-${b}/${size}`;
}
h['content-length'] = b - a + 1; res.writeHead(code, h);
return fs.createReadStream(f, { start: a, end: b }).pipe(res);
}
if (part === 'cover') {
if (req.method === 'GET') {
const c = coverFile(id); if (!c) return json(res, 404, { error: 'No cover' });
res.writeHead(200, { 'content-type': Object.entries(COVERS).find(([, e]) => c.endsWith('.' + e))[0], 'cache-control': 'private, max-age=86400' });
return fs.createReadStream(path.join(dir(), c)).pipe(res);
}
if (req.method === 'PUT') {
const type = String(req.headers['content-type'] || '').split(';')[0].trim();
if (!COVERS[type]) return json(res, 400, { error: 'Cover must be a JPEG, PNG, GIF or WebP image' });
const buf = await body(req, MAX_COVER); if (!buf) return json(res, 413, { error: 'Cover too large' });
const old = coverFile(id); if (old) fs.unlinkSync(path.join(dir(), old));
fs.writeFileSync(path.join(dir(), `${id}.cover.${COVERS[type]}`), buf, { mode: 0o600 });
book.cover = true; saveIndex(list); return json(res, 200, book);
}
}
if (part === 'pos' && req.method === 'POST') { // where you got to; the newest report wins so an old tab cannot undo a newer position
const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
const st = state(), cur = st[id] || {};
if ((+o.t || 0) >= (cur.t || 0)) st[id] = { ...cur, loc: clip(o.loc, 600), pct: Math.min(1, Math.max(0, +o.pct || 0)), t: +o.t || Date.now() };
saveState(st); return json(res, 200, st[id]);
}
if (part === 'notes') {
const st = state(), cur = st[id] || {};
if (req.method === 'GET') return json(res, 200, (cur.notes || []).filter(n => !n.del));
if (req.method === 'POST') {
const b = await body(req, MAX_JSON); let o; try { o = JSON.parse((b || '').toString('utf8') || '[]'); } catch { return json(res, 400, { error: 'Bad request' }); }
cur.notes = mergeNotes(cur.notes, o); st[id] = cur; saveState(st); return json(res, 200, cur.notes.filter(n => !n.del));
}
}
if (!part && req.method === 'PATCH') { // title and author (the browser reads them out of the book after it is added)
const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
if (o.title !== undefined) book.title = clip(o.title, 200) || book.title;
if (o.author !== undefined) book.author = clip(o.author, 200);
saveIndex(list); return json(res, 200, book);
}
if (!part && req.method === 'DELETE') {
for (const f of fs.readdirSync(dir())) if (f.startsWith(id + '.')) fs.unlinkSync(path.join(dir(), f));
saveIndex(list.filter(b => b.id !== id)); const st = state(); delete st[id]; saveState(st);
return json(res, 200, { removed: true });
}
}
return json(res, 404, { error: 'Not found' });
}
/** Where a book's file is on disk (for the signed-in person). */
const fileOf = id => { const b = index().find(x => x.id === id); return b ? { path: path.join(dir(), id + '.' + b.ext), ext: b.ext, title: b.title, author: b.author } : null; };
module.exports = { handle, mergeNotes, looksLike, addBook, index, TYPES, fileOf };
+159 -30
View File
@@ -222,60 +222,176 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD
return out;
}
// ---- The NE Hub calendar: events created in the app, stored here, and published as a subscribe feed.
const EV_FILE = 'events.json', FEED_FILE = 'feed.json';
// ---- NE Hub calendars: any number per person, stored here, shared by choice, and published as subscribe feeds.
const EV_FILE = 'events.json', FEED_FILE = 'feed.json', NCAL_FILE = 'ncals.json', COLOR_FILE = 'calcolors.json';
const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 });
const loadLocal = () => readJ(here(EV_FILE), []);
const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' };
const HEX = /^#[0-9a-f]{6}$/i;
const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
const FAM = 'family', FAMCAL = { id: 'local', name: 'Family', color: '#e0742b', shares: [] }; // the calendar the whole household shares
const dirFor = id => { if (id !== FAM) return users.dirOf(id); const d = path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family'); fs.mkdirSync(d, { recursive: true }); return d; };
const myId = () => users.current().id;
/** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */
function calsIn(dir) {
const l = readJ(path.join(dir, NCAL_FILE), null);
return Array.isArray(l) && l.length ? l : [{ id: 'local', name: 'NE Hub', color: DEFAULT_COLOR, shares: [] }];
}
const saveCals = v => writeJ(NCAL_FILE, v);
const eventsIn = dir => readJ(path.join(dir, EV_FILE), []);
const saveEvents = (dir, v) => atomicWrite(path.join(dir, EV_FILE), JSON.stringify(v, null, 2), { mode: 0o600 });
const evCal = e => e.cal || 'local';
const colorOverrides = () => readJ(here(COLOR_FILE), {});
/** Calendars other people shared with the current person: [{ id: "owner~calId", owner, ownerName, cal, mode }] */
function sharedWithMe() {
const me = myId(), out = users.current() && users.can(users.current(), 'calendar') ? [{ id: FAM + '~local', owner: FAM, ownerName: 'the whole family', cal: FAMCAL, mode: 'edit' }] : [];
for (const u of users.all()) {
if (u.disabled || u.id === me) continue;
for (const c of calsIn(dirFor(u.id))) {
const sh = (c.shares || []).find(x => x.user === me);
if (sh) out.push({ id: u.id + '~' + c.id, owner: u.id, ownerName: u.name, cal: c, mode: sh.mode === 'edit' ? 'edit' : 'view' });
}
}
return out;
}
/** Find the NE Hub calendar an id points at, with the current person's rights on it (null if none). */
function resolveCal(id) {
const me = myId();
if (typeof id === 'string' && id.includes('~')) {
const [owner, cid] = id.split('~');
if (owner === FAM) return cid === 'local' && users.can(users.current(), 'calendar') ? { owner: FAM, dir: dirFor(FAM), cal: FAMCAL, cid: 'local', mode: 'edit' } : null;
const u = users.get(owner);
if (!u || u.disabled) return null;
const c = calsIn(dirFor(owner)).find(x => x.id === cid);
const sh = c && (c.shares || []).find(x => x.user === me);
return sh ? { owner, dir: dirFor(owner), cal: c, cid, mode: sh.mode === 'edit' ? 'edit' : 'view' } : null;
}
const c = calsIn(dirFor(me)).find(x => x.id === id);
return c ? { owner: me, dir: dirFor(me), cal: c, cid: c.id, mode: 'owner' } : null;
}
const canWrite = r => r && (r.mode === 'owner' || r.mode === 'edit');
const icsOf = (dir, ids) => ics.buildCalendar(eventsIn(dir).filter(e => !ids || ids.includes(evCal(e))).map(e => ({ uid: e.uid, fields: e.fields })));
const localIcs = () => icsOf(dirFor(myId()));
function feedToken(reset) {
let t = readJ(here(FEED_FILE), {}).token;
if (!t || reset) { t = crypto.randomBytes(24).toString('hex'); writeJ(FEED_FILE, { token: t }); }
return t;
}
const localIcs = () => ics.buildCalendar(loadLocal().map(e => ({ uid: e.uid, fields: e.fields })));
function calToken(id, reset) {
const cals = calsIn(users.dir()), c = cals.find(x => x.id === id);
if (!c) throw new Error('Calendar not found');
if (!c.token || reset) { c.token = crypto.randomBytes(24).toString('hex'); saveCals(cals); }
return c.token;
}
/** GET /cal/feed/<token>.ics : public on purpose (Outlook/Google/Apple fetch it without a session); the token is the secret.
* Every person has their own token, and it only ever shows that person's own NE Hub events. */
* A person's own token shows all their NE Hub calendars; a calendar's token shows just that one. */
function feed(req, res) {
const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname);
const got = Buffer.from(m ? m[1] : '');
let owner = null;
const same = t => { const want = Buffer.from(String(t || '')); return want.length && want.length === got.length && crypto.timingSafeEqual(want, got); };
let body = null;
if (m) for (const u of users.all()) {
if (u.disabled) continue;
const want = Buffer.from(String(readJ(path.join(users.dirOf(u.id), FEED_FILE), {}).token || ''));
if (want.length && want.length === got.length && crypto.timingSafeEqual(want, got)) { owner = u; break; }
const dir = dirFor(u.id);
if (same(readJ(path.join(dir, FEED_FILE), {}).token)) { body = icsOf(dir); break; }
const c = calsIn(dir).find(x => same(x.token));
if (c) { body = icsOf(dir, [c.id]); break; }
}
if (!owner) { res.writeHead(404); return res.end('Not found'); }
if (body == null) { res.writeHead(404); return res.end('Not found'); }
res.writeHead(200, { 'content-type': 'text/calendar; charset=utf-8', 'cache-control': 'private, max-age=300' });
res.end(users.runAs(owner, () => localIcs()));
res.end(body);
}
// ---- HTTP API (called by server.js, behind the passkey gate)
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
const readBody = req => require('./body').readBody(req, 8e6);
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
/** Every event in the window from all calendars (ICS links, iCloud, NE Hub): { events, errors } */
/** Every event in the window from all calendars (ICS links, iCloud, NE Hub, shared): { events, errors } */
async function collectEvents(win) {
const all = [], errors = [];
const all = [], errors = [], over = colorOverrides();
await Promise.all(load().map(async c => {
try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, color: c.color }); }
try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, calId: c.id, color: c.color }); }
catch (e) { errors.push(`${c.name}: ${e.message}`); }
}));
try {
for (const r of await caldav.listRaw(win.from, win.to))
for (const e of expand(parseEvents(r.ics), win)) all.push({ ...e, cal: r.name, calId: r.calId, color: r.color, editable: true, href: r.href, etag: r.etag });
for (const e of expand(parseEvents(r.ics), win)) all.push({ ...e, cal: r.name, calId: r.calId, color: over[r.calId] || r.color, editable: true, href: r.href, etag: r.etag });
} catch (e) { errors.push('iCloud: ' + e.message); }
try { for (const e of expand(parseEvents(localIcs()), win)) all.push({ ...e, cal: LOCAL.name, calId: 'local', color: LOCAL.color, editable: true }); }
catch (e) { errors.push('NE Hub: ' + e.message); }
try {
const mine = dirFor(myId()), evs = eventsIn(mine);
const add = (dir, evs, c, id, extra) => {
const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields })));
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra });
};
for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true });
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName });
} catch (e) { errors.push('NE Hub: ' + e.message); }
try { all.push(...require('./contacts').birthdayEvents(win)); } catch {} // birthdays from Contacts
return { events: all, errors };
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x');
try {
if (u.pathname === '/api/cal/list') return json(res, 200, [{ ...LOCAL, local: true }, ...caldav.enabled().map(c => ({ ...c, icloud: true })), ...load().map(({ id, name, color }) => ({ id, name, color }))]);
if (u.pathname === '/api/cal/list') {
const over = colorOverrides();
return json(res, 200, [
...calsIn(users.dir()).map(c => ({ id: c.id, name: c.name, color: c.color || DEFAULT_COLOR, local: true, shares: (c.shares || []).filter(x => users.get(x.user)) })),
...sharedWithMe().map(s => ({ id: s.id, name: s.cal.name, color: over[s.id] || s.cal.color || DEFAULT_COLOR, local: true, shared: true, owner: s.ownerName, mode: s.mode })),
...caldav.enabled().map(c => ({ ...c, color: over[c.id] || c.color, icloud: true })),
...load().map(({ id, name, color }) => ({ id, name, color }))]);
}
if (u.pathname === '/api/cal/ncal' && req.method === 'POST') { // create
const { name, color } = JSON.parse(await readBody(req));
const cals = calsIn(users.dir()), nm = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 40);
if (!nm) throw new Error('Give the calendar a name');
if (cals.length >= 30) throw new Error('That is plenty of calendars');
const c = { id: crypto.randomBytes(5).toString('hex'), name: nm, color: HEX.test(color) ? color : COLORS[cals.length % COLORS.length], shares: [] };
saveCals([...cals, c]);
return json(res, 200, { ok: true, id: c.id });
}
if (u.pathname === '/api/cal/ncal/update' && req.method === 'POST') { // rename, recolor, share
const b = JSON.parse(await readBody(req));
const cals = calsIn(users.dir()), c = cals.find(x => x.id === b.id);
if (!c) throw new Error('Calendar not found');
if (b.name != null) { const nm = String(b.name).replace(/\s+/g, ' ').trim().slice(0, 40); if (!nm) throw new Error('Give the calendar a name'); c.name = nm; }
if (b.color != null) { if (!HEX.test(b.color)) throw new Error('Pick a color'); c.color = b.color; }
if (b.shares != null) {
if (!Array.isArray(b.shares)) throw new Error('Bad share list');
const seen = new Set();
c.shares = b.shares.filter(x => x && x.user !== myId() && users.get(x.user) && !seen.has(x.user) && seen.add(x.user)).map(x => ({ user: x.user, mode: x.mode === 'edit' ? 'edit' : 'view' }));
}
saveCals(cals);
return json(res, 200, { ok: true });
}
if (u.pathname === '/api/cal/ncal/delete' && req.method === 'POST') {
const { id } = JSON.parse(await readBody(req));
const cals = calsIn(users.dir());
if (!cals.some(x => x.id === id)) throw new Error('Calendar not found');
if (cals.length < 2) throw new Error('Keep at least one NE Hub calendar');
saveCals(cals.filter(x => x.id !== id));
saveEvents(users.dir(), eventsIn(users.dir()).filter(e => evCal(e) !== id));
return json(res, 200, { ok: true });
}
if (u.pathname === '/api/cal/ncal/feed' && req.method === 'POST') { // one calendar's subscribe link (reset: true makes a new one)
const b = JSON.parse(await readBody(req));
const origin = (process.env.PUBLIC_URL || '').replace(/\/$/, '') || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`;
return json(res, 200, { url: `${origin}/cal/feed/${calToken(b.id, !!b.reset)}.ics` });
}
if (u.pathname === '/api/cal/people') // who a calendar can be shared with
return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId()).map(x => ({ id: x.id, name: x.name })));
if (u.pathname === '/api/cal/color' && req.method === 'POST') { // your own color for iCloud, shared or link calendars
const { id, color } = JSON.parse(await readBody(req));
if (!HEX.test(color)) throw new Error('Pick a color');
const link = load(), l = link.find(x => x.id === id);
if (l) { l.color = color; save(link); } else writeJ(COLOR_FILE, { ...colorOverrides(), [id]: color });
return json(res, 200, { ok: true });
}
if (u.pathname === '/api/cal/add' && req.method === 'POST') {
const { name, url } = JSON.parse(await readBody(req));
if (!url) throw new Error('Paste the calendar link');
@@ -298,10 +414,11 @@ async function handle(req, res) {
}
if (u.pathname === '/api/cal/event' && req.method === 'GET') {
const q = u.searchParams, tz = q.get('tz') || 'UTC';
if (q.get('cal') === 'local') {
const e = loadLocal().find(x => x.uid === q.get('uid'));
const r = !caldav.isCal(q.get('cal')) && resolveCal(q.get('cal'));
if (r) {
const e = eventsIn(r.dir).find(x => x.uid === q.get('uid') && evCal(x) === r.cid);
if (!e) throw new Error('Event not found');
return json(res, 200, { cal: 'local', uid: e.uid, fields: e.fields });
return json(res, 200, { cal: q.get('cal'), uid: e.uid, fields: e.fields });
}
if (caldav.isCal(q.get('cal'))) {
const r = await caldav.getResource(q.get('cal'), q.get('href'));
@@ -313,12 +430,14 @@ async function handle(req, res) {
}
if (u.pathname === '/api/cal/event' && req.method === 'POST') {
const b = JSON.parse(await readBody(req));
if (b.cal === 'local') {
const r = !caldav.isCal(b.cal) && resolveCal(b.cal);
if (r) {
if (!canWrite(r)) throw new Error('This calendar is shared with you to view only');
ics.validate(b.fields);
const all = loadLocal(), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid);
const rec = { uid, fields: b.fields, updated: Date.now() };
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now() });
writeJ(EV_FILE, all);
const all = eventsIn(r.dir), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid && evCal(x) === r.cid);
const rec = { uid, cal: r.cid, fields: b.fields, updated: Date.now() };
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now(), by: users.current().id, byName: users.current().name }); // (who added it)
saveEvents(r.dir, all);
return json(res, 200, { ok: true, uid });
}
if (caldav.isCal(b.cal)) {
@@ -336,7 +455,12 @@ async function handle(req, res) {
}
if (u.pathname === '/api/cal/event/delete' && req.method === 'POST') {
const b = JSON.parse(await readBody(req));
if (b.cal === 'local') { writeJ(EV_FILE, loadLocal().filter(x => x.uid !== b.uid)); return json(res, 200, { ok: true }); }
const r = !caldav.isCal(b.cal) && resolveCal(b.cal);
if (r) {
if (!canWrite(r)) throw new Error('This calendar is shared with you to view only');
saveEvents(r.dir, eventsIn(r.dir).filter(x => !(x.uid === b.uid && evCal(x) === r.cid)));
return json(res, 200, { ok: true });
}
if (caldav.isCal(b.cal)) { await caldav.deleteEvent(b.cal, b.href, b.etag); return json(res, 200, { ok: true }); }
throw new Error('Unknown calendar');
}
@@ -353,4 +477,9 @@ async function handle(req, res) {
} catch (e) { json(res, 400, { error: e.message }); }
}
module.exports = { collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };
/** The Family calendar's events in a window, for the family board (no signed-in person needed). */
function familyEvents(win) {
const evs = eventsIn(dirFor(FAM)), ics_ = ics.buildCalendar(evs.map(e => ({ uid: e.uid, fields: e.fields })));
return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' }));
}
module.exports = { familyEvents, collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };
+6 -5
View File
@@ -87,11 +87,12 @@ async function connect(appleId, password) {
const principalHref = hrefOf(tag(a.text, 'current-user-principal') || '');
if (!principalHref) throw new Error('iCloud did not return an account (is calendar turned on for this Apple ID?)');
const b = await dav('PROPFIND', abs(principalHref, a.url), { ...o, depth: 0, body: PROPFIND_HOME });
const homeHref = hrefOf(tag(b.text, 'calendar-home-set') || '');
if (!homeHref) throw new Error('Could not find the calendar home for this account');
const home = abs(homeHref, b.url);
const c = await dav('PROPFIND', home, { ...o, depth: 1, body: PROPFIND_CALS });
if (c.status >= 400) throw new Error(`iCloud answered ${c.status} when listing the calendars`);
let homeHref = hrefOf(tag(b.text, 'calendar-home-set') || '');
const guess = abs(principalHref, a.url).replace(/\/principal\/?$/, '/calendars/');
const home0 = homeHref ? abs(homeHref, b.url) : guess.endsWith('/calendars/') ? guess : null; // (some accounts do not report the home at the principal; Apple keeps it beside it, as /<id>/calendars/)
if (!home0) throw new Error(`Could not find the calendar home for this account (iCloud answered ${b.status}: ${b.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160) || 'nothing'})`);
const c = await dav('PROPFIND', home0, { ...o, depth: 1, body: PROPFIND_CALS });
if (c.status >= 400) throw new Error(homeHref ? `iCloud answered ${c.status} when listing the calendars` : `Could not find the calendar home for this account (the principal gave none, and ${new URL(home0).pathname} answered ${c.status})`);
const calendars = parseCollections(c.text, c.url);
if (!calendars.found.length) throw new Error(`Signed in, but no calendars were found (iCloud listed ${calendars.seen.length} item${calendars.seen.length === 1 ? '' : 's'}: ${calendars.seen.slice(0, 8).join(', ') || 'none'}). Make sure Calendar is turned on for this Apple ID in iCloud settings.`);
save({ user: o.user, pass: o.pass, calendars: calendars.found });
+242
View File
@@ -0,0 +1,242 @@
// Family Chat: a messenger built into the hub, for the people who have an account here. Direct chats and groups, text, attachments, replies, reactions, edit and delete,
// unread counts, search, mute and archive, live delivery (server-sent events) and phone push when someone is not looking. Everything is kept on this server, in
// DATA_DIR/chat: conversations and messages as JSON, attachments as files. (Messages are stored readable on the server; end-to-end encryption is a later decision.)
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const { pipeline } = require('stream/promises');
const atomicWrite = require('./atomic');
const users = require('./users');
const inbox = require('./inbox');
const push = require('./push');
const cal = require('./cal'); // (its public-address check, for link previews)
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), ROOT = () => path.join(DATA_DIR, 'chat');
const MAX_TEXT = 8000, MAX_FILE = 100 * 1024 * 1024, MAX_FILES = 10, MAX_MEMBERS = 40, MAX_MSGS = 50000, PAGE = 60;
const EMOJI = /^[\p{Extended_Pictographic}‍️⃣\p{Emoji_Modifier}]{1,12}$/u;
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
const newId = () => crypto.randomBytes(8).toString('hex');
const idOk = s => /^[0-9a-f]{16}$/.test(s || '') || /^d:[0-9a-z]+:[0-9a-z]+$/.test(s || '');
// ---- storage (small enough to hold in memory and write whole)
const cvFile = () => path.join(ROOT(), 'convs.json'), msFile = id => path.join(ROOT(), 'msgs', id.replace(/:/g, '_') + '.json'), flFile = () => path.join(ROOT(), 'files.json'), blob = id => path.join(ROOT(), 'files', id);
const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const write = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 }); atomicWrite(f, JSON.stringify(v), { mode: 0o600 }); };
let convsC = null, filesC = null; const msgsC = new Map();
const convs = () => convsC || (convsC = read(cvFile(), []));
const saveConvs = () => write(cvFile(), convs());
const files = () => filesC || (filesC = read(flFile(), {}));
const saveFiles = () => write(flFile(), files());
const msgs = id => { let m = msgsC.get(id); if (!m) { m = read(msFile(id), []); msgsC.set(id, m); if (msgsC.size > 60) msgsC.delete(msgsC.keys().next().value); } return m; };
const saveMsgs = id => write(msFile(id), msgs(id));
// each person's own marks: how far they have read, and which chats they muted or archived
const stFile = uid => path.join(users.dirOf(uid), 'chat-state.json');
const stateOf = uid => { const s = read(stFile(uid), {}); return { read: s.read || {}, delivered: s.delivered || {}, muted: s.muted || [], archived: s.archived || [], quiet: s.quiet || { on: false, from: '21:00', to: '07:00' } }; };
const saveState = (uid, s) => write(stFile(uid), s);
/** Is it quiet time for this person now (in their own time zone, from their reminder settings)? Phone alerts wait; the app still shows messages. */
function quietNow(uid) {
const q = stateOf(uid).quiet; if (!q.on) return false; let tz = 'UTC'; try { tz = JSON.parse(fs.readFileSync(path.join(users.dirOf(uid), 'push.json'), 'utf8')).tz || 'UTC'; } catch { /* the default */ }
const hm = push.localNow(tz).hm; return q.from <= q.to ? hm >= q.from && hm < q.to : hm >= q.from || hm < q.to;
}
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chat'); };
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chat')).map(u => ({ id: u.id, name: u.name }));
const convOf = (id, me) => { const c = convs().find(x => x.id === id); if (!c || !c.members.includes(me.id)) throw fail(404, 'Conversation not found'); return c; };
const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted && !m.sys).length; };
const totalUnread = uid => { const st = stateOf(uid); return convs().filter(c => c.members.includes(uid) && !st.muted.includes(c.id) && !st.archived.includes(c.id)).reduce((n, c) => n + unreadIn(c, uid, st), 0); };
const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '📊 ' + m.poll.q : m.att && m.att[0] && m.att[0].voice && !m.text ? '🎤 Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '📎 ' + (m.att[0].name || 'Attachment') : '') : '';
// ---- live updates (server-sent events), one stream per open app
const annFile = () => path.join(ROOT(), 'announcement.json');
const lastSeen = new Map(); // user id -> when they last did anything (kept in memory)
const clients = new Map(); // user id -> Set of responses
const online = uid => (clients.get(uid) || new Set()).size > 0;
function emit(uids, ev, per) { for (const uid of uids) for (const res of clients.get(uid) || []) { try { res.write('data: ' + JSON.stringify(per ? per(uid, ev) : ev) + '\n\n'); } catch { /* closed */ } } }
const view = (c, uid, st) => { const last = msgs(c.id).slice(-1)[0], others = c.members.filter(x => x !== uid), o = c.type === 'direct' ? others[0] : null;
return { ttl: c.ttl || 0, ...(o ? { presence: { online: online(o), seen: lastSeen.get(o) || 0 } } : {}), id: c.id, type: c.type, name: c.type === 'group' ? c.name : nameOf(others[0] || uid), members: c.members.map(x => ({ id: x, name: nameOf(x) })), by: c.by, created: c.created, ts: last ? last.ts : c.created, preview: last ? (last.sys ? '' : c.type === 'group' && last.from !== uid ? nameOf(last.from) + ': ' : last.from === uid ? 'You: ' : '') + preview(last) : '', unread: unreadIn(c, uid, st), muted: st.muted.includes(c.id), archived: st.archived.includes(c.id) }; };
/** sent, delivered or read: for what you sent, from what the others have read and received (everyone, in a group). */
const statusOf = (m, c, uid, sts) => { if (m.from !== uid || m.sys) return undefined; const o = c.members.filter(x => x !== uid); if (!o.length) return 'sent'; if (o.every(x => (sts[x].read[c.id] || 0) >= m.ts)) return 'read'; if (o.every(x => (sts[x].delivered[c.id] || 0) >= m.ts)) return 'delivered'; return 'sent'; };
const stsOf = c => Object.fromEntries(c.members.map(x => [x, stateOf(x)]));
const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) });
/** Someone has received or read the chat up to now: the senders' ticks change. */
function markDelivered(uid, c, also) { const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; if (!last) return; let ch = false; if ((st.delivered[c.id] || 0) < last.ts) { st.delivered[c.id] = last.ts; ch = true; } if (also && (st.read[c.id] || 0) < last.ts) { st.read[c.id] = last.ts; ch = true; } if (ch) { saveState(uid, st); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); } }
const TTLS = [0, 3600, 86400, 604800], ttlWords = s => s === 3600 ? '1 hour' : s === 86400 ? '1 day' : s === 604800 ? '1 week' : 'off';
/** Disappearing messages that are due go, with their files. */
function purge() {
const now = Date.now(); let filesChanged = false;
for (const c of convs()) { const list = msgs(c.id), due = list.filter(m => m.exp && m.exp <= now); if (!due.length) continue;
for (const m of due) for (const a of m.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; filesChanged = true; }
const keep = list.filter(m => !(m.exp && m.exp <= now)); list.length = 0; list.push(...keep); c.pins = (c.pins || []).filter(id => keep.some(m => m.id === id)); saveMsgs(c.id); saveConvs(); emit(c.members, { t: 'upd', conv: c.id }); }
if (filesChanged) saveFiles();
}
setInterval(() => { try { purge(); } catch (e) { console.warn('[chat]', e.message); } }, 60e3).unref();
const mentionsIn = (text, c) => c.type !== 'group' ? [] : c.members.filter(uid => { const n = nameOf(uid); return n && new RegExp('(^|\\s)@' + n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w])', 'i').test(text); });
function send(me, b) {
const c = convOf(String(b.conv || ''), me), text = str(b.text, MAX_TEXT).trim(), ids = (Array.isArray(b.files) ? b.files : []).map(String).slice(0, MAX_FILES), att = [];
for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size, ...(f.voice ? { voice: true, wave: f.wave, dur: f.dur } : {}) }); }
if (!text && !att.length) throw fail(400, 'Write something or attach a file');
let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
return addMsg(me, c, { text, att, quote });
}
/** Put a message in a chat and tell everyone: the live stream, and a phone notification for those who are away. */
function addMsg(me, c, { text, att = [], quote, fwd, sys, poll }) {
const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined };
list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { if (files()[a.id]) files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles();
const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '📊 ' + poll.q : text ? str(text, 140) : att[0] && att[0].voice ? '🎤 Voice message' : '📎 ' + att[0].name), title = c.type === 'group' ? c.name : me.name;
emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, sys: !!sys, mention: !!(m.mentions && m.mentions.includes(uid)), name: title, preview: body, unread: totalUnread(uid) }));
for (const uid of c.members) if (online(uid) && (sys || uid !== me.id)) markDelivered(uid, c); // (what an open app has received counts as delivered)
if (!sys) for (const uid of c.members) if (uid !== me.id && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid)))) inbox.add(uid, { kind: 'chat', title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id });
if (!sys) for (const uid of c.members) if (uid !== me.id && !online(uid) && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid))) && !quietNow(uid)) push.notify(uid, { title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id }).catch(() => {});
if (!sys) { const url = /https:\/\/[^\s<>"']+/.exec(text || ''); if (url) previewOf(url[0].replace(/[.,;:!?)\]]+$/, '')).then(p => { if (p && msgs(c.id).includes(m) && !m.deleted) { m.link = p; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: m.id }); } }).catch(() => {}); }
return msgView(m, me.id, c, stsOf(c));
}
// ---- link previews: the server reads the page's own title, description and picture (public https addresses only), so nobody's device contacts the site
const metaOf = (html, names) => { for (const n of names) { const m = new RegExp('<meta[^>]+(?:property|name)=["\']' + n + '["\'][^>]*>', 'i').exec(html), c = m && /content=["']([^"']*)["']/i.exec(m[0]) || (m && /content=([^\s>]+)/i.exec(m[0])); if (c && c[1].trim()) return c[1]; } return ''; };
const unent = t => String(t).replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|#39);/gi, (x, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'" }[e.toLowerCase()] || (e[1] === 'x' || e[1] === 'X' ? String.fromCodePoint(parseInt(e.slice(2), 16)) : e[0] === '#' ? String.fromCodePoint(+e.slice(1)) : x))).replace(/\s+/g, ' ').trim();
/** The preview a page describes about itself: { url, site, title, desc, image } or null. */
function parsePreview(html, base) {
const title = unent(metaOf(html, ['og:title', 'twitter:title']) || (/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) || [])[1] || ''), desc = unent(metaOf(html, ['og:description', 'twitter:description', 'description'])), site = unent(metaOf(html, ['og:site_name']));
let image = metaOf(html, ['og:image', 'og:image:url', 'twitter:image']); try { image = image ? new URL(unent(image), base).href : ''; if (!/^https:/.test(image)) image = ''; } catch { image = ''; }
if (!title && !desc) return null; return { url: base, site: site.slice(0, 80), title: title.slice(0, 200), desc: desc.slice(0, 300), image: image.slice(0, 500) };
}
const previews = new Map();
async function previewOf(raw) {
const hit = previews.get(raw); if (hit && Date.now() - hit.t < 3600e3) return hit.p;
let url; try { url = new URL(raw); } catch { return null; } let r;
for (let hop = 0; hop < 4; hop++) { await cal.assertPublic(url); r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (compatible; NE-Hub/1.0; link preview)', accept: 'text/html' }, redirect: 'manual', signal: AbortSignal.timeout(6000) }); if ([301, 302, 303, 307, 308].includes(r.status) && r.headers.get('location')) { url = new URL(r.headers.get('location'), url); try { await r.body?.cancel(); } catch {} continue; } break; }
let p = null; if (r.ok && /html/i.test(r.headers.get('content-type') || '')) { const chunks = []; let n = 0; for await (const c of r.body) { n += c.length; chunks.push(c); if (n > 400000) break; } p = parsePreview(Buffer.concat(chunks).toString('utf8'), url.href); }
previews.set(raw, { t: Date.now(), p }); if (previews.size > 200) previews.delete(previews.keys().next().value); return p;
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = (() => { try { return decodeURIComponent(u.pathname.slice('/api/chat'.length)) || '/'; } catch { return '/'; } })(), me = users.current(), q = u.searchParams; // (a chat id has colons, which a browser writes as %3A)
try {
let m; lastSeen.set(me.id, Date.now()); purge();
if (p === '/status') return json(res, 200, { ok: true, me: { id: me.id, name: me.name }, unread: totalUnread(me.id) });
if (p === '/people') return json(res, 200, people().filter(x => x.id !== me.id));
if (p === '/events') {
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' }); res.write(': hello\n\n');
const set = clients.get(me.id) || new Set(); set.add(res); clients.set(me.id, set);
const ka = setInterval(() => { try { res.write(': \n\n'); } catch { /* closed */ } }, 25000);
res.on('close', () => { clearInterval(ka); set.delete(res); if (!set.size) clients.delete(me.id); lastSeen.set(me.id, Date.now()); }); return;
}
if (p === '/convs' && req.method === 'GET') { const st = stateOf(me.id); return json(res, 200, { convs: convs().filter(c => c.members.includes(me.id)).map(c => view(c, me.id, st)).sort((a, b) => b.ts - a.ts), unread: totalUnread(me.id) }); }
if (p === '/new' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'); let c;
if (b.type === 'group') {
const name = str(b.name, 60).trim(); if (!name) throw fail(400, 'Give the group a name'); const mem = [...new Set([me.id, ...(Array.isArray(b.members) ? b.members : [])].map(String))].filter(live); if (mem.length < 2) throw fail(400, 'Add at least one other person'); if (mem.length > MAX_MEMBERS) throw fail(400, 'That is a lot of people for one group');
c = { id: newId(), type: 'group', name, members: mem, by: me.id, created: Date.now() }; convs().push(c);
} else {
const other = String(b.with || ''); if (other === me.id || !live(other)) throw fail(400, 'Choose someone from the family'); const id = 'd:' + [me.id, other].sort().join(':'); c = convs().find(x => x.id === id);
if (!c) { c = { id, type: 'direct', members: [me.id, other].sort(), by: me.id, created: Date.now() }; convs().push(c); }
}
saveConvs(); emit(c.members, { t: 'conv', conv: c.id }); return json(res, 200, { conv: view(c, me.id, stateOf(me.id)) });
}
if (p === '/conv/update' && req.method === 'POST') { // rename, add or remove people, leave (groups)
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me); if (c.type !== 'group') throw fail(400, 'Only groups can be changed');
const isOwner = c.by === me.id || me.role === 'admin'; const before = [...c.members];
if (b.name !== undefined) { const n = str(b.name, 60).trim(); if (!n) throw fail(400, 'Give the group a name'); c.name = n; }
for (const id of Array.isArray(b.add) ? b.add : []) if (live(String(id)) && !c.members.includes(String(id)) && c.members.length < MAX_MEMBERS) c.members.push(String(id));
for (const id of Array.isArray(b.remove) ? b.remove : []) { if (!isOwner && String(id) !== me.id) throw fail(403, 'Only the person who made the group, or an admin, can remove others'); c.members = c.members.filter(x => x !== String(id)); }
if (b.leave) c.members = c.members.filter(x => x !== me.id);
if (!c.members.length) convsC = convs().filter(x => x !== c); else if (c.by && !c.members.includes(c.by)) c.by = c.members[0];
saveConvs(); emit([...new Set([...before, ...c.members])], { t: 'conv', conv: c.id }); return json(res, 200, c.members.includes(me.id) ? { conv: view(c, me.id, stateOf(me.id)) } : { left: true });
}
if ((m = /^\/conv\/(\w[\w:]*)$/.exec(p)) && req.method === 'GET') {
const c = convOf(m[1], me), all = msgs(c.id), before = +q.get('before') || Infinity, eligible = all.filter(x => x.ts < before), page = eligible.slice(-PAGE), sts = stsOf(c); markDelivered(me.id, c);
return json(res, 200, { conv: view(c, me.id, stateOf(me.id)), pinned: (c.pins || []).map(id => all.find(x => x.id === id)).filter(x => x && !x.deleted).map(x => msgView(x, me.id, c, sts)), msgs: page.map(x => msgView(x, me.id, c, sts)), more: eligible.length > page.length, names: Object.fromEntries(c.members.map(x => [x, nameOf(x)])) });
}
if (p === '/send' && req.method === 'POST') return json(res, 200, { msg: send(me, JSON.parse(await readBody(req, 5e4) || '{}')) });
if (p === '/upload' && req.method === 'POST') { // the body is the file; ?conv= says which chat it is for
const c = convOf(String(q.get('conv') || ''), me); if ((+req.headers['content-length'] || 0) > MAX_FILE) throw fail(413, 'That file is too large (100 MB at most)');
const id = newId(), type = /^[\w.+-]+\/[\w.+-]+$/.test(String(req.headers['content-type'] || '').split(';')[0].trim()) ? String(req.headers['content-type']).split(';')[0].trim().toLowerCase() : 'application/octet-stream', name = path.basename(str(q.get('name'), 160)).replace(/[\\/:*?"<>|]+/g, '_') || 'file';
fs.mkdirSync(path.dirname(blob(id)), { recursive: true, mode: 0o700 }); let n = 0; req.on('data', d => { n += d.length; if (n > MAX_FILE) req.destroy(fail(413, 'That file is too large')); });
try { await pipeline(req, fs.createWriteStream(blob(id) + '.part', { mode: 0o600 })); } catch (e) { fs.rm(blob(id) + '.part', { force: true }, () => {}); throw e.code === 413 ? e : fail(400, 'The upload did not finish'); }
fs.renameSync(blob(id) + '.part', blob(id)); const voice = q.get('voice') === '1' && /^audio\//.test(type), wave = voice ? String(q.get('wave') || '').split(',').map(Number).filter(v => Number.isFinite(v)).slice(0, 64).map(v => Math.max(0, Math.min(100, Math.round(v)))) : undefined, dur = voice ? Math.max(0, Math.min(900, Math.round(+q.get('dur') || 0))) : undefined;
files()[id] = { conv: c.id, by: me.id, name, type, size: n, ts: Date.now(), ...(voice ? { voice: true, wave, dur } : {}) }; saveFiles(); return json(res, 200, { id, name, type, size: n, voice: voice || undefined });
}
if ((m = /^\/file\/([0-9a-f]{16})$/.exec(p)) && req.method === 'GET') {
const f = files()[m[1]]; if (!f) throw fail(404, 'Not found'); convOf(f.conv, me); let st; try { st = fs.statSync(blob(m[1])); } catch { throw fail(404, 'Not found'); }
const inline = /^(image\/(png|jpeg|gif|webp|heic|heif|avif)|audio\/|video\/|application\/pdf$)/.test(f.type), h = { 'content-type': inline ? f.type : 'application/octet-stream', 'x-content-type-options': 'nosniff', 'cache-control': 'private, max-age=3600', 'accept-ranges': 'bytes', 'content-security-policy': "default-src 'none'; sandbox" };
if (!inline || q.get('dl') === '1') h['content-disposition'] = `attachment; filename="${f.name.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '')}"; filename*=UTF-8''${encodeURIComponent(f.name)}`;
const rg = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
if (rg && (rg[1] || rg[2])) { const a = rg[1] ? +rg[1] : Math.max(0, st.size - +rg[2]), b = rg[1] && rg[2] ? Math.min(+rg[2], st.size - 1) : st.size - 1; if (a > b || a >= st.size) { res.writeHead(416, { 'content-range': 'bytes */' + st.size }); return res.end(); } res.writeHead(206, { ...h, 'content-range': `bytes ${a}-${b}/${st.size}`, 'content-length': b - a + 1 }); return fs.createReadStream(blob(m[1]), { start: a, end: b }).pipe(res); }
res.writeHead(200, { ...h, 'content-length': st.size }); return fs.createReadStream(blob(m[1])).pipe(res);
}
if (p === '/react' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || x.deleted) throw fail(404, 'Message not found');
const e = String(b.emoji || ''); if (e && !EMOJI.test(e)) throw fail(400, 'That is not an emoji'); x.react = x.react || {}; if (e) x.react[me.id] = e; else delete x.react[me.id]; saveMsgs(c.id);
emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
}
if ((p === '/edit' || p === '/delete') && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 2e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x) throw fail(404, 'Message not found'); if (x.from !== me.id) throw fail(403, 'You can only change your own messages'); if (x.deleted) throw fail(400, 'That message was deleted');
if (p === '/edit') { const t = str(b.text, MAX_TEXT).trim(); if (!t) throw fail(400, 'Write something'); if (Date.now() - x.ts > 48 * 3600e3) throw fail(400, 'Messages can be edited for two days'); x.text = t; x.edited = true; }
else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.react = {}; saveFiles(); }
saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
}
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); st.delivered[c.id] = Math.max(st.delivered[c.id] || 0, st.read[c.id]); saveState(me.id, st); emit([me.id], { t: 'read', conv: c.id, unread: totalUnread(me.id) }); emit(c.members.filter(x => x !== me.id), { t: 'receipt', conv: c.id }); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); }
if ((p === '/conv/mute' || p === '/conv/archive') && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), k = p.endsWith('mute') ? 'muted' : 'archived'; st[k] = st[k].filter(x => x !== c.id); if (b.on) st[k].push(c.id); saveState(me.id, st); emit([me.id], { t: 'conv', conv: c.id, unread: totalUnread(me.id) }); return json(res, 200, { ok: true }); }
if (p === '/announcement' && req.method === 'GET') return json(res, 200, read(annFile(), null));
if (p === '/announcement' && req.method === 'POST') { // the family board on Home: an admin posts or clears it
if (me.role !== 'admin') throw fail(403, 'Only an admin can change the announcement'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = str(b.text, 300).trim();
if (!text) { fs.rmSync(annFile(), { force: true }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, null); }
const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); for (const x of people()) if (x.id !== me.id) inbox.add(x.id, { kind: 'announce', title: 'Family announcement', body: text, url: '#/family' }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a);
}
if (p === '/prefs' && req.method === 'GET') return json(res, 200, { quiet: stateOf(me.id).quiet });
if (p === '/prefs' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), st = stateOf(me.id), hm = /^([01]\d|2[0-3]):[0-5]\d$/; if (b.quiet) st.quiet = { on: !!b.quiet.on, from: hm.test(b.quiet.from) ? b.quiet.from : st.quiet.from, to: hm.test(b.quiet.to) ? b.quiet.to : st.quiet.to }; saveState(me.id, st); return json(res, 200, { quiet: st.quiet }); }
if (p === '/poll' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), q = str(b.question, 200).trim(), opts = (Array.isArray(b.options) ? b.options : []).map(o => str(o, 100).trim()).filter(Boolean).slice(0, 8);
if (!q) throw fail(400, 'Ask a question'); if (opts.length < 2) throw fail(400, 'Give at least two choices');
const msg = addMsg(me, c, { text: '', poll: { q, options: opts.map((t, i) => ({ id: String(i), text: t })), multi: !!b.multi } }); return json(res, 200, { msg });
}
if (p === '/vote' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || !x.poll || x.deleted) throw fail(404, 'Poll not found'); if (x.poll.closed) throw fail(400, 'This poll is closed');
const ids = [...new Set((Array.isArray(b.opts) ? b.opts : []).map(String))].filter(id => x.poll.options.some(o => o.id === id)); if (!x.poll.multi) ids.splice(1);
x.votes = x.votes || {}; if (ids.length) x.votes[me.id] = ids; else delete x.votes[me.id]; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
}
if (p === '/poll/close' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || !x.poll) throw fail(404, 'Poll not found'); if (x.from !== me.id) throw fail(403, 'Only the person who asked can close it'); x.poll.closed = true; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true }); }
if (p === '/typing' && req.method === 'POST') { const c = convOf(String(JSON.parse(await readBody(req, 1e4) || '{}').conv || ''), me); emit(c.members.filter(x => x !== me.id), { t: 'typing', conv: c.id, from: me.id, name: me.name }); return json(res, 200, { ok: true }); }
if (p === '/conv/ttl' && req.method === 'POST') { // disappearing messages: from now on, new messages go after this long
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), ttl = +b.ttl || 0; if (!TTLS.includes(ttl)) throw fail(400, 'Choose off, 1 hour, 1 day or 1 week');
if ((c.ttl || 0) !== ttl) { c.ttl = ttl; saveConvs(); addMsg(me, c, { sys: true, text: ttl ? `${me.name} set messages to disappear after ${ttlWords(ttl)}` : `${me.name} turned off disappearing messages` }); }
emit(c.members, { t: 'conv', conv: c.id }); return json(res, 200, { ok: true, ttl });
}
if (p === '/pin' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
c.pins = (c.pins || []).filter(id => id !== x.id); if (b.on) { c.pins.push(x.id); if (c.pins.length > 5) c.pins.shift(); } saveConvs(); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true, pins: c.pins });
}
if (p === '/forward' && req.method === 'POST') { // a copy of a message (and its files) into another chat
const b = JSON.parse(await readBody(req, 1e4) || '{}'), from = convOf(String(b.from || ''), me), to = convOf(String(b.to || ''), me), x = msgs(from.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found');
const att = (x.att || []).map(a => { const id = newId(); try { fs.copyFileSync(blob(a.id), blob(id)); } catch { return null; } files()[id] = { conv: to.id, by: me.id, name: a.name, type: a.type, size: a.size, ts: Date.now(), attached: true }; return { id, name: a.name, type: a.type, size: a.size, ...(a.voice ? { voice: true, wave: a.wave, dur: a.dur } : {}) }; }).filter(Boolean);
if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, fwd: { name: nameOf(x.from) } }) });
}
if (p === '/search' && req.method === 'GET') {
const needle = str(q.get('q'), 100).trim().toLowerCase(); if (needle.length < 2) return json(res, 200, { results: [] }); const out = [], st = stateOf(me.id);
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
return json(res, 200, { results: out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 40) });
}
return json(res, 404, { error: 'Not found' });
} catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
/** A child asks the parents (the admins) for something: a message in a direct chat with each of them. Returns how many were told. */
function askParents(me, text) {
let n = 0;
for (const a of users.all().filter(u => u.role === 'admin' && !u.disabled && u.id !== me.id)) {
const id = 'd:' + [me.id, a.id].sort().join(':'); let c = convs().find(x => x.id === id); if (!c) { c = { id, type: 'direct', members: [me.id, a.id].sort(), by: me.id, created: Date.now() }; convs().push(c); saveConvs(); }
addMsg(me, c, { text: str(text, 300) }); n++;
}
return n;
}
/** Messages in this person's chats that mention the words. */
function searchFor(me, q) {
const needle = q.toLowerCase(), out = [], st = stateOf(me.id);
for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); }
return out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 8);
}
module.exports = { announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor };
+134
View File
@@ -0,0 +1,134 @@
// Chores: jobs that repeat (daily, weekdays, on chosen days of the week, weekly, monthly) or happen once, given to one or more people or to "anyone". Each person ticks off their
// own; an admin can tick for someone. Finished chores earn points, which the family board adds up for the week and month. Kept in DATA_DIR/chores.json.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'chores.json');
const REPEATS = ['daily', 'weekdays', 'weekly', 'monthly', 'once'], MAX_CHORES = 300, 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);
const newId = () => crypto.randomBytes(6).toString('hex');
const ymdOk = s => /^\d{4}-\d{2}-\d{2}$/.test(s || '') && !Number.isNaN(Date.parse(s + 'T12:00:00Z'));
const utc = s => new Date(s + 'T12:00:00Z');
const ymd = d => d.toISOString().slice(0, 10);
const addDays = (s, n) => { const d = utc(s); d.setUTCDate(d.getUTCDate() + n); return ymd(d); };
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 : [] }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [] }; } })());
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'; };
const isKid = u => !!(u.kid && u.kid.on); // points and rewards are for child accounts only
const liveKid = uid => live(uid) && isKid(users.get(uid));
const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chores')).map(u => ({ id: u.id, name: u.name, board: u.board !== false, kid: isKid(u) }));
/** The period a chore is due in on this day (the key its completion is filed under), or null when it is not due. Weekly chores with no chosen days, monthly and one-off chores stay due until done. */
function dueKey(c, day) {
if (!c.active || (c.start && day < c.start)) return null; const wd = weekday(day);
switch (c.repeat) {
case 'daily': return day;
case 'weekdays': return wd >= 1 && wd <= 5 ? day : null;
case 'weekly': return c.days && c.days.length ? (c.days.includes(wd) ? day : null) : monday(day);
case 'monthly': { const dom = c.dom || 1, [y, m, d] = day.split('-').map(Number), last = new Date(Date.UTC(y, m, 0)).getUTCDate(); return d >= Math.min(dom, last) ? day.slice(0, 7) : null; }
case 'once': return c.date && day >= c.date ? 'once' : null;
default: return null;
}
}
const assignees = c => Array.isArray(c.assign) ? c.assign.filter(live) : [];
/** Everything due on a day, for everyone: one entry per person it is given to (or one for "anyone"), with who finished it. */
function board(day) {
const out = [];
for (const c of db().chores) {
const key = dueKey(c, day); if (!key) continue; const who = assignees(c);
const anyone = c.assign === 'anyone' || !who.length, claim = anyone ? db().claims.find(x => x.chore === c.id && x.key === key && live(x.user)) : null; // (someone has taken this one from the pool)
const targets = anyone ? [claim ? claim.user : null] : who;
for (const u of targets) {
const done = db().log.find(l => l.chore === c.id && l.key === key && (anyone || l.user === u));
if (c.repeat === 'once' && !done && c.date && c.date > day) continue; // (a one-off is shown from its day on)
out.push({ chore: c.id, key, title: c.title, notes: c.notes, points: c.points, repeat: c.repeat, user: u, userName: u ? nameOf(u) : 'Anyone', claimed: !!claim, claimable: anyone && !claim, done: done ? { by: done.by, byName: nameOf(done.by), at: done.at, pts: done.pts } : null, overdue: !done && c.repeat === 'once' && c.date < day });
}
}
return out.sort((a, b) => (a.done ? 1 : 0) - (b.done ? 1 : 0) || a.title.localeCompare(b.title));
}
function clean(b, old) {
const title = str(b.title, 120); if (!title) throw fail(400, 'Give the chore a name');
const repeat = REPEATS.includes(b.repeat) ? b.repeat : 'daily'; const c = { id: old ? old.id : newId(), title, notes: str(b.notes, 400), repeat, points: Math.max(0, Math.min(50, Math.round(+b.points) || 0)), active: b.active === false ? false : true, created: old ? old.created : Date.now(), start: ymdOk(b.start) ? b.start : (old ? old.start : undefined) };
c.assign = b.assign === 'anyone' ? 'anyone' : [...new Set((Array.isArray(b.assign) ? b.assign : []).map(String))].filter(live).slice(0, 20);
if (c.assign !== 'anyone' && !c.assign.length) c.assign = 'anyone';
if (repeat === 'weekly') c.days = [...new Set((Array.isArray(b.days) ? b.days : []).map(Number).filter(d => d >= 0 && d <= 6))].sort();
if (repeat === 'monthly') c.dom = Math.max(1, Math.min(31, Math.round(+b.dom) || 1));
if (repeat === 'once') { if (!ymdOk(b.date)) throw fail(400, 'Choose the day for this one-off chore'); c.date = b.date; }
return c;
}
/** Points and counts per person over a stretch of days ending on `end`. */
function summary(range, end) {
const days = range === 'month' ? 30 : 7, from = addDays(end, -(days - 1)), per = new Map(people().map(p => [p.id, { id: p.id, name: p.name, points: 0, count: 0, perDay: Array(days).fill(0) }]));
for (const l of db().log) { if (l.date < from || l.date > end) continue; const e = per.get(l.by) || (live(l.by) ? per.set(l.by, { id: l.by, name: nameOf(l.by), points: 0, count: 0, perDay: Array(days).fill(0) }).get(l.by) : null); if (!e) continue; e.points += l.pts || 0; e.count++; e.perDay[Math.round((utc(l.date) - utc(from)) / 864e5)]++; }
return { range, from, to: end, people: [...per.values()].sort((a, b) => b.points - a.points || b.count - a.count) };
}
/** One calendar week (Monday to Sunday): each person's points, the chores they did, which reward tiers they reached, and which rewards were handed over. Points start again each Monday; the history stays. */
const forMe = (t, uid) => !Array.isArray(t.for) || t.for.includes(uid); // a reward is for everyone, or for chosen people
function week(day) {
const from = monday(day), to = addDays(from, 6), d = db(), tiers = d.rewards.slice().sort((a, b) => a.points - b.points), per = new Map(people().filter(p => p.kid).map(p => [p.id, { id: p.id, name: p.name, points: 0, count: 0, done: [] }]));
for (const l of d.log) { if (l.date < from || l.date > to) continue; const e = per.get(l.by) || (liveKid(l.by) ? per.set(l.by, { id: l.by, name: nameOf(l.by), points: 0, count: 0, done: [] }).get(l.by) : null); if (!e) continue; const c = d.chores.find(x => x.id === l.chore); e.points += l.pts || 0; e.count++; e.done.push({ title: c ? c.title : 'A chore that was removed', date: l.date, pts: l.pts || 0 }); }
const out = [...per.values()].map(e => ({ ...e, done: e.done.sort((a, b) => a.date.localeCompare(b.date)), tiers: tiers.filter(t => forMe(t, e.id)).map(t => t.id), earned: tiers.filter(t => forMe(t, e.id) && e.points >= t.points).map(t => t.id), given: d.given.filter(g => g.week === from && g.user === e.id).map(g => g.reward) })).sort((a, b) => b.points - a.points || b.count - a.count || a.name.localeCompare(b.name));
return { from, to, tiers, people: out };
}
const cleanReward = (b, old) => { const title = str(b.title, 80); if (!title) throw fail(400, 'Say what the reward is'); const points = Math.round(+b.points); if (!(points >= 1 && points <= 1000)) throw fail(400, 'Choose the points needed (1 to 1000)'); const bonus = Math.max(0, Math.min(240, Math.round(+b.minutes) || 0)), r = { id: old ? old.id : newId(), points, title }; if (bonus) r.minutes = bonus; /* (optional: extra screen time that comes with the reward) */ const who = Array.isArray(b.for) ? [...new Set(b.for.map(String))].filter(liveKid).slice(0, 30) : null; if (who) { if (!who.length) throw fail(400, 'Choose who it is for (child accounts)'); r.for = who; } return r; };
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/chores'.length) || '/', me = users.current(), q = u.searchParams, admin = me.role === 'admin';
try {
let m;
const day = q.get('date') || ymd(new Date()); if (!ymdOk(day)) throw fail(400, 'Bad date');
if (p === '/' && req.method === 'GET') return json(res, 200, { me: { id: me.id, admin }, day, rewards: db().rewards.slice().sort((a, b) => a.points - b.points), people: people(), chores: db().chores, board: board(day).map(i => ({ ...i, mine: i.user === me.id || i.user === null, canDo: admin || i.user === me.id || i.user === null })) });
if (p === '/' && req.method === 'POST') {
if (!admin) throw fail(403, 'Only an admin can add or change chores'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), old = b.id ? db().chores.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Chore not found'); if (!old && db().chores.length >= MAX_CHORES) throw fail(400, 'That is plenty of chores');
const c = clean(b, old); if (old) db().chores[db().chores.indexOf(old)] = c; else db().chores.push(c); save(); return json(res, 200, c);
}
if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { if (!admin) throw fail(403, 'Only an admin can delete chores'); const d = db(); d.chores = d.chores.filter(x => x.id !== m[1]); d.log = d.log.filter(l => l.chore !== m[1]); d.claims = d.claims.filter(x => x.chore !== m[1]); save(); return json(res, 200, { ok: true }); }
if (p === '/done' && req.method === 'POST') { // tick a chore off (or take the tick 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');
const today = ymd(new Date()); if (date > addDays(today, 1) || date < addDays(today, -7)) throw fail(400, 'That day is out of range');
const key = dueKey(c, date); if (!key) throw fail(400, 'That chore is not due on that day');
const who = c.assign === 'anyone' ? null : String(b.user || me.id); if (who && !assignees(c).includes(who)) throw fail(400, 'That chore is not given to that person');
const taken = !who && db().claims.find(x => x.chore === c.id && x.key === key && live(x.user)); if (taken && taken.user !== me.id && !admin) throw fail(403, nameOf(taken.user) + ' took this one');
if (who && who !== me.id && !admin) throw fail(403, 'You can only tick off your own chores');
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 });
}
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');
if (!(c.assign === 'anyone' || !assignees(c).length)) throw fail(400, 'That chore is given to particular people'); const key = dueKey(c, date); if (!key) throw fail(400, 'That chore is not due on that day');
const d = db(), i = d.claims.findIndex(x => x.chore === c.id && x.key === key);
if (b.undo) { if (i < 0) return json(res, 200, { ok: true }); if (d.claims[i].user !== me.id && !admin) throw fail(403, 'Only the person who took it can put it back'); d.claims.splice(i, 1); save(); return json(res, 200, { ok: true }); }
if (d.log.some(l => l.chore === c.id && l.key === key)) throw fail(400, 'That one is already done');
if (i >= 0 && live(d.claims[i].user)) { if (d.claims[i].user === me.id) return json(res, 200, { ok: true, already: true }); throw fail(409, nameOf(d.claims[i].user) + ' already took this one'); }
if (i >= 0) d.claims.splice(i, 1); d.claims.push({ chore: c.id, key, user: me.id, at: Date.now() }); if (d.claims.length > 2000) d.claims.splice(0, d.claims.length - 2000); save(); return json(res, 200, { ok: true });
}
if (p === '/week' && req.method === 'GET') return json(res, 200, week(day));
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); 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 (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));
if (b.undo) { if (i >= 0) d.given.splice(i, 1); save(); return json(res, 200, { ok: true }); }
const w = week(wk), e = w.people.find(x => x.id === String(b.user)); if (!e || !e.earned.includes(String(b.reward))) throw fail(400, 'They have not earned that one'); if (i < 0) { d.given.push({ week: wk, user: e.id, reward: String(b.reward), at: Date.now(), by: me.id }); const rw = d.rewards.find(x => x.id === String(b.reward)); if (rw && rw.minutes) { try { require('./kidlimits').give(e.id, rw.minutes); } catch {} } } /* (a reward with bonus screen time adds it to today) */ if (d.given.length > 3000) d.given.splice(0, d.given.length - 3000); save(); return json(res, 200, { ok: true });
}
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 }); }
}
/** Chores whose name or notes mention the words. */
const find = q => db().chores.filter(c => (c.title + ' ' + c.notes).toLowerCase().includes(q)).map(c => ({ id: c.id, title: c.title, repeat: c.repeat, who: c.assign === 'anyone' ? 'Anyone' : assignees(c).map(nameOf).join(', '), points: c.points }));
module.exports = { handle, dueKey, board, summary, week, ymd, addDays, find };
+116
View File
@@ -0,0 +1,116 @@
// Contacts: each person's address book, kept in NE Hub. Mail uses it to suggest recipients, the calendar shows birthdays from it, and vCards (.vcf, the
// format iCloud, Google and phones export) can be imported and exported.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const MAX_CONTACTS = 5000, MAX_IMPORT = 8e6;
const file = uid => path.join(uid ? users.dirOf(uid) : users.dir(), 'contacts.json');
const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } };
const save = (l, uid) => atomicWrite(file(uid), JSON.stringify(l), { mode: 0o600 });
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const EMAIL = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
/** A birthday as "YYYY-MM-DD" (year known) or "--MM-DD" (year not known), or '' when it is not a real date. */
function cleanBirthday(v) {
const s = String(v || '').trim(); let m;
if ((m = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(s))) { const [, y, mo, d] = m; return real(+y, +mo, +d) ? `${y}-${mo}-${d}` : ''; }
if ((m = /^(?:--)?(\d{2})-?(\d{2})$/.exec(s))) { const [, mo, d] = m; return real(2000, +mo, +d) ? `--${mo}-${d}` : ''; }
return '';
}
const real = (y, mo, d) => { const t = new Date(Date.UTC(y, mo - 1, d)); return t.getUTCFullYear() === y && t.getUTCMonth() === mo - 1 && t.getUTCDate() === d; };
const list = (v, n, ok) => (Array.isArray(v) ? v : []).map(x => ({ label: str(x && x.label, 20) || 'other', value: str(x && x.value, 120) })).filter(x => x.value && (!ok || ok(x.value))).slice(0, n);
/** Whatever the browser or an import sent, reduced to the fields a contact has. */
function clean(b, old = {}) {
const c = {
id: old.id || crypto.randomBytes(6).toString('hex'), name: str(b.name, 120), org: str(b.org, 120),
emails: list(b.emails, 10, v => EMAIL.test(v)), phones: list(b.phones, 10), address: str(b.address, 300),
birthday: cleanBirthday(b.birthday), notes: str(b.notes, 2000), updated: Date.now(),
};
c.emails.forEach(e => { e.value = e.value.toLowerCase(); });
if (!c.name) c.name = c.emails[0] ? c.emails[0].value.split('@')[0] : c.org;
if (!c.name) throw fail(400, 'Give the contact a name or an email address');
return c;
}
// ---- vCards
const unesc = s => String(s).replace(/\\([nN,;\\])/g, (m, c) => c === 'n' || c === 'N' ? '\n' : c);
const esc = s => String(s || '').replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;');
/** The contacts in a vCard file (versions 2.1, 3.0 and 4.0; photos and other extras are left out). */
function parseVcf(text) {
const lines = []; for (const raw of String(text).replace(/\r\n?/g, '\n').split('\n')) { if (/^[ \t]/.test(raw) && lines.length) lines[lines.length - 1] += raw.slice(1); else lines.push(raw); }
const out = []; let cur = null;
for (const line of lines) {
if (/^BEGIN:VCARD/i.test(line)) { cur = { emails: [], phones: [] }; continue; }
if (/^END:VCARD/i.test(line)) { if (cur) out.push(cur); cur = null; continue; }
if (!cur) continue;
const i = line.indexOf(':'); if (i < 1) continue;
const head = line.slice(0, i).split(';'), name = head[0].replace(/^.*\./, '').toUpperCase(), params = head.slice(1).join(';'), val = line.slice(i + 1);
const label = ((/TYPE=([^;:]+)/i.exec(params) || /(?:^|;)(home|work|cell|mobile|fax|pref)(?:;|$)/i.exec(params) || [])[1] || 'other').split(',').filter(x => !/^(pref|internet|voice)$/i.test(x))[0] || 'other';
if (name === 'FN') cur.name = unesc(val);
else if (name === 'N' && !cur.n) { const p = val.split(';').map(unesc); cur.n = [p[1], p[0]].filter(Boolean).join(' '); }
else if (name === 'ORG') cur.org = unesc(val.split(';')[0]);
else if (name === 'EMAIL') cur.emails.push({ label: label.toLowerCase(), value: unesc(val) });
else if (name === 'TEL') cur.phones.push({ label: label.toLowerCase(), value: unesc(val).replace(/^tel:/i, '') });
else if (name === 'ADR') cur.address = val.split(';').map(unesc).filter(Boolean).join(', ');
else if (name === 'BDAY') cur.birthday = val.replace(/T.*$/, '');
else if (name === 'NOTE') cur.notes = unesc(val);
}
return out.map(c => ({ ...c, name: c.name || c.n || '' }));
}
function toVcf(l) {
return l.map(c => ['BEGIN:VCARD', 'VERSION:3.0', 'FN:' + esc(c.name), 'N:;' + esc(c.name) + ';;;', c.org && 'ORG:' + esc(c.org),
...c.emails.map(e => `EMAIL;TYPE=${/^(home|work)$/.test(e.label) ? e.label.toUpperCase() : 'INTERNET'}:${esc(e.value)}`),
...c.phones.map(p => `TEL;TYPE=${/^(home|work|cell|fax)$/.test(p.label) ? p.label.toUpperCase() : 'VOICE'}:${esc(p.value)}`),
c.address && 'ADR:;;' + esc(c.address) + ';;;;', c.birthday && 'BDAY:' + (c.birthday.startsWith('--') ? c.birthday.replace(/-/g, '').replace(/^/, '--') : c.birthday.replace(/-/g, '')),
c.notes && 'NOTE:' + esc(c.notes), 'END:VCARD'].filter(Boolean).join('\r\n')).join('\r\n') + (l.length ? '\r\n' : '');
}
/** Birthdays as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Repeats every year. */
function birthdayEvents(win, uid) {
const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4);
for (const c of load(uid)) {
if (!c.birthday) continue;
const [mo, d] = c.birthday.startsWith('--') ? [c.birthday.slice(2, 4), c.birthday.slice(5, 7)] : [c.birthday.slice(5, 7), c.birthday.slice(8, 10)], born = c.birthday.startsWith('--') ? 0 : +c.birthday.slice(0, 4);
for (let y = y0; y <= y1; y++) {
const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue;
const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10);
out.push({ title: `${c.name}'s birthday${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: 'birthday:' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color: '#d9578a', editable: false, contactId: c.id });
}
}
return out;
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/contacts'.length) || '/';
try {
if (p === '/' && req.method === 'GET') return json(res, 200, load().sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })));
if (p === '/' && req.method === 'POST') { // add or change one
const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = load(), old = b.id ? l.find(x => x.id === b.id) : null;
if (b.id && !old) throw fail(404, 'Contact not found');
if (!old && l.length >= MAX_CONTACTS) throw fail(400, 'That is plenty of contacts');
const c = clean(b, old || {}); if (old) l[l.indexOf(old)] = c; else l.push(c);
save(l); return json(res, 200, c);
}
if (p === '/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); save(load().filter(x => x.id !== id)); return json(res, 200, { ok: true }); }
if (p === '/import' && req.method === 'POST') { // a .vcf file's text: new people are added, and ones with the same email address are filled in rather than doubled
const b = JSON.parse(await readBody(req, MAX_IMPORT) || '{}'), l = load(); let added = 0, merged = 0, skipped = 0;
for (const raw of parseVcf(b.vcf || '')) {
let c; try { c = clean(raw); } catch { skipped++; continue; }
const have = l.find(x => x.emails.some(e => c.emails.some(f => f.value === e.value))) || (!c.emails.length && l.find(x => x.name.toLowerCase() === c.name.toLowerCase()));
if (have) { for (const k of ['org', 'address', 'birthday', 'notes']) if (!have[k] && c[k]) have[k] = c[k]; for (const k of ['emails', 'phones']) for (const x of c[k]) if (!have[k].some(y => y.value.toLowerCase() === x.value.toLowerCase())) have[k].push(x); merged++; }
else if (l.length < MAX_CONTACTS) { l.push(c); added++; } else skipped++;
}
save(l); return json(res, 200, { added, merged, skipped });
}
if (p === '/export' && req.method === 'GET') {
const body = toVcf(load()); res.writeHead(200, { 'content-type': 'text/vcard; charset=utf-8', 'content-disposition': 'attachment; filename="contacts.vcf"', 'cache-control': 'no-store' }); return res.end(body);
}
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status || 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, load, parseVcf, toVcf, birthdayEvents, clean, cleanBirthday };
+13
View File
@@ -0,0 +1,13 @@
// Trusted devices: a browser that is remembered on the sign-in page, so it shows name tiles. A family device (kind 'family') is a shared screen that opens
// on the family board and signs people out quickly; a 'trusted' one is a person's own device. Family devices: shared screens (a kitchen tablet, the family computer) that open on the family board instead of a sign-in. An admin makes a device one
// while signed in on it; the device keeps a signed cookie, and this list is what lets an admin switch one off again. Kept in DATA_DIR/devices.json.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'devices.json');
const load = () => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j.devices) ? j.devices : []; } catch { return []; } };
const save = l => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify({ devices: l }), { mode: 0o600 }); };
const has = id => load().some(d => d.id === id);
const get = id => load().find(d => d.id === id) || null;
const add = (name, by, kind = 'family', byId = '') => { const d = { id: crypto.randomBytes(9).toString('base64url'), name: String(name || (kind === 'family' ? 'Family device' : 'My device')).slice(0, 40), by, byId, kind, created: Date.now() }; save([...load(), d]); return d; };
const remove = id => save(load().filter(d => d.id !== id));
module.exports = { load, has, get, add, remove };
+20
View File
@@ -22,8 +22,15 @@ services:
MS_CLIENT_ID: ${MS_CLIENT_ID:-} # Outlook.com mail (Microsoft app registration, see README -> Mail); leave empty to switch Mail off
MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-}
LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it
TRANSCRIBE_API_URL: http://127.0.0.1:8000 # the bundled speech-to-text service (whisper, below): makes a transcript for a podcast or audiobook on request. Remove this line and the service to skip it
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
OPENAUDIBLE_URL: ${OPENAUDIBLE_URL:-} # audiobooks straight from OpenAudible (e.g. http://172.16.16.106:8000); the username and password in Settings -> Audiobooks sign-in are OpenAudible's. Leave empty to go through the audiobook player instead
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
PODGRAB_PASSWORD: ${PODGRAB_PASSWORD:-} # Podgrab's PASSWORD setting, if it has one (the sign-in name is always podgrab); stays on the server
PODCASTINDEX_KEY: ${PODCASTINDEX_KEY:-} # optional: a free key from https://api.podcastindex.org (with the secret below) adds Podcast Index to podcast search and browsing by topic
PODCASTINDEX_SECRET: ${PODCASTINDEX_SECRET:-}
MEALIE_URL: ${MEALIE_URL:-https://mealie.necloud.us} # Recipes tab: your Mealie server, and an API token (Mealie: your profile -> API tokens); the token stays on the server
MEALIE_TOKEN: ${MEALIE_TOKEN:-}
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
@@ -46,6 +53,17 @@ services:
volumes:
- signal-data:/home/.local/share/signal-cli
# Speech to text (the "Transcribe" button on podcasts and audiobooks). faster-whisper behind an OpenAI-compatible API (the speaches project); the model
# is downloaded the first time it is used. CPU only: a one-hour episode takes roughly 5-15 minutes with the small model. Nothing leaves your server.
whisper:
image: ghcr.io/speaches-ai/speaches:latest-cpu
restart: unless-stopped
network_mode: "service:hub" # reached by the hub at 127.0.0.1:8000 and by nothing else
depends_on:
- hub
volumes:
- whisper-models:/home/ubuntu/.cache/huggingface/hub
# Lyrics and karaoke for the music player. Lyrica looks songs up on public lyrics sources (LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz);
# the song's artist and title are sent to them. Plex's own lyrics are used first when the song has them. See README -> Music.
lyrica:
@@ -63,3 +81,5 @@ volumes:
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
signal-data:
name: nehub-signal
whisper-models:
name: nehub-whisper
+32
View File
@@ -0,0 +1,32 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Kitchen</title>
<style>
:root{--bg:#fff;--fg:#1b1e24;--mut:#6b7280;--line:#e0e3e9;--acc:#e0742b;--card:#f4f5f8}@media(prefers-color-scheme:dark){:root{--bg:#181b21;--fg:#eceef2;--mut:#8b93a3;--line:#2a2f38;--acc:#f0883e;--card:#20242c}}
*{box-sizing:border-box}body{margin:0;padding:14px;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg);max-width:720px}h2{margin:18px 0 8px}h2:first-child{margin-top:0}
input,select,textarea,button{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px}button{cursor:pointer}button.p{background:var(--acc);color:#fff;border-color:var(--acc)}
.r{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:6px 0}textarea{width:100%;height:150px}pre{background:var(--card);border-radius:8px;padding:10px;white-space:pre-wrap;margin:8px 0}.m{color:var(--mut);font-size:13px}
</style></head><body>
<h2>Convert</h2>
<div class="r"><input id="v" type="number" step="any" value="1" style="width:110px"><select id="from"></select><span>to</span><select id="to"></select></div><div id="out" style="font-size:22px;font-weight:600"></div>
<h2>Scale a recipe</h2>
<div class="r"><span>From</span><input id="a" type="number" min="1" value="4" style="width:70px"><span>servings to</span><input id="b" type="number" min="1" value="6" style="width:70px"></div>
<textarea id="t" placeholder="Paste ingredients, one per line&#10;2 cups flour&#10;1 1/2 tsp salt&#10;3/4 cup sugar"></textarea>
<div class="r"><button class="p" id="go">Scale</button><button id="sl" disabled>Add to my Shopping list</button></div><pre id="res" hidden></pre><div class="m" id="msg"></div>
<script>
var U={cup:['Volume',236.588],tbsp:['Volume',14.787],tsp:['Volume',4.929],ml:['Volume',1],l:['Volume',1000],'fl oz':['Volume',29.574],pint:['Volume',473.176],quart:['Volume',946.353],
g:['Weight',1],kg:['Weight',1000],oz:['Weight',28.3495],lb:['Weight',453.592]};
var opts=Object.keys(U).map(function(k){return '<option>'+k+'</option>'}).join('')+'<option>F</option><option>C</option>';
from.innerHTML=opts;to.innerHTML=opts;from.value='cup';to.value='ml';
function conv(){var x=parseFloat(v.value),f=from.value,t=to.value,o;if(isNaN(x)){out.textContent='';return}
if((f==='F'||f==='C')&&(t==='F'||t==='C'))o=f===t?x:f==='F'?(x-32)*5/9:x*9/5+32;
else if(U[f]&&U[t]&&U[f][0]===U[t][0])o=x*U[f][1]/U[t][1];else{out.textContent='Cannot convert '+(U[f]?U[f][0]:'temperature')+' to '+(U[t]?U[t][0]:'temperature').toLowerCase();return}
out.textContent=x+' '+f+' = '+(Math.round(o*100)/100)+' '+t}
[v,from,to].forEach(function(e){e.oninput=conv});conv();
var FR={'½':.5,'¼':.25,'¾':.75,'⅓':1/3,'⅔':2/3,'⅛':.125};
function num(s){s=s.trim();var m;if((m=/^(\d+)\s+(\d+)\/(\d+)$/.exec(s)))return +m[1]+m[2]/m[3];if((m=/^(\d+)\/(\d+)$/.exec(s)))return m[1]/m[2];if((m=/^(\d*)\s*([½¼¾⅓⅔⅛])$/.exec(s)))return (+m[1]||0)+FR[m[2]];return /^\d*\.?\d+$/.test(s)?parseFloat(s):null}
function show(n){var w=Math.floor(n+1e-9),f=n-w,names=[[0,''],[.125,'1/8'],[.25,'1/4'],[1/3,'1/3'],[.5,'1/2'],[2/3,'2/3'],[.75,'3/4'],[1,'']],best=names[0];names.forEach(function(c){if(Math.abs(c[0]-f)<Math.abs(best[0]-f))best=c});
if(Math.abs(best[0]-f)>0.04)return String(Math.round(n*100)/100);if(best[0]===1){w++;best=names[0]}return (w?w+(best[1]?' ':''):'')+best[1]||'0'}
var lines=[];
go.onclick=function(){var k=(+b.value)/(+a.value);if(!(k>0))return;lines=t.value.split('\n').map(function(l){return l.trim()}).filter(Boolean).map(function(l){var m=/^((?:\d+\s+)?\d+\/\d+|\d*\s*[½¼¾⅓⅔⅛]|\d*\.?\d+)(.*)$/.exec(l);if(!m)return l;var n=num(m[1]);return n==null?l:show(n*k)+m[2]});
res.hidden=false;res.textContent=lines.join('\n');sl.disabled=!lines.length;msg.textContent='Scaled by '+(Math.round(k*100)/100)+'x'};
sl.onclick=function(){sl.disabled=true;(function next(i){if(i>=lines.length){hub.toast('Added '+lines.length+' items to Shopping');sl.disabled=false;return}hub.tasks.add({list:'Shopping',title:lines[i]}).then(function(){next(i+1)},function(e){hub.toast(e.message);sl.disabled=false})})(0)};
</script></body></html>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html><html><head><meta charset="utf-8"><title>Notepad</title><style>body{font-family:sans-serif;margin:12px}textarea{width:100%;height:200px}</style></head><body><h3>Notepad</h3><textarea id="t"></textarea><p id="who"></p><script>
hub.user().then(function(u){document.getElementById('who').textContent='Hello '+u.name});
hub.storage.get('note').then(function(v){document.getElementById('t').value=v||''});
document.getElementById('t').oninput=function(e){hub.storage.set('note',e.target.value)};
</script></body></html>
+42
View File
@@ -0,0 +1,42 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Notes</title>
<style>
:root{--bg:#fff;--fg:#1b1e24;--mut:#6b7280;--line:#e0e3e9;--acc:#3d6bf0;--card:#f4f5f8}@media(prefers-color-scheme:dark){:root{--bg:#181b21;--fg:#eceef2;--mut:#8b93a3;--line:#2a2f38;--acc:#6a8cff;--card:#20242c}}
*{box-sizing:border-box}body{margin:0;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg);display:flex;height:100vh}
#side{width:240px;border-right:1px solid var(--line);display:flex;flex-direction:column}#side header{padding:10px;display:flex;gap:6px}
input,textarea,button{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px}button{cursor:pointer}button.p{background:var(--acc);color:#fff;border-color:var(--acc)}
#list{overflow:auto;flex:1}.n{padding:10px 12px;border-bottom:1px solid var(--line);cursor:pointer}.n.on{background:var(--card)}.n b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.n small{color:var(--mut)}
#main{flex:1;padding:12px;overflow:auto;min-width:0}#t{width:100%;font-size:20px;font-weight:600;margin-bottom:8px}#b{width:100%;height:60vh}
.it{display:flex;gap:8px;align-items:center;margin:4px 0}.it input[type=text]{flex:1}.it.d input[type=text]{text-decoration:line-through;color:var(--mut)}.bar{display:flex;gap:6px;margin:8px 0;flex-wrap:wrap}
@media(max-width:560px){body{flex-direction:column}#side{width:100%;height:38vh;border-right:0;border-bottom:1px solid var(--line)}}
</style></head><body>
<div id="side"><header><input id="q" placeholder="Search" style="flex:1;min-width:0"><button class="p" id="nn" title="New note">+ Note</button><button id="nl" title="New checklist">+ List</button></header><div id="list"></div></div>
<div id="main"><p style="color:var(--mut)">Pick a note, or make a new one.</p></div>
<script>
var notes=[],cur=null,q='',timer;
function id(){return Math.random().toString(36).slice(2,10)}
function save(){clearTimeout(timer);timer=setTimeout(function(){hub.storage.set('notes',notes)},400)}
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function drawList(){var l=notes.filter(function(n){var t=(n.title+' '+(n.body||'')+' '+(n.items||[]).map(function(i){return i.t}).join(' ')).toLowerCase();return !q||t.indexOf(q)>=0}).sort(function(a,b){return b.updated-a.updated});
document.getElementById('list').innerHTML=l.map(function(n){var sub=n.type==='list'?(n.items.filter(function(i){return i.done}).length+' of '+n.items.length+' done'):(n.body||'').slice(0,40);return '<div class="n'+(cur&&cur.id===n.id?' on':'')+'" data-i="'+n.id+'"><b>'+esc(n.title||'Untitled')+'</b><small>'+esc(sub)+'</small></div>'}).join('')||'<p style="padding:12px;color:var(--mut)">No notes.</p>';
[].forEach.call(document.querySelectorAll('.n'),function(e){e.onclick=function(){cur=notes.filter(function(n){return n.id===e.dataset.i})[0];drawList();drawMain()}})}
function touch(){cur.updated=Date.now();save()}
function drawMain(){var m=document.getElementById('main');if(!cur){m.innerHTML='<p style="color:var(--mut)">Pick a note, or make a new one.</p>';return}
var h='<input id="t" placeholder="Title" value="'+esc(cur.title)+'">';
if(cur.type==='list'){h+='<div id="items">'+cur.items.map(function(it,i){return '<div class="it'+(it.done?' d':'')+'"><input type="checkbox" data-c="'+i+'"'+(it.done?' checked':'')+'><input type="text" data-t="'+i+'" value="'+esc(it.t)+'"><button data-x="'+i+'">x</button></div>'}).join('')+'</div><div class="bar"><button id="ai">+ Item</button><button id="cl">Clear done</button><button id="ts" title="Add the open items to your Tasks">Send open items to Tasks</button></div>'}
else h+='<textarea id="b" placeholder="Write here">'+esc(cur.body||'')+'</textarea><div class="bar"><button id="tt" title="Add this note to your Tasks as a task">Make a task from the title</button></div>';
h+='<div class="bar"><button id="dl">Delete</button></div>';m.innerHTML=h;
document.getElementById('t').oninput=function(e){cur.title=e.target.value;touch();drawList()};
var b=document.getElementById('b');if(b)b.oninput=function(e){cur.body=e.target.value;touch()};
[].forEach.call(m.querySelectorAll('[data-c]'),function(e){e.onchange=function(){cur.items[+e.dataset.c].done=e.checked;touch();drawMain();drawList()}});
[].forEach.call(m.querySelectorAll('[data-t]'),function(e){e.oninput=function(){cur.items[+e.dataset.t].t=e.value;touch()};e.onkeydown=function(k){if(k.key==='Enter'){cur.items.splice(+e.dataset.t+1,0,{t:'',done:false});touch();drawMain();var x=m.querySelector('[data-t="'+(+e.dataset.t+1)+'"]');if(x)x.focus()}}});
[].forEach.call(m.querySelectorAll('[data-x]'),function(e){e.onclick=function(){cur.items.splice(+e.dataset.x,1);touch();drawMain();drawList()}});
var ai=document.getElementById('ai');if(ai)ai.onclick=function(){cur.items.push({t:'',done:false});touch();drawMain();var a=m.querySelectorAll('[data-t]');a[a.length-1].focus()};
var cl=document.getElementById('cl');if(cl)cl.onclick=function(){cur.items=cur.items.filter(function(i){return !i.done});touch();drawMain();drawList()};
var ts=document.getElementById('ts');if(ts)ts.onclick=function(){var open=cur.items.filter(function(i){return !i.done&&i.t.trim()});ts.disabled=true;(function next(k){if(k>=open.length){hub.toast('Sent '+open.length+' to Tasks');ts.disabled=false;return}hub.tasks.add({title:open[k].t}).then(function(){next(k+1)},function(e){hub.toast(e.message);ts.disabled=false})})(0)};
var tt=document.getElementById('tt');if(tt)tt.onclick=function(){hub.tasks.add({title:cur.title||'Untitled',notes:cur.body||''}).then(function(){hub.toast('Added to Tasks')},function(e){hub.toast(e.message)})};
document.getElementById('dl').onclick=function(){if(confirm('Delete this?')){notes=notes.filter(function(n){return n!==cur});cur=null;save();drawList();drawMain()}}}
function add(type){var n={id:id(),type:type,title:'',body:'',items:type==='list'?[{t:'',done:false}]:[],updated:Date.now()};notes.push(n);cur=n;save();drawList();drawMain();document.getElementById('t').focus()}
document.getElementById('nn').onclick=function(){add('note')};document.getElementById('nl').onclick=function(){add('list')};
document.getElementById('q').oninput=function(e){q=e.target.value.toLowerCase();drawList()};
hub.storage.get('notes').then(function(v){notes=Array.isArray(v)?v:[];drawList()});
</script></body></html>
+23
View File
@@ -0,0 +1,23 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Habits</title>
<style>
:root{--bg:#fff;--fg:#1b1e24;--mut:#6b7280;--line:#e0e3e9;--acc:#2f9e6e;--card:#f4f5f8}@media(prefers-color-scheme:dark){:root{--bg:#181b21;--fg:#eceef2;--mut:#8b93a3;--line:#2a2f38;--acc:#3dbb85;--card:#20242c}}
*{box-sizing:border-box}body{margin:0;padding:14px;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg)}h2{margin:0 0 10px}
input,button{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px}button{cursor:pointer}button.p{background:var(--acc);color:#fff;border-color:var(--acc)}
.h{background:var(--card);border-radius:12px;padding:12px;margin:0 0 12px}.top{display:flex;gap:8px;align-items:center}.top b{flex:1}.streak{color:var(--mut);font-size:13px}
.g{display:grid;grid-template-columns:repeat(14,1fr);gap:3px;margin-top:10px}.g i{aspect-ratio:1;border-radius:3px;background:var(--line)}.g i.y{background:var(--acc)}.g i.t{outline:2px solid var(--fg)}
</style></head><body><h2>Habits</h2><div id="hs"></div>
<form id="f" style="display:flex;gap:6px"><input id="nh" placeholder="New habit (for example Read the Bible)" style="flex:1" required><button class="p">Add</button></form>
<script>
var habits=[],timer;
function save(){clearTimeout(timer);timer=setTimeout(function(){hub.storage.set('habits',habits)},300)}
function key(d){var m=d.getMonth()+1,x=d.getDate();return d.getFullYear()+'-'+(m<10?'0':'')+m+'-'+(x<10?'0':'')+x}
function back(n){var d=new Date();d.setDate(d.getDate()-n);return key(d)}
function streak(h){var s=0,i=h.days[back(0)]?0:1;while(h.days[back(i)]){s++;i++}return s}
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function draw(){document.getElementById('hs').innerHTML=habits.map(function(h,i){var cells='';for(var k=27;k>=0;k--){var d=back(k);cells+='<i class="'+(h.days[d]?'y':'')+(k===0?' t':'')+'" title="'+d+'"></i>'}
var done=h.days[back(0)];return '<div class="h"><div class="top"><b>'+esc(h.name)+'</b><span class="streak">'+streak(h)+' day streak</span><button class="'+(done?'':'p')+'" data-t="'+i+'">'+(done?'Done today':'Check in')+'</button><button data-x="'+i+'" title="Delete">x</button></div><div class="g">'+cells+'</div></div>'}).join('')||'<p style="color:var(--mut)">Add a habit to start a streak.</p>';
[].forEach.call(document.querySelectorAll('[data-t]'),function(b){b.onclick=function(){var h=habits[+b.dataset.t],t=back(0);if(h.days[t])delete h.days[t];else h.days[t]=1;save();draw()}});
[].forEach.call(document.querySelectorAll('[data-x]'),function(b){b.onclick=function(){if(confirm('Delete this habit and its history?')){habits.splice(+b.dataset.x,1);save();draw()}}})}
document.getElementById('f').onsubmit=function(e){e.preventDefault();var v=document.getElementById('nh').value.trim();if(v){habits.push({name:v,days:{}});document.getElementById('nh').value='';save();draw()}};
hub.storage.get('habits').then(function(v){habits=Array.isArray(v)?v:[{name:'Read the Bible',days:{}}];draw()});
</script></body></html>
+24
View File
@@ -0,0 +1,24 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Today</title>
<style>
:root{--bg:#fff;--fg:#1b1e24;--mut:#6b7280;--line:#e0e3e9;--acc:#3d6bf0;--card:#f4f5f8}@media(prefers-color-scheme:dark){:root{--bg:#181b21;--fg:#eceef2;--mut:#8b93a3;--line:#2a2f38;--acc:#6a8cff;--card:#20242c}}
*{box-sizing:border-box}body{margin:0;padding:14px;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg);max-width:720px}
input,button{font:inherit;color:inherit;background:var(--card);border:1px solid var(--line);border-radius:8px;padding:8px}button{cursor:pointer}
.c{background:var(--card);border-radius:12px;padding:14px;margin:0 0 12px}.big{font-size:40px;font-weight:600}.d{display:flex;gap:8px;overflow:auto}.d div{min-width:78px;text-align:center;background:var(--bg);border-radius:10px;padding:8px}.m{color:var(--mut);font-size:13px}blockquote{margin:0;font-size:17px;line-height:1.5}
</style></head><body>
<div class="c"><form id="f" style="display:flex;gap:6px"><input id="city" placeholder="City (for example Lynchburg)" style="flex:1"><button>Set</button></form><div id="w" class="m" style="margin-top:10px">Pick a city for the weather.</div></div>
<div class="c"><div class="m">Verse of the day</div><blockquote id="verse">Loading…</blockquote><div class="m" id="ref" style="margin-top:6px"></div></div>
<script>
var CODES={0:'Clear',1:'Mostly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Fog',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',80:'Showers',81:'Showers',82:'Heavy showers',95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm'};
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function today(){var d=new Date();return d.getFullYear()+'-'+(d.getMonth()+1)+'-'+d.getDate()}
function weather(p){document.getElementById('w').textContent='Loading…';
fetch('https://api.open-meteo.com/v1/forecast?latitude='+p.lat+'&longitude='+p.lon+'&current=temperature_2m,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&wind_speed_unit=mph&timezone=auto&forecast_days=6').then(function(r){return r.json()}).then(function(j){
var c=j.current,d=j.daily;document.getElementById('w').innerHTML='<div class="m">'+esc(p.name)+'</div><div class="big">'+Math.round(c.temperature_2m)+'&deg;F</div><div>'+(CODES[c.weather_code]||'')+', wind '+Math.round(c.wind_speed_10m)+' mph</div><div class="d" style="margin-top:10px">'+d.time.map(function(t,i){return '<div><div class="m">'+(i?new Date(t+'T12:00').toLocaleDateString([], {weekday:'short'}):'Today')+'</div><div>'+Math.round(d.temperature_2m_max[i])+'&deg;</div><div class="m">'+Math.round(d.temperature_2m_min[i])+'&deg;</div></div>'}).join('')+'</div>'}).catch(function(){document.getElementById('w').textContent='Could not load the weather.'})}
document.getElementById('f').onsubmit=function(e){e.preventDefault();var q=document.getElementById('city').value.trim();if(!q)return;
fetch('https://geocoding-api.open-meteo.com/v1/search?count=1&name='+encodeURIComponent(q)).then(function(r){return r.json()}).then(function(j){var g=j.results&&j.results[0];if(!g){document.getElementById('w').textContent='City not found.';return}
var p={name:g.name+(g.admin1?', '+g.admin1:''),lat:g.latitude,lon:g.longitude};hub.storage.set('place',p);weather(p)})};
hub.storage.get('place').then(function(p){if(p)weather(p)});
hub.storage.get('verse').then(function(v){if(v&&v.day===today()){showV(v);return}
fetch('https://bible-api.com/?random=verse').then(function(r){return r.json()}).then(function(j){var n={day:today(),text:String(j.text||'').trim(),ref:j.reference||''};hub.storage.set('verse',n);showV(n)}).catch(function(){document.getElementById('verse').textContent='Could not load a verse today.'})});
function showV(v){document.getElementById('verse').textContent=v.text;document.getElementById('ref').textContent=v.ref}
</script></body></html>
+93
View File
@@ -0,0 +1,93 @@
# NE Hub 2.0 roadmap
A family-first hub. Status: `[ ]` planned, `[~]` in progress, `[x]` done. Size: S (a day or less), M (a few days), L (a week or more).
Decided: no photos tab, no voice or video calling (text messaging only).
## 1. Family Chat (a built-in messenger; text only)
A messenger inside the hub for the family, so family chats do not need Signal. Every hub account is a contact, and a "Family" group exists from the start. Family chats and Signal chats are meant to show in one inbox (labelled by source; not built yet: for now Chat and Signal are separate tabs); people outside the family still use Signal. Messages are kept on the family's own server.
**Phase 1: the basics (L): done (chat.js, public/chat-view.js; the Chat tab)**
- [x] Direct chats and group chats (names, members, add and remove people)
- [x] Text with links, emoji and line breaks
- [x] Attachments: photos, video, files (stored on the server)
- [x] Replies (quote), reactions
- [x] Unread counts, search, drafts that survive closing the app
- [x] Live delivery in the open app, push notifications in the background
- [x] Mute and archive per chat
**Phase 2: feels like Signal (M): done**
- [x] Edit and delete for everyone
- [x] Sent, delivered and read ticks
- [x] Typing indicators and online presence
- [x] Disappearing messages (a timer per chat)
- [x] @mentions in groups, pinned messages, forward a message
**Phase 3: richer messages (M): done except the GIF picker and shared places**
- [x] Voice notes (record and play, with a waveform)
- [x] Link previews (fetched safely by the server)
- [x] Polls
- [ ] GIF or sticker picker (optional)
- [ ] Share a place as a link
**Family extras (M): mostly done**
- [x] Turn a message into a task, an event or a shopping item in one tap
- [x] Share a song, recipe, book or podcast episode from the hub into a chat
- [x] Pinned family announcement on Home ("dinner at 6")
- [~] Kid accounts can only message family (already true: only hub accounts can chat); quiet hours are set by each person for themselves (a parent setting them for a child is not built); sharing so far covers recipes and songs, not books or podcast episodes
**Privacy (decide first)**
- [ ] Open question: server-side storage on the family's own machine to start (TLS + encrypted disk), or end-to-end encryption from day one? Starting without E2EE and adding it later is much harder than choosing now.
- [~] Per-person notification settings (mute, quiet hours; the push kind can be turned off in the reminders settings)
Not planned: voice and video calls, stories, usernames and phone-number lookup, sealed sender.
## 2. Family core
- [x] **Family Home (M):** one shared start page: who is around, today's family calendar, shared shopping list and chores, what everyone is listening to or reading **Done: the Family page: calendar, chores, shopping, notes, chat and tasks on one start page.**
- [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.**
- [ ] **Family calendar (M):** a colour per person, a shared week view, birthdays from Contacts, event invitations inside the family
- [ ] **Meal planning (M):** a weekly plan from Recipes that builds the shopping list (and can flag what is already in the pantry)
- [ ] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list
## 3. Kids and safety
- [x] **Kid profiles (M):** tabs limited per child, content filters (the Smashwords-style filter, Store, podcasts and music in a clean mode), screen-time limits **Done: child accounts get a clean Store, podcasts and daily time limit and bedtime (kidlimits.js); tabs per child already existed.**
- [ ] **Approvals (S):** a child asks to add a book, app or extension and a parent approves
- [x] **Family device and profile picker (M):** a shared screen opens on the family board; kids tap their name, everyone else enters a PIN; it returns to the board when idle **Done: board.js, devices.js**
- [ ] **Simple mode (S):** a big-button layout with few tabs for young kids or grandparents
## 4. Everyday convenience
- [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.**
- [ ] **Continue row (S):** the last audiobook, episode, book, movie and song on Home
- [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.**
- [ ] **Tasks 2.0 (M):** projects, subtasks everywhere, due-time reminders, Today and Upcoming views
- [ ] **Mail 2.0 (L):** unified inbox, snooze, templates, attachments saved to Files
- [ ] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas
## 5. Media
- [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote
- [ ] **Podcast and audiobook stats (S):** listening history like Music has
- [ ] **Reading goals (S):** a family reading challenge with streaks; export notes and highlights
- [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.**
- [ ] **Casting (M):** pick AirPlay or Cast targets for audio
## 6. Reliability (the admin's side)
- [ ] **Browser smoke tests in CI (M):** many bugs this year showed only in the interface
- [ ] **Admin health page (S):** all good or something is down: Seafile, Plex, Mealie, mail, storage
- [ ] **Backup and restore screen (S):** one-click restore, per-person export
- [ ] **Offline mode (L):** tasks, lists, recipes, saved books and music keep working with no signal (worth it if the family travels)
- [ ] **Performance pass (S):** lazy-load the big pieces (the Bible text, the reader libraries)
- [ ] **Accessibility (M):** larger text and high-contrast settings, keyboard navigation everywhere, reduced motion
## 7. Platform (later)
- [ ] **Extensions 2.0 (L):** more hub data in the bridge (notes, music, recipes), a permissions screen, extension updates
- [ ] **Automations (L):** "when X then Y" rules (a new episode adds a task, a low pantry item adds to Shopping)
- [ ] **Themes (S):** accent colours and an AMOLED dark mode per person
## Suggested order
1. Family Chat phase 1, Family Home, Shared lists and notes, Chores
2. Kid profiles, Search everywhere, Notifications center, Family Chat phase 2
3. Meal planning, Family calendar, Family Chat phase 3 and family extras
4. Reliability items alongside everything, so each step stays safe to ship
## Done already (for reference, 1.x)
Store (books, audiobooks, radio, My Store, extensions), Recipes with a shared shopping list, Contacts, Music (Featured, Mixes, Favorites, Stats, smart and Family playlists), podcast discovery and transcript search, Smashwords with a family filter, and the sidebar and Radio fixes.
+24
View File
@@ -0,0 +1,24 @@
// One list of genres for books and audiobooks, with how each source says it: Project Gutenberg subjects ("s." searches), Standard Ebooks tags, Internet Archive and Open Library
// subjects, Smashwords category numbers, and a pattern for LibriVox's own genre names (LibriVox's genre filter does not work, so its genres are matched locally).
const GENRES = [
{ id: 'fiction', label: 'Fiction', gu: 'fiction', se: 'Fiction', ia: 'fiction', ol: 'fiction', sw: 3, lv: /fiction|novel/i },
{ id: 'mystery', label: 'Mystery', gu: 'mystery', se: 'Mystery', ia: 'mystery', ol: 'mystery_and_detective_stories', sw: 879, lv: /mystery|crime|detective/i },
{ id: 'fantasy', label: 'Fantasy', gu: 'fantasy', se: 'Fantasy', ia: 'fantasy', ol: 'fantasy', sw: 1206, lv: /fantas|myths|fairy/i },
{ id: 'scifi', label: 'Science fiction', gu: 'science fiction', se: 'Science Fiction', ia: 'science fiction', ol: 'science_fiction', sw: 1213, lv: /science fiction|sci-fi/i },
{ id: 'adventure', label: 'Adventure', gu: 'adventure', se: 'Adventure', ia: 'adventure', ol: 'adventure_and_adventurers', sw: 892, lv: /adventure|nautical|western/i },
{ id: 'classics', label: 'Classics', gu: 'classic', se: null, ia: 'classics', ol: 'classic_literature', sw: 1348, lv: /classic|epic/i },
{ id: 'children', label: "Children's", gu: 'juvenile', se: 'Children', ia: 'children', ol: 'juvenile_fiction', sw: 61, lv: /child/i },
{ id: 'history', label: 'History', gu: 'history', se: 'History', ia: 'history', ol: 'history', sw: 884, lv: /history|historical/i },
{ id: 'biography', label: 'Biography', gu: 'biography', se: 'Biography', ia: 'biography', ol: 'biography', sw: 1105, lv: /biograph|memoir/i },
{ id: 'faith', label: 'Faith', gu: 'religion', se: 'Religion', ia: 'religion', ol: 'religion', sw: 1223, lv: /religion|bible|christian|spiritual/i },
{ id: 'poetry', label: 'Poetry', gu: 'poetry', se: 'Poetry', ia: 'poetry', ol: 'poetry', sw: 56, lv: /poetry|sonnet/i },
{ id: 'humor', label: 'Humor', gu: 'humor', se: 'Humor', ia: 'humor', ol: 'humor', sw: 882, lv: /humor|satire/i },
{ id: 'philosophy', label: 'Philosophy', gu: 'philosophy', se: 'Philosophy', ia: 'philosophy', ol: 'philosophy', sw: null, lv: /philosoph/i },
{ id: 'science', label: 'Science', gu: 'science', se: 'Science', ia: 'science', ol: 'science', sw: null, lv: /science|astronomy|physics|psycholog|nature/i },
{ id: 'travel', label: 'Travel', gu: 'travel', se: 'Travel', ia: 'travel', ol: 'travel', sw: null, lv: /travel|geograph/i },
{ id: 'cooking', label: 'Cooking', gu: 'cooking', se: 'Cooking', ia: 'cooking', ol: 'cooking', sw: null, lv: /cooking|house & home/i },
];
const byId = id => GENRES.find(g => g.id === id) || null;
const list = () => GENRES.map(({ id, label }) => ({ id, label }));
const KID = ['children', 'adventure', 'fantasy', 'classics', 'history', 'biography', 'science', 'poetry', 'humor', 'faith', 'travel', 'cooking']; // what a child account may browse by
module.exports = { GENRES, byId, list, KID };
+33
View File
@@ -0,0 +1,33 @@
// Notifications center: a short list of what happened for each person (chat messages, reminders, announcements) so nothing depends on a phone buzz being seen.
// Each person's list lives in their own folder (inbox.json); a chat conversation keeps one entry that grows a count instead of filling the list.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const MAX = 120, json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
const file = uid => path.join(users.dirOf(uid), 'inbox.json');
const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf8')); return Array.isArray(j.items) ? j.items : []; } catch { return []; } };
const save = (uid, items) => { try { fs.mkdirSync(path.dirname(file(uid)), { recursive: true }); atomicWrite(file(uid), JSON.stringify({ items }), { mode: 0o600 }); } catch (e) { console.warn('[inbox]', e.message); } };
/** Add something for a person. With a `tag`, a still-unread entry with the same tag is updated (and counted) instead of added again. */
function add(uid, { kind = 'info', title, body = '', url = '', tag = '' }) {
if (!users.get(uid)) return; const items = load(uid), now = Date.now();
const old = tag ? items.find(i => i.tag === tag && !i.read) : null;
if (old) { old.count = (old.count || 1) + 1; old.title = str(title, 100); old.body = str(body, 200); old.at = now; items.sort((a, b) => b.at - a.at); }
else items.unshift({ id: crypto.randomBytes(6).toString('hex'), kind, title: str(title, 100), body: str(body, 200), url: str(url, 200), tag: str(tag, 80), at: now, read: false, count: 1 });
save(uid, items.slice(0, MAX));
}
const unread = uid => load(uid).filter(i => !i.read).length;
async function handle(req, res) {
const p = new URL(req.url, 'http://x').pathname.slice('/api/inbox'.length) || '/', me = users.current();
try {
if (p === '/' && req.method === 'GET') { const items = load(me.id); return json(res, 200, { items, unread: items.filter(i => !i.read).length }); }
if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 5e3) || '{}'), items = load(me.id); for (const i of items) if (b.all || i.id === b.id) i.read = true; save(me.id, items); return json(res, 200, { unread: items.filter(i => !i.read).length }); }
if (p === '/clear' && req.method === 'POST') { save(me.id, []); return json(res, 200, { unread: 0 }); }
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e instanceof SyntaxError ? 400 : 500, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, add, unread, load };
+74
View File
@@ -0,0 +1,74 @@
// Child accounts: a daily time limit and a bedtime, enforced here on the server (so a child cannot get round them by changing the device's clock or reloading), with a way to ask
// a parent for more time. Time counts while the app is open and visible (the app pings once a minute). A locked account can still reach its own status and the "ask a parent" button.
const fs = require('fs'), path = require('path');
const atomicWrite = require('./atomic');
const users = require('./users');
const push = require('./push');
const activity = require('./activity');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'kid-usage.json');
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 });
// Every request from a child account asks where they stand, so the usage is kept in memory and only written to disk when it changes (one process serves everyone).
let mem = null, memFile = '';
const load = () => { if (mem && memFile === FILE()) return mem; memFile = FILE(); try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); mem = j && typeof j === 'object' ? j : {}; } catch { mem = {}; } return mem; };
const save = d => { mem = d; memFile = FILE(); atomicWrite(FILE(), JSON.stringify(d), { mode: 0o600 }); };
const hm = /^([01]\d|2[0-3]):[0-5]\d$/;
const AREAS = ['video', 'music', 'listen', 'read', 'chat'], AREA_LABEL = { video: 'Movies and TV', music: 'Music', listen: 'Podcasts, audiobooks and radio', read: 'Books', chat: 'Chat' };
const minutes = v => { const n = Math.round(+v) || 0; return n <= 0 ? 0 : Math.max(15, Math.min(720, n)); };
/** What a child account may be set to: { on, limitMin (0 = no limit), weekendMin (0 = same as other days), areaLimits: { video, music, ... } in minutes, bedtime: { on, from, to }, safe }. */
function cleanKid(k) {
if (!k || typeof k !== 'object') return undefined; const b = k.bedtime || {}, al = {};
for (const a of AREAS) { const n = minutes(k.areaLimits && k.areaLimits[a]); if (n) al[a] = n; }
return { on: !!k.on, limitMin: minutes(k.limitMin), weekendMin: minutes(k.weekendMin), areaLimits: al, bedtime: { on: !!b.on, from: hm.test(b.from) ? b.from : '20:30', to: hm.test(b.to) ? b.to : '07:00' }, safe: k.safe !== false };
}
const weekend = day => { const d = new Date(day + 'T12:00:00Z').getUTCDay(); return d === 0 || d === 6; };
/** Which area of the app a request belongs to, when the address says (a limit on that area closes only that area). */
const areaOfPath = p => /^\/api\/music\//.test(p) ? 'music' : /^\/plex\/video\//.test(p) ? 'video' : /^\/api\/(podcast|vbv|ab|audiobooks|radio)\//.test(p) ? 'listen' : /^\/api\/read\//.test(p) ? 'read' : /^\/api\/(chat|signal)\//.test(p) ? 'chat' : null;
const tzOf = uid => { try { return JSON.parse(fs.readFileSync(path.join(users.dirOf(uid), 'push.json'), 'utf8')).tz || process.env.HUB_TZ || 'UTC'; } catch { return process.env.HUB_TZ || 'UTC'; } };
/** Where this child stands right now. */
function status(u) {
const k = u && u.kid; if (!k || !k.on || u.role === 'admin') return { on: false };
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;
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) {
const k = u && u.kid; if (!k || !k.on || u.role === 'admin') return status(u); const st = status(u); if (st.locked) return st;
const d = load(), now = push.localNow(tzOf(u.id)), mine = d[u.id] || (d[u.id] = {}), day = mine[now.day] || (mine[now.day] = { secs: 0, extra: 0, last: 0 }), t = Date.now();
const delta = day.last ? Math.min(90, Math.max(0, (t - day.last) / 1000)) : 30; day.secs += delta; day.last = t; if (AREAS.includes(area) || area === 'other') { day.areas = day.areas || {}; day.areas[area] = (day.areas[area] || 0) + delta; }
for (const k2 of Object.keys(mine)) if (k2 < new Date(t - 40 * 864e5).toISOString().slice(0, 10)) delete mine[k2]; save(d);
const after = status(u); if (after.locked) activity.alertParents(u, 'limit', after.locked === 'bedtime' ? 'Bedtime has started and the app is locked' : 'Used all of today\'s time');
for (const a of after.lockedAreas) if (!st.lockedAreas.includes(a)) activity.alertParents(u, 'limit', 'Used all of today\'s ' + AREA_LABEL[a] + ' time'); return after;
}
/** 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); }
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) {
if (!u || !u.kid || !u.kid.on || u.role === 'admin' || ALLOWED_WHILE_LOCKED.test(pathname)) return null; const st = status(u); if (st.locked) return st;
const a = areaOfPath(pathname); return a && st.lockedAreas.includes(a) ? { ...st, locked: 'area:' + a, area: a, label: AREA_LABEL[a] } : null;
}
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 === '/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 === '/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 }); }
}
module.exports = { handle, status, ping, give, lockedFor, cleanKid, usageByDay, AREAS, AREA_LABEL };
+50
View File
@@ -0,0 +1,50 @@
// A restricted web search for children: it only ever asks Simple English Wikipedia (written for young readers) and shows the answer inside the hub, so a child never
// lands on an unfiltered site. A built-in list of words, plus any words a parent adds, stops a search before it is made and hides any result that mentions them.
// This lowers the risk; it is not perfect, which is why every search is also in the parents' activity view.
const fs = require('fs'), path = require('path');
const atomicWrite = require('./atomic');
const users = require('./users');
const activity = require('./activity');
const { readBody } = require('./body');
const FILE = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'kidsearch.json');
const API = 'https://simple.wikipedia.org/w/api.php', UA = 'NE-Hub/2.0 (family hub; kids search)';
const BASE = ['porn', 'porno', 'pornography', 'sex', 'sexual', 'sexy', 'nude', 'nudes', 'naked', 'nsfw', 'xxx', 'erotic', 'fetish', 'orgasm', 'masturbation', 'genitals', 'vagina', 'penis', 'breasts', 'rape', 'molest', 'prostitute', 'prostitution', 'escort', 'gore', 'torture', 'suicide', 'self-harm', 'cutting', 'cocaine', 'heroin', 'meth', 'methamphetamine', 'marijuana', 'cannabis', 'weed', 'lsd', 'ecstasy', 'opioid', 'overdose', 'gambling', 'casino', 'betting', 'hentai', 'onlyfans', 'tinder', 'hookup', 'bomb making', 'how to kill', 'school shooting', 'terrorist'];
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 clean = w => String(w || '').toLowerCase().replace(/[^a-z0-9 '-]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 40);
let extra = null;
const words = () => extra || (extra = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j.words) ? j.words.map(clean).filter(Boolean).slice(0, 200) : []; } catch { return []; } })());
const setWords = l => { extra = [...new Set(l.map(clean).filter(Boolean))].slice(0, 200); fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify({ words: extra }), { mode: 0o600 }); return extra; };
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Does the text contain a blocked word (whole words, so "sextant" or "class" are fine)? */
const blocked = text => { const t = ' ' + String(text || '').toLowerCase().replace(/[^a-z0-9' -]/g, ' ').replace(/\s+/g, ' ') + ' '; return [...BASE, ...words()].some(w => new RegExp('(^|[^a-z0-9])' + esc(w) + '(?![a-z0-9])').test(t)); };
const get = async params => { const r = await fetch(API + '?' + new URLSearchParams({ format: 'json', ...params }), { headers: { 'User-Agent': UA, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) }); if (!r.ok) throw fail(502, 'The kids search could not be reached right now'); return r.json(); };
async function search(q) {
const j = await get({ action: 'query', generator: 'search', gsrsearch: q, gsrlimit: '8', prop: 'extracts', exintro: '1', explaintext: '1', exlimit: 'max', exchars: '420' });
return Object.values((j.query && j.query.pages) || {}).sort((a, b) => a.index - b.index).map(p => ({ title: p.title, extract: String(p.extract || '').trim() })).filter(p => p.extract && !blocked(p.title + ' ' + p.extract)).slice(0, 5);
}
async function page(title) {
const j = await get({ action: 'query', prop: 'extracts', explaintext: '1', titles: title, redirects: '1' }), p = Object.values((j.query && j.query.pages) || {})[0];
if (!p || p.missing !== undefined || !p.extract) throw fail(404, 'No page like that'); if (blocked(p.title + ' ' + p.extract.slice(0, 4000))) throw fail(403, 'That page is not available');
return { title: p.title, text: p.extract.slice(0, 12000) };
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/kidsearch'.length) || '/', me = users.current(), admin = me.role === 'admin';
try {
if (p === '/words') { // the extra words a parent blocks (the built-in list is not shown)
if (!admin) throw fail(403, 'Only a parent (an admin) can do this'); if (req.method === 'POST') { const b = JSON.parse(await readBody(req, 8e3) || '{}'); return json(res, 200, { words: setWords(Array.isArray(b.words) ? b.words : String(b.words || '').split(/[,\n]/)) }); }
return json(res, 200, { words: words() });
}
if (p === '/' && req.method === 'GET') {
const q = String(u.searchParams.get('q') || '').replace(/\s+/g, ' ').trim().slice(0, 100); if (q.length < 2) return json(res, 200, { results: [] });
if (blocked(q)) { if (activity.isKid(me)) activity.alertParents(me, 'blocked', 'searched for something blocked: "' + q + '"'); return json(res, 200, { results: [], blocked: true, message: 'That search is not available. You can ask a parent.' }); }
return json(res, 200, { results: await search(q), source: 'Simple English Wikipedia' });
}
if (p === '/page' && req.method === 'GET') return json(res, 200, await page(String(u.searchParams.get('title') || '').slice(0, 120)));
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e instanceof SyntaxError ? 400 : 502, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, blocked, setWords, words, BASE };
+106
View File
@@ -0,0 +1,106 @@
// Free audiobooks from LibriVox (public-domain books read by volunteers). The hub searches LibriVox's own catalogue, hands the browser each chapter's audio
// address (the recordings are hosted by the Internet Archive), keeps each person's shelf of saved books and where they got to in each one.
const fs = require('fs'), path = require('path');
const users = require('./users'), genres = require('./genres');
const prog = require('./progress')('librivox-progress.json'); // keyed lv:<book id>: seconds into the whole book
const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, '');
const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname];
const allowed = u => { try { const x = new URL(u); return /^https?:$/.test(x.protocol) && HOSTS().some(h => x.hostname === h || x.hostname.endsWith('.' + h)); } catch { return false; } };
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 plain = s => String(s || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#0?39;/g, "'").replace(/\s+/g, ' ').trim();
const shelfFile = () => path.join(users.dir(), 'librivox.json');
const shelf = () => { try { const l = JSON.parse(fs.readFileSync(shelfFile(), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; } };
const saveShelf = l => { const f = shelfFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(l), { mode: 0o600 }); fs.renameSync(tmp, f); };
async function api(query) { // LibriVox answers 404 with a message when nothing matches
const url = `${BASE()}/api/feed/audiobooks/?format=json&coverart=1&${query}`;
let r; try { r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0', accept: 'application/json' }, signal: AbortSignal.timeout(12000) }); } catch (e) { throw fail(502, e.name === 'TimeoutError' ? 'LibriVox did not answer in time' : 'Could not reach LibriVox'); }
const text = await r.text(); let j; try { j = JSON.parse(text); } catch { if (r.status === 404) return []; throw fail(502, 'LibriVox sent something unexpected'); }
if (!r.ok || !Array.isArray(j.books)) { if (r.status === 404 || (j && j.error)) return []; throw fail(502, 'LibriVox answered ' + r.status); }
return j.books;
}
const author = b => (b.authors || []).map(a => [a.first_name, a.last_name].filter(Boolean).join(' ')).join(', ');
const summary = b => ({ id: String(b.id), title: plain(b.title), author: author(b), secs: Math.round(+b.totaltimesecs || 0), language: b.language || '', year: b.copyright_year || '', description: plain(b.description).slice(0, 800), cover: b.coverart_jpg || b.coverart_thumbnail || '', link: b.url_librivox || '' });
// ---- genres: LibriVox's own genre filter does not work, so the genres of its catalogue are gathered once (a page of 100 books at a time, with their genres) into a small
// index kept in the data folder, and refreshed weekly in the background. Searching by genre looks through that index.
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), IDX = () => path.join(DATA_DIR, 'librivox-index.json');
let idx = null, building = null;
const loadIdx = () => { if (idx) return idx; try { const j = JSON.parse(fs.readFileSync(IDX(), 'utf8')); if (j && Array.isArray(j.books)) idx = j; } catch { /* none yet */ } return idx; };
async function crawl() {
const pages = +process.env.LIBRIVOX_INDEX_PAGES || 30, books = [], seen = new Set();
for (let i = 0; i < pages; i += 2) for (const l of await Promise.all([i, i + 1].filter(n => n < pages).map(n => api(`extended=1&limit=100&offset=${n * 100}`).catch(() => [])))) for (const b of l) if (!seen.has(b.id)) { seen.add(b.id); books.push({ ...summary(b), genres: (b.genres || []).map(g => g.name).filter(Boolean) }); }
if (books.length) { idx = { t: Date.now(), books }; fs.mkdirSync(path.dirname(IDX()), { recursive: true }); const tmp = IDX() + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(idx)); fs.renameSync(tmp, IDX()); }
}
function ensureIndex() { const ix = loadIdx(); if ((!ix || Date.now() - ix.t > 7 * 864e5) && !building) building = crawl().catch(e => console.warn('[librivox] index:', e.message)).finally(() => { building = null; }); return ix; }
const inGenre = (b, g) => b.genres.some(n => g.lv.test(n));
const cache = new Map(); // book id -> { t, book } (chapters included)
async function book(id) {
if (!/^\d{1,9}$/.test(id)) throw fail(400, 'Bad book');
const c = cache.get(id); if (c && Date.now() - c.t < 3600e3) return c.book;
const [b] = await api(`id=${id}&extended=1`); if (!b) throw fail(404, 'Book not found');
const chapters = (b.sections || []).filter(s => allowed(s.listen_url)).map((s, i) => ({ n: i, title: plain(s.title) || 'Chapter ' + (i + 1), secs: Math.round(+s.playtime || 0), url: s.listen_url, reader: (s.readers || []).map(r => r.display_name).filter(Boolean).join(', ') }));
const full = { ...summary(b), chapters }; if (cache.size > 100) cache.clear(); cache.set(id, { t: Date.now(), book: full }); return full;
}
const publicBook = b => ({ ...b, chapters: b.chapters.map(({ url, ...c }) => c), cover: b.cover ? `/api/ab/free/cover/${b.id}` : '' });
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } };
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab/free'.length) || '/';
try {
let m;
if (p === '/genres' && req.method === 'GET') { const ix = ensureIndex(); return json(res, 200, { building: !!building, ready: !!ix, genres: genres.list().map(g => ({ ...g, count: ix ? ix.books.filter(b => inGenre(b, genres.byId(g.id))).length : undefined })) }); }
const me0 = users.current(), safe = !!(me0 && me0.kid && me0.kid.on && me0.kid.safe); if (safe && p === '/search' && !genres.KID.includes(u.searchParams.get('genre') || '')) u.searchParams.set('genre', 'children'); // (a child account browses a few safe genres)
if (p === '/search' && req.method === 'GET' && u.searchParams.get('genre')) { // by genre: from the index
const g = genres.byId(u.searchParams.get('genre')), q = (u.searchParams.get('q') || '').trim().slice(0, 100).toLowerCase(), page = Math.min(50, Math.max(1, +u.searchParams.get('page') || 1)); if (!g) return json(res, 200, { items: [], more: false });
const ix = ensureIndex(); if (!ix) return json(res, 200, { items: [], more: false, building: true });
const words = q.split(/\s+/).filter(Boolean), hits = ix.books.filter(b => inGenre(b, g) && words.every(w => (b.title + ' ' + b.author + ' ' + b.description).toLowerCase().includes(w)));
return json(res, 200, { items: hits.slice((page - 1) * 20, page * 20).map(({ genres: gs, ...b }) => ({ ...b, cover: b.cover ? `/api/ab/free/cover/${b.id}?u=${encodeURIComponent(b.cover)}` : '' })), more: hits.length > page * 20, building: !!building, total: hits.length });
}
if (p === '/search' && req.method === 'GET') {
const q = (u.searchParams.get('q') || '').trim().slice(0, 100), page = Math.min(50, Math.max(1, +u.searchParams.get('page') || 1)), off = (page - 1) * 20;
const lists = q ? await Promise.all([api(`title=${encodeURIComponent(q)}&limit=20&offset=${off}`), q.split(/\s+/).length <= 2 ? api(`author=${encodeURIComponent(q.split(/\s+/).pop())}&limit=20&offset=${off}`) : []]) : [await api(`limit=20&offset=${off}`)];
const seen = new Set(), items = []; for (const b of lists.flat()) if (!seen.has(String(b.id))) { seen.add(String(b.id)); items.push(summary(b)); }
return json(res, 200, { items: items.map(b => ({ ...b, cover: b.cover ? `/api/ab/free/cover/${b.id}?u=${encodeURIComponent(b.cover)}` : '' })), more: lists.flat().length >= 20 });
}
if ((m = /^\/book\/(\d+)$/.exec(p)) && req.method === 'GET') return json(res, 200, publicBook(await book(m[1])));
if ((m = /^\/audio\/(\d+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording is on the Internet Archive; the hub fetches it (redirects and all) and passes it on, with seeking
const b = await book(m[1]), c = b.chapters[+m[2]]; if (!c) throw fail(404, 'No such chapter');
const ctl = new AbortController(); res.on('close', () => ctl.abort());
let url = c.url, r;
for (let i = 0; i < 6; i++) {
if (!allowed(url)) throw fail(502, 'That audio address is not allowed');
r = await fetch(url, { headers: { 'user-agent': 'NE-Hub/1.0', 'accept-encoding': 'identity', ...(req.headers.range ? { range: req.headers.range } : {}) }, redirect: 'manual', signal: ctl.signal }).catch(e => { throw fail(502, e.name === 'AbortError' ? 'Cancelled' : 'The Internet Archive did not answer'); });
if ([301, 302, 303, 307, 308].includes(r.status) && r.headers.get('location')) { url = new URL(r.headers.get('location'), url).href; try { await r.body?.cancel(); } catch {} continue; }
break;
}
if (r.status === 416) { res.writeHead(416, { 'content-range': r.headers.get('content-range') || 'bytes */0' }); return res.end(); }
if (!r.ok && r.status !== 206) throw fail(502, 'The Internet Archive answered ' + r.status + ' for that chapter');
const out = { 'content-type': /^audio\//.test(r.headers.get('content-type') || '') ? r.headers.get('content-type') : 'audio/mpeg', 'accept-ranges': 'bytes', 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' };
for (const k of ['content-length', 'content-range']) if (r.headers.get(k)) out[k] = r.headers.get(k);
res.writeHead(r.status, out); if (!r.body) return res.end();
return require('stream').Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res);
}
if ((m = /^\/cover\/(\d+)$/.exec(p)) && req.method === 'GET') {
let cu = u.searchParams.get('u'); if (!cu || !allowed(cu)) { const b = await book(m[1]); cu = b.cover; } if (!cu || !allowed(cu)) throw fail(404, 'No cover');
let r = await fetch(cu, { headers: { 'user-agent': 'NE-Hub/1.0' }, redirect: 'manual', signal: AbortSignal.timeout(10000) });
for (let i = 0; i < 4 && [301, 302, 303, 307, 308].includes(r.status); i++) { const to = new URL(r.headers.get('location') || '', cu).href; if (!allowed(to)) throw fail(502, 'Bad cover address'); cu = to; r = await fetch(cu, { headers: { 'user-agent': 'NE-Hub/1.0' }, redirect: 'manual', signal: AbortSignal.timeout(10000) }); }
const type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (!r.ok || !/^image\/(jpeg|png|gif|webp)$/.test(type)) throw fail(404, 'No cover');
const buf = Buffer.from(await r.arrayBuffer()); if (buf.length > 3e6) throw fail(502, 'Cover too large'); res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800' }); return res.end(buf);
}
if (p === '/shelf' && req.method === 'GET') return json(res, 200, shelf().map(b => ({ ...b, cover: b.cover ? `/api/ab/free/cover/${b.id}` : '' })));
if (p === '/shelf' && req.method === 'POST') {
const b = await book(String((await readBody(req)).id || '')), l = shelf().filter(x => x.id !== b.id);
l.push({ id: b.id, title: b.title, author: b.author, secs: b.secs, language: b.language, cover: b.cover, added: Date.now() }); saveShelf(l); return json(res, 200, { saved: true });
}
if ((m = /^\/shelf\/(\d+)$/.exec(p)) && req.method === 'DELETE') { saveShelf(shelf().filter(x => x.id !== m[1])); return json(res, 200, { removed: true }); }
if (p === '/progress') {
if (req.method === 'GET') return json(res, 200, prog.load());
if (req.method === 'POST') return json(res, 200, prog.merge(await readBody(req)));
}
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); }
}
module.exports = { handle, book, allowed, crawl, loadIdx };
+29 -11
View File
@@ -6,15 +6,16 @@ const atomicWrite = require('./atomic');
const path = require('path');
const crypto = require('crypto');
const cfg = { api: '', dir: '' };
const cfg = { api: '', dir: '', lrc: '' };
const HIT_DAYS = 90, MISS_HOURS = 24;
const inflight = new Map();
function init({ dataDir, apiUrl }) {
cfg.api = (apiUrl || '').replace(/\/$/, ''); cfg.dir = path.join(dataDir, 'lyrics');
if (cfg.api) fs.mkdirSync(cfg.dir, { recursive: true, mode: 0o700 });
cfg.lrc = process.env.LYRICS_LRCLIB === '0' ? '' : (process.env.LRCLIB_URL || 'https://lrclib.net').replace(/\/+$/, ''); // LRCLIB: free synced and plain lyrics, no key (set LYRICS_LRCLIB=0 to never ask it)
if (cfg.api || cfg.lrc) fs.mkdirSync(cfg.dir, { recursive: true, mode: 0o700 });
}
const enabled = () => !!cfg.api;
const enabled = () => !!(cfg.api || cfg.lrc);
const norm = s => String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/\(.*?\)|\[.*?\]/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
const keyOf = (artist, title) => crypto.createHash('sha1').update(norm(artist) + '|' + norm(title)).digest('hex');
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
@@ -62,6 +63,19 @@ function shape(d, want) {
return { found: true, source: src, sync, lines, album: x.album || '' };
}
/** LRCLIB: the exact song first, then a search for the closest title. Synced lines when it has them, otherwise plain words. */
async function lrclib(artist, title, seconds) {
const get = async p => { const r = await fetch(cfg.lrc + p, { headers: { 'user-agent': 'NE-Hub/1.0 (self-hosted)' }, signal: AbortSignal.timeout(15000) }); return r.ok ? r.json().catch(() => null) : null; };
let x = await get('/api/get?' + new URLSearchParams({ artist_name: artist, track_name: title }));
if (!x || x.statusCode) { const list = await get('/api/search?' + new URLSearchParams({ track_name: title, ...(artist ? { artist_name: artist } : {}) })); x = Array.isArray(list) ? list.find(c => norm(c.trackName) === norm(title) && (!artist || norm(c.artistName).includes(norm(artist).split(' ')[0]) || norm(artist).includes(norm(c.artistName).split(' ')[0]))) : null; }
if (!x) return { found: false, why: 'not found' };
if (x.instrumental) return { found: true, instrumental: true, source: 'LRCLIB', sync: 'none', lines: [] };
if (seconds && x.duration && Math.abs(+x.duration - seconds) > 10) return { found: false, why: 'different version' };
const timed = /\[\d+:\d{2}/.test(x.syncedLyrics || '') ? parseLrc(x.syncedLyrics) : [];
if (timed.length >= 2) return { found: true, source: 'LRCLIB', sync: 'line', lines: timed.filter(l => l.text), album: x.albumName || '' };
const lines = String(x.plainLyrics || '').split(/\r?\n/).map(text => ({ text: text.trim() })); return lines.some(l => l.text) ? { found: true, source: 'LRCLIB', sync: 'none', lines, album: x.albumName || '' } : { found: false, why: 'empty' };
}
async function lookup(artist, title, seconds) {
const k = keyOf(artist, title), f = path.join(cfg.dir, k + '.json');
try { // already known?
@@ -70,14 +84,18 @@ async function lookup(artist, title, seconds) {
} catch { /* not cached */ }
if (inflight.has(k)) return inflight.get(k);
const p = (async () => {
const u = new URL(cfg.api + '/lyrics/'); // sources that give real lyrics, Musixmatch's word-synced first; never YouTube captions
u.search = new URLSearchParams({ artist, song: title, timestamps: 'true', word: 'true', sequence: '7,2,4,5,1', pass: 'true' });
const r = await fetch(u, { signal: AbortSignal.timeout(45000) });
if (r.status === 429) throw Object.assign(new Error('The lyrics service is busy; try again in a minute'), { status: 429 });
const d = await r.json().catch(() => null);
if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 });
const result = shape(d, seconds);
try { atomicWrite(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ }
let result = { found: false, why: 'not found' };
if (cfg.api) {
const u = new URL(cfg.api + '/lyrics/'); // sources that give real lyrics, Musixmatch's word-synced first; never YouTube captions
u.search = new URLSearchParams({ artist, song: title, timestamps: 'true', word: 'true', sequence: '7,2,4,5,1', pass: 'true' });
const r = await fetch(u, { signal: AbortSignal.timeout(45000) });
if (r.status === 429) throw Object.assign(new Error('The lyrics service is busy; try again in a minute'), { status: 429 });
const d = await r.json().catch(() => null);
if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 });
result = shape(d, seconds);
}
if (!result.found && cfg.lrc && result.why !== 'different version') { try { const alt = await lrclib(artist, title, seconds); if (alt.found || !cfg.api) result = alt; } catch (e) { if (!cfg.api) throw Object.assign(new Error('The lyrics service is not answering'), { status: 502 }); } }
if (result.why !== 'different version') { try { atomicWrite(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ } } // (whether a version is "different" depends on the length asked about, so that answer is not kept)
return result;
})().finally(() => inflight.delete(k));
inflight.set(k, p); return p;
+20 -19
View File
@@ -41,27 +41,28 @@ function friendly(a, e) {
// ---- connections: one per account, kept for a minute after the last use
const IDLE = 5 * 60e3; // a connection is kept this long after its last use: opening one (TLS and login) is the slowest part of a request
const pool = new Map(); // account id -> { client, timer, key }
const forget = id => { folderCache.delete(id); boxCache.delete(id); const e = pool.get(id); if (e) { clearTimeout(e.timer); try { e.client.close(); } catch {} pool.delete(id); } };
const drop = id => { const e = pool.get(id); if (e) { clearTimeout(e.timer); try { e.client.close(); } catch {} pool.delete(id); } };
const forget = id => { folderCache.delete(id); boxCache.delete(id); drop(id); drop(id + ':bg'); };
const keyOf = a => a.address + '\n' + a.pass;
async function open(a) {
const e = pool.get(a.id);
if (e && e.key === keyOf(a) && e.client.usable) { clearTimeout(e.timer); e.timer = setTimeout(() => forget(a.id), IDLE).unref(); return e.client; }
forget(a.id);
async function open(a, slot) { // slot 'bg': a second connection for counts and folder lists, so they never queue in front of opening or deleting a message
const pid = slot ? a.id + ':' + slot : a.id, e = pool.get(pid);
if (e && e.key === keyOf(a) && e.client.usable) { clearTimeout(e.timer); e.timer = setTimeout(() => drop(pid), IDLE).unref(); return e.client; }
drop(pid);
const c = cfgOf(a);
const client = new ImapFlow({ host: c.host, port: c.port, secure: c.secure, ...(c.test ? { doSTARTTLS: false, tls: { rejectUnauthorized: false } } : {}), auth: { user: a.address, pass: a.pass }, logger: false, emitLogs: false, socketTimeout: 60e3, greetingTimeout: 20e3, connectionTimeout: 20e3 });
client.on('error', () => {}); client.on('close', () => { const cur = pool.get(a.id); if (cur && cur.client === client) pool.delete(a.id); });
client.on('error', () => {}); client.on('close', () => { const cur = pool.get(pid); if (cur && cur.client === client) pool.delete(a.id); });
try { await client.connect(); } catch (err) { throw fail(err.authenticationFailed ? 401 : 502, friendly(a, err)); }
pool.set(a.id, { client, key: keyOf(a), timer: setTimeout(() => forget(a.id), IDLE).unref() });
pool.set(pid, { client, key: keyOf(a), timer: setTimeout(() => drop(pid), IDLE).unref() });
return client;
}
/** Run `fn(client)`; if the connection had gone stale, reconnect once. */
async function withClient(a, fn) {
async function withClient(a, fn, slot) {
for (let attempt = 0; ; attempt++) {
const client = await open(a);
const client = await open(a, slot);
try { return await fn(client); }
catch (err) {
if (err.status) throw err;
if (attempt === 0 && (!client.usable || /closed|ECONNRESET|socket|EPIPE|Connection not available/i.test(err.message || ''))) { forget(a.id); continue; }
if (attempt === 0 && (!client.usable || /closed|ECONNRESET|socket|EPIPE|Connection not available/i.test(err.message || ''))) { drop(slot ? a.id + ':' + slot : a.id); continue; }
throw fail(502, friendly(a, err));
}
}
@@ -105,7 +106,7 @@ async function folders(a) {
const rows = keep.map(b => { const k = byPath[b.path], st = counts.get(b.path) || {}; return { id: k || 'p-' + Buffer.from(b.path).toString('base64url'), name: k ? NAMES[k] : b.path.replace(/^\[.*?\]\//, ''), unread: st.unseen || 0, total: st.messages || 0, well: !!k }; });
const order = Object.keys(NAMES);
return rows.sort((x, y) => (x.well ? order.indexOf(x.id) : 99) - (y.well ? order.indexOf(y.id) : 99) || x.name.localeCompare(y.name));
});
}, 'bg');
folderCache.set(a.id, { t: Date.now(), v }); return v;
}
@@ -141,12 +142,10 @@ async function source(a, mid) {
return withClient(a, async client => {
const lock = await client.getMailboxLock(path);
try {
const dl = await client.download(String(uid), undefined, { uid: true });
if (!dl || !dl.content) throw fail(404, 'That message is gone');
const chunks = []; let n = 0;
for await (const ch of dl.content) { n += ch.length; if (n > MAX_SOURCE) throw fail(400, 'That message is too large to open here'); chunks.push(ch); }
const meta = await client.fetchOne(String(uid), { flags: true }, { uid: true }).catch(() => null);
return { parsed: await simpleParser(Buffer.concat(chunks)), path, uid, flags: meta ? meta.flags : new Set() };
const m = await client.fetchOne(String(uid), { source: { maxLength: MAX_SOURCE + 1 }, flags: true }, { uid: true }); // (one round trip for the body and the flags)
if (!m || !m.source) throw fail(404, 'That message is gone');
if (m.source.length > MAX_SOURCE) throw fail(400, 'That message is too large to open here');
return { parsed: await simpleParser(m.source), path, uid, flags: m.flags || new Set() };
} finally { lock.release(); }
});
}
@@ -191,7 +190,9 @@ async function del(a, mid, permanent) {
const [path, uid] = dec(mid);
return withClient(a, async client => { const lock = await client.getMailboxLock(path); try { await client.messageDelete(String(uid), { uid: true }); } finally { lock.release(); } });
}
async function unread(a) { return withClient(a, async client => (await client.status('INBOX', { unseen: true })).unseen || 0); }
/** Counts shown in the folder list are kept 30 s; reading, moving, deleting or sending changes them, so the next request must ask again. */
const dropFolders = id => { if (id) folderCache.delete(id); else folderCache.clear(); };
async function unread(a) { return withClient(a, async client => (await client.status('INBOX', { unseen: true })).unseen || 0, 'bg'); }
// ---- sending
function transport(a) {
@@ -251,4 +252,4 @@ async function connectNew({ type, address, password }) {
return { type, address: addr, pass, name: '' };
}
module.exports = { PRESETS: Object.fromEntries(Object.entries(PRESETS).map(([k, v]) => [k, { name: v.name }])), folders, list, message, attachment, update, move, del, unread, send, reply, connectNew, forget };
module.exports = { dropFolders, PRESETS: Object.fromEntries(Object.entries(PRESETS).map(([k, v]) => [k, { name: v.name }])), folders, list, message, attachment, update, move, del, unread, send, reply, connectNew, forget };
+12 -2
View File
@@ -155,7 +155,7 @@ const safeName = n => String(n || 'attachment').replace(/[\r\n"\\/]+/g, '_').sli
const cleanAttachments = list => { let total = 0; const out = (Array.isArray(list) ? list : []).slice(0, 10).map(a => { const data = String(a.data || ''); total += Buffer.from(data, 'base64').length; return { name: safeName(a.name), type: String(a.type || 'application/octet-stream').slice(0, 100), data }; }); if (total > MAX_ATTACH) throw fail(400, 'Attachments can total about 3 MB'); return out; };
let folderCache = new Map(); // account id -> { t, v }
const dropFolderCache = id => { if (id) { folderCache.delete(id); unreadCache.delete(id); } else { folderCache.clear(); unreadCache.clear(); } };
const dropFolderCache = id => { if (id) { folderCache.delete(id); unreadCache.delete(id); } else { folderCache.clear(); unreadCache.clear(); } imap.dropFolders(id); };
// ---- Outlook.com through Microsoft Graph. Every function takes the account and Graph's own ids.
const outlook = {
@@ -329,4 +329,14 @@ async function handle(req, res) {
} catch (e) { json(res, e.status || 400, { error: e.message }); }
}
module.exports = { enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
/** The newest messages in each connected account's Inbox (for the push alerts). One account that fails does not stop the others. */
async function newest() {
const out = [];
for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } }
return out;
}
/** For the admin health page: can each connected account be reached right now? */
async function health() {
return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } }));
}
module.exports = { health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
+107
View File
@@ -0,0 +1,107 @@
// Music data kept by the hub (Plex only lets the hub read the library, so what is about a person lives here): what they played (for history, stats and mixes), their
// favorites, their smart playlists (rules that pick songs from the Plex library), and Family playlists that everyone with Music can add to. Songs are remembered
// by their Plex key plus enough to show them; the player fetches the song itself from Plex when it is played.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FAMILY = () => path.join(DATA_DIR, 'music-family.json');
const mine = n => path.join(users.dir(), n);
const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const write = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true }); atomicWrite(f, JSON.stringify(v), { mode: 0o600 }); };
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const keyOk = k => /^\d{1,12}$/.test(String(k || ''));
const newId = () => crypto.randomBytes(6).toString('hex');
const DAY = 864e5, MAX_PLAYS = 20000, MAX_FAVS = 5000, MAX_FAMILY_TRACKS = 2000;
/** A song as it is kept: its Plex key and what is needed to list it. */
function song(b) {
if (!b || !keyOk(b.key)) throw fail(400, 'That is not a song from your music library');
return { key: String(b.key), title: str(b.title, 200) || 'Song', artist: str(b.artist, 200), album: str(b.album, 200), thumb: str(b.thumb, 300), artistKey: keyOk(b.artistKey) ? String(b.artistKey) : '', albumKey: keyOk(b.albumKey) ? String(b.albumKey) : '', secs: Math.max(0, Math.min(36000, Math.round(+b.secs || (+b.dur || 0) / 1000 || 0))) };
}
// ---- what was played
const plays = () => read(mine('music-history.json'), []);
function recordPlay(b) {
const s = song(b), l = plays(), last = l[l.length - 1], now = Date.now();
if (last && last.key === s.key && now - last.t < 20000) return { ok: true, duplicate: true }; // (the same song counted twice by a reload)
l.push({ ...s, t: now }); write(mine('music-history.json'), l.slice(-MAX_PLAYS)); return { ok: true };
}
const since = (l, days) => days ? l.filter(p => p.t >= Date.now() - days * DAY) : l;
const top = (l, kf, mk, n) => { const m = new Map(); for (const p of l) { const k = kf(p); if (!k) continue; const e = m.get(k) || { ...mk(p), plays: 0, secs: 0 }; e.plays++; e.secs += p.secs || 0; m.set(k, e); } return [...m.values()].sort((a, b) => b.plays - a.plays || b.secs - a.secs).slice(0, n).map(e => ({ ...e, minutes: Math.round(e.secs / 60) })); };
const dayKey = t => { const d = new Date(t); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
/** Summary numbers for a stretch of time: totals, top artists, songs and albums, minutes per day (or per month for a long stretch), and the listening streak. */
function stats(range) {
const days = { week: 7, month: 30, year: 365, all: 0 }[range] ?? 30, all = plays(), l = since(all, days);
const perDay = new Map(); for (const p of l) perDay.set(dayKey(p.t), (perDay.get(dayKey(p.t)) || 0) + (p.secs || 0) / 60);
const series = []; if (days && days <= 31) for (let i = days - 1; i >= 0; i--) { const k = dayKey(Date.now() - i * DAY); series.push({ d: k, minutes: Math.round(perDay.get(k) || 0) }); }
else { const m = new Map(); for (const [k, v] of perDay) m.set(k.slice(0, 7), (m.get(k.slice(0, 7)) || 0) + v); const keys = [...m.keys()].sort(); for (const k of (days ? keys.slice(-12) : keys.slice(-24))) series.push({ d: k, minutes: Math.round(m.get(k)) }); }
const played = new Set(all.map(p => dayKey(p.t))); let streak = 0; for (let i = played.has(dayKey(Date.now())) ? 0 : 1; played.has(dayKey(Date.now() - i * DAY)); i++) streak++;
return { range, plays: l.length, minutes: Math.round(l.reduce((n, p) => n + (p.secs || 0), 0) / 60), artistsHeard: new Set(l.map(p => p.artist).filter(Boolean)).size, streak,
artists: top(l, p => p.artist, p => ({ name: p.artist, key: p.artistKey, thumb: p.thumb }), 10), songs: top(l, p => p.key, p => ({ key: p.key, title: p.title, artist: p.artist, thumb: p.thumb }), 10),
albums: top(l, p => p.albumKey || p.album, p => ({ key: p.albumKey, title: p.album, artist: p.artist, thumb: p.thumb }), 8), series };
}
// ---- favorites
const favs = () => read(mine('music-favs.json'), []);
const saveFavs = l => write(mine('music-favs.json'), l.slice(-MAX_FAVS));
// ---- smart playlists: rules the player turns into a question for the Plex library
const GENRE_OK = /^[\w .&'/+-]{1,60}$/;
function rules(r = {}) {
const n = (v, lo, hi) => { v = Math.round(+v); return Number.isFinite(v) && v >= lo && v <= hi ? v : 0; };
return { genres: (Array.isArray(r.genres) ? r.genres : []).map(String).filter(g => GENRE_OK.test(g)).slice(0, 10), yearFrom: n(r.yearFrom, 1000, 2100), yearTo: n(r.yearTo, 1000, 2100), addedDays: n(r.addedDays, 1, 3650), notPlayedDays: n(r.notPlayedDays, 1, 3650), minPlays: n(r.minPlays, 1, 1000),
onlyFavorites: !!r.onlyFavorites, order: ['random', 'newest', 'mostPlayed', 'leastRecent'].includes(r.order) ? r.order : 'random', limit: n(r.limit, 5, 500) || 50 };
}
const smart = () => read(mine('music-smart.json'), []);
// ---- Family playlists
const family = () => read(FAMILY(), []);
const saveFamily = l => write(FAMILY(), l);
const view = p => ({ id: p.id, name: p.name, owner: p.owner, ownerName: p.ownerName, created: p.created, count: p.tracks.length, tracks: p.tracks });
const canChange = (p, me) => me && (me.role === 'admin' || p.owner === me.id);
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/music'.length) || '/', me = users.current(), q = u.searchParams;
try {
let m;
if (p === '/played' && req.method === 'POST') return json(res, 200, recordPlay(JSON.parse(await readBody(req, 2e4) || '{}')));
if (p === '/history' && req.method === 'GET') { const n = Math.min(200, Math.max(1, +q.get('limit') || 40)); return json(res, 200, plays().slice(-n).reverse()); }
if (p === '/stats' && req.method === 'GET') return json(res, 200, stats(q.get('range') || 'month'));
if (p === '/top' && req.method === 'GET') { const days = Math.min(3650, Math.max(0, +q.get('days') || 30)); return json(res, 200, top(since(plays(), days), x => x.key, x => ({ key: x.key, title: x.title, artist: x.artist }), Math.min(200, +q.get('limit') || 50))); }
if (p === '/history' && req.method === 'DELETE') { write(mine('music-history.json'), []); return json(res, 200, { ok: true }); }
// favorites
if (p === '/favs' && req.method === 'GET') return json(res, 200, favs().reverse());
if (p === '/favs' && req.method === 'POST') { const s = song(JSON.parse(await readBody(req, 2e4) || '{}')), l = favs().filter(x => x.key !== s.key); l.push({ ...s, t: Date.now() }); saveFavs(l); return json(res, 200, { ok: true }); }
if ((m = /^\/favs\/(\d{1,12})$/.exec(p)) && req.method === 'DELETE') { saveFavs(favs().filter(x => x.key !== m[1])); return json(res, 200, { ok: true }); }
// smart playlists
if (p === '/smart' && req.method === 'GET') return json(res, 200, smart());
if (p === '/smart' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), name = str(b.name, 60); if (!name) throw fail(400, 'Give the playlist a name');
const l = smart(), old = b.id ? l.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Playlist not found'); if (!old && l.length >= 50) throw fail(400, 'That is plenty of smart playlists');
const e = old || { id: newId() }; Object.assign(e, { name, rules: rules(b.rules) }); if (!old) l.push(e); write(mine('music-smart.json'), l); return json(res, 200, e);
}
if ((m = /^\/smart\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { write(mine('music-smart.json'), smart().filter(x => x.id !== m[1])); return json(res, 200, { ok: true }); }
// family playlists
if (p === '/family' && req.method === 'GET') return json(res, 200, family().map(view));
if (p === '/family' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 2e5) || '{}'), name = str(b.name, 60); if (!name) throw fail(400, 'Give the playlist a name'); const l = family(); if (l.length >= 100) throw fail(400, 'That is plenty of family playlists');
const e = { id: newId(), name, owner: me.id, ownerName: me.name, created: Date.now(), tracks: (Array.isArray(b.tracks) ? b.tracks : []).slice(0, 500).map(t => ({ ...song(t), by: me.name })) }; l.push(e); saveFamily(l); return json(res, 200, view(e));
}
if ((m = /^\/family\/([0-9a-f]{12})(?:\/(add|tracks\/(\d{1,12})))?$/.exec(p))) {
const l = family(), e = l.find(x => x.id === m[1]); if (!e) throw fail(404, 'Playlist not found');
if (!m[2] && req.method === 'GET') return json(res, 200, view(e));
if (!m[2] && req.method === 'POST') { if (!canChange(e, me)) throw fail(403, 'Only the person who made it, or an admin, can rename it'); const name = str(JSON.parse(await readBody(req, 1e4) || '{}').name, 60); if (!name) throw fail(400, 'Give the playlist a name'); e.name = name; saveFamily(l); return json(res, 200, view(e)); }
if (!m[2] && req.method === 'DELETE') { if (!canChange(e, me)) throw fail(403, 'Only the person who made it, or an admin, can delete it'); saveFamily(l.filter(x => x !== e)); return json(res, 200, { ok: true }); }
if (m[2] === 'add' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e5) || '{}'), add = (Array.isArray(b.tracks) ? b.tracks : []).slice(0, 500).map(t => ({ ...song(t), by: me.name })); let n = 0;
for (const t of add) if (!e.tracks.some(x => x.key === t.key) && e.tracks.length < MAX_FAMILY_TRACKS) { e.tracks.push(t); n++; } saveFamily(l); return json(res, 200, { ...view(e), added: n }); }
if (m[3] && req.method === 'DELETE') { e.tracks = e.tracks.filter(x => x.key !== m[3]); saveFamily(l); return json(res, 200, view(e)); } // (anyone in the family may take a song off)
}
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, stats, rules, song };
+167
View File
@@ -0,0 +1,167 @@
// My Store: a catalogue of your own content that everyone with the Store tab can use. The admin adds books, music, apps (files to download) and extensions
// (small web apps that add features to the hub), by uploading files or copying them from their Seafile. Books go onto a person's shelf in one tap, music plays
// in the hub's player, apps download, and extensions run in a sandbox of their own (an iframe with no access to the hub's sign-in or data; it gets a small
// message bridge for its own saved settings, below). Items and files live in the data folder, shared by everyone.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const { pipeline } = require('stream/promises');
const atomicWrite = require('./atomic');
const users = require('./users'), settings = require('./settings'), books = require('./books');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const ROOT = () => path.join(DATA_DIR, 'mystore'), ITEMS = () => path.join(ROOT(), 'items.json'), FILES = () => path.join(ROOT(), 'files');
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const MAX_FILE = 1024 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_EXT = 2 * 1024 * 1024, MAX_STATE = 256 * 1024, MAX_FILES_PER_ITEM = 200;
const KINDS = ['book', 'music', 'app', 'extension', 'link'];
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write']; // what an extension may ask the person for
const BOOK_EXT = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'docx'];
const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw3: 'application/vnd.amazon.mobi8-ebook', azw: 'application/vnd.amazon.ebook', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', html: 'text/html; charset=utf-8', apk: 'application/vnd.android.package-archive', zip: 'application/zip', dmg: 'application/x-apple-diskimage', exe: 'application/vnd.microsoft.portable-executable', pkg: 'application/octet-stream', txt: 'text/plain; charset=utf-8' };
const IMAGE = { jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const idOk = s => /^[0-9a-f]{12}$/.test(s || '');
const newId = () => crypto.randomBytes(6).toString('hex');
const extOf = n => (/\.([A-Za-z0-9]{1,8})$/.exec(n) || [])[1] ? (/\.([A-Za-z0-9]{1,8})$/.exec(n))[1].toLowerCase() : '';
const safeName = n => str(path.basename(String(n || 'file')), 160).replace(/[\\/:*?"<>|]+/g, '_') || 'file';
const needAdmin = me => { if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can change the store'); };
const loadItems = () => { try { const j = JSON.parse(fs.readFileSync(ITEMS(), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } };
const saveItems = l => { fs.mkdirSync(FILES(), { recursive: true, mode: 0o700 }); atomicWrite(ITEMS(), JSON.stringify(l, null, 1), { mode: 0o600 }); };
const fileOn = (item, fid) => path.join(FILES(), item.id + '-' + fid);
const coverOn = item => path.join(FILES(), item.id + '-cover');
const view = it => ({ id: it.id, kind: it.kind, title: it.title, creator: it.creator, description: it.description, version: it.version, url: it.url, cover: !!it.cover, added: it.added, updated: it.updated,
files: it.files.map(f => ({ id: f.id, name: f.name, size: f.size, ext: f.ext })), ...(it.kind === 'extension' || it.kind === 'link' ? { icon: it.icon || '' } : {}), ...(it.kind === 'link' ? { embed: !!it.embed } : {}) });
// ---- the per-person bits: which extensions are installed, and each extension's saved settings
const stateFile = () => path.join(users.dir(), 'mystore.json');
const stateOf = () => { try { const j = JSON.parse(fs.readFileSync(stateFile(), 'utf8')); return { installed: Array.isArray(j.installed) ? j.installed : [], data: j.data && typeof j.data === 'object' ? j.data : {}, grants: j.grants && typeof j.grants === 'object' ? j.grants : {} }; } catch { return { installed: [], data: {}, grants: {} }; } };
const saveState = s => atomicWrite(stateFile(), JSON.stringify(s), { mode: 0o600 });
/** Stream a stored file, with Range support so audio can be skipped through. */
function send(req, res, file, type, name, download) {
let st; try { st = fs.statSync(file); } catch { return json(res, 404, { error: 'Not found' }); }
const h = { 'content-type': type, 'accept-ranges': 'bytes', 'cache-control': 'private, max-age=3600', 'x-content-type-options': 'nosniff' };
if (download) h['content-disposition'] = `attachment; filename="${name.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '')}"; filename*=UTF-8''${encodeURIComponent(name)}`;
const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
if (m && (m[1] || m[2])) {
let a = m[1] ? +m[1] : Math.max(0, st.size - +m[2]), b = m[1] && m[2] ? Math.min(+m[2], st.size - 1) : st.size - 1;
if (a > b || a >= st.size) { res.writeHead(416, { 'content-range': 'bytes */' + st.size }); return res.end(); }
res.writeHead(206, { ...h, 'content-range': `bytes ${a}-${b}/${st.size}`, 'content-length': b - a + 1 }); return fs.createReadStream(file, { start: a, end: b }).pipe(res);
}
res.writeHead(200, { ...h, 'content-length': st.size }); fs.createReadStream(file).pipe(res);
}
/** Save a request body to a file, refusing more than `max` bytes. */
async function receive(req, file, max) {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
if ((+req.headers['content-length'] || 0) > max) throw fail(413, 'That file is too large');
const tmp = file + '.part'; let n = 0;
req.on('data', c => { n += c.length; if (n > max) req.destroy(fail(413, 'That file is too large')); });
try { await pipeline(req, fs.createWriteStream(tmp, { mode: 0o600 })); } catch (e) { fs.rm(tmp, { force: true }, () => {}); throw e.code === 413 ? e : fail(e.code === 413 ? 413 : 400, e.message || 'The upload did not finish'); }
fs.renameSync(tmp, file); return n;
}
// ---- copying a file from the admin's Seafile (the Seafile sign-in the hub already holds for them)
async function fromSeafile(repo, p) {
const token = settings.get('sf_token'); if (!token) throw fail(401, 'Open Files and sign in first, then try again');
if (!/^[\w-]{8,64}$/.test(repo) || !p || !p.startsWith('/') || p.split('/').includes('..')) throw fail(400, 'Choose a file');
const call = async url => fetch(url, { headers: { Authorization: 'Token ' + token }, signal: AbortSignal.timeout(30000) });
const lr = await call(`${SEAFILE()}/api2/repos/${repo}/file/?p=${encodeURIComponent(p)}&reuse=1`);
if (lr.status === 401) throw fail(401, 'Your Files sign-in has expired. Open Files and sign in again.'); if (!lr.ok) throw fail(lr.status === 404 ? 404 : 502, lr.status === 404 ? 'That file was not found in Files' : 'Files answered ' + lr.status);
const link = new URL(JSON.parse(await lr.text()), SEAFILE());
const r = await fetch(link, { signal: AbortSignal.timeout(30 * 60e3) }); if (!r.ok || !r.body) throw fail(502, 'Files would not send that file (' + r.status + ')');
if ((+r.headers.get('content-length') || 0) > MAX_FILE) throw fail(413, 'That file is too large');
return { stream: require('stream').Readable.fromWeb(r.body), name: safeName(p.split('/').pop()) };
}
// ---- extensions: one self-contained HTML file, run in a sandboxed frame with this Content-Security-Policy (no hub cookies, no hub address, no other frames)
const EXT_CSP = "sandbox allow-scripts allow-forms allow-popups allow-modals; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob: https:; media-src data: blob: https:; font-src data: https:; connect-src https:; base-uri 'none'; form-action 'none'; frame-ancestors 'self'";
const EXT_META = '<meta http-equiv="Content-Security-Policy" content="' + EXT_CSP.replace(/^sandbox [^;]*; /, '').replace(/; frame-ancestors 'self'$/, '') + '">'; // (the same rules, for when the page is shown from the hub's own copy of it)
const BRIDGE = EXT_META + `<script>(function(){var n=0,w={};window.addEventListener('message',function(e){var m=e.data;if(m&&m.hub&&w[m.id]){var f=w[m.id];delete w[m.id];m.error?f[1](new Error(m.error)):f[0](m.result)}});
function call(method,args){return new Promise(function(ok,no){var id=++n;w[id]=[ok,no];parent.postMessage({hub:1,id:id,method:method,args:args||[]},'*')})}
window.hub={storage:{get:function(k){return call('storage.get',[k])},set:function(k,v){return call('storage.set',[k,v])},all:function(){return call('storage.all')},remove:function(k){return call('storage.remove',[k])}},toast:function(t){return call('toast',[String(t)])},user:function(){return call('user')},open:function(u){return call('open',[String(u)])},
tasks:{lists:function(){return call('tasks.lists')},list:function(l){return call('tasks.list',[{list:l}])},add:function(o){return call('tasks.add',[o])},complete:function(l,id){return call('tasks.complete',[{list:l,id:id}])}},
contacts:{list:function(){return call('contacts.list')},add:function(o){return call('contacts.add',[o])}},
calendar:{events:function(from,to){return call('calendar.events',[{from:from,to:to}])},add:function(o){return call('calendar.add',[o])}}}})()</script>`;
const withBridge = html => /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, m => m + BRIDGE) : BRIDGE + html;
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/store/my'.length) || '/', me = users.current();
try {
let m;
if (p === '/items' && req.method === 'GET') return json(res, 200, { items: loadItems().sort((a, b) => b.added - a.added).map(view), installed: stateOf().installed, admin: !!me && me.role === 'admin' });
if (p === '/items' && req.method === 'POST') { // add or change an item's details (admin)
needAdmin(me); const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = loadItems(), old = b.id ? l.find(x => x.id === b.id) : null;
if (b.id && !old) throw fail(404, 'Item not found'); if (!old && !KINDS.includes(b.kind)) throw fail(400, 'Choose what kind of thing this is');
const title = str(b.title, 120); if (!title) throw fail(400, 'Give it a title');
const url = str(b.url, 300); if (url && !/^https?:\/\/\S+$/i.test(url)) throw fail(400, 'The link must start with http');
if ((old ? old.kind : b.kind) === 'link' && !url) throw fail(400, 'Paste the address of the site');
const it = old || { id: newId(), kind: b.kind, files: [], cover: false, added: Date.now() };
Object.assign(it, { title, creator: str(b.creator, 120), description: str(b.description, 2000), version: str(b.version, 30), url, updated: Date.now() });
if (it.kind === 'extension' || it.kind === 'link') it.icon = str(b.icon, 4);
if (it.kind === 'link') it.embed = !!b.embed;
if (!old) { if (l.length >= 2000) throw fail(400, 'That is plenty of items'); l.push(it); } saveItems(l); return json(res, 200, view(it));
}
if ((m = /^\/items\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') {
needAdmin(me); const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Item not found');
for (const f of it.files) fs.rmSync(fileOn(it, f.id), { force: true }); fs.rmSync(coverOn(it), { force: true }); saveItems(l.filter(x => x !== it)); return json(res, 200, { ok: true });
}
if ((m = /^\/items\/([0-9a-f]{12})\/files$/.exec(p)) && req.method === 'POST') { // upload one file (the body is the file; its name is in ?name=) or copy one from Seafile (JSON { repo, path })
needAdmin(me); const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Item not found');
if (it.files.length >= MAX_FILES_PER_ITEM) throw fail(400, 'That is plenty of files for one item');
if (it.kind === 'link') throw fail(400, 'A site has no files'); if (it.kind !== 'music' && it.kind !== 'app' && it.files.length >= 1) throw fail(400, 'This kind of item holds one file. Remove the old one first.');
const fid = newId(); let name, size;
if (/json/.test(req.headers['content-type'] || '')) { const b = JSON.parse(await readBody(req, 1e4) || '{}'), src = await fromSeafile(String(b.repo || ''), String(b.path || '')); name = src.name; const tmp = fileOn(it, fid) + '.part'; fs.mkdirSync(FILES(), { recursive: true, mode: 0o700 }); let n = 0; src.stream.on('data', c => { n += c.length; if (n > MAX_FILE) src.stream.destroy(fail(413, 'That file is too large')); }); await pipeline(src.stream, fs.createWriteStream(tmp, { mode: 0o600 })).catch(e => { fs.rm(tmp, { force: true }, () => {}); throw e.code ? e : fail(502, 'The copy did not finish'); }); fs.renameSync(tmp, fileOn(it, fid)); size = n; }
else { name = safeName(u.searchParams.get('name')); size = await receive(req, fileOn(it, fid), it.kind === 'extension' ? MAX_EXT : MAX_FILE); }
const ext = extOf(name);
const bad = it.kind === 'book' && !BOOK_EXT.includes(ext) ? 'A book must be an EPUB, PDF, MOBI, AZW3 or DOCX file' : it.kind === 'music' && !/^(mp3|m4a|aac|flac|ogg|opus|wav)$/.test(ext) ? 'Music must be an MP3, M4A, AAC, FLAC, OGG, OPUS or WAV file' : it.kind === 'extension' && ext !== 'html' ? 'An extension is one .html file' : '';
if (bad) { fs.rm(fileOn(it, fid), { force: true }, () => {}); throw fail(400, bad); }
it.files.push({ id: fid, name, size, ext }); it.updated = Date.now(); saveItems(l); return json(res, 200, view(it));
}
if ((m = /^\/items\/([0-9a-f]{12})\/files\/([0-9a-f]{12})$/.exec(p))) {
const l = loadItems(), it = l.find(x => x.id === m[1]), f = it && it.files.find(x => x.id === m[2]); if (!f) throw fail(404, 'Not found');
if (req.method === 'DELETE') { needAdmin(me); fs.rmSync(fileOn(it, f.id), { force: true }); it.files = it.files.filter(x => x !== f); it.updated = Date.now(); saveItems(l); return json(res, 200, view(it)); }
if (req.method === 'GET') return send(req, res, fileOn(it, f.id), it.kind === 'extension' ? 'application/octet-stream' : TYPES[f.ext] || 'application/octet-stream', f.name, u.searchParams.get('dl') === '1' || !/^(audio|application\/pdf)/.test(TYPES[f.ext] || ''));
}
if ((m = /^\/items\/([0-9a-f]{12})\/cover$/.exec(p))) {
const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Not found');
if (req.method === 'POST') { // the picture is the body; its type must be a picture
needAdmin(me); const type = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase(); if (!Object.values(IMAGE).includes(type)) throw fail(400, 'The cover must be a JPEG, PNG, GIF or WebP picture');
await receive(req, coverOn(it), MAX_COVER); it.cover = type; it.updated = Date.now(); saveItems(l); return json(res, 200, view(it));
}
if (req.method === 'DELETE') { needAdmin(me); fs.rm(coverOn(it), { force: true }, () => {}); it.cover = false; saveItems(l); return json(res, 200, view(it)); }
if (req.method === 'GET') { if (!it.cover) throw fail(404, 'No cover'); return send(req, res, coverOn(it), it.cover, 'cover', false); }
}
if ((m = /^\/items\/([0-9a-f]{12})\/to-library$/.exec(p)) && req.method === 'POST') { // a book goes onto the person's own shelf
if (!users.can(me, 'read')) throw fail(403, 'You do not have the Books tab');
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'book' || !it.files[0]) throw fail(404, 'That is not a book');
const f = it.files[0], buf = fs.readFileSync(fileOn(it, f.id)); let cover; if (it.cover) { try { cover = { type: it.cover, data: fs.readFileSync(coverOn(it)) }; } catch {} }
try { return json(res, 200, books.addBook(buf, f.ext, { title: it.title, author: it.creator }, cover)); } catch (e) { throw fail(400, e.message); }
}
// ---- extensions
if ((m = /^\/ext\/([0-9a-f]{12})\/page$/.exec(p)) && req.method === 'GET') { // the page itself, framed by the hub
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension' || !it.files[0]) throw fail(404, 'Not found');
const html = withBridge(fs.readFileSync(fileOn(it, it.files[0].id), 'utf8'));
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': EXT_CSP, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' }); return res.end(html);
}
if ((m = /^\/ext\/([0-9a-f]{12})\/(install|uninstall)$/.exec(p)) && req.method === 'POST') {
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found');
const s = stateOf(); s.installed = s.installed.filter(x => x !== it.id); if (m[2] === 'install') s.installed.push(it.id); if (m[2] === 'uninstall') { delete s.data[it.id]; delete s.grants[it.id]; } saveState(s); return json(res, 200, { installed: s.installed });
}
if ((m = /^\/ext\/([0-9a-f]{12})\/grants$/.exec(p))) { // what the person has allowed this extension to reach in the hub
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf();
if (req.method === 'GET') return json(res, 200, { grants: s.grants[it.id] || [] });
if (req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'); s.grants[it.id] = [...new Set((Array.isArray(b.grants) ? b.grants : []).filter(x => PERMS.includes(x)))]; saveState(s); return json(res, 200, { grants: s.grants[it.id] }); }
}
if ((m = /^\/ext\/([0-9a-f]{12})\/data$/.exec(p))) { // the extension's own saved settings for this person
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf();
if (req.method === 'GET') return json(res, 200, s.data[it.id] || {});
if (req.method === 'POST') { const b = JSON.parse(await readBody(req, MAX_STATE + 1000) || '{}'); if (!b || typeof b !== 'object' || Array.isArray(b)) throw fail(400, 'Bad data'); if (JSON.stringify(b).length > MAX_STATE) throw fail(413, 'That is more than an extension may keep (256 KB)'); s.data[it.id] = b; saveState(s); return json(res, 200, { ok: true }); }
}
return json(res, 404, { error: 'Not found' });
} catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, loadItems, EXT_CSP };
+70
View File
@@ -0,0 +1,70 @@
// Notes and lists: notes and checklists that are private, shared with the whole family, or shared with chosen people. Anyone a note is shared with can edit it;
// checklist items are changed one at a time (tick, add, edit, remove), so two people ticking things at once never overwrite each other. Kept in DATA_DIR/notes.json.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FILE = () => path.join(DATA_DIR, 'notes.json');
const COLORS = ['', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink'];
const MAX_NOTES = 3000, MAX_ITEMS = 300, MAX_BODY = 50000;
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message, extra) => Object.assign(new Error(message), { code, extra });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
const newId = () => crypto.randomBytes(6).toString('hex');
let cache = null;
const all = () => cache || (cache = (() => { try { const j = JSON.parse(fs.readFileSync(FILE(), 'utf8')); return Array.isArray(j.notes) ? j.notes : []; } catch { return []; } })());
const save = () => { fs.mkdirSync(path.dirname(FILE()), { recursive: true }); atomicWrite(FILE(), JSON.stringify({ notes: all() }), { mode: 0o600 }); };
const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'notes'); };
const sees = (n, me) => n.owner === me.id || (n.share === 'family' && users.can(me, 'notes')) || (Array.isArray(n.share) && n.share.includes(me.id));
const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; };
const view = (n, me) => ({ id: n.id, type: n.type, title: n.title, body: n.body, items: n.items, owner: n.owner, ownerName: nameOf(n.owner), mine: n.owner === me.id, share: n.share, sharedWith: Array.isArray(n.share) ? n.share.map(id => ({ id, name: nameOf(id) })) : [], color: n.color, pinned: n.pinned, archived: n.archived, created: n.created, updated: n.updated, updatedByName: n.updatedBy ? nameOf(n.updatedBy) : '', rev: n.rev });
const cleanShare = (v, me) => v === 'family' ? 'family' : Array.isArray(v) ? [...new Set(v.map(String))].filter(id => id !== me.id && live(id)).slice(0, 30) : 'me';
const touch = (n, me) => { n.updated = Date.now(); n.updatedBy = me.id; n.rev = (n.rev || 0) + 1; };
const find = (id, me, ownerOnly) => { const n = all().find(x => x.id === id); if (!n || !sees(n, me)) throw fail(404, 'Note not found'); if (ownerOnly && n.owner !== me.id && me.role !== 'admin') throw fail(403, 'Only the person who made it, or an admin, can do that'); return n; };
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/notes'.length) || '/', me = users.current();
try {
let m;
if (p === '/' && req.method === 'GET') return json(res, 200, { notes: all().filter(n => sees(n, me)).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).map(n => view(n, me)), people: users.all().filter(x => x.id !== me.id && live(x.id)).map(x => ({ id: x.id, name: x.name })) });
if (p === '/' && req.method === 'POST') { // create, or change the note's own fields
const b = JSON.parse(await readBody(req, MAX_BODY + 5e4) || '{}');
if (!b.id) {
if (all().length >= MAX_NOTES) throw fail(400, 'That is plenty of notes');
const type = b.type === 'list' ? 'list' : 'note', n = { id: newId(), type, title: str(b.title, 120).trim(), body: type === 'note' ? str(b.body, MAX_BODY) : '', items: type === 'list' ? (Array.isArray(b.items) ? b.items : []).map(t => str(t, 300).trim()).filter(Boolean).slice(0, MAX_ITEMS).map(text => ({ id: newId(), text, done: false })) : [], owner: me.id, share: cleanShare(b.share === undefined ? 'family' : b.share, me), color: COLORS.includes(b.color) ? b.color : '', pinned: false, archived: false, created: Date.now(), updated: Date.now(), updatedBy: me.id, rev: 1 };
all().push(n); save(); return json(res, 200, view(n, me));
}
const n = find(String(b.id), me, b.share !== undefined);
if (b.rev !== undefined && +b.rev !== n.rev && (b.title !== undefined || b.body !== undefined)) throw fail(409, 'Someone else changed this note while you were editing', { note: view(n, me) });
if (b.title !== undefined) n.title = str(b.title, 120).trim(); if (b.body !== undefined && n.type === 'note') n.body = str(b.body, MAX_BODY);
if (b.share !== undefined) n.share = cleanShare(b.share, me); if (b.color !== undefined && COLORS.includes(b.color)) n.color = b.color;
if (b.pinned !== undefined) n.pinned = !!b.pinned; if (b.archived !== undefined) n.archived = !!b.archived;
touch(n, me); save(); return json(res, 200, view(n, me));
}
if ((m = /^\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { const n = find(m[1], me, true); cache = all().filter(x => x !== n); save(); return json(res, 200, { ok: true }); }
if ((m = /^\/([0-9a-f]{12})\/items$/.exec(p)) && req.method === 'POST') { // one change to a checklist
const n = find(m[1], me); if (n.type !== 'list') throw fail(400, 'That is not a checklist'); const b = JSON.parse(await readBody(req, 2e4) || '{}'), it = id => n.items.find(x => x.id === String(id));
if (b.text !== undefined && !b.edit) { const t = str(b.text, 300).trim(); if (!t) throw fail(400, 'Write the item'); if (n.items.length >= MAX_ITEMS) throw fail(400, 'That is a long list'); n.items.push({ id: newId(), text: t, done: false, addedBy: me.name }); }
else if (b.toggle) { const x = it(b.toggle); if (!x) throw fail(404, 'Item not found'); x.done = !!b.done; if (x.done) { x.by = me.id; x.byName = me.name; x.at = Date.now(); } else { delete x.by; delete x.byName; delete x.at; } }
else if (b.edit) { const x = it(b.edit), t = str(b.text, 300).trim(); if (!x || !t) throw fail(400, 'Write the item'); x.text = t; }
else if (b.remove) n.items = n.items.filter(x => x.id !== String(b.remove));
else if (b.clearDone) n.items = n.items.filter(x => !x.done);
else throw fail(400, 'Nothing to do');
touch(n, me); save(); return json(res, 200, view(n, me));
}
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message, ...(e.extra || {}) }); }
}
/** Notes and lists this person can see that mention the words: [{ id, type, title, snippet, share }]. */
function search(me, q) {
const needle = q.toLowerCase(), out = [];
for (const n of all()) { if (!sees(n, me) || n.archived) continue; const text = n.type === 'list' ? n.items.map(i => i.text).join(' · ') : n.body, hay = (n.title + ' ' + text).toLowerCase(); if (!hay.includes(needle)) continue;
const at = text.toLowerCase().indexOf(needle); out.push({ id: n.id, type: n.type, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note'), snippet: (at > 20 ? '…' : '') + text.slice(Math.max(0, at - 20), Math.max(0, at - 20) + 90), share: n.share === 'family' ? 'Family' : n.share === 'me' ? 'Just me' : 'Shared' }); }
return out.slice(0, 8);
}
/** Notes and lists shared with the whole family, for the family board. */
const familyNotes = () => all().filter(n => n.share === 'family' && !n.archived).sort((a, b) => (b.pinned - a.pinned) || b.updated - a.updated).slice(0, 20).map(n => ({ id: n.id, type: n.type, title: n.title || (n.type === 'list' ? 'Checklist' : 'Note'), pinned: n.pinned, total: n.items.length, done: n.items.filter(i => i.done).length, next: (n.items.find(i => !i.done) || {}).text || '', items: n.type === 'list' ? n.items.slice(0, 80).map(i => ({ text: i.text, done: !!i.done, by: i.addedBy || '' })) : [], body: n.type === 'note' ? n.body.slice(0, 400) : '' }));
module.exports = { handle, search, familyNotes };
+25 -59
View File
@@ -309,13 +309,13 @@
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.27",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.27.tgz",
"integrity": "sha512-9008ddFqqKk7lMF2S1sLU7ExfJZP9zPw5tkZy24EVt45Ryme2x85f/5vJZZqQ+vCJkKcCY78dJ5yuEVG3ZrVZA==",
"version": "5.4.29",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.29.tgz",
"integrity": "sha512-CCUNycmUjrV5M0wNf23waWAt9eOHczhjLbUQS7k0FNJvR0py9VSqHFt+tSl/D0+XI73VLG0U+PwYtq9R0zsm9Q==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.4.1",
"libmime": "5.5.2",
"libmime": "5.6.3",
"libqp": "3.0.1"
},
"engines": {
@@ -650,16 +650,16 @@
}
},
"node_modules/imapflow": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-2.9.0.tgz",
"integrity": "sha512-8drBcDmYhDNkMHzoXVd46/XnZg3krcZyLsyOyVuAxTQPPKUc0u/bpb3ocl36u2b9ZD/8J6rz2YdUbzHyaj1Pmw==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-2.9.1.tgz",
"integrity": "sha512-hOq2/+T+WnQzRy7D40gYlio25nG+c9bhtrxowokOQM57SwZFqRbqAtxFQcIpx82lhYBPQEEVVjmkYpd9Fplj7g==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.27",
"@zone-eu/mailsplit": "5.4.29",
"encoding-japanese": "2.4.0",
"iconv-lite": "0.7.3",
"libbase64": "1.4.1",
"libmime": "5.5.2",
"libmime": "5.6.3",
"libqp": "3.0.1",
"pino": "10.3.1",
"socks": "2.8.10"
@@ -730,9 +730,9 @@
}
},
"node_modules/libmime": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.5.2.tgz",
"integrity": "sha512-Byw3CchK4eSqbgzpttsUoC4hx2VPP+hnveAuYAYZNipEc+0WlDFjVb1ISNscjq/idvyDP/Xjg8mnBcdgbsgvzg==",
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.6.3.tgz",
"integrity": "sha512-cuTUW/Jybvh5O4/U9p61gyutHOjDtZyGQYuY99+bfnaFwqcDf2Nb+R6hMHY6DDen2H9wLKQSMpofWUgn2TI5JQ==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.4.0",
@@ -773,17 +773,17 @@
}
},
"node_modules/mailparser": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.12.2.tgz",
"integrity": "sha512-JbOt+AjJIr7+bhMp8g7ILevleoDAD0bXVlAJmikrz0sbQqmt88YELKDwYQL5JNugkV9lcN+DFv32dyhPsvqpHQ==",
"version": "3.12.4",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.12.4.tgz",
"integrity": "sha512-FdoMF+S89/AQaJHY7StwTfABPHZ8Vey+CDyUno5j+TO0jF0UxM+la4Pe9MeqKqBO4Y9ZMB2eomdyg5i+jzpfZw==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.27",
"@zone-eu/mailsplit": "5.4.29",
"he": "1.2.0",
"html-to-text": "10.0.1",
"libmime": "5.6.0",
"libmime": "5.6.3",
"linkify-it": "5.0.2",
"nodemailer": "10.1.7",
"nodemailer": "10.2.1",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
},
@@ -791,30 +791,6 @@
"node": ">=20.0.0"
}
},
"node_modules/mailparser/node_modules/libmime": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.6.0.tgz",
"integrity": "sha512-b5HR41QUt1whlvLnY3AhAYwba56DDiNv1xVTH5viVq86/m+wvW+HeooLx6x+MdNapho9ki7Lo/fTxuqwhEs/sQ==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.4.0",
"iconv-lite": "0.7.3",
"libbase64": "1.4.1",
"libqp": "3.0.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "10.1.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -837,9 +813,9 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.0.tgz",
"integrity": "sha512-/XyGw6kX2stMdKGUJdAQmYAeY1FscWfdRydfd2NtK3A3KDfUUXCqZzExSNAt2xhh90X2qLCR+o2Gd48YPJcJwA==",
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.1.tgz",
"integrity": "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
@@ -1035,30 +1011,20 @@
}
},
"node_modules/smtp-server": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.21.0.tgz",
"integrity": "sha512-vhJTg1JR6+mM5vlCD/TzUvFcQ5JQiCqP0FY3TgMJ0u3MPuvxawKD/6PA+TacPYEFVAgZIjLaCOSf87L4jcN0+w==",
"version": "3.21.1",
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.21.1.tgz",
"integrity": "sha512-gj95f6YhcL0uu3m3FvRnQ6LSotursugAuVjK5V4f0f2C/4OSVpn/+h28F4pPJFob/myli9V+F7/Hxx9DJVdwvg==",
"dev": true,
"license": "MIT-0",
"dependencies": {
"ipv6-normalize": "1.0.1",
"nodemailer": "10.1.7",
"nodemailer": "10.2.1",
"punycode.js": "2.3.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/smtp-server/node_modules/nodemailer": {
"version": "10.1.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
"dev": true,
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/socks": {
"version": "2.8.10",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "for t in smoke tasks push users plex player lyrics signal mail mail-imap files; do node tools/$t-test.js || exit 1; done",
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
+177
View File
@@ -0,0 +1,177 @@
// Podcasts from Podgrab (https://github.com/akhilrex/podgrab). The hub talks to Podgrab's own web API on the person's behalf, so the
// Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub.
const fs = require('fs'), path = require('path');
const users = require('./users');
const prog = require('./progress')('podcast-progress.json'); // where each person got to in each episode
const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, '');
const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : '';
// Only these Podgrab calls are passed through: [method, path pattern]. Podgrab's page routes and its settings, backup and OPML routes are not.
const ALLOWED = [
['GET', /^\/podcasts$/], ['POST', /^\/podcasts$/],
['GET', /^\/podcasts\/[\w-]+$/], ['DELETE', /^\/podcasts\/[\w-]+$/],
['GET', /^\/podcasts\/[\w-]+\/(items|image|download|pause|unpause)$/], ['DELETE', /^\/podcasts\/[\w-]+\/items$/],
['GET', /^\/podcastitems$/], ['GET', /^\/podcastitems\/[\w-]+$/], ['PATCH', /^\/podcastitems\/[\w-]+$/],
['GET', /^\/podcastitems\/[\w-]+\/(image|file|markPlayed|markUnplayed|bookmark|unbookmark|download|delete)$/],
];
// Podgrab holds every show in one shared library; which of them are on a person's own list is kept here, per person. A new person starts with an empty
// list (the admin starts with everything already in Podgrab). Taking a show off your list leaves Podgrab alone unless nobody else follows it.
const subsFile = uid => path.join(users.dirOf(uid), 'podcast-subs.json');
const subsRead = uid => { try { const j = JSON.parse(fs.readFileSync(subsFile(uid), 'utf8')); return Array.isArray(j) ? j : null; } catch { return null; } };
const subsWrite = (uid, ids) => { const f = subsFile(uid), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify([...new Set(ids)]), { mode: 0o600 }); fs.renameSync(tmp, f); };
// ---- Podcast Index (https://podcastindex.org): a free, open directory searched by topic as well as name. Needs a free key and secret (PODCASTINDEX_KEY, PODCASTINDEX_SECRET).
const PI_URL = (process.env.PODCASTINDEX_URL || 'https://api.podcastindex.org/api/1.0').replace(/\/$/, '');
const PI_TOPICS = ['News', 'Technology', 'Business', 'Science', 'History', 'Health', 'Religion', 'Education', 'Comedy', 'Sports', 'Music', 'Society'];
const piOn = () => !!(process.env.PODCASTINDEX_KEY && process.env.PODCASTINDEX_SECRET);
async function piGet(path) {
const date = String(Math.floor(Date.now() / 1000)), sig = require('crypto').createHash('sha1').update(process.env.PODCASTINDEX_KEY + process.env.PODCASTINDEX_SECRET + date).digest('hex');
const r = await fetch(PI_URL + path, { headers: { 'user-agent': 'NE-Hub/1.0', 'x-auth-key': process.env.PODCASTINDEX_KEY, 'x-auth-date': date, authorization: sig }, signal: AbortSignal.timeout(10000) });
if (!r.ok) throw new Error('status ' + r.status); return r.json();
}
const piShape = f => ({ title: f.title, author: f.author || f.ownerName || '', feedUrl: f.url, image: f.image || f.artwork || '', explicit: !!f.explicit });
const piKeep = f => f && /^https?:\/\//i.test(f.url || '') && !f.dead;
const piSearch = async q => ((await piGet('/search/byterm?max=20&q=' + encodeURIComponent(q))).feeds || []).filter(piKeep).map(piShape);
const piTrending = async cat => ((await piGet('/podcasts/trending?max=20&lang=en&cat=' + encodeURIComponent(cat))).feeds || []).filter(piKeep).map(piShape);
async function appleSearch(q) {
const r = await fetch((process.env.APPLE_PODCASTS_URL || 'https://itunes.apple.com/search') + '?' + new URLSearchParams({ media: 'podcast', limit: '15', term: q }), { signal: AbortSignal.timeout(10000) });
if (!r.ok) throw new Error('status ' + r.status);
return ((await r.json()).results || []).filter(x => /^https?:\/\//i.test(x.feedUrl || '')).map(x => ({ title: x.collectionName, author: x.artistName, feedUrl: x.feedUrl, image: x.artworkUrl100, explicit: x.collectionExplicitness === 'explicit' || x.trackExplicitness === 'explicit' }));
}
const idOf = o => o && (o.ID !== undefined ? o.ID : o.id);
async function podgrab(p, init = {}) {
const r = await fetch(BASE + p, { ...init, headers: { accept: 'application/json', ...(AUTH ? { authorization: AUTH } : {}), ...init.headers }, redirect: 'manual', signal: AbortSignal.timeout(20000) });
return r;
}
async function mySubs(me) {
const have = subsRead(me.id); if (have) return have;
let ids = [];
if (me.role === 'admin') { try { const l = await (await podgrab('/podcasts')).json(); ids = (Array.isArray(l) ? l : []).map(idOf).filter(Boolean); } catch { return []; } }
subsWrite(me.id, ids); return ids;
}
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const enabled = () => !!BASE;
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/podcast'.length) || '/';
if (p === '/discover') { // find new shows: Podcast Index (free, with a key) and Apple's free directory (the search words go to them; nothing else does)
const q = (u.searchParams.get('q') || '').trim().slice(0, 100), topic = (u.searchParams.get('topic') || '').trim().slice(0, 40);
if (topic && !piOn()) return json(res, 200, []);
if (!topic && q.length < 2) return json(res, 200, []);
const [pi, ap] = await Promise.allSettled([piOn() ? (topic ? piTrending(topic) : piSearch(q)) : Promise.resolve([]), topic ? Promise.resolve([]) : appleSearch(q)]);
if (pi.status === 'rejected' && ap.status === 'rejected') return json(res, 502, { error: 'The podcast directory did not answer' });
const seen = new Set(), out = [];
for (const x of [...(pi.value || []), ...(ap.value || [])]) { const k = x.feedUrl.replace(/\/+$/, '').toLowerCase(); if (!seen.has(k)) { seen.add(k); out.push(x); } }
const me0 = users.current(), safe = !!(me0 && me0.kid && me0.kid.on && me0.kid.safe); // (a child account does not see podcasts marked explicit)
return json(res, 200, out.filter(x => !(safe && x.explicit)).slice(0, 30));
}
if (p === '/discover/topics') return json(res, 200, piOn() ? PI_TOPICS : []);
if (p === '/progress') {
if (req.method === 'GET') return json(res, 200, prog.load());
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e6) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
return json(res, 200, prog.merge(body));
}
if (!BASE) return json(res, 503, { error: 'Podcasts are not set up on this server' });
if (p === '/status') return json(res, 200, { ok: true, host: new URL(BASE).host });
if (!ALLOWED.some(([m, re]) => m === req.method && re.test(p))) return json(res, 404, { error: 'Not found' });
const me = users.current(), mine = await mySubs(me);
let m;
try {
if (req.method === 'GET' && p === '/podcasts') { // only the shows on this person's list
const r = await podgrab('/podcasts' + u.search); const l = await r.json();
return json(res, r.status, Array.isArray(l) ? l.filter(x => mine.includes(idOf(x))) : l);
}
if (req.method === 'POST' && p === '/podcasts') { // add to Podgrab (if it is not there yet) and to this person's list
const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e5) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
let url = ''; try { url = String(JSON.parse(Buffer.concat(chunks).toString('utf8')).url || '').trim(); } catch {}
if (!/^https?:\/\//i.test(url)) return json(res, 400, { error: 'Enter a feed address starting with http' });
const r = await podgrab('/podcasts', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ url }) });
let added = null; if (r.status === 200) { try { added = await r.json(); } catch {} }
let id = idOf(added);
if (!id) { // Podgrab already had it (409), or did not say which: find it by its feed address
const l = await (await podgrab('/podcasts')).json(); const norm = x => String(x || '').replace(/\/+$/, '').toLowerCase();
const hit = (Array.isArray(l) ? l : []).find(x => norm(x.URL || x.url) === norm(url)); id = idOf(hit); added = hit;
if (!id) return json(res, r.status === 200 ? 502 : r.status, { error: 'Podgrab could not add that feed' });
}
subsWrite(me.id, [...mine, id]); return json(res, 200, added || { ID: id });
}
if (req.method === 'DELETE' && (m = /^\/podcasts\/([\w-]+)$/.exec(p))) { // off this person's list; out of Podgrab only when nobody else follows it
subsWrite(me.id, mine.filter(x => x !== m[1]));
const others = users.all().some(x => x.id !== me.id && (subsRead(x.id) || []).includes(m[1]));
if (!others) await podgrab(p, { method: 'DELETE' }).catch(() => {});
return json(res, 200, { ok: true, removed: !others });
}
if ((m = /^\/podcasts\/([\w-]+)(\/|$)/.exec(p)) && !mine.includes(m[1])) return json(res, 404, { error: 'That podcast is not on your list' });
} catch (e) { return json(res, 502, { error: 'Podgrab did not answer' + (e.name === 'TimeoutError' ? ' in time' : '') }); }
const hasBody = req.method === 'POST' || req.method === 'PATCH';
let body;
if (hasBody) { const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e5) return json(res, 413, { error: 'Too large' }); chunks.push(c); } body = Buffer.concat(chunks); }
const h = { accept: req.headers.accept || '*/*', 'accept-encoding': 'identity' }; // so the length Podgrab reports is the length we pass on
if (AUTH) h.authorization = AUTH;
if (req.headers.range) h.range = req.headers.range;
if (hasBody) h['content-type'] = 'application/json';
const ctl = new AbortController(), streaming = p.endsWith('/file'), t = streaming ? null : setTimeout(() => ctl.abort(), 20000); // audio may take as long as it takes
res.on('close', () => { clearTimeout(t); ctl.abort(); });
try {
const up = await fetch(BASE + p + u.search, { method: req.method, headers: h, body, signal: ctl.signal, redirect: 'manual' });
const out = { 'cache-control': p.endsWith('/image') ? 'private, max-age=86400' : 'no-store' };
for (const k of ['content-type', 'content-length', 'content-range', 'accept-ranges']) if (up.headers.get(k)) out[k] = up.headers.get(k);
if (up.status >= 300 && up.status < 400) { // Podgrab sends you to the original file or picture when it has no copy of its own
const loc = up.headers.get('location');
if (loc && /^https?:\/\//i.test(loc)) { res.writeHead(302, { location: loc, 'referrer-policy': 'no-referrer' }); return res.end(); }
return json(res, 502, { error: 'Podgrab sent an unexpected redirect' });
}
res.writeHead(up.status, out);
if (!up.body) return res.end();
const { Readable } = require('stream');
Readable.fromWeb(up.body).on('error', () => res.destroy()).pipe(res);
} catch (e) {
if (res.headersSent) return res.destroy();
json(res, 502, { error: 'Podgrab did not answer' + (e.name === 'AbortError' ? ' in time' : '') });
}
}
/** For transcripts: where an episode came from (its feed, its id in that feed, its file). With a person, only the shows on their own list; without one (the server's own background work), any. */
async function feedInfo(me, itemId) {
if (!BASE || !/^[\w-]+$/.test(String(itemId))) return null;
const f = (o, k) => { if (!o) return undefined; const hit = Object.keys(o).find(x => x.toLowerCase() === k.toLowerCase()); return hit === undefined ? undefined : o[hit]; }; // (Podgrab's field names, whatever their capitals)
const it = await (await podgrab('/podcastitems/' + itemId)).json().catch(() => null); if (!it) return null;
const pid = f(it, 'PodcastID') || f(it, 'podcastId'); if (!pid || (me && !(await mySubs(me)).includes(pid))) return null;
const sh = await (await podgrab('/podcasts/' + pid)).json().catch(() => null);
return { guid: f(it, 'GUID') || f(it, 'guid') || '', fileUrl: f(it, 'FileURL') || f(it, 'fileUrl') || '', feedUrl: (sh && (f(sh, 'URL') || f(sh, 'url'))) || '' };
}
/** The newest `n` episodes of every show in Podgrab, newest first overall: [{ id, title, show, guid, fileUrl, feedUrl }]. */
async function recentEpisodes(n) {
if (!BASE) return [];
const f = (o, k) => { const hit = Object.keys(o || {}).find(x => x.toLowerCase() === k.toLowerCase()); return hit === undefined ? undefined : o[hit]; };
const shows = await (await podgrab('/podcasts')).json().catch(() => []), out = [];
for (const sh of Array.isArray(shows) ? shows : []) {
const sid = idOf(sh); if (!sid) continue;
const items = await (await podgrab('/podcasts/' + sid + '/items')).json().catch(() => []);
for (const it of (Array.isArray(items) ? items : []).sort((x, y) => String(f(y, 'PubDate')).localeCompare(String(f(x, 'PubDate')))).slice(0, n))
out.push({ id: String(idOf(it)), title: f(it, 'Title') || '', show: f(sh, 'Title') || '', date: String(f(it, 'PubDate') || ''), guid: f(it, 'GUID') || '', fileUrl: f(it, 'FileURL') || '', feedUrl: f(sh, 'URL') || '' });
}
return out.sort((x, y) => y.date.localeCompare(x.date));
}
/** The audio of one episode, straight from Podgrab (for the server's own background work). */
async function episodeAudio(itemId) {
if (!BASE || !/^[\w-]+$/.test(String(itemId))) throw new Error('Bad episode');
const r = await podgrab('/podcastitems/' + itemId + '/file'), loc = r.headers.get('location');
if (r.status >= 300 && r.status < 400 && loc && /^https?:\/\//i.test(loc)) return fetch(loc, { signal: AbortSignal.timeout(2 * 3600e3) }); // (Podgrab has no copy of its own: the original)
return r;
}
/** Every episode of the shows on this person's list: [{ id, title, show }], remembered for a few minutes (the transcript search uses it to know what they may see). */
const epsCache = new Map();
async function myEpisodes(me) {
const c = epsCache.get(me.id); if (c && Date.now() - c.t < 300000) return c.v;
const f = (o, k) => { const hit = Object.keys(o || {}).find(x => x.toLowerCase() === k.toLowerCase()); return hit === undefined ? undefined : o[hit]; }, v = [];
if (BASE) for (const sid of await mySubs(me)) {
const sh = await (await podgrab('/podcasts/' + sid)).json().catch(() => null), items = await (await podgrab('/podcasts/' + sid + '/items')).json().catch(() => []);
for (const it of Array.isArray(items) ? items : []) v.push({ id: String(idOf(it)), title: f(it, 'Title') || '', show: (sh && f(sh, 'Title')) || '', thumb: '/api/podcast/podcasts/' + sid + '/image' });
}
epsCache.set(me.id, { t: Date.now(), v }); return v;
}
module.exports = { handle, myEpisodes, enabled, feedInfo, recentEpisodes, episodeAudio };
+15
View File
@@ -0,0 +1,15 @@
// Where each person has got to in each thing they listen to (audiobooks, podcast episodes), kept on the server so every device agrees.
// One file per person; for each item the newest report wins, so an old device cannot overwrite a newer position.
const fs = require('fs'), path = require('path');
const users = require('./users');
module.exports = name => {
const file = () => path.join(users.dir(), name);
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } };
const merge = body => {
const mine = load();
for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) mine[k] = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine;
};
return { load, merge };
};
+157 -29
View File
@@ -4,13 +4,14 @@ const _fetch = window.fetch.bind(window);
window.fetch = async (...a) => {
const r = await _fetch(...a);
if (r.status === 401 && r.headers.get('x-hub-auth') === 'required') location.replace('/login');
if (r.status === 403 && r.headers.get('x-hub-limit') && typeof ME !== 'undefined' && ME && ME.kid) r.clone().json().then(j => { if (String(r.headers.get('x-hub-limit')).startsWith('area:')) { toast(j.error || 'That time is up for today'); return; } kidUi.st = j.status || { on: true, locked: j.limit }; kidPaint(); }).catch(() => {}); /* (an area's time is up: say so; the whole app locks only when the day's time or bedtime is up) */ // (a child account whose time is up: show the lock screen)
return r;
};
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']);
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups', 'music_feat_pls']);
const outbox = new Map(); let outTimer = null, warned = false;
function flushSettings() {
outTimer = null;
@@ -37,7 +38,9 @@ async function loadMe() {
}
// Signal is for desktop-class screens only (a window at least 900 px wide with a mouse or trackpad), and needs the server to have it set up.
const sgMQ = matchMedia('(min-width: 900px) and (pointer: fine)');
const allowed = id => id === 'planner' ? (allowed('calendar') || allowed('tasks')) : id === 'links' ? (!ME || ME.tabs.includes('links') || ME.tabs.includes('verse')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
// Related tabs share one menu entry (a row of buttons at the top of the page switches between them); each still needs its own permission.
const MERGED = { listen: ['Listen', ['music', 'radio', 'podcast', 'audio']], reading: ['Read', ['bible', 'read']] };
const allowed = id => MERGED[id] ? MERGED[id][1].some(allowed) : id === 'planner' ? (allowed('calendar') || allowed('tasks')) : id === 'links' ? (!ME || ME.tabs.includes('links') || ME.tabs.includes('verse')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
async function loadSettings() {
try {
@@ -91,6 +94,14 @@ const ICONS = {
shuffle: '<path d="M3 7h3c4 0 5 10 9 10h6M3 17h3c1.5 0 2.5-1.5 3.5-3M15 7h6M18 4l3 3-3 3M18 14l3 3-3 3"/>',
more: '<circle cx="5" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="19" cy="12" r="1.6" fill="currentColor"/>',
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
store: '<path d="M4 9l1.5-5h13L20 9M4 9v11h16V9M4 9c0 1.7 1.3 3 3 3s3-1.3 3-3c0 1.7 1.3 3 3 3s3-1.3 3-3 1.3 3 3 3"/><path d="M10 20v-5h4v5"/>',
download: '<path d="M12 4v11M7 11l5 5 5-5M5 20h14"/>',
arrup: '<path d="M6 14l6-6 6 6"/>',
arrdn: '<path d="M6 10l6 6 6-6"/>',
bell: '<path d="M6 17V11a6 6 0 0112 0v6l1.5 2h-15zM10 21h4"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
chef: '<path d="M7 14a4 4 0 11.8-7.9A4 4 0 0116 6.5 4 4 0 0117 14v6H7z"/><path d="M7 17h10"/>',
people: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0113 0M16 4.5a3.5 3.5 0 010 7M18 14a6 6 0 013.5 6"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/>',
book: '<path d="M4 5a2 2 0 012-2h13v16H6a2 2 0 00-2 2z"/><path d="M19 19v2H6a2 2 0 01-2-2"/><path d="M9 7h6"/>',
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0014 0M12 18v3M9 21h6"/>',
@@ -98,6 +109,12 @@ const ICONS = {
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"/>',
bookmark: '<path d="M7 4h10v16l-5-4-5 4z"/>',
radio: '<rect x="3" y="8" width="18" height="12" rx="2"/><path d="M7 8l10-4M8 14h5M16 14h.01M16 17h.01"/>',
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"/>',
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>`;
@@ -152,19 +169,30 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
// ---- Navigation
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry (each part still needs its own permission). */
const navTabs = () => { const out = []; let done = false; for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; if (t[0] === 'tasks' || t[0] === 'calendar') { if (!done) { out.push(['planner', 'Planner']); done = true; } continue; } out.push(t); } return out; };
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' };
/** 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];
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', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['music', 'video', 'audio', 'podcast']], ['', ['settings']]];
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 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 PRIMARY = ['home', 'files', 'planner', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
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' };
// 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); };
const navRecent = () => { try { const l = JSON.parse(localStorage.getItem(nk('nav_recent'))); return Array.isArray(l) ? l : []; } catch { return []; } };
const navTouch = id => { if (!id || id === 'home' || id === 'settings') return; try { localStorage.setItem(nk('nav_recent'), JSON.stringify([id, ...navRecent().filter(x => x !== id)].slice(0, 8))); } catch {} };
const phone = matchMedia('(max-width: 767px)');
let activeTab = 'home';
@@ -173,14 +201,14 @@ function renderNav(active = activeTab) {
if (typeof sgDockBadge === 'function') sgDockBadge();
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
const tab = ([id, label]) => `<a href="#/${id}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
const mine = navTabs();
if (phone.matches) {
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
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>` : '');
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>`;
$('#navcol').onclick = () => setNav({ mini: !navPref().mini });
@@ -188,14 +216,48 @@ function renderNav(active = activeTab) {
}
phone.addEventListener('change', () => renderNav());
function openMore() {
const rest = navTabs().filter(t => !PRIMARY.includes(t[0]));
const rowOf = ([id, label]) => `<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `<i class="nbadge" style="position:static">${id === 'signal' ? sg.unread : mailUnread}</i>` : ''}${icon('chev', 'chev')}</a>`;
const el = openSheet('More', '', navGroups(rest).map(g => `${g.label ? `<h2 style="margin:14px 0 6px">${esc(g.label)}</h2>` : ''}<div class="list" ${g.label ? '' : 'style="margin-top:10px"'}>${g.tabs.map(rowOf).join('')}</div>`).join(''));
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
/** 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 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>`);
el.querySelector('.sheet').classList.add('launcher');
const box = el.querySelector('#lnb'), draw = q => {
q = q.trim().toLowerCase();
if (q) { const hits = leafTabs().filter(t => t[1].toLowerCase().includes(q)).map(([id, l]) => [id, l]); box.innerHTML = hits.length ? `<div class="lgrid">${hits.map(tile).join('')}</div>` : '<p class="empty">No app matches.</p>'; }
else {
const rec = navRecent().map(id => tabs.find(t => t[0] === id)).filter(Boolean).slice(0, 4);
box.innerHTML = (rec.length ? `<h2>Recent</h2><div class="lgrid">${rec.map(tile).join('')}</div>` : '') + navGroups(tabs).map(g => `${g.label ? `<h2>${esc(g.label)}</h2>` : ''}<div class="lgrid" ${g.label ? '' : 'style="margin-top:14px"'}>${g.tabs.map(tile).join('')}</div>`).join('');
}
box.querySelectorAll('a.lt').forEach(a => a.addEventListener('click', closeSheet));
};
draw(''); el.querySelector('#lnq').oninput = e => draw(e.target.value); setTimeout(() => { if (document.activeElement && document.activeElement.id === 'lnq') document.activeElement.blur(); }, 0); // (no keyboard popping up until you tap the box)
el.querySelector('#lnpin').onclick = () => { closeSheet(); openPinsSheet(); };
}
/** Choose which entries sit on the phone's bottom bar (up to five, in the order you tap them). */
function openPinsSheet() {
const tabs = navTabs(); let pins = navPins();
const el = openSheet('Bottom bar', 'Tap up to five apps for the bar, in the order you want them. Everything else stays under More.', '<div class="list" id="pnl"></div><div class="bar"><button class="btn ghost" id="pnr" style="flex:1;justify-content:center">Reset</button><button class="btn" id="pns" style="flex:1;justify-content:center">Save</button></div>');
const draw = () => { el.querySelector('#pnl').innerHTML = tabs.map(([id, label]) => { const at = pins.indexOf(id); return `<button class="row" data-id="${id}"><span class="ic" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}</span><span class="grow"><b>${esc(label)}</b></span><span class="pinno${at >= 0 ? ' on' : ''}">${at >= 0 ? at + 1 : ''}</span></button>`; }).join('');
el.querySelectorAll('#pnl .row').forEach(b => b.onclick = () => { const id = b.dataset.id; if (pins.includes(id)) pins = pins.filter(x => x !== id); else if (pins.length >= 5) return toast('The bar has room for five'); else pins.push(id); draw(); }); };
draw();
el.querySelector('#pnr').onclick = () => { pins = DEFAULT_PINS.filter(id => tabs.some(t => t[0] === id)); draw(); };
el.querySelector('#pns').onclick = () => { store.set('nav_pins', pins.length ? pins : DEFAULT_PINS); closeSheet(); renderNav(); toast('Bottom bar saved'); };
}
/** Where a "Listen" or "Read" tap goes: the part you used last. */
const lastSub = g => { let l = null; try { l = JSON.parse(localStorage.getItem(nk('nav_last_' + g))); } catch {} return MERGED[g][1].includes(l) && allowed(l) ? l : MERGED[g][1].find(allowed); };
/** 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) : [];
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;
}
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
let curRoute = '';
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: () => location.replace('#/links'), podcast: viewPodcast, 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, 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 {}; } };
@@ -223,7 +285,7 @@ function drawNav() {
$('#nvf').onclick = () => history.forward();
}
$('#nvb').disabled = !canBack; $('#nvf').disabled = !canFwd;
const t = TABS.find(x => x[0] === activeTab); $('#nvt').textContent = t ? t[1] : '';
const t = TABS.find(x => x[0] === (curRoute || activeTab)); $('#nvt').textContent = t ? t[1] : '';
el.hidden = false;
}
addEventListener('popstate', e => { if (e.state && e.state.i != null) { navIdx = e.state.i; drawNav(); } });
@@ -239,7 +301,8 @@ function route() {
document.body.classList.remove('inedit', 'onaudio');
sg.timers.forEach(clearInterval); sg.timers = []; // (Signal screens start their own)
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
renderNav(name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : name);
curRoute = name; navTouch(navIdOf(name)); drawSubnav(name);
renderNav(navIdOf(name));
drawNav();
routes[name](rest.map(decodeURIComponent));
sgDockSync(name);
@@ -262,27 +325,48 @@ function homeApps() {
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
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'),
store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'),
recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'),
contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'),
radio: () => tile('#/radio', '#7b4fd6', 'radio', 'Radio', 'The Journey FM & more'),
read: () => tile('#/read', '#2b8a5e', 'book', 'Books', 'EPUB, PDF, DOCX'),
signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'),
links: () => tile('#/links', '#0f9d9a', 'link', 'Links', allLinks().length + ' saved'),
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
};
return navGroups().filter(g => g.tabs.some(t => T[t[0]])).map(g => `<h2>${esc(g.label || 'Apps')}</h2><div class="grid">${g.tabs.map(t => T[t[0]] ? T[t[0]]() : '').join('')}</div>`).join('');
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('');
}
function viewHome() {
const h = new Date().getHours();
const evening = h >= 17 || h < 5; // after work the media comes first; during the day the agenda and tasks do
const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
const recents = store.get('recents', []).slice(0, 5);
const t = typeof cur === 'function' ? cur() : null;
$('#view').innerHTML = `<div class="wrap"><h1>${greet}</h1>
const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
$('#view').innerHTML = `<div class="wrap home">
<header class="homehead"><div><h1>${greet}</h1><p class="sub">${today}</p></div>
<div class="homeact">${inboxBell()}<button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div></header>
<div id="annc"></div>
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
<div class="bar" style="margin-top:8px"><button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div>
${allowed('music') ? '<div id="npslot"></div>' : ''}
<div id="tcard"></div>
<div id="agenda"></div>
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
${homeApps()}
${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}</div>`;
${evening ? '<div id="resume"></div>' : ''}
<div class="homecols">
<div class="hcol"><div id="tcard"></div><div id="agenda"></div></div>
<div class="hcol">${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}</div>
</div>
${evening ? '' : '<div id="resume"></div>'}
<div class="homeapps">${homeApps()}</div><div id="homeext"></div></div>`;
if (typeof stHomeExt === 'function' && allowed('store')) stHomeExt($('#homeext'));
inboxWire();
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
if (typeof homeResume === 'function') homeResume($('#resume'));
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
@@ -310,3 +394,47 @@ async function walkSearch(repos, term) {
}
return out;
}
// ---- A child account: the app counts the time it is open (a ping each minute), shows how much is left, and locks when the time is up or it is bedtime (the server refuses
// requests too, so this screen is only the explanation). "Ask a parent" sends a message to the parents.
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(); };
kidUi.tick = tick; await tick(); kidUi.timer = setInterval(tick, 60e3); 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();
/** Tell the server what a child just played, read or watched (for the parents' view). Only child accounts report. */
function actLog(kind, title) { if (!ME || !ME.kid || !ME.kid.on || !title) return; const k = kind + '|' + title, now = Date.now(); if (now - (actSeen.get(k) || 0) < 10 * 60e3) return; actSeen.set(k, now); jfetch('/api/kid/activity', { method: 'POST', body: JSON.stringify({ kind, title }) }).catch(() => {}); }
/** A small "Ask" button for a child: ask a parent for something (a movie, a book, a part of the hub, more time). */
function kidAskButton() {
if (document.getElementById('kidreq')) return; const b = document.createElement('button'); b.id = 'kidreq'; b.type = 'button'; b.textContent = '🙋 Ask'; b.setAttribute('aria-label', 'Ask a parent for something'); document.body.appendChild(b);
b.onclick = () => {
const off = (typeof TABS !== 'undefined' ? TABS : []).filter(t => !allowed(t[0]) && !['settings', 'home', 'family'].includes(t[0]));
const el = openSheet('Ask a parent', 'Tell them what you would like. They will see it and answer.', `<form id="krf"><label for="krt">What would you like?</label><input type="text" id="krt" maxlength="200" placeholder="Can I watch a movie? Can I have this book?" autocomplete="off" required>
${off.length ? `<label for="krb">Open a part of the app (optional)</label><select id="krb"><option value="">Nothing</option>${off.map(t => `<option value="${esc(t[0])}">${esc(t[1])}</option>`).join('')}</select>` : ''}
<label for="krm">More time (optional)</label><select id="krm"><option value="0">No</option><option value="15">15 minutes</option><option value="30">30 minutes</option><option value="60">An hour</option></select>
<div class="bar"><button type="button" class="btn ghost" id="krx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Send</button></div></form>`);
el.querySelector('#krx').onclick = closeSheet;
el.querySelector('#krf').onsubmit = async e => { e.preventDefault(); try { await jfetch('/api/kid/requests', { method: 'POST', body: JSON.stringify({ text: el.querySelector('#krt').value, tab: el.querySelector('#krb') ? el.querySelector('#krb').value : '', minutes: +el.querySelector('#krm').value }) }); closeSheet(); toast('Sent. A parent will answer.'); } catch (x) { toast(x.message); } };
};
}
function kidPaint() {
const st = kidUi.st; if (!st || !st.on) return; let chip = document.getElementById('kidchip');
if (st.left !== null && !st.locked) {
if (!chip) { chip = document.createElement('div'); chip.id = 'kidchip'; document.body.appendChild(chip); }
chip.textContent = '⏱ ' + (st.left >= 60 ? Math.floor(st.left / 60) + ' h ' + (st.left % 60) + ' min' : st.left + ' min') + ' left today'; chip.classList.toggle('low', st.left <= 10);
for (const m of [10, 5, 1]) if (st.left <= m && !kidUi.told.has(m) && st.left > 0) { kidUi.told.add(m); toast(st.left + (st.left === 1 ? ' minute' : ' minutes') + ' left today'); break; }
} else if (chip) chip.remove();
if (st.locked) kidLock(st); else { const l = document.getElementById('kidlock'); if (l) l.remove(); }
}
function kidLock(st) {
if (document.getElementById('kidlock')) return; try { closeSheet(); } catch {} try { pl.a.pause(); } catch {}
const el = document.createElement('div'); el.id = 'kidlock'; el.setAttribute('role', 'alertdialog');
el.innerHTML = `<div class="kidbox"><div style="font-size:56px">${st.locked === 'bedtime' ? '🌙' : '⏰'}</div><h1>${st.locked === 'bedtime' ? 'It is bedtime' : 'Time is up for today'}</h1><p>${st.locked === 'bedtime' ? 'The hub opens again at ' + esc(st.bedtime ? st.bedtime.to : 'morning') + '.' : 'You have used all of today\'s time.'}</p><button class="btn" id="kidask">Ask a parent for more time</button><p class="sub" id="kidmsg"></p></div>`;
document.body.appendChild(el); const poll = setInterval(() => { if (!document.getElementById('kidlock')) return clearInterval(poll); if (kidUi.tick) kidUi.tick(); }, 8000); /* (so the lock lifts soon after a parent adds time) */
el.querySelector('#kidask').onclick = async e => { e.currentTarget.disabled = true; try { const r = await jfetch('/api/kid/ask', { method: 'POST', body: JSON.stringify({ text: st.locked === 'bedtime' ? 'Can I stay up a little longer, please?' : 'Can I have a little more time, please?' }) }); el.querySelector('#kidmsg').textContent = r.told ? 'I asked. Wait for a yes.' : 'Nobody could be asked right now.'; } catch (err) { el.querySelector('#kidmsg').textContent = err.message; e.currentTarget.disabled = false; } };
}
+111
View File
@@ -0,0 +1,111 @@
'use strict';
// ---- Audiobooks: your library from the audiobook player, through the hub (the hub signs in for you). Books play in the same player as Music,
// and where you got to is kept by the audiobook player, so it follows you between this app, the player's own page and your other devices.
const abApi = (p, opts) => jfetch('/api/ab' + p, opts);
const abPick = (o, ...ks) => { for (const k of ks) if (o && o[k] != null && o[k] !== '') return o[k]; };
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 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; };
let abBooks = null, abProg = {};
const abIsPlaying = () => { const t = cur(); return t && String(t.key).startsWith('ab:') ? t.key.slice(3) : ''; };
async function abFile(id) {
const data = await abApi(`/books/${encodeURIComponent(id)}/filelist`);
const arr = Array.isArray(data) ? data : (data.files || Object.values(data));
const names = arr.map(f => typeof f === 'string' ? f : abPick(f, 'name', 'filename', 'file', 'path')).filter(Boolean).map(n => n.split('/').pop());
return names.find(n => /\.mp3$/i.test(n)) || names.find(n => /\.(mp3|m4b|m4a|aac|ogg|opus|flac|wav)$/i.test(n));
}
async function abChapters(id) {
try {
const data = await abApi(`/books/${encodeURIComponent(id)}/chapters/flat`), arr = Array.isArray(data) ? data : (data.chapters || []);
return arr.map(c => { let s = abPick(c, 'start', 'start_sec', 'start_seconds', 'startOffsetSec', 'start_offset_sec');
if (s == null) { const ms = abPick(c, 'start_ms', 'start_offset_ms', 'startOffsetMs', 'offset_ms'); if (ms != null) s = ms / 1000; }
return { title: abPick(c, 'title', 'name') || 'Chapter', start: Number(s) }; }).filter(c => !isNaN(c.start));
} catch { return []; }
}
/** Start (or carry on with) a book: from where you got to, unless that was the very end. */
async function abPlay(b, at) {
const id = abId(b);
if (abIsPlaying() === id) { if (at != null) pl.a.currentTime = at; else if (pl.a.paused) pl.a.play().catch(() => {}); return; }
const file = await abFile(id);
if (!file) throw new Error('No playable MP3 or M4B for this book. Convert it in OpenAudible first.');
const pr = abProg[id], start = at != null ? at : pr && pr.pos > 10 && (!pr.dur || pr.pos < pr.dur - 30) ? pr.pos : 0;
playQueue([{ key: 'ab:' + id, title: abTitle(b), artist: abAuthor(b), album: abSeries(b), thumb: abCover(b), src: `/api/ab/books/${encodeURIComponent(id)}/files/${encodeURIComponent(file)}`, dur: 0, nocache: true }], 0);
if (start) pl.a.addEventListener('loadedmetadata', () => { if (abIsPlaying() === id) pl.a.currentTime = start; }, { once: true });
}
// Where you got to goes back to the audiobook player every few seconds while listening, and when you pause or leave.
let abLast = 0, abQueued = {};
const abPush = keepalive => {
if (!Object.keys(abQueued).length) return;
const body = JSON.stringify(abQueued); abQueued = {};
fetch('/api/ab/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive })
.then(r => { if (!r.ok) throw 0; }).catch(() => { abQueued = { ...JSON.parse(body), ...abQueued }; });
};
const abSave = keepalive => {
const id = abIsPlaying(), d = pl.a.duration; if (!id || !isFinite(d) || d <= 0) return;
abProg[id] = abQueued[id] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; abPush(keepalive);
};
pl.a.addEventListener('timeupdate', () => { if (Date.now() - abLast > 8000) { abLast = Date.now(); abSave(); } });
pl.a.addEventListener('pause', () => abSave());
pl.a.addEventListener('ended', () => abSave());
addEventListener('pagehide', () => abSave(true));
/** Fetch where you got to in each book (from the audiobook player) and fold it into what this page already knows. */
const abLoadProg = async () => { try { for (const [id, p] of Object.entries(await abApi('/progress') || {})) if (!abProg[id] || p.t > abProg[id].t) abProg[id] = p; } catch {} };
async function viewBooks(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty">Loading…</p></div>';
try {
const [data] = await Promise.all([abApi('/books'), abLoadProg(), lvPrime()]);
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
} catch (e) {
if (!location.hash.startsWith('#/audio')) return;
await lvPrime(); if (!location.hash.startsWith('#/audio')) return;
v.innerHTML = `<div class="wrap"><h1>Audiobooks</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="abretry">Try again</button><a class="btn ghost" href="#/audio/free">Free audiobooks</a><a class="btn ghost" href="#/settings">Settings</a><a class="btn ghost" href="#/audio/classic">Open the classic player</a></div>${lvShelf.length ? `<h2>My free audiobooks</h2><div class="vgrid">${lvRecent().map(b => lvCard(b)).join('')}</div>` : ''}</div>`;
$('#abretry').onclick = () => route(); lvBind(v, () => route()); return;
}
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>`;
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>` : ''}
${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>`;
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)));
$('#abgrid').innerHTML = list.map(b => card(b)).join('') || `<p class="empty">${abBooks.length ? 'No books match.' : 'No books in the library yet.'}</p>`;
};
draw(); $('#abq').oninput = 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());
}
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 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></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('#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 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));
};
}
-12
View File
@@ -19,18 +19,6 @@ function showFrame(key, url, title, allow) {
wrap.className = 'on'; document.body.classList.add('onaudio');
}
// Podcast: opens in a frame when it has a secure (https) address; a plain-http address cannot be framed by a secure page.
// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is
// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN).
function viewPodcast() {
const url = cfg.podcast, u = new URL(url), canFrame = u.protocol === 'https:';
$('#view').innerHTML = `<div class="wrap"><h1>Podcast</h1><p class="sub">${esc(u.host)}</p>
<div class="bar" style="margin-bottom:0"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a></div>
${canFrame ? '<p class="sub" style="margin:8px 0 0">If the page below stays blank, the app does not allow being shown inside another app. Use Open in new tab.</p>'
: '<div class="card" style="max-width:none;margin-top:12px"><p style="margin:0">This is a plain <b>http</b> address, which a secure page cannot show inside itself. It opens in its own tab, and works at home or on your VPN.</p></div>'}</div>`;
if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write');
}
// The frame is created once and kept alive, so playback carries on while you use other tabs.
// It loads /api/audiobooks/enter, where the server adds the site's secret sign-in link itself (the key never reaches this page).
const ENTER = '/api/audiobooks/enter';
+170
View File
@@ -0,0 +1,170 @@
'use strict';
// ---- Bible: the Berean Standard Bible (public domain, https://berean.bible/). The text is one file the browser loads once (public/vendor/bible/bsb.json) and keeps,
// so reading is instant and works offline. Your place, highlights and notes are kept on the server, so they follow you between devices.
const bbApi = (p, opts) => jfetch('/api/bible' + p, opts);
const bbOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
const BB_COLORS = { yellow: '#ffd43b', green: '#69db7c', blue: '#74c0fc', pink: '#f783ac' };
const BB_ABBR = { gen: 'GEN', ge: 'GEN', gn: 'GEN', ex: 'EXO', exo: 'EXO', exod: 'EXO', lev: 'LEV', lv: 'LEV', num: 'NUM', nm: 'NUM', nu: 'NUM', deut: 'DEU', dt: 'DEU', deu: 'DEU', josh: 'JOS', jos: 'JOS', judg: 'JDG', jdg: 'JDG', jg: 'JDG', ru: 'RUT', '1sam': '1SA', '1sa': '1SA', '2sam': '2SA', '2sa': '2SA', '1ki': '1KI', '1kgs': '1KI', '1kings': '1KI', '2ki': '2KI', '2kgs': '2KI', '2kings': '2KI', '1chr': '1CH', '1ch': '1CH', '2chr': '2CH', '2ch': '2CH',
neh: 'NEH', est: 'EST', esth: 'EST', ps: 'PSA', psa: 'PSA', pss: 'PSA', psalm: 'PSA', psalms: 'PSA', prov: 'PRO', pr: 'PRO', pro: 'PRO', eccl: 'ECC', ecc: 'ECC', qoh: 'ECC', song: 'SNG', sos: 'SNG', sng: 'SNG', songofsongs: 'SNG', isa: 'ISA', is: 'ISA', jer: 'JER', lam: 'LAM', ezek: 'EZK', eze: 'EZK', ezk: 'EZK', dan: 'DAN', dn: 'DAN', hos: 'HOS', joel: 'JOL', jol: 'JOL', amos: 'AMO', amo: 'AMO', obad: 'OBA', oba: 'OBA', jonah: 'JON', jon: 'JON', mic: 'MIC', nah: 'NAM', nam: 'NAM', hab: 'HAB', zeph: 'ZEP', zep: 'ZEP', hag: 'HAG', zech: 'ZEC', zec: 'ZEC', mal: 'MAL',
matt: 'MAT', mt: 'MAT', mat: 'MAT', mark: 'MRK', mk: 'MRK', mrk: 'MRK', luke: 'LUK', lk: 'LUK', luk: 'LUK', john: 'JHN', jn: 'JHN', jhn: 'JHN', ac: 'ACT', act: 'ACT', rom: 'ROM', ro: 'ROM', rm: 'ROM', '1cor': '1CO', '1co': '1CO', '2cor': '2CO', '2co': '2CO', gal: 'GAL', ga: 'GAL', eph: 'EPH', phil: 'PHP', php: 'PHP', pp: 'PHP', col: 'COL', '1thess': '1TH', '1th': '1TH', '2thess': '2TH', '2th': '2TH', '1tim': '1TI', '1ti': '1TI', '2tim': '2TI', '2ti': '2TI', tit: 'TIT', philem: 'PHM', phm: 'PHM', heb: 'HEB', jas: 'JAS', jm: 'JAS', '1pet': '1PE', '1pe': '1PE', '2pet': '2PE', '2pe': '2PE', '1john': '1JN', '1jn': '1JN', '2john': '2JN', '2jn': '2JN', '3john': '3JN', '3jn': '3JN', jud: 'JUD', rev: 'REV', re: 'REV', revelation: 'REV' };
let bb = null, bbLoading = null, bbSt = { pos: null, marks: [] }, bbSel = new Set(), bbCur = null, bbPosT = 0;
const bbPrefs = () => { try { return { size: 19, serif: true, ...JSON.parse(localStorage.getItem('bb-prefs') || '{}') }; } catch { return { size: 19, serif: true }; } };
const bbSetPrefs = p => { try { localStorage.setItem('bb-prefs', JSON.stringify(p)); } catch {} };
const bbUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const bbBook = id => bb.books.find(b => b.id === id);
const bbRef = (b, c, v, ve) => `${bbBook(b).name} ${c}${v ? ':' + v + (ve && ve > v ? '-' + ve : '') : ''}`;
function bbLoad() {
if (bb) return Promise.resolve(bb);
return bbLoading || (bbLoading = fetch('/vendor/bible/bsb.json').then(r => { if (!r.ok) throw new Error('Could not load the Bible text'); return r.json(); }).then(d => { bb = d; return d; }).catch(e => { bbLoading = null; throw e; }));
}
/** "John 3:16", "jn 3", "1 cor 13:4-7", "Psalm 23": a book (and chapter, and verse) typed in words. */
function bbParseRef(q) {
const m = /^\s*((?:[1-3]\s*)?[a-z][a-z .]*?)\s*(\d+)?\s*(?:[:.]\s*(\d+)(?:\s*[-–]\s*(\d+))?)?\s*$/i.exec(q.replace(/\bchapter\b|\bverse\b/gi, ' ')); if (!m) return null;
const key = m[1].toLowerCase().replace(/[^a-z0-9]/g, ''); if (key.length < 2) return null;
let id = BB_ABBR[key]; if (!id) { const hit = bb.books.find(b => b.name.toLowerCase().replace(/[^a-z0-9]/g, '') === key) || (key.length >= 3 ? bb.books.find(b => b.name.toLowerCase().replace(/[^a-z0-9]/g, '').startsWith(key)) : null); id = hit && hit.id; }
const book = id && bbBook(id); if (!book) return null; const c = Math.min(book.chapters.length, Math.max(1, +m[2] || 1)), v = m[3] ? +m[3] : 0;
return { b: book.id, c, v, ve: m[4] ? +m[4] : v };
}
// ---- drawing a chapter
function bbVerseHtml(n, parts, notes, showNo) {
const poem = parts.some(Array.isArray), vn = `<sup class="vn">${n}</sup>`, mark = p => `<sup class="fn" data-v="${n}" data-i="${p - 1}" role="button" aria-label="Footnote">${String.fromCharCode(96 + Math.min(p, 26))}</sup>`;
if (!poem) return (showNo ? vn : '') + parts.map(p => typeof p === 'string' ? (p === '\n' ? '<br>' : esc(p)) : mark(p)).join('');
const lines = []; // poetry: one line per part; a footnote marker stays at the end of its line
for (const p of parts) {
if (Array.isArray(p)) lines.push({ l: p[0], h: esc(p[1]) });
else if (p === '\n') continue;
else { if (!lines.length) lines.push({ l: 1, h: '' }); lines[lines.length - 1].h += typeof p === 'string' ? esc(p) : mark(p); }
}
return lines.map((x, i) => `<span class="pl l${x.l}">${i === 0 && showNo ? vn : ''}${x.h}</span>`).join('');
}
function bbChapterHtml(book, c) {
let html = '', para = ''; const flush = () => { if (para.trim()) html += `<p class="bp">${para}</p>`; para = ''; };
for (const b of book.chapters[c - 1]) {
if (b[0] === 'h') { flush(); html += `<h3 class="bh">${esc(b[1])}</h3>`; }
else if (b[0] === 'p') flush();
else if (b[0] === 's') { flush(); html += `<p class="bsub">${esc(b[1])}</p>`; }
else if (b[0] === 'v') {
if (b[2].some(Array.isArray)) { flush(); html += `<div class="bpoem vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</div>`; }
else para += `<span class="vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</span> `;
}
}
flush(); return html;
}
const bbMarksHere = (b, c) => bbSt.marks.filter(m => m.b === b && m.c === c && !m.del);
function bbPaint(root) { // highlights, notes and picked verses, on top of the text
const here = bbMarksHere(bbCur.b, bbCur.c);
root.querySelectorAll('.vs').forEach(el => {
const v = +el.dataset.v, hl = here.find(m => m.type === 'hl' && m.v === v), nt = here.some(m => m.type === 'note' && v >= m.v && v <= m.ve);
el.className = el.className.replace(/\b(hl-\w+|hasnote|pick)\b/g, '').replace(/\s+/g, ' ').trim() + (hl ? ' hl-' + hl.color : '') + (nt ? ' hasnote' : '') + (bbSel.has(v) ? ' pick' : '');
});
bbDrawBar();
}
async function viewBible(args = []) {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Bible</h1><p class="empty">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 {}
if (args[0] === 'marks') return bbMarks();
let b = args[0] && bbBook(args[0]) ? args[0] : null, c = +args[1] || 1, vs = +args[2] || 0;
if (!b) { const mine = (() => { try { return JSON.parse(localStorage.getItem(nk('bb-last'))); } catch { return null; } })(), p = mine && (!bbSt.pos || mine.t > bbSt.pos.t) ? mine : bbSt.pos; b = p && bbBook(p.b) ? p.b : 'JHN'; c = p ? p.c : 1; vs = 0; return location.replace('#/bible/' + b + '/' + c); }
const book = bbBook(b); c = Math.min(book.chapters.length, Math.max(1, c)); bbCur = { b, c }; bbSel = new Set();
const P = bbPrefs(), name = book.name, last = c === book.chapters.length, order = bb.books.findIndex(x => x.id === b);
const prev = c > 1 ? [b, c - 1] : order > 0 ? [bb.books[order - 1].id, bb.books[order - 1].chapters.length] : null, next = !last ? [b, c + 1] : order < 65 ? [bb.books[order + 1].id, 1] : null, href = x => '#/bible/' + x[0] + '/' + x[1];
v.innerHTML = `<div class="wrap bbwrap"><div class="bbbar"><button class="btn ghost" id="bbpick" aria-label="Choose a book and chapter">${icon('bible')}<b>${esc(name)} ${c}</b></button><span class="grow"></span>
<button class="ibtn" id="bbsrch" aria-label="Search the Bible" title="Search">${icon('search')}</button><button class="ibtn" id="bbmk" aria-label="My highlights and notes" title="My highlights and notes">${icon('bookmark')}</button><button class="ibtn" id="bbaa" aria-label="Text size" title="Text size">${icon('font')}</button></div>
<article id="bbtext" class="bbtext ${P.serif ? 'serif' : ''}" style="font-size:${P.size}px">${bbChapterHtml(book, c)}</article>
<div class="bbnav">${prev ? `<a class="btn ghost" href="${href(prev)}">${icon('back')}${esc(bbBook(prev[0]).name)} ${prev[1]}</a>` : '<span></span>'}${next ? `<a class="btn ghost" href="${href(next)}">${esc(bbBook(next[0]).name)} ${next[1]}${icon('chev')}</a>` : '<span></span>'}</div>
<p class="sub" style="text-align:center;margin:18px 0 0;font-size:12px">Berean Standard Bible (BSB), public domain · <a href="https://berean.bible/" target="_blank" rel="noopener" style="color:var(--accent)">berean.bible</a></p><div class="rdsel bbsel" id="bbsel" hidden></div></div>`;
const root = $('#bbtext'); bbPaint(root);
if (vs) { const el = root.querySelector(`.vs[data-v="${vs}"]`); if (el) { setTimeout(() => el.scrollIntoView({ block: 'center' }), 50); el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 2200); } }
root.addEventListener('click', e => {
const fn = e.target.closest('.fn'); if (fn) { const n = bbNote(b, c, +fn.dataset.v, +fn.dataset.i); if (n) openSheet(bbRef(b, c, +fn.dataset.v), '', `<p style="font-size:17px;line-height:1.5">${esc(n)}</p>`); return; }
const el = e.target.closest('.vs'); if (!el) { bbSel.clear(); bbPaint(root); return; } const n = +el.dataset.v; if (bbSel.has(n)) bbSel.delete(n); else bbSel.add(n); bbPaint(root);
});
$('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbaa').onclick = () => bbTextSheet(root);
let x0 = 0, y0 = 0, t0 = 0; root.addEventListener('touchstart', e => { x0 = e.changedTouches[0].clientX; y0 = e.changedTouches[0].clientY; t0 = Date.now(); }, { passive: true });
root.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - x0, dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2 && Date.now() - t0 < 600 && !bbSel.size) { const to = dx < 0 ? next : prev; if (to) location.hash = href(to); } });
bbCur.next = next && href(next); bbCur.prev = prev && href(prev);
const here = { b, c, v: 0, t: Date.now() }; try { localStorage.setItem(nk('bb-last'), JSON.stringify(here)); } catch {}
clearTimeout(bbPosT); bbPosT = setTimeout(() => { fetch('/api/bible/pos', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(here) }).catch(() => {}); bbSt.pos = here; }, 1000);
}
addEventListener('keydown', e => { if (!location.hash.startsWith('#/bible') || !bbCur || /^(INPUT|TEXTAREA|SELECT)$/.test((e.target.tagName || '')) || document.getElementById('sheet')) return; if (e.key === 'ArrowRight' && bbCur.next) location.hash = bbCur.next; else if (e.key === 'ArrowLeft' && bbCur.prev) location.hash = bbCur.prev; });
const bbNote = (b, c, v, i) => { const blk = bbBook(b).chapters[c - 1].find(x => x[0] === 'v' && x[1] === v); return blk && blk[3] ? blk[3][i] : ''; };
// ---- the verses you tapped: highlight, note, copy
function bbDrawBar() {
const bar = $('#bbsel'); if (!bar) return; if (!bbSel.size) { bar.hidden = true; return; }
const vs = [...bbSel].sort((a, b) => a - b), hasHl = bbMarksHere(bbCur.b, bbCur.c).some(m => m.type === 'hl' && bbSel.has(m.v));
bar.hidden = false; bar.innerHTML = `<span class="bbref">${esc(bbRef(bbCur.b, bbCur.c, vs[0], vs[vs.length - 1]))}${vs.length > 1 && vs[vs.length - 1] - vs[0] + 1 !== vs.length ? ' …' : ''}</span>` +
Object.entries(BB_COLORS).map(([c, hex]) => `<button class="rdcol" data-c="${c}" style="background:${hex}" aria-label="Highlight ${c}"></button>`).join('') +
`${hasHl ? `<button class="rdsb" data-a="clear">${icon('x')}Clear</button>` : ''}<button class="rdsb" data-a="note">${icon('edit')}Note</button><button class="rdsb" data-a="copy">${icon('copy')}Copy</button>`;
bar.querySelectorAll('.rdcol').forEach(b => b.onclick = () => bbHighlight(b.dataset.c)); bar.querySelector('[data-a=copy]').onclick = bbCopy; bar.querySelector('[data-a=note]').onclick = () => bbNoteDialog(null);
const cl = bar.querySelector('[data-a=clear]'); if (cl) cl.onclick = () => bbHighlight(null);
}
const bbVerseText = (b, c, v) => { const blk = bbBook(b).chapters[c - 1].find(x => x[0] === 'v' && x[1] === v); return blk ? blk[2].map(p => typeof p === 'string' ? (p === '\n' ? ' ' : p) : typeof p === 'number' ? '' : p[1]).join(' ').replace(/\s+/g, ' ').trim() : ''; };
async function bbSaveMarks(list) { try { bbSt.marks = await bbApi('/marks', bbOpt('POST', list)); } catch (e) { toast('Could not save: ' + e.message); } }
async function bbHighlight(color) {
const { b, c } = bbCur, now = Date.now(), out = [];
for (const v of bbSel) { const old = bbSt.marks.find(m => m.b === b && m.c === c && m.type === 'hl' && m.v === v && !m.del);
if (color === null) { if (old) out.push({ ...old, del: true, t: now }); } else out.push({ id: old ? old.id : bbUid(), t: now, type: 'hl', b, c, v, ve: v, color, text: bbVerseText(b, c, v) }); }
bbSel = new Set(); if (out.length) { for (const m of out) { const i = bbSt.marks.findIndex(x => x.id === m.id); if (i >= 0) bbSt.marks[i] = m; else bbSt.marks.push(m); } bbPaint($('#bbtext')); await bbSaveMarks(out); bbPaint($('#bbtext')); } else bbPaint($('#bbtext'));
}
function bbCopy() {
const { b, c } = bbCur, vs = [...bbSel].sort((a, b) => a - b); const text = vs.map(v => (vs.length > 1 ? v + ' ' : '') + bbVerseText(b, c, v)).join(' ');
navigator.clipboard?.writeText(`${text} — ${bbRef(b, c, vs[0], vs[vs.length - 1])} (BSB)`).then(() => toast('Copied'), () => toast('Could not copy')); bbSel = new Set(); bbPaint($('#bbtext'));
}
function bbNoteDialog(existing) { // a note on the picked verses, or edit one that exists
const m = existing, vs = m ? null : [...bbSel].sort((a, b) => a - b), b = m ? m.b : bbCur.b, c = m ? m.c : bbCur.c, v = m ? m.v : vs[0], ve = m ? m.ve : vs[vs.length - 1];
const el = openSheet(bbRef(b, c, v, ve), '', `<blockquote class="rdq">${esc([...Array(ve - v + 1)].map((_, i) => bbVerseText(b, c, v + i)).join(' ').slice(0, 400))}</blockquote><label>Note</label><textarea id="bbnt" rows="5" maxlength="5000" placeholder="Write a note">${esc(m ? m.note : '')}</textarea>
<div class="bar">${m ? '<button class="btn danger" id="bbnd" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" id="bbns" style="flex:1;justify-content:center">Save</button></div>`);
if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#bbns').onclick = async () => { const note = el.querySelector('#bbnt').value.trim(); closeSheet(); const mk = { id: m ? m.id : bbUid(), t: Date.now(), type: 'note', b, c, v, ve, note, color: 'yellow', text: bbVerseText(b, c, v), del: !note };
const i = bbSt.marks.findIndex(x => x.id === mk.id); if (i >= 0) bbSt.marks[i] = mk; else bbSt.marks.push(mk); bbSel = new Set(); await bbSaveMarks([mk]); if ($('#bbtext')) bbPaint($('#bbtext')); else if (location.hash.startsWith('#/bible/marks')) bbMarks(); };
if (m) el.querySelector('#bbnd').onclick = async () => { closeSheet(); const mk = { ...m, del: true, t: Date.now() }; bbSt.marks = bbSt.marks.filter(x => x.id !== m.id); await bbSaveMarks([mk]); if (location.hash.startsWith('#/bible/marks')) bbMarks(); };
}
// ---- choosing a place, searching, your highlights and notes, text size
function bbPicker() {
let testament = bb.books.findIndex(b => b.id === bbCur.b) >= 39 ? 1 : 0, chosen = null;
const el = openSheet('Choose a book', '', '<div id="bpk"></div>'), box = el.querySelector('#bpk');
const draw = () => {
if (chosen) { const book = bbBook(chosen); box.innerHTML = `<p class="sub" style="margin:0 0 10px"><button class="btn ghost" id="bpb" style="min-height:36px">${icon('back')}Books</button> <b>${esc(book.name)}</b> · pick a chapter</p><div class="bgrid">${book.chapters.map((_, i) => `<a href="#/bible/${chosen}/${i + 1}" class="${chosen === bbCur.b && i + 1 === bbCur.c ? 'on' : ''}">${i + 1}</a>`).join('')}</div>`; box.querySelector('#bpb').onclick = () => { chosen = null; draw(); }; box.querySelectorAll('.bgrid a').forEach(a => a.addEventListener('click', closeSheet)); return; }
const list = bb.books.slice(testament ? 39 : 0, testament ? 66 : 39);
box.innerHTML = `<div class="seg" style="margin-top:0"><a href="#" data-t="0" class="${testament ? '' : 'on'}">Old Testament</a><a href="#" data-t="1" class="${testament ? 'on' : ''}">New Testament</a></div><div class="bbooks">${list.map(b => `<button data-b="${b.id}" class="${b.id === bbCur.b ? 'on' : ''}">${esc(b.name)}</button>`).join('')}</div>`;
box.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); testament = +a.dataset.t; draw(); }); box.querySelectorAll('.bbooks button').forEach(b => b.onclick = () => { chosen = b.dataset.b; if (bbBook(chosen).chapters.length === 1) { closeSheet(); location.hash = '#/bible/' + chosen + '/1'; return; } draw(); });
};
draw();
}
function bbSearch() {
const el = openSheet('Search the Bible', 'A word or phrase, or a place such as John 3:16', '<form><input type="search" id="bbq" placeholder="love, or Romans 8, or 1 Cor 13:4-7" autocomplete="off" autocapitalize="off"></form><div id="bbres" class="list rdlist" style="margin-top:10px"></div>');
const inp = el.querySelector('#bbq'), res = el.querySelector('#bbres');
const run = () => {
const q = inp.value.trim(); if (q.length < 2) { res.innerHTML = ''; return; }
const ref = bbParseRef(q), words = q.toLowerCase().split(/\s+/).filter(Boolean), hits = []; let total = 0;
for (const book of bb.books) book.chapters.forEach((ch, ci) => ch.forEach(blk => { if (blk[0] !== 'v') return; const t = bbVerseText(book.id, ci + 1, blk[1]), low = t.toLowerCase(); if (words.every(w => low.includes(w))) { total++; if (hits.length < 120) hits.push({ b: book.id, c: ci + 1, v: blk[1], t }); } }));
const mark = t => { let h = esc(t); for (const w of words) h = h.replace(new RegExp('(' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'ig'), '<mark>$1</mark>'); return h; };
res.innerHTML = (ref ? `<a class="row" href="#/bible/${ref.b}/${ref.c}${ref.v ? '/' + ref.v : ''}"><span class="ic" style="background:#8b5a2b">${icon('bible')}</span><span class="grow"><b>Go to ${esc(bbRef(ref.b, ref.c, ref.v, ref.ve))}</b></span>${icon('chev', 'chev')}</a>` : '') +
(hits.length ? hits.map(h => `<a class="row" href="#/bible/${h.b}/${h.c}/${h.v}"><span class="grow"><b style="white-space:normal;font-weight:400">${mark(h.t)}</b><small>${esc(bbRef(h.b, h.c, h.v))}</small></span></a>`).join('') + (total > hits.length ? `<p class="sub" style="padding:10px 16px;margin:0">Showing ${hits.length} of ${total}. Add a word to narrow it down.</p>` : `<p class="sub" style="padding:10px 16px;margin:0">${total} verse${total === 1 ? '' : 's'}</p>`) : ref ? '' : '<p class="empty">Nothing found.</p>');
res.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
};
let t; inp.oninput = () => { clearTimeout(t); t = setTimeout(run, 250); }; el.querySelector('form').onsubmit = e => { e.preventDefault(); clearTimeout(t); run(); }; inp.focus();
}
async function bbMarks() {
const order = id => bb.books.findIndex(b => b.id === id), list = bbSt.marks.filter(m => !m.del).sort((a, b) => order(a.b) - order(b.b) || a.c - b.c || a.v - b.v);
$('#view').innerHTML = `<div class="wrap bbwrap"><div class="ptop"><a class="btn ghost" href="#/bible">${icon('back')}Bible</a><h1 style="margin:0">My highlights and notes</h1></div>${list.length ? `<div class="list">${list.map(m => `<div class="row bbm" data-id="${esc(m.id)}"><button class="grow bbmj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0"><b>${m.type === 'hl' ? `<span class="rddot" style="background:${BB_COLORS[m.color] || BB_COLORS.yellow}"></span>` : icon('edit')} ${esc(bbRef(m.b, m.c, m.v, m.ve))}</b><small style="white-space:normal;display:block">${esc(m.text)}</small>${m.note ? `<span style="white-space:pre-wrap;display:block;margin-top:3px">${esc(m.note)}</span>` : ''}</button>
${m.type === 'note' ? `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>` : ''}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>` : '<p class="empty">Nothing yet. Tap verses while reading to highlight them or add a note.</p>'}</div>`;
document.querySelectorAll('.bbm').forEach(r => { const m = list.find(x => x.id === r.dataset.id);
r.querySelector('.bbmj').onclick = () => { location.hash = `#/bible/${m.b}/${m.c}/${m.v}`; };
const e = r.querySelector('[data-e]'); if (e) e.onclick = () => bbNoteDialog(m);
r.querySelector('[data-d]').onclick = async () => { const mk = { ...m, del: true, t: Date.now() }; bbSt.marks = bbSt.marks.filter(x => x.id !== m.id); await bbSaveMarks([mk]); bbMarks(); }; });
}
function bbTextSheet(root) {
const P = bbPrefs(), el = openSheet('Text', '', `<label>Size</label><div class="rdstep"><button type="button" class="btn ghost" data-s="-1">A−</button><span id="bbsv">${P.size}</span><button type="button" class="btn ghost" data-s="1">A+</button></div>
<label>Font</label><div class="rdseg"><button type="button" data-f="1" class="${P.serif ? 'on' : ''}">Serif</button><button type="button" data-f="0" class="${P.serif ? '' : 'on'}">Sans-serif</button></div><div class="bar"><button class="btn" id="bbok" style="flex:1;justify-content:center">Done</button></div>`);
const apply = () => { bbSetPrefs(P); root.style.fontSize = P.size + 'px'; root.classList.toggle('serif', P.serif); };
el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { P.size = Math.min(34, Math.max(14, P.size + +b.dataset.s)); el.querySelector('#bbsv').textContent = P.size; apply(); });
el.querySelectorAll('[data-f]').forEach(b => b.onclick = () => { P.serif = b.dataset.f === '1'; el.querySelectorAll('[data-f]').forEach(x => x.classList.toggle('on', x === b)); apply(); }); el.querySelector('#bbok').onclick = closeSheet;
}
+104
View File
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f1115">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="NE Hub">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="apple-touch-icon" sizes="120x120" href="/icons/apple-touch-icon-120.png">
<link rel="apple-touch-icon" sizes="152x152" href="/icons/apple-touch-icon-152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/icons/apple-touch-icon-167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon-180.png">
<link rel="apple-touch-startup-image" href="/icons/splash-1320x2868.png" media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-1206x2622.png" media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-1290x2796.png" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-1179x2556.png" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-1284x2778.png" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-1170x2532.png" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-1125x2436.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-1242x2688.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-828x1792.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-1242x2208.png" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-750x1334.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-2048x2732.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-2732x2048.png" media="(device-width: 1366px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="/icons/splash-1668x2388.png" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-2388x1668.png" media="(device-width: 1194px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="/icons/splash-1668x2224.png" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-2224x1668.png" media="(device-width: 1112px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="/icons/splash-1640x2360.png" media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-2360x1640.png" media="(device-width: 1180px) and (device-height: 820px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="/icons/splash-1620x2160.png" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-2160x1620.png" media="(device-width: 1080px) and (device-height: 810px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="/icons/splash-1488x2266.png" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-2266x1488.png" media="(device-width: 1133px) and (device-height: 744px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="apple-touch-startup-image" href="/icons/splash-1536x2048.png" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image" href="/icons/splash-2048x1536.png" media="(device-width: 1024px) and (device-height: 768px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">
<link rel="stylesheet" href="/style.css">
<title>Family board · NE Hub</title>
<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 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; }
.tile { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 104px; padding: 12px 6px; border-radius: 18px; background: var(--panel); border: 1px solid var(--line); color: var(--text); cursor: pointer; font: inherit; }
.tile:active { transform: scale(.97); } .tile .av { width: 60px; height: 60px; border-radius: 50%; color: #fff; display: grid; place-items: center; font-size: 26px; font-weight: 700; }
.tile b { font-size: 16px; } .tile small { color: var(--muted); font-size: 12px; min-height: 14px; }
.tile .kid { background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; }
.grid { display: grid; gap: 14px; grid-template-columns: 1fr; } @media (min-width: 760px) { .grid { grid-template-columns: 1fr 1fr; } } @media (min-width: 1100px) { .grid { grid-template-columns: repeat(3, 1fr); } }
.card { max-width: none; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; min-width: 0; } .card h2 { margin: 0 0 6px; font-size: 14px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.ln { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); font-size: 17px; } .ln:last-child { border-bottom: 0; } .ln .g { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ln small { color: var(--muted); }
.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; } .none { color: var(--muted); margin: 6px 0; }
.ann { background: var(--panel2); border: 1px solid var(--line); border-radius: 16px; padding: 14px 18px; margin: 14px 0; font-size: 24px; font-weight: 700; }
.bar2 { height: 8px; background: var(--panel2); border-radius: 4px; overflow: hidden; } .bar2 i { display: block; height: 100%; background: var(--accent); }
.pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; max-width: 300px; margin: 14px auto 0; } .pad button { height: 64px; font-size: 26px; border-radius: 16px; background: var(--panel2); color: var(--text); border: 1px solid var(--line); font: inherit; font-size: 26px; }
.dots { display: flex; justify-content: center; gap: 12px; margin: 12px 0; min-height: 18px; } .dots i { width: 16px; height: 16px; border-radius: 50%; background: var(--line); } .dots i.on { background: var(--accent); }
#ov { position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,.6); display: grid; place-items: center; padding: 18px; } #ov[hidden] { display: none; }
#ovb .tiles { flex-wrap: wrap; overflow: visible; justify-content: center; } #ovb .tile { width: 96px; } #ovb { background: var(--panel); border: 1px solid var(--line); border-radius: 20px; padding: 20px; width: 100%; max-width: 520px; text-align: center; max-height: 92dvh; overflow-y: auto; scrollbar-width: none; }
.err { color: var(--danger); min-height: 20px; }
/* touch screen: things scroll by dragging, with no scroll bars */
.card, .chl, #ovb div { scrollbar-width: none; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; } .card::-webkit-scrollbar, .chl::-webkit-scrollbar, #ovb div::-webkit-scrollbar { display: none; }
.nt { margin-top: 6px; } .nti { padding: 3px 0; border: 0; font-size: 16px; } .nti .chk { width: 20px; height: 20px; border: 2px solid var(--line); border-radius: 6px; display: grid; place-items: center; flex: none; font-size: 13px; } .nti.dn .chk { background: var(--accent); border-color: var(--accent); color: #fff; } .nti.dn .g { text-decoration: line-through; opacity: .6; } .ntbody { margin: 2px 0 6px; color: var(--muted); white-space: pre-wrap; font-size: 15px; }
.pbtn { width: 34px; height: 34px; border-radius: 50%; background: var(--accent); color: var(--accent-t, #fff); border: 0; font-size: 24px; line-height: 1; cursor: pointer; flex: none; }
#ovb input[type=text], #ovb input[type=date], #ovb input[type=time], #ovb select { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); font-size: 17px; }
.pill { display: inline-flex; align-items: center; gap: 6px; margin: 3px 6px 3px 0; padding: 6px 12px; border-radius: 16px; background: var(--panel2); border: 1px solid var(--line); color: var(--text); font: inherit; font-size: 15px; cursor: pointer; } .pill.pool { border-color: var(--accent); } .pill small { color: var(--muted); font-size: 12px; } .pill.more { background: none; border-style: dashed; color: var(--muted); cursor: default; }
.chp { margin-top: 8px; } .chp .top { display: flex; justify-content: space-between; align-items: baseline; }
/* a tablet (like a 12.9-inch iPad) shows the whole board at once, with no scrolling */
@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; }
.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; }
.grid { flex: 1; min-height: 0; grid-template-columns: repeat(2, 1fr); grid-template-rows: minmax(0, 1.4fr) repeat(3, minmax(0, 1fr)); gap: 10px; }
.card.wide { grid-column: 1 / -1; }
.card.wide { display: flex; flex-direction: column; } .chcols { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; align-items: stretch; } .chcols .chp { display: flex; flex-direction: column; min-height: 0; } .chl { flex: 1; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; display: flex; flex-direction: column; align-items: stretch; overscroll-behavior: contain; } .chl .pill { margin: 3px 0; justify-content: flex-start; } .chl .pill.more { justify-content: center; } .chcols .chp { margin-top: 6px; min-width: 0; }
.card { overflow-y: auto; padding: 10px 14px; } .card.wide { overflow: hidden; } .card h2 { position: sticky; top: -10px; z-index: 1; background: var(--panel); padding-top: 2px; } .card h2 { margin-bottom: 4px; font-size: 13px; } .ln { padding: 4px 0; font-size: 16px; }
.bd > p.sub { display: none; }
}
@media (min-width: 900px) and (min-height: 1100px) { .ln { font-size: 20px; padding: 6px 0; } .pill { font-size: 18px; padding: 8px 14px; } .card h2 { font-size: 15px; } .tile { width: 108px; } .tile .av { width: 54px; height: 54px; font-size: 24px; } .tile b { font-size: 16px; } .bdtop h1 { font-size: 34px; } .bdclock { font-size: 40px; } .card small, .chp small { font-size: 14px; } }
@media (min-width: 1200px) and (min-height: 700px) { .grid { grid-template-columns: repeat(5, 1fr); grid-template-rows: minmax(0, 1.5fr) minmax(0, 1fr); } }
.chrow { width: 100%; background: none; border: 0; border-bottom: 1px solid var(--line); color: var(--text); font: inherit; font-size: 17px; text-align: left; cursor: pointer; } .chrow .chk { width: 24px; height: 24px; border: 2px solid var(--line); border-radius: 7px; display: grid; place-items: center; flex: none; font-size: 15px; } .chrow.dn .chk { background: var(--accent); border-color: var(--accent); color: #fff; } .chrow.dn .g { text-decoration: line-through; opacity: .6; }
</style>
</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 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>
<div id="ov" hidden><div id="ovb"></div></div>
<script src="/board.js"></script>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
'use strict';
// ---- Family board: what a family device shows before anyone signs in, and the "who is this?" step. Only the family's shared things are shown here.
const $ = s => document.querySelector(s);
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
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' }); }
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), 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(', ')) : ''}${next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
}
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>';
$('#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')
+ card('Family tasks', d.tasks.map(t => `<div class="ln"><span class="g">${esc(t.title)}${t.by ? ` <small>${esc(t.by)}</small>` : ''}</span><small style="${t.due && t.due < t0 ? 'color:var(--danger)' : ''}">${t.due === t0 ? 'Today' : esc(t.due)}</small></div>`).join('') || none('Nothing due'), 'task')
+ card('Family notes and lists', d.notes.map(n => `<div class="nt"><div class="ln" style="border:0;padding-bottom:2px"><span class="g"><b>${esc(n.title)}</b>${n.type === 'list' ? ` <small>${n.done} of ${n.total} done</small>` : ''}</span></div>${n.type === 'list' ? n.items.map(i => `<div class="ln nti ${i.done ? 'dn' : ''}"><span class="chk">${i.done ? '✓' : ''}</span><span class="g">${esc(i.text)}${i.by ? ` <small>${esc(i.by)}</small>` : ''}</span></div>`).join('') || '<p class="none">Empty</p>' : `<p class="ntbody">${esc(n.body)}</p>`}</div>`).join('') || none('No shared notes'), d.notes.some(n => n.type === 'list') ? 'item' : '');
wireChores();
}
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)));
}
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 pick(p) {
if (!p) return;
const ov = $('#ov'), box = $('#ovb'); ov.hidden = false;
const close = () => { ov.hidden = true; box.innerHTML = ''; };
const go = async pin => { const r = await fetch('/api/board/switch', { method: 'POST', body: JSON.stringify({ uid: p.id, pin }) }), j = await r.json().catch(() => ({})); if (r.ok) return location.replace('/'); throw new Error(j.error || 'Could not sign in'); };
const head = `<div class="tile" style="margin:0 auto;border:0;background:none;cursor:default"><span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><b>${esc(p.name)}</b>${p.kid ? '<span class="kid">KID</span>' : ''}</div>`;
if (p.kid && !p.pin) { box.innerHTML = head + `<p>Sign in as <b>${esc(p.name)}</b>?</p><button class="btn" id="yes" style="width:100%;min-height:52px;justify-content:center">Yes, that's me</button><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Not me</button></p><div class="err" id="e"></div>`; $('#no').onclick = close; $('#yes').onclick = () => go().catch(e => $('#e').textContent = e.message); return; }
if (!p.pin) { box.innerHTML = head + `<p>${esc(p.name)} has not set a PIN yet. Set one in Settings on your own device, or <a href="/login" style="color:var(--accent)">sign in with your passkey</a>.</p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Close</button>`; $('#no').onclick = close; return; }
let pin = ''; const paint = () => { $('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => `<i class="${i < pin.length ? 'on' : ''}"></i>`).join(''); };
box.innerHTML = head + `<p class="sub">Enter your PIN</p><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>`;
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);
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
const avatar = p => `<span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span>`;
function sheet(html) { const ov = $('#ov'); ov.hidden = false; $('#ovb').innerHTML = html; return { box: $('#ovb'), close: () => { ov.hidden = true; $('#ovb').innerHTML = ''; } }; }
function choreTap(c) {
if (!c) return; const title = `<h3 style="margin:0 0 4px">${esc(c.title)}</h3>`;
const who = action => { // step: which person
const list = c.user ? data.people.filter(p => p.id === c.user) : data.people, { box, close } = sheet(`${title}<p class="sub">${c.user && !c.done ? 'This one is ' + esc(c.userName) + "'s. Tap your name" : 'Who are you?'}</p><div class="tiles" style="justify-content:center">${list.map(p => `<button class="tile" data-id="${esc(p.id)}">${avatar(p)}<b>${esc(p.name.split(' ')[0])}</b><small>${p.kid ? '<span class="kid">KID</span>' : ''}</small></button>`).join('')}</div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Cancel</button></p>`);
box.querySelector('#no').onclick = close; box.querySelectorAll('.tile').forEach(t => t.onclick = () => proof(data.people.find(p => p.id === t.dataset.id), action, close));
};
const proof = (p, action, close) => { // step: PIN if needed, then do it
const send = async pin => { const r = await fetch('/api/board/chore', { method: 'POST', body: JSON.stringify({ uid: p.id, pin, chore: c.chore, action, date: ymd(new Date()) }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'That did not work'); close(); await load(); };
if (p.kid && !p.pin) return send().catch(e => { const x = sheet(`${title}<p class="err">${esc(e.message)}</p><button class="btn ghost" id="no" style="width:100%;justify-content:center">OK</button>`); x.box.querySelector('#no').onclick = x.close; });
if (!p.pin) { const x = sheet(`${title}<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>`); x.box.querySelector('#no').onclick = x.close; return; }
let pin = ''; const { box } = sheet(`${title}<p class="sub">${esc(p.name)}, enter your PIN</p><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(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(pin); return; } catch (e) { box.querySelector('#e').textContent = e.message; pin = ''; } } paint(); });
};
if (c.done) return who('undo');
if (c.claimable) { const { box, close } = sheet(`${title}<p class="sub">${c.points ? c.points + ' points' : 'Up for grabs'}</p><button class="btn" id="did" style="width:100%;min-height:52px;justify-content:center;margin-bottom:10px">I did it</button><button class="btn ghost" id="take" style="width:100%;min-height:52px;justify-content:center">Take it for later</button><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Cancel</button></p>`); box.querySelector('#no').onclick = close; box.querySelector('#did').onclick = () => who('done'); box.querySelector('#take').onclick = () => who('claim'); return; }
who('done');
}
// ---- add to a family list: write it, pick your name (PIN unless you are a child), and it is added as you
const ADD = {
shopping: { title: 'Add to the shopping list', ph: 'Milk, bread...', done: 'Added to the shopping list' },
task: { title: 'Add a family task', ph: 'What needs doing?', due: true, done: 'Task added' },
item: { title: 'Add to a list', ph: 'What to add?', lists: true, done: 'Added' },
event: { title: 'Add to the family calendar', ph: 'What is happening?', when: true, done: 'Event added' },
};
function addFlow(kind) {
const a = ADD[kind]; if (!a) return; const today = ymd(new Date());
const lists = data.notes.filter(n => n.type === 'list'), { box, close } = sheet(`<h3 style="margin:0 0 8px">${a.title}</h3><form id="af" style="text-align:left">${a.lists ? `<select id="al" style="margin-bottom:8px">${lists.map(n => `<option value="${esc(n.id)}">${esc(n.title)}</option>`).join('')}</select>` : ''}<input type="text" id="at" placeholder="${a.ph}" maxlength="200" autocomplete="off" required>
${a.due ? '<label class="sub" for="ad">Due (optional)</label><input type="date" id="ad">' : ''}${a.when ? `<label class="sub" for="ad">Day</label><input type="date" id="ad" value="${today}" required><label class="sub" for="atm">Time (leave empty for all day)</label><input type="time" id="atm">` : ''}
<div class="err" id="ae"></div><div style="display:flex;gap:10px;margin-top:10px"><button type="button" class="btn ghost" id="no" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Next</button></div></form>`);
box.querySelector('#no').onclick = close; setTimeout(() => { const t = box.querySelector('#at'); if (t) t.focus(); }, 50);
box.querySelector('#af').onsubmit = e => { e.preventDefault(); const payload = { kind, text: box.querySelector('#at').value.trim(), note: a.lists ? box.querySelector('#al').value : undefined, due: a.due ? box.querySelector('#ad').value : undefined, date: a.when ? box.querySelector('#ad').value : undefined, time: a.when ? box.querySelector('#atm').value : undefined, tz: Intl.DateTimeFormat().resolvedOptions().timeZone }; if (payload.text) whoThen(a.title, payload, a.done); };
}
function whoThen(title, payload, doneMsg) {
const head = `<h3 style="margin:0 0 4px">${esc(title)}</h3><p class="sub" style="margin:0 0 6px">${esc(payload.text)}</p>`;
const send = async (p, pin) => { const r = await fetch('/api/board/add', { method: 'POST', body: JSON.stringify({ ...payload, uid: p.id, pin }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'That did not work'); close(); await load(); toast(doneMsg); };
const { box, close } = sheet(`${head}<p class="sub">Who are you?</p><div class="tiles" style="justify-content:center">${data.people.map(p => `<button class="tile" data-id="${esc(p.id)}">${avatar(p)}<b>${esc(p.name.split(' ')[0])}</b><small>${p.kid ? '<span class="kid">KID</span>' : ''}</small></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;
box.querySelectorAll('.tile').forEach(t => t.onclick = () => { const p = data.people.find(x => x.id === t.dataset.id); if (p.kid && !p.pin) return send(p).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 = `${head}<p class="sub">${esc(p.name)}, enter your PIN</p><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(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); }
+7 -2
View File
@@ -18,11 +18,16 @@
setInterval(checkForUpdate, 10 * 60e3);
await loadMe();
await loadSettings();
sgStart();
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
addEventListener('hashchange', route);
restorePlayer();
route();
mailPoll(); setInterval(mailPoll, 2 * 60e3);
shareCheck();
// Unread mail and tasks due (the badge): checked on a timer only while the app is on screen, and again the moment you come back to it.
const badgePoll = () => { mailPoll(); taskPoll(); };
let lastBadge = Date.now(); badgePoll();
setInterval(() => { if (document.visibilityState === 'visible') { lastBadge = Date.now(); badgePoll(); } }, 2 * 60e3);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Date.now() - lastBadge > 30e3) { lastBadge = Date.now(); badgePoll(); } });
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
})();
+73 -10
View File
@@ -27,7 +27,7 @@ const evStore = []; // events currently on screen, so a tap can open the right o
const evRow = e => { const i = evStore.push(e) - 1;
if (e.task) return `<div class="row it"><button class="ibtn tick" data-tt="${i}" aria-label="Mark done">${icon('check')}</button><button class="rmain" data-ei="${i}" style="text-align:left"><span class="grow"><b>${esc(e.title)}</b><small>Task due${e.t.repeat && e.t.repeat !== 'none' ? ' · repeats' : ''} · ${esc(e.cal)}</small></span></button></div>`;
return `<button class="row" data-ei="${i}" style="text-align:left"><span class="dot" style="background:${esc(e.color)}"></span><span class="grow"><b>${esc(e.title)}</b>
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; };
<small>${e.allDay ? 'All day' : fmtTime(e.start) + ' – ' + fmtTime(e.end)}${e.location ? ' · ' + esc(e.location) : ''} · ${esc(e.cal)}${e.by ? ' · added by ' + esc(e.by) : ''}${e.series ? ' · repeats' : ''}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; };
document.addEventListener('click', e => {
const tk = e.target.closest('[data-tt]');
if (tk) { const ev = evStore[+tk.dataset.tt]; tk.disabled = true; jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: ev.t.list, id: ev.t.id, href: ev.t.href, done: true }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); route(); }).catch(err => { tk.disabled = false; toast(err.message); }); return; }
@@ -124,11 +124,16 @@ function openEvent(e) {
if (sh) mo.observe(document.body, { childList: true });
return;
}
if (e.contactId) { // a birthday from Contacts
const el = openSheet(e.title, 'Birthdays', `<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button>${allowed('contacts') ? '<a class="btn" id="bco" style="flex:1;justify-content:center">Open contact</a>' : ''}</div>`);
el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; };
return;
}
if (e.editable) return editEvent(e);
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
const el = openSheet(e.title, e.cal, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div>
${e.location ? `<div class="row"><span class="grow"><b>Where</b><small style="white-space:normal">${esc(e.location)}</small></span></div>` : ''}</div>
<p class="sub">This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
<p class="sub">${e.shared ? esc(e.owner) + ' shared this calendar with you to view only.' : 'This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.'}</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
}
@@ -142,11 +147,12 @@ async function editEvent(e) {
async function newEvent(day) {
try {
const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud);
const cals = (await jfetch('/api/cal/list')).filter(c => (c.local && c.mode !== 'view') || c.icloud);
if (!cals.length) return toast('No calendar here can take new events');
const d = new Date(day + 'T00:00:00'), now = new Date();
if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0);
const end = new Date(d.getTime() + 36e5);
eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
eventForm({ mode: 'new', cals, calId: (cals.find(c => c.id === store.get('cal_default', 'local')) || cals[0]).id, fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
} catch (err) { toast(err.message); }
}
@@ -155,7 +161,7 @@ function eventForm(o) {
const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']];
if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']);
const calPick = editing ? `<div class="row" style="background:var(--panel2);border-radius:12px"><span class="grow"><small>Calendar</small><b>${esc(o.calName)}</b></span></div>`
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : ''}</option>`).join('')}</select>`;
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : c.shared ? ' (' + esc(c.owner) + ')' : ''}</option>`).join('')}</select>`;
const el = openSheet(editing ? 'Edit event' : 'New event', '', `<form id="ef"><input type="text" id="ft" placeholder="Title" value="${esc(f.title)}" autocomplete="off" required style="margin-top:6px">
<label class="chk"><input type="checkbox" id="fa" ${f.allDay ? 'checked' : ''}> All day</label>
<div id="fdt"></div>
@@ -219,16 +225,70 @@ function addCalendar() {
};
}
const CAL_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#e8559a', '#14a3b8', '#7a8a3a', '#6b7280'];
const swatches = cur => `<div class="swatches">${CAL_COLORS.map(c => `<button type="button" class="sw ${c.toLowerCase() === String(cur).toLowerCase() ? 'on' : ''}" data-c="${c}" aria-label="${c}" style="background:${c}"></button>`).join('')}<label class="sw custom" title="Any color"><input type="color" value="${/^#[0-9a-f]{6}$/i.test(cur) ? cur : '#4f7cff'}"></label></div>`;
// Wires swatches() inside root; calls onPick(hex) whenever the color changes.
function wireSwatches(root, onPick) {
root.querySelectorAll('.sw[data-c]').forEach(b => b.onclick = () => { root.querySelectorAll('.sw').forEach(x => x.classList.toggle('on', x === b)); onPick(b.dataset.c); });
const cu = root.querySelector('.sw.custom input'); if (cu) cu.oninput = () => { root.querySelectorAll('.sw').forEach(x => x.classList.remove('on')); onPick(cu.value); };
}
function colorSheet(c) {
const el = openSheet(c.name, 'Calendar color', `${swatches(c.color)}<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
el.querySelector('#sx').onclick = () => { closeSheet(); viewCalendar(); };
wireSwatches(el, async hex => { try { await jfetch('/api/cal/color', { method: 'POST', body: JSON.stringify({ id: c.id, color: hex }) }); } catch (e) { toast(e.message); } });
}
// Create (c = null) or edit one of your NE Hub calendars: name, color, who it is shared with (view or edit), subscribe link, delete.
async function calendarSheet(c) {
const people = c ? await jfetch('/api/cal/people').catch(() => []) : [];
const st = { name: c ? c.name : '', color: c ? c.color : CAL_COLORS[Math.floor(Math.random() * CAL_COLORS.length)], shares: Object.fromEntries(((c && c.shares) || []).map(x => [x.user, x.mode])) };
const el = openSheet(c ? 'Edit calendar' : 'New calendar', '', `<form id="cf"><label for="cn">Name</label><input type="text" id="cn" value="${esc(st.name)}" placeholder="Family" autocomplete="off" required>
<label>Color</label>${swatches(st.color)}
${c ? `<h2 style="margin-top:14px">Sharing</h2><p class="sub" style="margin-top:0">Private unless you share it. For each person you choose whether they can only look, or add and change events too.</p>
${people.length ? `<div class="list" style="background:var(--panel2);border:0">${people.map(p => `<div class="row"><span class="grow"><b>${esc(p.name)}</b></span><select data-sh="${esc(p.id)}" style="width:auto;min-height:36px">${[['', 'Not shared'], ['view', 'Can view'], ['edit', 'Can edit']].map(([v, l]) => `<option value="${v}" ${(st.shares[p.id] || '') === v ? 'selected' : ''}>${l}</option>`).join('')}</select></div>`).join('')}</div>` : '<p class="sub">Nobody else has an account on this hub yet.</p>'}
<h2 style="margin-top:14px">Subscribe link</h2><p class="sub" style="margin-top:0">Show only this calendar in Outlook, Google or Apple Calendar (read-only there). Anyone with the link can see its events.</p>
<div class="bar"><button class="btn ghost" type="button" id="cl" style="flex:1;justify-content:center">${icon('copy')}Copy link</button><button class="btn ghost" type="button" id="cr" style="flex:1;justify-content:center">Make a new link</button></div>` : ''}
<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">${c ? 'Save' : 'Create'}</button></div>
${c ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="cd" style="flex:1;justify-content:center">Delete calendar</button></div>' : ''}
<div class="err" id="ce"></div></form>`);
el.querySelector('.sheet').classList.add('wide');
wireSwatches(el, hex => { st.color = hex; });
el.querySelectorAll('[data-sh]').forEach(sel => sel.onchange = () => { if (sel.value) st.shares[sel.dataset.sh] = sel.value; else delete st.shares[sel.dataset.sh]; });
el.querySelector('#sx').onclick = () => { closeSheet(); manageCalendars(); };
el.querySelector('#cf').onsubmit = async ev => {
ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#ce').textContent = '';
const name = el.querySelector('#cn').value.trim();
try {
if (c) await jfetch('/api/cal/ncal/update', { method: 'POST', body: JSON.stringify({ id: c.id, name, color: st.color, shares: Object.entries(st.shares).map(([user, mode]) => ({ user, mode })) }) });
else await jfetch('/api/cal/ncal', { method: 'POST', body: JSON.stringify({ name, color: st.color }) });
closeSheet(); toast(c ? 'Calendar saved' : 'Calendar created'); viewCalendar();
} catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
};
if (!c) return;
const link = async reset => (await jfetch('/api/cal/ncal/feed', { method: 'POST', body: JSON.stringify({ id: c.id, reset }) })).url;
el.querySelector('#cl').onclick = async () => { try { const u = await link(false); await navigator.clipboard.writeText(u); toast('Link copied'); } catch (e) { toast(e.message || 'Could not copy'); } };
el.querySelector('#cr').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { try { await navigator.clipboard.writeText(await link(true)); toast('New link copied'); } catch (e) { toast(e.message); } });
el.querySelector('#cd').onclick = () => confirmSheet('Delete ' + c.name + '?', 'Every event in it is deleted too' + (Object.keys(st.shares).length ? ', and the people it is shared with lose it' : '') + '. This cannot be undone.', 'Delete', async () => {
try { await jfetch('/api/cal/ncal/delete', { method: 'POST', body: JSON.stringify({ id: c.id }) }); toast('Calendar deleted'); viewCalendar(); } catch (e) { toast(e.message); }
});
}
async function manageCalendars() {
const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
const links = cals.filter(c => !c.local && !c.icloud);
const el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0">
<div class="row"><span class="dot" style="background:#4f7cff"></span><span class="grow"><b>NE Hub</b><small>Events you add here</small></span></div>
${links.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('')}
const links = cals.filter(c => !c.local && !c.icloud), mine = cals.filter(c => c.local), icol = Object.fromEntries(cals.filter(c => c.icloud).map(c => [c.id, c.color]));
const dot = c => `<button type="button" class="cdot" data-col="${esc(c.id)}" aria-label="Color of ${esc(c.name)}" style="background:${esc(c.color)}"></button>`;
const el = openSheet('Calendars', '', `<h2>NE Hub calendars</h2><div class="list" style="background:var(--panel2);border:0">
${mine.map(c => `<div class="row">${dot(c)}<span class="grow"><b>${esc(c.name)}</b><small>${c.shared ? 'Shared by ' + esc(c.owner) + ' · ' + (c.mode === 'edit' ? 'you can edit' : 'view only') : c.shares && c.shares.length ? 'Shared with ' + c.shares.length + (c.shares.length === 1 ? ' person' : ' people') : 'Private'}</small></span>${c.shared ? '' : `<button class="btn ghost" data-ed="${esc(c.id)}" style="min-height:36px">Edit</button>`}</div>`).join('')}
</div>
<div class="bar"><button class="btn ghost" id="nc" style="flex:1;justify-content:center">New calendar</button></div>
<h2 style="margin-top:14px">Calendar links</h2>
<div class="list" style="background:var(--panel2);border:0">
${links.length ? links.map(c => `<div class="row">${dot(c)}<span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('') : '<div class="row"><small>None yet</small></div>'}
</div>
<div class="bar"><button class="btn ghost" id="al" style="flex:1;justify-content:center">Add a calendar link</button></div>
<h2 style="margin-top:14px">iCloud calendars</h2>
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<label class="row" style="cursor:pointer"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></label>`).join('')}</div>
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<div class="row">${dot({ id: c.id, name: c.name, color: icol[c.id] || c.color })}<span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></div>`).join('')}</div>
<p class="sub">Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.</p><div class="bar"><button class="btn danger" id="icx" style="flex:1;justify-content:center">Disconnect iCloud</button></div>`
: `<p class="sub">Read and add events in your iCloud calendars. Use an <b>app-specific password</b> (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.</p>
<form id="icf"><input type="text" id="iu" placeholder="Apple ID (email)" autocomplete="off" autocapitalize="off" required><input type="password" id="ip" placeholder="App-specific password" autocomplete="off" style="margin-top:8px" required>
@@ -240,6 +300,9 @@ async function manageCalendars() {
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('.sheet').classList.add('wide');
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
el.querySelector('#nc').onclick = () => calendarSheet(null);
el.querySelectorAll('[data-ed]').forEach(b => b.onclick = () => calendarSheet(mine.find(c => c.id === b.dataset.ed)));
el.querySelectorAll('[data-col]').forEach(b => b.onclick = () => colorSheet(cals.find(c => c.id === b.dataset.col) || { id: b.dataset.col, name: 'Calendar', color: b.style.background }));
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); });
el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } });
const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); };
+12
View File
@@ -0,0 +1,12 @@
'use strict';
// Send what is playing to a TV or speaker. Safari/iOS use the AirPlay picker; Chrome and Edge use the browser's own cast picker (Chromecast and
// compatible TVs). The TV fetches the stream itself, so this works best where it can reach the server the same way this device does.
const castOk = el => !!el && (!!(el.remote && el.remote.prompt) || typeof el.webkitShowPlaybackTargetPicker === 'function');
async function castTo(el) {
if (!el) return toast('Start something playing first');
try {
if (el.remote && el.remote.prompt) await el.remote.prompt();
else if (typeof el.webkitShowPlaybackTargetPicker === 'function') el.webkitShowPlaybackTargetPicker();
else toast('This browser cannot send to a TV or speaker. Try Safari (AirPlay) or Chrome (Cast).');
} catch (e) { if (e && e.name !== 'NotAllowedError' && e.name !== 'AbortError') toast(e.name === 'NotSupportedError' || e.name === 'InvalidStateError' ? 'No TV or speaker found on this network' : (e.message || 'Could not cast')); }
}
+354
View File
@@ -0,0 +1,354 @@
'use strict';
// ---- Chat: the family messenger (see chat.js on the server). It looks like the Signal screen on purpose and shares its styles (sg*) and helpers (sgTime, sgLink...).
const ch = { convs: [], cur: null, curConv: null, root: null, es: null, unread: 0, reply: null, tray: [], names: {}, people: null, showArchived: false, sendQ: null, found: null, typing: null, typedAt: 0, pins: [] };
const chq = q => (ch.root || document).querySelector(q);
const chApi = (p, o) => jfetch('/api/chat' + p, o);
const chPost = (p, body) => chApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const chOnView = () => location.hash.startsWith('#/chat');
const chMounted = () => !!ch.root && document.contains(ch.root);
const chDraftKey = id => 'ch_draft:' + (ME ? ME.id : '') + ':' + id;
const chGetDraft = id => { try { return localStorage.getItem(chDraftKey(id)) || ''; } catch { return ''; } };
const chSetDraft = (id, t) => { try { t ? localStorage.setItem(chDraftKey(id), t) : localStorage.removeItem(chDraftKey(id)); } catch {} };
// ---- live updates, kept running whatever screen is open (so the unread count on the menu is always right)
async function chStart() {
if (!allowed('chat') || ch.es) return;
try { const st = await chApi('/status'); ch.unread = st.unread || 0; renderNav(activeTab); } catch { return; }
ch.es = new EventSource('/api/chat/events');
ch.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } chEvent(ev); };
}
let chEvT = null;
function chEvent(ev) {
if (typeof ev.unread === 'number' && ev.unread !== ch.unread) { ch.unread = ev.unread; renderNav(activeTab); }
if (ev.t === 'msg' && !ev.out) {
const looking = chMounted() && ch.cur === ev.conv && document.visibilityState === 'visible' && document.hasFocus();
if (!looking) { toast((ev.name || 'Chat') + ': ' + (ev.preview || '')); if (typeof sgAlerts === 'function' && sgAlerts().sound && typeof sgChime === 'function') sgChime(); }
}
if (ev.t === 'announce' && typeof chAnnounceCard === 'function') chAnnounceCard(document.getElementById('annc'));
if (ev.t === 'typing') { if (chMounted() && ch.cur === ev.conv) { ch.typing = { conv: ev.conv, name: ev.name, until: Date.now() + 4500 }; chPaintSub(); setTimeout(chPaintSub, 4600); } return; }
if (!chMounted()) return;
clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150);
}
function viewChat(args = []) { ch.cur = args[0] ? decodeURIComponent(args[0]) : null; return chMount($('#view')); }
async function chMount(root) {
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty">Loading…</p></div>';
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
await chRefresh(true);
}
async function chRefresh(first) {
try {
const r = await chApi('/convs'); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab);
if (!chq('#chlist')) return; chDrawList();
if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '<p class="empty">Choose a chat, or start a new one.</p>';
} catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
}
async function chSearchMessages() {
const q = (chq('#chq') || {}).value ? chq('#chq').value.trim() : ''; if (q.length < 2) { ch.found = null; return chDrawList(); }
try { const r = await chApi('/search?q=' + encodeURIComponent(q)); if ((chq('#chq') || {}).value.trim() === q) { ch.found = r.results; chDrawList(); } } catch { /* the list still works */ }
}
function chRow(c) {
return `<a class="row sgrow ${c.id === ch.cur ? 'on' : ''}" data-id="${esc(c.id)}" href="#/chat/${encodeURIComponent(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span>
<span class="grow"><b>${esc(c.name)}${c.muted ? ' 🔕' : ''}</b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.preview || c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static${c.muted ? ';opacity:.5' : ''}">${c.unread}</i>` : ''}</span></a>`;
}
function chDrawList() {
const q = (chq('#chq') || {}).value ? chq('#chq').value.toLowerCase().trim() : '', box = chq('#chlist'); if (!box) return;
const match = c => !q || c.name.toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q), live = ch.convs.filter(c => !c.archived && match(c)), arch = ch.convs.filter(c => c.archived && match(c));
const found = q.length >= 2 && ch.found ? ch.found : [];
box.innerHTML = live.map(chRow).join('') + (arch.length ? `<button class="row" id="charch"><span class="grow"><small>${ch.showArchived || q ? '▾' : '▸'} Archived (${arch.length})</small></span></button>${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '')
+ (found.length ? `<p class="sub" style="margin:12px 12px 4px">In messages</p>${found.map(f => `<a class="row sgrow" href="#/chat/${encodeURIComponent(f.conv)}"><span class="grow"><b>${esc(f.convName)}</b><small>${esc(f.msg.name)}: ${esc(f.msg.text)}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|| '';
if (!box.innerHTML) box.innerHTML = `<p class="empty">${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}</p>`;
const a = box.querySelector('#charch'); if (a) a.onclick = () => { ch.showArchived = !ch.showArchived; chDrawList(); };
box.oncontextmenu = e => { const r = e.target.closest('[data-id]'); if (r) { e.preventDefault(); chChatMenu(r.dataset.id); } };
}
/** @Name in a message stands out (and means that person gets an alert even if they muted the group). */
const chMention = html => { const names = Object.values(ch.names || {}).filter(Boolean).sort((a, b) => b.length - a.length); let out = html; for (const n of names) out = out.replace(new RegExp('(^|\\s)@' + esc(n).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w])', 'gi'), (x, pre) => pre + '<b class="mention">@' + esc(n) + '</b>'); return out; };
const chDur = s => { s = Math.round(+s || 0); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function chPoll(m) {
const P = m.poll, votes = P.votes || {}, count = id => Object.values(votes).filter(v => v.includes(id)).length, total = Object.keys(votes).length, mine = (ME && votes[ME.id]) || [];
return `<div class="sgpoll"><b class="sgpq" title="Who voted">📊 ${esc(P.q)}</b><small>${P.multi ? 'Choose any' : 'Choose one'}${P.closed ? ' · closed' : ''}</small>
${P.options.map(o => { const n = count(o.id); return `<button type="button" class="sgopt ${mine.includes(o.id) ? 'on' : ''}" data-o="${esc(o.id)}" ${P.closed ? 'disabled' : ''}><i style="width:${total ? Math.round(n / total * 100) : 0}%"></i><span>${mine.includes(o.id) ? '✓ ' : ''}${esc(o.text)}</span><small>${n}</small></button>`; }).join('')}
<small>${total} ${total === 1 ? 'person has' : 'people have'} voted</small></div>`;
}
function chBubble(m, c, prev) {
const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id;
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
: a.voice ? `<div class="sgvoice" data-src="${u}" data-dur="${a.dur || 0}"><button type="button" class="ibtn sgvp" aria-label="Play">${icon('play')}</button><span class="sgbars">${(a.wave && a.wave.length ? a.wave : Array(28).fill(30)).map(v => `<i style="height:${Math.max(12, v)}%"></i>`).join('')}</span><small class="sgvt">${chDur(a.dur)}</small></div>`
: /^audio\//.test(a.type || '') ? `<audio controls preload="none" src="${u}"></audio>` : /^video\//.test(a.type || '') ? `<video controls preload="metadata" playsinline src="${u}"></video>`
: `<a class="sgfile" href="${u}?dl=1" target="_blank" rel="noopener">${icon('clip')}<span>${esc(a.name || 'file')}${a.size ? ' · ' + sgSize(a.size) : ''}</span></a>`; }).join('');
const reacts = {}; Object.values(m.react || {}).forEach(e => { reacts[e] = (reacts[e] || 0) + 1; });
const day = !prev || new Date(prev.ts).toDateString() !== new Date(m.ts).toDateString() ? `<div class="sgday">${new Date(m.ts).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}</div>` : '';
if (m.sys) return `${day}<div class="sgday sgsys">${esc(m.text)}</div>`;
const tick = m.out && m.status ? `<span class="${m.status === 'read' ? 'sgrd' : ''}">${m.status === 'sent' ? '✓' : '✓✓'}</span>` : '';
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-id="${esc(m.id)}">${m.fwd ? `<small class="sgfwd">↪ Forwarded from ${esc(m.fwd.name)}</small>` : ''}${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name)}</small>` : ''}
${m.poll ? chPoll(m) : ''}${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${chMention(sgLink(esc(m.text)).replace(/\n/g, '<br>'))}</div>` : ''}
${m.link && !m.deleted ? `<a class="sgprev" href="${esc(m.link.url)}" target="_blank" rel="noopener noreferrer">${m.link.image ? `<img alt="" loading="lazy" src="${esc(stOk(m.link.image))}" onerror="this.remove()">` : ''}<span><small>${esc(m.link.site || new URL(m.link.url).hostname)}</small><b>${esc(m.link.title)}</b>${m.link.desc ? `<em>${esc(m.link.desc)}</em>` : ''}</span></a>` : ''}
<span class="sgmeta">${m.exp ? '⏱ ' : ''}${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ' ' + tick : ''}</span>
${Object.keys(reacts).length ? `<span class="sgre">${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')}</span>` : ''}</div>`;
}
async function chDrawThread(first) {
const box = chq('#chm'); if (!box) return;
const id = ch.cur; let r;
try { r = await chApi('/conv/' + encodeURIComponent(id)); } catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
if (ch.cur !== id || !chq('#chm')) return; ch.names = r.names || {};
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
if (!box.querySelector('.sgbar')) {
box.innerHTML = `<div class="sgbar"><a class="ibtn sgback" href="#/chat" aria-label="Back to chats">${icon('back')}</a><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b id="chtitle"></b><small id="chsub"></small></span><button class="ibtn" id="chmore" aria-label="Chat options">${icon('more')}</button></div>
<button type="button" class="sgpin" id="chpin" hidden></button><div class="sgmsgs" id="chmsgs"></div><div id="chrep" class="sgrep" hidden></div><div id="chtray" class="sgtray" hidden></div>
<form class="sgc" id="chform"><button type="button" class="ibtn" id="chat" aria-label="Attach">${icon('clip')}</button><input type="file" id="chfile" multiple hidden>
<textarea id="chtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button type="button" class="ibtn" id="chem" aria-label="Emoji">😊</button><button type="button" class="ibtn" id="chmic" aria-label="Record a voice message">${icon('mic')}</button><button class="ibtn sgsend" id="chsend" aria-label="Send">${icon('send')}</button></form>`;
chWireComposer(); chq('#chmore').onclick = () => chChatMenu(ch.cur); const ta = chq('#chtext'); ta.value = chGetDraft(id); ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
}
ch.curConv = c; ch.names = Object.fromEntries(c.members.map(x => [x.id, x.name])); ch.pins = r.pinned || []; chq('#chtitle').textContent = c.name; chPaintSub(); chDrawPin();
const list = r.msgs, el = chq('#chmsgs');
el.innerHTML = (r.more ? '<p class="sub" style="text-align:center">Older messages are not shown here</p>' : '') + list.map((m, i) => chBubble(m, c, list[i - 1])).join('') || '<p class="empty">No messages yet. Say hello.</p>';
if (first || stick) el.scrollTop = el.scrollHeight;
el.onclick = e => {
const b = e.target.closest('.sgb'), m0 = b && list.find(m => m.id === b.dataset.id), opt = e.target.closest('.sgopt'), vc = e.target.closest('.sgvoice'), pq = e.target.closest('.sgpq');
if (opt && m0) return chVote(m0, opt.dataset.o); if (vc) return chPlayVoice(vc); if (pq && m0) return chPollWho(m0);
if (!b || e.target.closest('a, audio, video')) return; chMessageMenu(m0, c);
};
if (c.unread) chPost('/read', { id }).then(() => { const x = ch.convs.find(q => q.id === id); if (x) x.unread = 0; chDrawList(); }).catch(() => {});
}
function chMessageMenu(m, c) {
if (!m || m.deleted) return;
const mine = m.out, el = openSheet('', '', `<div class="sgemo">${['👍', '❤️', '😂', '😮', '😢', '🙏'].map(e => `<button class="ibtn" data-e="${e}" aria-label="React ${e}">${e}</button>`).join('')}</div>
<div class="list" style="background:var(--panel2);border:0;margin-top:8px"><button class="row" id="chr">${icon('back')}<span class="grow"><b>Reply</b></span></button>${m.text ? `<button class="row" id="chcp">${icon('copy')}<span class="grow"><b>Copy text</b></span></button>` : ''}
${m.text && allowed('tasks') ? `<button class="row" id="chtk">${icon('check')}<span class="grow"><b>Make a task</b></span></button>` : ''}${m.text && allowed('recipes') ? `<button class="row" id="chsh">${icon('chef')}<span class="grow"><b>Add to the shopping list</b></span></button>` : ''}${m.text && allowed('calendar') ? `<button class="row" id="chev">${icon('cal')}<span class="grow"><b>Add to the calendar</b></span></button>` : ''}
<button class="row" id="chfw">${icon('send')}<span class="grow"><b>Forward</b></span></button><button class="row" id="chpn">${icon('bookmark')}<span class="grow"><b>${ch.pins.some(p => p.id === m.id) ? 'Unpin' : 'Pin'}</b></span></button>
${mine && m.text ? `<button class="row" id="ched">${icon('edit')}<span class="grow"><b>Edit</b></span></button>` : ''}${mine ? `<button class="row" id="chdl">${icon('trash')}<span class="grow"><b style="color:var(--danger)">Delete for everyone</b></span></button>` : ''}</div>`);
el.querySelectorAll('[data-e]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/react', { conv: c.id, msg: m.id, emoji: (m.react && ME && m.react[ME.id]) === b.dataset.e ? '' : b.dataset.e }); } catch (e) { toast(e.message); } });
el.querySelector('#chr').onclick = () => { closeSheet(); ch.reply = m; chDrawReply(); chq('#chtext').focus(); };
const tk = el.querySelector('#chtk'); if (tk) tk.onclick = async () => { closeSheet(); try { await jfetch('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ list: 'local:inbox', fields: { title: m.text.slice(0, 200), notes: 'From Chat (' + m.name + ')', due: '', done: false } }) }); toast('Added to your tasks', { label: 'Open', run: () => { location.hash = '#/tasks'; } }); } catch (e) { toast(e.message); } };
const sh = el.querySelector('#chsh'); if (sh) sh.onclick = async () => { closeSheet(); try { await jfetch('/api/recipes/shopping/add', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: m.text.slice(0, 200) }) }); toast('Added to the shopping list', { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (e) { toast(e.message); } };
const ev = el.querySelector('#chev'); if (ev) ev.onclick = () => { closeSheet(); chMakeEvent(m); };
el.querySelector('#chfw').onclick = () => { closeSheet(); chForward(m, c.id); };
el.querySelector('#chpn').onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: c.id, msg: m.id, on: !ch.pins.some(p => p.id === m.id) }); } catch (e) { toast(e.message); } };
const cp = el.querySelector('#chcp'); if (cp) cp.onclick = async () => { closeSheet(); try { await navigator.clipboard.writeText(m.text); toast('Copied'); } catch { toast('Could not copy'); } };
const ed = el.querySelector('#ched'); if (ed) ed.onclick = () => { closeSheet(); textSheet('Edit message', 'Message', 'Save', async t => { try { await chPost('/edit', { conv: c.id, msg: m.id, text: t }); } catch (e) { toast(e.message); } }, m.text); };
const dl = el.querySelector('#chdl'); if (dl) dl.onclick = () => { closeSheet(); confirmSheet('Delete this message?', 'It disappears for everyone in the chat.', 'Delete', async () => { try { await chPost('/delete', { conv: c.id, msg: m.id }); } catch (e) { toast(e.message); } }); };
}
function chDrawReply() {
const b = chq('#chrep'); if (!b) return; if (!ch.reply) { b.hidden = true; return; }
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
chq('#chrx').onclick = () => { ch.reply = null; chDrawReply(); };
}
function chDrawTray() {
const t = chq('#chtray'); if (!t) return; t.hidden = !ch.tray.length;
t.innerHTML = ch.tray.map((f, i) => `<span class="sgchip">${/^image\//.test(f.type) ? `<img alt="" src="${URL.createObjectURL(f)}">` : icon('clip')}<small>${esc(f.name)}</small><button type="button" class="ibtn" data-x="${i}" aria-label="Remove">${icon('x')}</button></span>`).join('');
t.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { ch.tray.splice(+b.dataset.x, 1); chDrawTray(); });
}
function chWireComposer() {
const ta = chq('#chtext'), grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px'; };
ta.oninput = () => { grow(); chSetDraft(ch.cur, ta.value); if (ta.value.trim() && Date.now() - ch.typedAt > 3000) { ch.typedAt = Date.now(); chPost('/typing', { conv: ch.cur }).catch(() => {}); } };
chMentionBox(ta);
ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); chq('#chform').requestSubmit(); } };
chq('#chat').onclick = () => actionSheet('Attach', '', [{ label: 'Photo or file', icon: 'clip', run: () => chq('#chfile').click() }, { label: 'Poll', icon: 'check', run: chNewPoll }]);
chq('#chem').onclick = chEmojiPanel; chq('#chmic').onclick = chRecStart;
const sw = () => { const has = !!ta.value.trim() || ch.tray.length; chq('#chmic').hidden = has; chq('#chsend').hidden = !has; }; ta.addEventListener('input', sw); sw(); ch.swSend = sw;
chq('#chfile').onchange = e => { ch.tray.push(...e.target.files); e.target.value = ''; chDrawTray(); ch.swSend(); };
ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { ch.tray.push(...fs); chDrawTray(); } });
// The message appears at once, dimmed as "Sending…"; if it fails, the words and files go back into the box so nothing is lost.
chq('#chform').onsubmit = e => {
e.preventDefault(); const text = ta.value, files = [...ch.tray], reply = ch.reply, to = ch.cur; if (!text.trim() && !files.length) return;
ta.value = ''; grow(); chSetDraft(to, ''); ch.tray = []; ch.reply = null; chDrawTray(); chDrawReply(); ta.focus(); ch.swSend();
const list = chq('#chmsgs'), tmp = document.createElement('div'); tmp.className = 'sgb out'; tmp.style.opacity = '.65'; tmp.innerHTML = `${esc(text) || 'Attachment'}<div class="sgmeta"><small>Sending…</small></div>`;
if (list) { list.appendChild(tmp); list.scrollTop = list.scrollHeight; }
ch.sendQ = (ch.sendQ || Promise.resolve()).then(async () => { // one at a time, so quick messages arrive in the order you wrote them
try {
const ids = []; for (const f of files) { const up = await chApi('/upload?conv=' + encodeURIComponent(to) + '&name=' + encodeURIComponent(f.name), { method: 'POST', body: f, headers: { 'content-type': f.type || 'application/octet-stream' } }); ids.push(up.id); }
await chPost('/send', { conv: to, text, files: ids, quote: reply ? reply.id : undefined }); tmp.remove(); await chRefresh(); const el = chq('#chmsgs'); if (el) el.scrollTop = el.scrollHeight;
} catch (err) { tmp.remove(); toast(err.message || 'Could not send'); if (ch.cur === to && !ta.value) { ta.value = text; grow(); ch.tray = files; ch.reply = reply; chDrawTray(); chDrawReply(); } }
});
};
}
// ---- starting chats, groups and managing them
async function chPeople() { if (!ch.people) ch.people = await chApi('/people'); return ch.people; }
async function chNewChat() {
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
const el = openSheet('New chat', 'Pick someone in the family', `<div class="list">${people.map(p => `<button class="row" data-p="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span></button>`).join('') || '<p class="empty">There is nobody else with Chat yet. An admin can turn it on for people in Settings.</p>'}</div>
${people.length > 1 ? '<div class="bar"><button class="btn ghost" id="chg" style="width:100%;justify-content:center">New group…</button></div>' : ''}`);
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { try { const r = await chPost('/new', { type: 'direct', with: b.dataset.p }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (e) { toast(e.message); } });
const g = el.querySelector('#chg'); if (g) g.onclick = () => { closeSheet(); chNewGroup(people); };
}
function chNewGroup(people) {
const el = openSheet('New group', '', `<form id="chgf"><label for="chgn">Group name</label><input type="text" id="chgn" maxlength="60" required autocomplete="off" placeholder="Family"><label>Who is in it?</label>
<div class="list">${people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}" checked><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div><div class="err" id="chge"></div>
<div class="bar"><button class="btn ghost" type="button" id="chgx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div></form>`);
el.querySelector('#chgx').onclick = closeSheet; setTimeout(() => el.querySelector('#chgn').focus(), 60);
el.querySelector('#chgf').onsubmit = async e => { e.preventDefault(); try { const r = await chPost('/new', { type: 'group', name: el.querySelector('#chgn').value, members: [...el.querySelectorAll('[data-p]:checked')].map(c => c.dataset.p) }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (x) { el.querySelector('#chge').textContent = x.message; } };
}
function chChatMenu(id) {
const c = ch.convs.find(x => x.id === id); if (!c) return; const acts = [];
acts.push({ label: c.muted ? 'Unmute' : 'Mute', icon: 'bell', run: async () => { try { await chPost('/conv/mute', { id, on: !c.muted }); toast(c.muted ? 'Unmuted' : 'Muted. No alerts from this chat.'); chRefresh(); } catch (e) { toast(e.message); } } });
acts.push({ label: c.archived ? 'Move out of the archive' : 'Archive', icon: 'files', run: async () => { try { await chPost('/conv/archive', { id, on: !c.archived }); if (!c.archived && ch.cur === id) location.hash = '#/chat'; chRefresh(); } catch (e) { toast(e.message); } } });
acts.push({ label: 'Disappearing messages' + (c.ttl ? ' · on' : ''), icon: 'clock', run: () => chTtlSheet(c) });
if (c.type === 'group') {
acts.push({ label: 'Rename the group', icon: 'edit', run: () => textSheet('Rename group', 'Group name', 'Rename', async n => { try { await chPost('/conv/update', { id, name: n }); chRefresh(); } catch (e) { toast(e.message); } }, c.name) });
acts.push({ label: 'People in the group', icon: 'people', run: () => chMembers(c) });
acts.push({ label: 'Leave the group', icon: 'trash', danger: true, run: () => confirmSheet('Leave this group?', c.name, 'Leave', async () => { try { await chPost('/conv/update', { id, leave: true }); location.hash = '#/chat'; chRefresh(); } catch (e) { toast(e.message); } }) });
}
actionSheet(c.name, c.type === 'group' ? c.members.length + ' people' : 'Chat', acts);
}
async function chMembers(c) {
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
const owner = ME && (ME.role === 'admin' || c.by === ME.id), others = people.filter(p => !c.members.some(m => m.id === p.id));
const el = openSheet(c.name, 'People in the group', `<div class="list">${c.members.map(m => `<div class="row"><span class="sgav" style="background:${sgColor(m.id)}">${esc(sgInitial(m.name))}</span><span class="grow"><b>${esc(m.name)}${ME && m.id === ME.id ? ' (you)' : ''}${m.id === c.by ? ' · made it' : ''}</b></span>${owner && !(ME && m.id === ME.id) ? `<button class="btn danger" data-r="${esc(m.id)}" style="min-height:34px">Remove</button>` : ''}</div>`).join('')}</div>
${others.length ? `<h2>Add someone</h2><div class="list">${others.map(p => `<button class="row" data-a="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span>${icon('plus')}</button>`).join('')}</div>` : ''}`);
const act = async body => { try { await chPost('/conv/update', { id: c.id, ...body }); closeSheet(); chRefresh(); } catch (e) { toast(e.message); } };
el.querySelectorAll('[data-r]').forEach(b => b.onclick = () => act({ remove: [b.dataset.r] })); el.querySelectorAll('[data-a]').forEach(b => b.onclick = () => act({ add: [b.dataset.a] }));
}
/** The line under the chat's name: who is typing, or who is here, or (for a group) who is in it, and the disappearing-messages timer. */
function chPaintSub() {
const el = chq('#chsub'), c = ch.curConv; if (!el || !c) return;
const ty = ch.typing && ch.typing.conv === c.id && ch.typing.until > Date.now() ? ch.typing.name + ' is typing…' : '';
const ago = t => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' }); };
const base = c.type === 'group' ? c.members.map(x => x.name).join(', ') : c.presence ? (c.presence.online ? '● Online' : c.presence.seen ? 'Active ' + ago(c.presence.seen) : '') : '';
el.textContent = ty || (base + (c.ttl ? (base ? ' · ' : '') + '⏱ ' + (c.ttl === 3600 ? '1 hour' : c.ttl === 86400 ? '1 day' : '1 week') : ''));
el.style.color = ty ? 'var(--accent)' : '';
}
function chDrawPin() {
const b = chq('#chpin'); if (!b) return; if (!ch.pins.length) { b.hidden = true; return; }
const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `<span>📌</span><span class="grow"><small>${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}</small><b>${esc(last.text || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}</b></span>`;
b.onclick = () => { const el = openSheet('Pinned', '', `<div class="list">${ch.pins.slice().reverse().map(m => `<div class="row"><button class="rmain" data-go="${esc(m.id)}"><span class="grow"><b>${esc(m.name)}</b><small style="white-space:normal">${esc(m.text || 'Attachment')}</small></span></button><button class="ibtn" data-un="${esc(m.id)}" aria-label="Unpin">${icon('x')}</button></div>`).join('')}</div>`);
el.querySelectorAll('[data-go]').forEach(x => x.onclick = () => { closeSheet(); const t = chq(`.sgb[data-id="${CSS.escape(x.dataset.go)}"]`); if (t) { t.scrollIntoView({ block: 'center' }); t.classList.add('flash'); setTimeout(() => t.classList.remove('flash'), 1400); } });
el.querySelectorAll('[data-un]').forEach(x => x.onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: ch.cur, msg: x.dataset.un, on: false }); } catch (e) { toast(e.message); } }); };
}
function chTtlSheet(c) {
actionSheet('Disappearing messages', 'New messages in this chat go after the time you pick', [[0, 'Off'], [3600, '1 hour'], [86400, '1 day'], [604800, '1 week']].map(([v, l]) => ({ label: l + ((c.ttl || 0) === v ? ' ✓' : ''), icon: 'clock', run: async () => { try { await chPost('/conv/ttl', { id: c.id, ttl: v }); } catch (e) { toast(e.message); } } })));
}
function chForward(m, from) {
const el = openSheet('Forward to…', '', `<div class="list" style="max-height:50dvh;overflow:auto">${ch.convs.map(c => `<button class="row" data-t="${esc(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b></span></button>`).join('') || '<p class="empty">No chats yet.</p>'}</div>`);
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/forward', { from, to: b.dataset.t, msg: m.id }); toast('Forwarded'); } catch (e) { toast(e.message); } });
}
/** Type @ in a group and the people in it are offered. */
function chMentionBox(ta) {
let box; const close = () => { if (box) { box.remove(); box = null; } };
const show = () => {
const c = ch.curConv; if (!c || c.type !== 'group') return close(); const before = ta.value.slice(0, ta.selectionStart), m = /(^|\s)@(\w*)$/.exec(before); if (!m) return close();
const rows = c.members.filter(x => ME && x.id !== ME.id && x.name.toLowerCase().startsWith(m[2].toLowerCase())).slice(0, 6); if (!rows.length) return close();
if (!box) { box = document.createElement('div'); box.className = 'ctsug'; box.style.cssText += ';bottom:100%;top:auto'; ta.closest('form').style.position = 'relative'; ta.closest('form').appendChild(box); }
box.innerHTML = rows.map((r, i) => `<button type="button" data-i="${i}"><b>@${esc(r.name)}</b></button>`).join('');
box.querySelectorAll('button').forEach(b => b.onmousedown = e => { e.preventDefault(); const r = rows[+b.dataset.i], pos = ta.selectionStart - m[2].length; ta.value = ta.value.slice(0, pos) + r.name + ' ' + ta.value.slice(ta.selectionStart); ta.selectionStart = ta.selectionEnd = pos + r.name.length + 1; close(); ta.dispatchEvent(new Event('input')); });
};
ta.addEventListener('input', show); ta.addEventListener('blur', () => setTimeout(close, 120));
}
async function chVote(m, opt) {
const P = m.poll, mine = (ME && (P.votes || {})[ME.id]) || []; let opts;
if (P.multi) opts = mine.includes(opt) ? mine.filter(x => x !== opt) : [...mine, opt]; else opts = mine.includes(opt) ? [] : [opt];
try { await chPost('/vote', { conv: ch.cur, msg: m.id, opts }); } catch (e) { toast(e.message); }
}
function chPollWho(m) {
const P = m.poll, votes = P.votes || {};
const el = openSheet(P.q, P.multi ? 'Choose any' : 'Choose one', `<div class="list">${P.options.map(o => { const who = Object.entries(votes).filter(([, v]) => v.includes(o.id)).map(([u]) => ch.names[u] || 'Someone'); return `<div class="row"><span class="grow"><b>${esc(o.text)}</b><small style="white-space:normal">${who.length ? esc(who.join(', ')) : 'No votes yet'}</small></span></div>`; }).join('')}</div>
${m.out && !P.closed ? '<div class="bar"><button class="btn ghost" id="chpc" style="width:100%;justify-content:center">Close the poll</button></div>' : ''}`);
const c = el.querySelector('#chpc'); if (c) c.onclick = async () => { closeSheet(); try { await chPost('/poll/close', { conv: ch.cur, msg: m.id }); } catch (e) { toast(e.message); } };
}
function chNewPoll() {
const el = openSheet('New poll', 'Ask the chat to vote', `<form id="chpf"><label for="chpq">Question</label><input type="text" id="chpq" maxlength="200" required autocomplete="off" placeholder="Pizza or tacos?">
<label>Choices</label><div id="chpo">${[1, 2].map(i => `<input type="text" class="chpopt" maxlength="100" placeholder="Choice ${i}" autocomplete="off" style="margin-top:6px">`).join('')}</div>
<div class="bar" style="justify-content:flex-start"><button type="button" class="btn ghost" id="chpadd">${icon('plus')}Add a choice</button></div>
<label class="row" style="cursor:pointer"><span class="grow"><b>Allow several answers</b></span><input type="checkbox" id="chpm"></label><div class="err" id="chpe"></div>
<div class="bar"><button class="btn ghost" type="button" id="chpx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Send</button></div></form>`);
el.querySelector('#chpx').onclick = closeSheet; setTimeout(() => el.querySelector('#chpq').focus(), 60);
el.querySelector('#chpadd').onclick = () => { const box = el.querySelector('#chpo'); if (box.children.length >= 8) return; const i = document.createElement('input'); i.type = 'text'; i.className = 'chpopt'; i.maxLength = 100; i.placeholder = 'Choice ' + (box.children.length + 1); i.style.marginTop = '6px'; box.appendChild(i); i.focus(); };
el.querySelector('#chpf').onsubmit = async e => { e.preventDefault(); try { await chPost('/poll', { conv: ch.cur, question: el.querySelector('#chpq').value, options: [...el.querySelectorAll('.chpopt')].map(x => x.value), multi: el.querySelector('#chpm').checked }); closeSheet(); await chRefresh(); const l = chq('#chmsgs'); if (l) l.scrollTop = l.scrollHeight; } catch (x) { el.querySelector('#chpe').textContent = x.message; } };
}
// ---- voice messages: play (one at a time) ...
function chPlayVoice(box) {
const a = ch.audio || (ch.audio = new Audio()), same = ch.playing === box;
if (ch.playing && ch.playing !== box) chStopVoice();
if (same && !a.paused) { a.pause(); box.querySelector('.sgvp').innerHTML = icon('play'); return; }
if (!same) { a.src = box.dataset.src; ch.playing = box; a.onended = chStopVoice; a.ontimeupdate = () => { const d = a.duration || +box.dataset.dur || 1, bars = box.querySelectorAll('.sgbars i'), k = Math.round(a.currentTime / d * bars.length); bars.forEach((b, i) => b.classList.toggle('on', i < k)); box.querySelector('.sgvt').textContent = chDur(a.currentTime); }; }
a.play().then(() => { box.querySelector('.sgvp').innerHTML = icon('pause'); }).catch(() => toast('Could not play that'));
}
function chStopVoice() { const a = ch.audio, b = ch.playing; if (a) a.pause(); if (b) { b.querySelector('.sgvp').innerHTML = icon('play'); b.querySelectorAll('.sgbars i').forEach(x => x.classList.remove('on')); b.querySelector('.sgvt').textContent = chDur(b.dataset.dur); } ch.playing = null; }
// ... and record
async function chRecStart() {
if (ch.rec) return; if (!navigator.mediaDevices || !window.MediaRecorder) return toast('This browser cannot record audio');
let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { return toast('Allow the microphone to record a voice message'); }
const type = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'].find(t => MediaRecorder.isTypeSupported(t)) || '', mr = new MediaRecorder(stream, type ? { mimeType: type } : undefined), chunks = [], wave = [];
let ac, an, tick; try { ac = new (window.AudioContext || window.webkitAudioContext)(); an = ac.createAnalyser(); an.fftSize = 256; ac.createMediaStreamSource(stream).connect(an); const buf = new Uint8Array(an.fftSize); tick = setInterval(() => { an.getByteTimeDomainData(buf); let mx = 0; for (const v of buf) mx = Math.max(mx, Math.abs(v - 128)); wave.push(Math.min(100, Math.round(mx / 128 * 140))); }, 200); } catch { /* no waveform: the message still works */ }
mr.ondataavailable = e => { if (e.data.size) chunks.push(e.data); }; mr.start(); ch.rec = { mr, chunks, wave, stream, ac, tick, t0: Date.now(), type: mr.mimeType || type || 'audio/webm' };
chDrawRec(); ch.rec.timer = setInterval(chDrawRec, 500);
}
function chRecEnd() { const r = ch.rec; if (!r) return; clearInterval(r.timer); clearInterval(r.tick); try { r.mr.state !== 'inactive' && r.mr.stop(); } catch {} r.stream.getTracks().forEach(t => t.stop()); try { r.ac && r.ac.close(); } catch {} ch.rec = null; const f = chq('#chform'); if (f) f.classList.remove('rec'); const b = chq('#chrecbar'); if (b) b.remove(); }
function chDrawRec() {
const r = ch.rec, f = chq('#chform'); if (!r || !f) return; f.classList.add('rec'); let b = chq('#chrecbar');
if (!b) { b = document.createElement('div'); b.id = 'chrecbar'; b.className = 'chrecbar'; b.innerHTML = `<button type="button" class="ibtn" id="chrx2" aria-label="Cancel recording">${icon('trash')}</button><span class="recdot"></span><span id="chrt">0:00</span><span class="grow"></span><button type="button" class="ibtn sgsend" id="chrs" aria-label="Send voice message">${icon('send')}</button>`; f.appendChild(b);
b.querySelector('#chrx2').onclick = chRecEnd; b.querySelector('#chrs').onclick = chRecSend; }
b.querySelector('#chrt').textContent = chDur((Date.now() - r.t0) / 1000);
}
async function chRecSend() {
const r = ch.rec; if (!r) return; const to = ch.cur, secs = Math.round((Date.now() - r.t0) / 1000); if (secs < 1) return chRecEnd();
const done = new Promise(ok => { r.mr.onstop = ok; }); try { r.mr.stop(); } catch {} await done; const blob = new Blob(r.chunks, { type: r.type }), wave = r.wave.slice(); chRecEnd();
const bars = []; const n = 32; for (let i = 0; i < n; i++) { const seg = wave.slice(Math.floor(i * wave.length / n), Math.max(Math.floor(i * wave.length / n) + 1, Math.floor((i + 1) * wave.length / n))); bars.push(seg.length ? Math.max(...seg) : 20); }
const ext = /mp4/.test(r.type) ? 'm4a' : /ogg/.test(r.type) ? 'ogg' : 'webm';
try { const up = await chApi(`/upload?conv=${encodeURIComponent(to)}&name=${encodeURIComponent('Voice message.' + ext)}&voice=1&dur=${secs}&wave=${bars.join(',')}`, { method: 'POST', body: blob, headers: { 'content-type': r.type.split(';')[0] } }); await chPost('/send', { conv: to, files: [up.id] }); await chRefresh(); const l = chq('#chmsgs'); if (l) l.scrollTop = l.scrollHeight; }
catch (e) { toast(e.message || 'Could not send the voice message'); }
}
// ---- emoji
const CH_EMOJI = '😀 😃 😄 😁 😆 😅 😂 🤣 😊 🙂 😉 😍 🥰 😘 😋 😎 🤗 🤔 😐 😴 😢 😭 😮 😱 😡 🥳 😇 🙏 👍 👎 👏 🙌 💪 👋 ❤️ 🧡 💛 💚 💙 💜 🔥 ⭐ ✨ 🎉 🎂 🎁 🍕 🍔 🍎 ☕ 🍪 🏠 🚗 ✈️ ☀️ 🌧️ ❄️ 🌈 🐶 🐱 🌸 ✅ ❌ ❓ 💯'.split(' ');
function chEmojiPanel() {
const f = chq('#chform'), old = chq('#chemo'); if (old) return old.remove(); const box = document.createElement('div'); box.id = 'chemo'; box.className = 'chemo'; box.innerHTML = CH_EMOJI.map(e => `<button type="button" data-e="${e}">${e}</button>`).join(''); f.appendChild(box);
box.onclick = e => { const b = e.target.closest('[data-e]'); if (!b) return; const ta = chq('#chtext'), pos = ta.selectionStart || ta.value.length; ta.value = ta.value.slice(0, pos) + b.dataset.e + ta.value.slice(ta.selectionEnd || pos); ta.selectionStart = ta.selectionEnd = pos + b.dataset.e.length; ta.dispatchEvent(new Event('input')); ta.focus(); };
}
// ---- from a message into the rest of the hub
function chMakeEvent(m) {
const today = new Date(), d = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0');
const el = openSheet('Add to the calendar', 'From a message in Chat', `<form id="chef"><label for="chet">What</label><input type="text" id="chet" value="${esc(m.text.slice(0, 120))}" maxlength="200" required><label for="ched2">Day</label><input type="date" id="ched2" value="${d}" required>
<label for="chetm">Time (leave empty for all day)</label><input type="time" id="chetm"><div class="err" id="chee"></div><div class="bar"><button class="btn ghost" type="button" id="chex" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Add</button></div></form>`);
el.querySelector('#chex').onclick = closeSheet;
el.querySelector('#chef').onsubmit = async e => { e.preventDefault(); try {
const cals = await jfetch('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('There is no calendar to add to'); const day = el.querySelector('#ched2').value, tm = el.querySelector('#chetm').value, allDay = !tm, end = tm ? new Date(new Date(day + 'T' + tm).getTime() + 3600e3) : null;
const endStr = allDay ? day : day + 'T' + String(end.getHours()).padStart(2, '0') + ':' + String(end.getMinutes()).padStart(2, '0');
await jfetch('/api/cal/event', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cal: c.id, fields: { title: el.querySelector('#chet').value, location: '', notes: 'From Chat (' + m.name + ')', allDay, start: allDay ? day : day + 'T' + tm, end: endStr, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, repeat: 'none' } }) });
closeSheet(); toast('Added to the calendar', { label: 'Open', run: () => { location.hash = '#/calendar'; } }); } catch (x) { el.querySelector('#chee').textContent = x.message; } };
}
/** From anywhere in the hub: "Share to Chat". Picks a chat and sends the text (with a link back to the thing). */
async function shareToChat(text) {
if (!allowed('chat')) return toast('Chat is not turned on for you');
let convs = []; try { convs = (await chApi('/convs')).convs.filter(c => !c.archived); } catch (e) { return toast(e.message); }
const el = openSheet('Share to Chat', text.length > 90 ? text.slice(0, 90) + '…' : text, `<div class="list" style="max-height:50dvh;overflow:auto">${convs.map(c => `<button class="row" data-t="${esc(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b></span></button>`).join('') || '<p class="empty">Start a chat first.</p>'}</div>`);
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/send', { conv: b.dataset.t, text }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(b.dataset.t); } }); } catch (e) { toast(e.message); } });
}
// ---- settings: quiet hours, and the family announcement (admin)
async function chSettings() {
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
const q = pr.quiet, admin = ME && ME.role === 'admin';
const el = openSheet('Chat settings', '', `<form id="chsf"><label class="row" style="cursor:pointer"><span class="grow"><b>Quiet hours</b><small style="white-space:normal">No phone alerts from Chat in these hours. Messages still show up in the app.</small></span><input type="checkbox" id="chqo"${q.on ? ' checked' : ''}></label>
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="chqf">From</label><input type="time" id="chqf" value="${esc(q.from)}"></div><div style="flex:1"><label for="chqt">To</label><input type="time" id="chqt" value="${esc(q.to)}"></div></div>
${admin ? `<h2>Family announcement</h2><p class="sub" style="white-space:normal">Shown at the top of everyone's Home. Leave it empty and save to take it down.</p><textarea id="chan" rows="2" maxlength="300" placeholder="Dinner at 6">${esc(an ? an.text : '')}</textarea>` : ''}<div class="err" id="chse"></div>
<div class="bar"><button class="btn ghost" type="button" id="chsx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#chsx').onclick = closeSheet;
el.querySelector('#chsf').onsubmit = async e => { e.preventDefault(); try { await chPost('/prefs', { quiet: { on: el.querySelector('#chqo').checked, from: el.querySelector('#chqf').value, to: el.querySelector('#chqt').value } }); if (admin) await chPost('/announcement', { text: el.querySelector('#chan').value }); closeSheet(); toast('Saved'); } catch (x) { el.querySelector('#chse').textContent = x.message; } };
}
/** Home: the family announcement, if there is one (each person can dismiss it). */
async function chAnnounceCard(el) {
if (!el || !allowed('chat')) return; let a = null; try { a = await chApi('/announcement'); } catch { return; }
let gone = ''; try { gone = localStorage.getItem('ch_ann_gone:' + (ME ? ME.id : '')) || ''; } catch {}
if (!a || a.id === gone) { el.innerHTML = ''; return; }
el.innerHTML = `<div class="annc"><span class="ic" style="background:var(--accent)">${icon('chat')}</span><span class="grow"><small>Family announcement · ${esc(a.by)}</small><b>${esc(a.text)}</b></span><button class="ibtn" id="ancx" aria-label="Dismiss">${icon('x')}</button></div>`;
el.querySelector('#ancx').onclick = () => { try { localStorage.setItem('ch_ann_gone:' + (ME ? ME.id : ''), a.id); } catch {} el.innerHTML = ''; };
}
/** On a phone the open chat has to end above the mini player and the bottom bar, or the message box lands off screen: size it from what is really at the bottom. */
function chFit() {
const m = chMounted() && ch.root.querySelector('.sgm'); if (!m || m.offsetParent === null) return;
if (window.innerWidth >= 1024) { m.style.height = ''; return; }
let bottom = window.visualViewport ? window.visualViewport.height : window.innerHeight;
const top0 = m.getBoundingClientRect().top; // (look up from the screen's edge for the first spot that is page content, not the bottom bar or mini player)
for (let y = Math.floor(bottom) - 2; y > top0 + 130; y -= 4) { const hit = document.elementsFromPoint(innerWidth / 2, y).find(e => !e.closest('.sgm')); const covers = hit && !hit.closest('#view'); if (!covers) { bottom = y + 4; break; } }
const top = m.getBoundingClientRect().top; m.style.height = Math.max(260, Math.floor(bottom - top - 10)) + 'px';
}
window.addEventListener('resize', chFit); if (window.visualViewport) window.visualViewport.addEventListener('resize', chFit);
setInterval(chFit, 700);
+89
View File
@@ -0,0 +1,89 @@
'use strict';
// ---- Chores: today's chores for you and the family, ticking them off, points for the week, and (admin) making and changing chores. See chores.js.
const cr = { day: '', data: null, wk: '', week: null };
const crApi = (p, o) => jfetch('/api/chores' + p, o);
const crPost = (p, b) => crApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
const crYmd = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const crShift = (s, n) => { const d = new Date(s + 'T12:00:00'); d.setDate(d.getDate() + n); return crYmd(d); };
const crDayLabel = s => s === crYmd(new Date()) ? 'Today' : s === crShift(crYmd(new Date()), -1) ? 'Yesterday' : s === crShift(crYmd(new Date()), 1) ? 'Tomorrow' : new Date(s + 'T12:00:00').toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' });
const CR_REPEAT = { daily: 'Every day', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', once: 'One time' };
const CR_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const crMonday = s => crShift(s, -((new Date(s + 'T12:00:00').getDay() + 6) % 7));
const crWeekLabel = w => { const f = new Date(w.from + 'T12:00:00'), t = new Date(w.to + 'T12:00:00'), o = { month: 'short', day: 'numeric' }; return (w.from === crMonday(crYmd(new Date())) ? 'This week · ' : '') + f.toLocaleDateString(undefined, o) + ' to ' + t.toLocaleDateString(undefined, o); };
/** One person's week: points, how far to the next reward, the rewards reached (an admin marks each as handed over), and the chores they did. */
function crWeekPerson(p) {
const w = cr.week, admin = cr.data.me.admin, tiers = w.tiers.filter(t => p.tiers.includes(t.id)), top = Math.max(1, ...tiers.map(t => t.points), p.points), next = tiers.find(t => t.points > p.points);
return `<div class="crwk"><div class="crrank" style="border:0;padding-bottom:4px"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b><div class="statbar" style="position:relative"><i style="width:${Math.round(p.points / top * 100)}%"></i>${tiers.map(t => `<span class="crtick" style="left:${Math.round(t.points / top * 100)}%" title="${esc(t.points + ' pts: ' + t.title)}"></span>`).join('')}</div></span><small>${p.points} pts · ${p.count} done</small></div>
${next ? `<small class="sub" style="display:block;margin:0 0 4px 52px">${next.points - p.points} more for ${esc(next.title)}</small>` : tiers.length ? '<small class="sub" style="display:block;margin:0 0 4px 52px">Every reward reached 🎉</small>' : ''}
${p.earned.map(id => { const t = w.tiers.find(x => x.id === id), given = p.given.includes(id); return `<div class="crearn"><span>🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${given ? 'Given' : 'Earned, not given yet'}</small></span>${admin ? `<button class="btn ${given ? 'ghost' : ''}" data-give="${esc(p.id)}|${esc(id)}" ${given ? 'data-undo="1"' : ''}>${given ? 'Undo' : 'Mark given'}</button>` : ''}</div>`; }).join('')}
${p.done.length ? `<details class="crdet"><summary>Chores done (${p.done.length})</summary>${p.done.map(c => `<div class="crrank" style="border:0;padding:3px 0"><span class="grow">${esc(c.title)}<small style="display:block">${esc(new Date(c.date + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }))}</small></span><small>${c.pts ? c.pts + ' pts' : ''}</small></div>`).join('')}</details>` : ''}</div>`;
}
/** After a change in the manage sheets: redraw the Chores page, or (when opened from Settings) reopen the manager. */
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>';
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);
const item = i => `<label class="critem ${i.done ? 'done' : ''}"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}" ${i.done ? 'checked' : ''} ${i.canDo ? '' : 'disabled'}><span class="grow"><b>${esc(i.title)}</b>${i.notes ? `<small>${esc(i.notes)}</small>` : ''}${i.claimed && !i.done && i.user !== me ? `<small>${esc(i.userName)} took this</small>` : ''}${i.done ? `<small>Done by ${esc(i.done.byName)}</small>` : i.overdue ? '<small style="color:var(--danger)">Overdue</small>' : ''}</span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}${i.claimable && !i.done ? `<button type="button" class="btn ghost crclaim" data-c="${esc(i.chore)}">Take it</button>` : ''}${i.claimed && !i.done && (i.user === me || d.me.admin) ? `<button type="button" class="btn ghost crclaim" data-c="${esc(i.chore)}" data-undo="1">Put back</button>` : ''}</label>`;
const person = p => { const l = d.board.filter(i => i.user === p.id), done = l.filter(i => i.done).length; return `<div class="crperson"><div class="crph"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><b>${esc(p.name)}</b><small>${l.length ? done + ' of ' + l.length : 'nothing today'}</small></div>${l.length ? `<div class="crbar"><i style="width:${Math.round(done / l.length * 100)}%"></i></div>` : ''}<div>${l.map(item).join('')}</div></div>`; };
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Chores</h1><span class="pspace grow"></span>${d.me.admin ? `<button class="btn ghost" id="crman">${icon('cog')}Manage</button>` : ''}</div>
<div class="bar" style="justify-content:flex-start;margin:0 0 8px"><button class="ibtn" id="crprev" aria-label="Previous day">${icon('back')}</button><b style="min-width:150px;text-align:center">${esc(crDayLabel(cr.day))}</b><button class="ibtn" id="crnext" aria-label="Next day" style="transform:scaleX(-1)">${icon('back')}</button>${cr.day !== crYmd(new Date()) ? '<button class="btn ghost" id="crtoday">Today</button>' : ''}</div>
<h2>Mine</h2><div class="crcard">${mine.map(item).join('') || '<p class="empty" style="margin:8px 0">Nothing for you ' + (cr.day === crYmd(new Date()) ? 'today' : 'on this day') + '. 🎉</p>'}</div>
${anyone.length ? `<h2>Anyone can do</h2><div class="crcard">${anyone.map(item).join('')}</div>` : ''}
${others.length ? `<h2>The family</h2><div class="crgrid">${others.map(person).join('')}</div>` : ''}
<div class="ptop"><h2 style="margin:18px 0 6px">Weekly goals</h2><span class="pspace grow"></span></div>
<div class="bar" style="justify-content:flex-start;margin:0 0 8px"><button class="ibtn" id="wkprev" aria-label="Previous week">${icon('back')}</button><b style="min-width:170px;text-align:center">${esc(crWeekLabel(cr.week))}</b><button class="ibtn" id="wknext" aria-label="Next week" style="transform:scaleX(-1)">${icon('back')}</button>${cr.week.from !== crMonday(crYmd(new Date())) ? '<button class="btn ghost" id="wktoday">This week</button>' : ''}</div>
${cr.week.tiers.length ? `<p class="sub" style="margin:0 0 8px">${cr.week.tiers.map(t => esc(t.points) + ' pts: ' + esc(t.title) + (Array.isArray(t.for) ? ' (' + esc(t.for.map(id => (cr.data.people.find(p => p.id === id) || { name: '?' }).name).join(', ')) + ')' : '')).join(' · ')}. Points start again every Monday.</p>` : (d.me.admin ? '<p class="sub">Add rewards in Manage (for example 20 points: pick the movie) and the children work toward them each week.</p>' : '')}
<div class="crcard">${cr.week.people.map(crWeekPerson).join('') || '<p class="empty">No child accounts yet. Points and rewards are for children: turn on <b>This is a child</b> for a person in Settings.</p>'}</div></div>`;
v.querySelectorAll('.crclaim').forEach(b => b.onclick = async e => { e.preventDefault(); try { await crPost('/claim', { chore: b.dataset.c, date: cr.day, undo: !!b.dataset.undo }); if (!b.dataset.undo) toast('It is yours'); viewChores(); } catch (x) { toast(x.message); viewChores(); } });
v.querySelectorAll('.critem input').forEach(c => c.onchange = async () => { try { await crPost('/done', { chore: c.dataset.c, date: cr.day, user: c.dataset.u || undefined, undo: !c.checked }); if (c.checked) toast('Nice work!'); viewChores(); } catch (e) { toast(e.message); c.checked = !c.checked; } });
$('#crprev').onclick = () => { cr.day = crShift(cr.day, -1); viewChores(); }; $('#crnext').onclick = () => { cr.day = crShift(cr.day, 1); viewChores(); }; if ($('#crtoday')) $('#crtoday').onclick = () => { cr.day = crYmd(new Date()); viewChores(); };
$('#wkprev').onclick = () => { cr.wk = crShift(cr.week.from, -7); viewChores(); }; $('#wknext').onclick = () => { cr.wk = crShift(cr.week.from, 7); viewChores(); }; if ($('#wktoday')) $('#wktoday').onclick = () => { cr.wk = crYmd(new Date()); viewChores(); };
v.querySelectorAll('[data-give]').forEach(b => b.onclick = async () => { const [u, r] = b.dataset.give.split('|'); try { await crPost('/give', { week: cr.week.from, user: u, reward: r, undo: !!b.dataset.undo }); viewChores(); } catch (e) { toast(e.message); } });
if ($('#crman')) $('#crman').onclick = crManage;
}
// ---- making and changing chores (admin)
function crManage() {
const d = cr.data, who = c => c.assign === 'anyone' ? 'Anyone' : d.people.filter(p => c.assign.includes(p.id)).map(p => p.name).join(', ');
const when = c => CR_REPEAT[c.repeat] + (c.repeat === 'weekly' && c.days && c.days.length ? ' · ' + c.days.map(x => CR_DAYS[x]).join(' ') : '') + (c.repeat === 'monthly' ? ' · day ' + (c.dom || 1) : '') + (c.repeat === 'once' ? ' · ' + c.date : '');
const el = openSheet('Manage chores', 'Make jobs for the family and choose who does them', `<div class="list" style="max-height:52dvh;overflow:auto">${d.chores.map(c => `<button class="row" data-e="${esc(c.id)}"><span class="grow"><b>${esc(c.title)}${c.active ? '' : ' (paused)'}</b><small style="white-space:normal">${esc(when(c))} · ${esc(who(c))}${c.points ? ' · ' + c.points + ' pts' : ''}</small></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty">No chores yet.</p>'}</div>
<div class="bar"><button class="btn" id="crnew" style="width:100%;justify-content:center">${icon('plus')}New chore</button></div>
<h2 style="margin:14px 0 6px">Weekly rewards</h2><div class="list">${d.rewards.map(r => `<button class="row" data-rw="${esc(r.id)}"><span class="grow"><b>${esc(r.title)}</b><small>${r.points} points in a week${r.minutes ? ' · +' + r.minutes + ' min screen time' : ''} · ${Array.isArray(r.for) ? esc(r.for.map(id => (d.people.find(p => p.id === id) || { name: '?' }).name).join(', ')) : 'all children'}</small></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty" style="margin:6px 0">No rewards yet.</p>'}</div>
<div class="bar"><button class="btn ghost" id="rwnew" style="width:100%;justify-content:center">${icon('plus')}New reward</button></div>`);
el.querySelector('#rwnew').onclick = () => { closeSheet(); crReward(null); }; el.querySelectorAll('[data-rw]').forEach(b => b.onclick = () => { closeSheet(); crReward(d.rewards.find(r => r.id === b.dataset.rw)); });
el.querySelector('#crnew').onclick = () => { closeSheet(); crEdit(null); };
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { closeSheet(); crEdit(d.chores.find(c => c.id === b.dataset.e)); });
}
function crReward(r) {
const el = openSheet(r ? 'Edit reward' : 'New reward', 'Anyone who reaches the points in a week earns it. Points start again each Monday.', `<form id="rwf"><label for="rwt">The reward</label><input type="text" id="rwt" value="${esc(r ? r.title : '')}" maxlength="80" required autocomplete="off" placeholder="Pick the movie, ice cream, $5..."><label for="rwp">Points needed in a week</label><input type="number" id="rwp" min="1" max="1000" value="${r ? r.points : 20}" required>
<label for="rwm">Bonus screen time with it, in minutes (optional)</label><input type="number" id="rwm" min="0" max="240" value="${r && r.minutes ? r.minutes : ''}" placeholder="0">
<label>Who is it for? (child accounts)</label><div class="list"><label class="row" style="cursor:pointer"><input type="checkbox" id="rwall"${!r || !Array.isArray(r.for) ? ' checked' : ''}><span class="grow"><b>All children</b></span></label></div><div class="list" id="rwpp">${cr.data.people.filter(p => p.kid).map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${r && Array.isArray(r.for) && r.for.includes(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div><div class="err" id="rwe"></div>
<div class="bar">${r ? '<button type="button" class="btn danger" id="rwdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="rwx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
const rsync = () => { el.querySelector('#rwpp').hidden = el.querySelector('#rwall').checked; }; el.querySelector('#rwall').onchange = rsync; rsync();
el.querySelector('#rwx').onclick = closeSheet; if (r) el.querySelector('#rwdel').onclick = async () => { try { await crPost('/reward', { id: r.id, remove: true }); closeSheet(); crRefresh(); } catch (e) { toast(e.message); } };
el.querySelector('#rwf').onsubmit = async e => { e.preventDefault(); try { await crPost('/reward', { id: r && r.id, title: el.querySelector('#rwt').value, points: +el.querySelector('#rwp').value, minutes: +el.querySelector('#rwm').value || 0, for: el.querySelector('#rwall').checked ? 'all' : [...el.querySelectorAll('#rwpp [data-p]:checked')].map(i => i.dataset.p) }); closeSheet(); crRefresh(); } catch (x) { el.querySelector('#rwe').textContent = x.message; } };
}
function crEdit(c) {
const d = cr.data, x = c || { title: '', notes: '', repeat: 'daily', days: [], dom: 1, date: crYmd(new Date()), assign: 'anyone', points: 0, active: true }, chosen = new Set(x.assign === 'anyone' ? [] : x.assign);
const el = openSheet(c ? 'Edit chore' : 'New chore', '', `<form id="cref"><label for="crt">What needs doing</label><input type="text" id="crt" value="${esc(x.title)}" maxlength="120" required autocomplete="off" placeholder="Empty the dishwasher">
<label for="crn">Notes (optional)</label><input type="text" id="crn" value="${esc(x.notes)}" maxlength="400" autocomplete="off">
<label for="crr">How often</label><select id="crr">${Object.entries(CR_REPEAT).map(([k, l]) => `<option value="${k}"${x.repeat === k ? ' selected' : ''}>${l}</option>`).join('')}</select>
<div id="crd" hidden><label>On which days (none: any day that week)</label><div class="seg">${CR_DAYS.map((n, i) => `<a href="#" data-d="${i}" class="${(x.days || []).includes(i) ? 'on' : ''}">${n}</a>`).join('')}</div></div>
<div id="crm" hidden><label for="crdom">Day of the month</label><input type="number" id="crdom" min="1" max="31" value="${x.dom || 1}"></div><div id="cro" hidden><label for="crdt">On</label><input type="date" id="crdt" value="${esc(x.date || '')}"></div>
<label>Who does it</label><div class="list"><label class="row" style="cursor:pointer"><input type="radio" name="crw" value="anyone"${x.assign === 'anyone' ? ' checked' : ''}><span class="grow"><b>Anyone</b><small>Whoever gets to it first</small></span></label><label class="row" style="cursor:pointer"><input type="radio" name="crw" value="people"${x.assign !== 'anyone' ? ' checked' : ''}><span class="grow"><b>These people</b><small>Each one has their own to do</small></span></label></div>
<div class="list" id="crp">${d.people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${chosen.has(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div>
<label for="crpt">Points for doing it</label><input type="number" id="crpt" min="0" max="50" value="${x.points || 0}">
<label class="row" style="cursor:pointer"><span class="grow"><b>Active</b><small>Turn off to pause it</small></span><input type="checkbox" id="cra"${x.active ? ' checked' : ''}></label><div class="err" id="cre"></div>
<div class="bar">${c ? '<button type="button" class="btn danger" id="crdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="crx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
const sync = () => { const r = el.querySelector('#crr').value; el.querySelector('#crd').hidden = r !== 'weekly'; el.querySelector('#crm').hidden = r !== 'monthly'; el.querySelector('#cro').hidden = r !== 'once'; el.querySelector('#crp').hidden = el.querySelector('[name=crw]:checked').value !== 'people'; };
el.querySelector('#crr').onchange = sync; el.querySelectorAll('[name=crw]').forEach(r => r.onchange = sync); sync(); el.querySelectorAll('#crd [data-d]').forEach(a => a.onclick = e => { e.preventDefault(); a.classList.toggle('on'); });
el.querySelector('#crx').onclick = closeSheet;
if (c) el.querySelector('#crdel').onclick = () => { closeSheet(); confirmSheet('Delete this chore?', c.title + ' and its history.', 'Delete', async () => { try { await crApi('/' + c.id, { method: 'DELETE' }); toast('Deleted'); crRefresh(); } catch (e) { toast(e.message); } }); };
el.querySelector('#cref').onsubmit = async e => { e.preventDefault(); const people = [...el.querySelectorAll('[data-p]:checked')].map(i => i.dataset.p);
try { await crPost('/', { id: c && c.id, title: el.querySelector('#crt').value, notes: el.querySelector('#crn').value, repeat: el.querySelector('#crr').value, days: [...el.querySelectorAll('#crd .on')].map(a => +a.dataset.d), dom: +el.querySelector('#crdom').value, date: el.querySelector('#crdt').value, assign: el.querySelector('[name=crw]:checked').value === 'anyone' ? 'anyone' : people, points: +el.querySelector('#crpt').value, active: el.querySelector('#cra').checked }); closeSheet(); toast('Saved'); crRefresh(); }
catch (x) { el.querySelector('#cre').textContent = x.message; } };
}
+121
View File
@@ -0,0 +1,121 @@
'use strict';
// ---- Contacts: your address book. Mail suggests these as you type a recipient, a message can be saved as a contact, birthdays show up in the calendar,
// and a .vcf file (iCloud, Google or a phone export) can be imported or exported.
const ctApi = (p, opts) => jfetch('/api/contacts' + p, opts);
const ctOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
let ctList = null, ctAt = 0, ctQuery = '';
const ctLoad = async force => { if (!force && ctList && Date.now() - ctAt < 60000) return ctList; ctList = await ctApi('/'); ctAt = Date.now(); return ctList; };
const ctInit = c => (c.name.match(/\p{L}/gu) || ['?']).slice(0, 2).join('').toUpperCase();
const ctHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
const ctAv = c => `<span class="ctav" style="background:hsl(${ctHue(c.id)} 45% 45%)">${esc(ctInit(c))}</span>`;
const ctBday = b => { if (!b) return ''; const noYear = b.startsWith('--'), [y, m, d] = noYear ? [0, b.slice(2, 4), b.slice(5, 7)] : [b.slice(0, 4), b.slice(5, 7), b.slice(8, 10)]; return new Date(2000, +m - 1, +d).toLocaleDateString([], { month: 'long', day: 'numeric' }) + (noYear ? '' : ', ' + y); };
const ctFind = (l, address) => { const a = String(address || '').toLowerCase(); return a ? l.find(c => c.emails.some(e => e.value === a)) : null; };
async function viewContacts(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Contacts</h1><p class="empty">Loading…</p></div>';
try {
const l = await ctLoad(true); if (!location.hash.startsWith('#/contacts')) return;
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Contacts</h1><span class="pspace grow"></span><button class="btn ghost" id="ctmore" aria-label="More">${icon('more')}</button><button class="btn" id="ctnew">${icon('plus')}New</button></div>
<input type="search" id="ctq" placeholder="Search contacts" value="${esc(ctQuery)}" autocomplete="off" aria-label="Search contacts" style="width:100%;margin-bottom:8px"><div id="ctl"></div></div>`;
const draw = () => {
const q = ctQuery.toLowerCase(), rows = l.filter(c => !q || [c.name, c.org, c.notes, ...c.emails.map(e => e.value), ...c.phones.map(p => p.value)].some(x => String(x).toLowerCase().includes(q)));
let last = '';
$('#ctl').innerHTML = rows.length ? rows.map(c => { const g = (ctInit(c)[0] || '#'), head = g !== last ? `<div class="ctgrp">${esc(last = g)}</div>` : '';
return head + `<a class="row" href="#/contacts/c/${esc(c.id)}">${ctAv(c)}<span class="grow"><b>${esc(c.name)}</b><small>${esc(c.org || (c.emails[0] || {}).value || (c.phones[0] || {}).value || '')}</small></span></a>`; }).join('')
: `<p class="empty">${l.length ? 'No one matches.' : 'No contacts yet. Add one, or import a .vcf file from iCloud, Google or your phone.'}</p>`;
};
draw(); $('#ctq').oninput = e => { ctQuery = e.target.value.trim(); draw(); };
$('#ctnew').onclick = () => ctEdit(null);
$('#ctmore').onclick = ctMore;
if (args[0] === 'c' && args[1]) { const c = l.find(x => x.id === args[1]); if (c) ctShow(c); }
} catch (e) {
if (!location.hash.startsWith('#/contacts')) return;
v.innerHTML = `<div class="wrap"><h1>Contacts</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="ctretry">Try again</button></div></div>`; $('#ctretry').onclick = () => route();
}
}
function ctMore() {
const el = openSheet('Contacts', '', `<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Import a .vcf file</b><small>From iCloud, Google or your phone. People already here are filled in, not doubled.</small></span><input type="file" id="ctfile" accept=".vcf,text/vcard,text/x-vcard" hidden></label>
<a class="row" href="/api/contacts/export" download="contacts.vcf"><span class="grow"><b>Export all as .vcf</b><small>A backup you can open in any contacts app</small></span></a></div>`);
el.querySelector('#ctfile').onchange = async e => {
const f = e.target.files[0]; if (!f) return;
try { if (f.size > 7e6) throw new Error('That file is too large'); const r = await ctApi('/import', ctOpt('POST', { vcf: await f.text() })); closeSheet(); toast(`Added ${r.added}, updated ${r.merged}${r.skipped ? ', skipped ' + r.skipped : ''}`); route(); } catch (err) { toast(err.message); }
};
}
function ctShow(c) {
const mailOn = allowed('mail') && cfg.mail;
const line = (label, html) => html ? `<div class="row"><span class="grow"><small>${esc(label)}</small><span style="display:block;white-space:normal">${html}</span></span></div>` : '';
const el = openSheet(c.name, c.org || '', `<div class="list">
${c.emails.map(e => line(e.label, mailOn ? `<a href="#" data-mail="${esc(e.value)}" style="color:var(--accent)">${esc(e.value)}</a>` : esc(e.value))).join('')}
${c.phones.map(p => line(p.label, `<a href="tel:${esc(p.value.replace(/[^\d+]/g, ''))}" style="color:var(--accent)">${esc(p.value)}</a>`)).join('')}
${line('address', c.address ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">${esc(c.address)}</a>` : '')}
${line('birthday', esc(ctBday(c.birthday)))}${line('notes', esc(c.notes).replace(/\n/g, '<br>'))}</div>
<div class="bar">${mailOn && c.emails[0] ? `<button class="btn" id="ctw">${icon('mail')}Email</button><button class="btn ghost" id="ctm">Mail from them</button>` : ''}<button class="btn ghost" id="cte">Edit</button><button class="btn danger" id="ctd">Delete</button></div>`);
el.querySelectorAll('[data-mail]').forEach(a => a.onclick = e => { e.preventDefault(); closeSheet(); ctWrite(a.dataset.mail); });
if (el.querySelector('#ctw')) el.querySelector('#ctw').onclick = () => { closeSheet(); ctWrite(c.emails[0].value); };
if (el.querySelector('#ctm')) el.querySelector('#ctm').onclick = () => { closeSheet(); mail.q = c.emails[0].value; location.hash = '#/mail'; if (location.hash === '#/mail' && typeof mailLoadList === 'function') mailLoadList(true); };
el.querySelector('#cte').onclick = () => { closeSheet(); ctEdit(c); };
el.querySelector('#ctd').onclick = () => { closeSheet(); confirmSheet('Delete this contact?', c.name, 'Delete', async () => { try { await ctApi('/delete', ctOpt('POST', { id: c.id })); toast('Deleted'); ctList = null; route(); } catch (e) { toast(e.message); } }); };
}
/** A new message to this address. */
function ctWrite(address) { if (typeof mailCompose === 'function') mailCompose({ to: address }); }
function ctEdit(c, seed) {
const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', notes: '' };
const rows = (kind, items, ph) => `<div class="ctrows" data-k="${kind}">${(items.length ? items : [{ label: '', value: '' }]).map(i => `<div class="bar" style="margin:4px 0;gap:6px;flex-wrap:nowrap"><input type="text" data-l placeholder="label" value="${esc(i.label === 'other' ? '' : i.label)}" style="width:80px;flex:none"><input type="text" data-v placeholder="${ph}" value="${esc(i.value)}" style="flex:1" autocapitalize="off"></div>`).join('')}</div><a href="#" data-more="${kind}" style="color:var(--accent);font-size:14px">Add another</a>`;
const el = openSheet(c ? 'Edit contact' : 'New contact', '', `<form id="ctf"><label for="ctn">Name</label><input type="text" id="ctn" value="${esc(x.name)}" autocomplete="off"><label for="cto">Company</label><input type="text" id="cto" value="${esc(x.org)}" autocomplete="off">
<label>Email</label>${rows('emails', x.emails, 'name@example.com')}<label>Phone</label>${rows('phones', x.phones, '555 123 4567')}
<label for="cta">Address</label><input type="text" id="cta" value="${esc(x.address)}" autocomplete="off"><label for="ctb">Birthday</label><input type="date" id="ctb" value="${esc(x.birthday && !x.birthday.startsWith('--') ? x.birthday : '')}">
<label for="ctt">Notes</label><textarea id="ctt" rows="3">${esc(x.notes)}</textarea><div class="err" id="cter"></div>
<div class="bar"><button class="btn ghost" type="button" id="ctx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#ctx').onclick = closeSheet;
el.querySelectorAll('[data-more]').forEach(a => a.onclick = e => { e.preventDefault(); const box = el.querySelector(`.ctrows[data-k="${a.dataset.more}"]`), first = box.firstElementChild; const n = first.cloneNode(true); n.querySelectorAll('input').forEach(i => { i.value = ''; }); box.appendChild(n); n.querySelector('[data-v]').focus(); });
const grab = k => [...el.querySelectorAll(`.ctrows[data-k="${k}"] .bar`)].map(r => ({ label: r.querySelector('[data-l]').value.trim().toLowerCase(), value: r.querySelector('[data-v]').value.trim() })).filter(i => i.value);
el.querySelector('#ctf').onsubmit = async e => {
e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = '';
const keepYearless = x.birthday && x.birthday.startsWith('--') && !el.querySelector('#ctb').value;
const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, notes: el.querySelector('#ctt').value };
try { const saved = await ctApi('/', ctOpt('POST', body)); ctList = null; closeSheet(); toast('Saved'); if (location.hash.startsWith('#/contacts')) route(); return saved; } catch (er) { err.textContent = er.message; }
};
el.querySelector('#ctn').focus();
}
// ---- Mail: suggestions while typing a recipient, and "Save contact" on a message you are reading
/** Suggest matching contacts under a recipient box (a comma-separated list: the part being typed is what is matched). */
function ctSuggest(input) {
if (!input || typeof allowed !== 'function' || !allowed('contacts')) return;
let box, sel = -1, rows = [];
const part = () => { const v = input.value, i = v.lastIndexOf(','); return { head: v.slice(0, i + 1), word: v.slice(i + 1).trim() }; };
const close = () => { if (box) { box.remove(); box = null; } sel = -1; };
const pick = r => { const { head } = part(); input.value = (head ? head.replace(/\s*$/, ' ') : '') + r.email + ', '; close(); input.focus(); };
const show = async () => {
const { word } = part(), w = word.toLowerCase(); if (w.length < 1) return close();
let l; try { l = await ctLoad(); } catch { return; }
rows = []; for (const c of l) for (const e of c.emails) if (c.name.toLowerCase().includes(w) || e.value.includes(w) || (c.org || '').toLowerCase().includes(w)) rows.push({ name: c.name, email: e.value });
rows = rows.slice(0, 6); if (!rows.length) return close();
if (!box) { box = document.createElement('div'); box.className = 'ctsug'; input.parentNode.style.position = input.parentNode.style.position || 'relative'; input.insertAdjacentElement('afterend', box); }
box.innerHTML = rows.map((r, i) => `<button type="button" data-i="${i}"><b>${esc(r.name)}</b> <small>${esc(r.email)}</small></button>`).join('');
box.querySelectorAll('button').forEach(b => b.onmousedown = e => { e.preventDefault(); pick(rows[b.dataset.i]); });
};
input.addEventListener('input', show); input.addEventListener('blur', () => setTimeout(close, 120));
input.addEventListener('keydown', e => {
if (!box) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); sel = (sel + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length; box.querySelectorAll('button').forEach((b, i) => b.classList.toggle('on', i === sel)); }
else if (e.key === 'Enter' && sel >= 0) { e.preventDefault(); pick(rows[sel]); }
else if (e.key === 'Escape') { e.stopPropagation(); close(); }
});
}
/** On a message being read: save the sender, or open them if they are already a contact. */
async function ctMailHook(rd, m) {
if (typeof allowed !== 'function' || !allowed('contacts') || !m.from || !m.from.address) return;
const p = rd.querySelector('.mfrom'); if (!p) return;
try {
const l = await ctLoad(), have = ctFind(l, m.from.address);
if (!rd.contains(p)) return;
const a = document.createElement('a'); a.href = '#'; a.style.cssText = 'color:var(--accent);font-size:13px;margin-left:8px'; a.textContent = have ? 'In contacts' : 'Save contact';
a.onclick = e => { e.preventDefault(); if (have) location.hash = '#/contacts/c/' + have.id; else ctEdit(null, { name: m.from.name || '', org: '', emails: [{ label: '', value: m.from.address }], phones: [], address: '', birthday: '', notes: '' }); };
p.appendChild(a);
} catch {}
}
+28
View File
@@ -0,0 +1,28 @@
'use strict';
// ---- Family devices: on a shared screen, a label that says whose account this is, a Switch button, and a return to the family board after a few idle minutes.
// Also the Settings part: your PIN for shared screens, and (admins) making this screen a family device.
let devOn = false, devIdle = 0, devIdleMs = 5 * 60e3;
async function devBack() { try { await fetch('/api/auth/logout', { method: 'POST', body: '{}' }); } catch {} location.replace('/board'); }
async function devStart() {
try { const r = await jfetch('/api/device'); devOn = !!r.device; if (r.idleMin) devIdleMs = r.idleMin * 60e3; } catch { return; }
if (!devOn || !ME) return;
const kid = ME.kid && ME.kid.on, chip = document.createElement('div'); chip.id = 'devchip';
chip.innerHTML = `<span class="devwho"><b>${esc(ME.name)}</b>${kid ? '<i class="devkid">KID</i>' : ''}</span><button class="btn" id="devswitch">Switch user</button>`;
document.body.appendChild(chip); document.body.classList.add('ondevice'); chip.querySelector('#devswitch').onclick = devBack;
const bump = () => { devIdle = Date.now(); }; ['pointerdown', 'keydown', 'scroll', 'touchstart'].forEach(e => addEventListener(e, bump, { passive: true })); bump();
setInterval(() => { const a = (typeof pl !== 'undefined' && pl.a && !pl.a.paused) || [...document.querySelectorAll('video, audio')].some(m => !m.paused && !m.ended); /* music, a movie or an episode playing counts as being in use */ if (!a && Date.now() - devIdle > devIdleMs) devBack(); }, 15000); // (music or a video playing keeps it signed in)
}
async function devSettings(box) {
if (!box) return; let r; try { r = await jfetch('/api/device'); } catch { return; }
const admin = ME && ME.role === 'admin', post = (p, b) => jfetch('/api/device/' + p, { method: 'POST', body: JSON.stringify(b || {}) });
box.innerHTML = `<h2>Shared screens</h2><div class="list">
${r.device ? '' : `<div class="row"><span class="grow"><b>${r.trusted ? 'This device is remembered' : 'Remember this device'}</b><small style="white-space:normal">${r.trusted ? 'The sign-in page shows everyone\'s name on this device. Tap yours, then use your PIN or passkey.' : 'Show name tiles on the sign-in page of this device, so you can tap your name and use your PIN or passkey. Only do this on a device you trust.'}</small></span><button class="btn ${r.trusted ? 'ghost' : ''}" id="devtrust">${r.trusted ? 'Forget' : 'Remember'}</button></div>`}
<div class="row"><span class="grow"><b>${r.pin ? 'Your PIN is set' : 'Set a PIN'}</b><small style="white-space:normal">On a shared family screen, you pick your name and enter this PIN (children can just tap their name). 4 to 8 digits.</small></span><button class="btn ghost" id="devpin">${r.pin ? 'Change' : 'Set PIN'}</button>${r.pin ? '<button class="btn ghost" id="devpinx">Remove</button>' : ''}</div>
${admin ? `<div class="row"><span class="grow"><b>Back to the board after</b><small style="white-space:normal">When nobody touches a family device for this long, it signs the person out and shows the family board. Music or a video playing keeps it signed in.</small></span><select id="devidle" style="width:auto">${(r.idleChoices || [5]).map(n => `<option value="${n}"${n === r.idleMin ? ' selected' : ''}>${n} min</option>`).join('')}</select></div>
<div class="row"><span class="grow"><b>${r.device ? 'This is a family device' : 'Make this a family device'}</b><small style="white-space:normal">${r.device ? 'It opens on the family board and signs people out after a few idle minutes.' : 'For a kitchen tablet or the family computer: it opens on the family board, and everyone picks their name to use it. You will be signed out here.'}</small></span><button class="btn ${r.device ? 'ghost' : ''}" id="devtoggle">${r.device ? 'Turn off' : 'Turn on'}</button></div>` : ''}</div>`;
box.querySelector('#devpin').onclick = () => { const el = openSheet('Your PIN', 'Used to pick your name on a shared family screen.', '<input type="password" inputmode="numeric" pattern="[0-9]*" id="pn" maxlength="8" placeholder="4 to 8 digits" autocomplete="off"><div class="bar"><button class="btn" id="pns" style="flex:1;justify-content:center">Save</button></div>'); el.querySelector('#pns').onclick = async () => { try { await post('pin', { pin: el.querySelector('#pn').value }); closeSheet(); toast('PIN saved'); devSettings(box); } catch (e) { toast(e.message); } }; };
const tr = box.querySelector('#devtrust'); if (tr) tr.onclick = async () => { try { await post(r.trusted ? 'remove' : 'trust'); toast(r.trusted ? 'This device is forgotten' : 'This device is remembered'); devSettings(box); } catch (e) { toast(e.message); } };
const idle = box.querySelector('#devidle'); if (idle) idle.onchange = async () => { try { await post('idle', { minutes: +idle.value }); toast('Saved'); } catch (e) { toast(e.message); } };
const x = box.querySelector('#devpinx'); if (x) x.onclick = async () => { try { await jfetch('/api/device/pin', { method: 'DELETE' }); devSettings(box); } catch (e) { toast(e.message); } };
const t = box.querySelector('#devtoggle'); if (t) t.onclick = () => r.device ? confirmSheet('Turn off family device?', 'This screen goes back to the normal sign-in.', 'Turn off', async () => { try { await post('remove'); location.replace('/login'); } catch (e) { toast(e.message); } }) : confirmSheet('Make this a family device?', 'You will be signed out, and this screen opens on the family board from now on.', 'Make it', async () => { try { await post('enable', { name: /iPad/.test(navigator.userAgent) ? 'iPad' : 'Family screen' }); location.replace('/board'); } catch (e) { toast(e.message); } });
}
+53
View File
@@ -0,0 +1,53 @@
'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 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>`; };
function viewFamily() {
const h = new Date().getHours(), greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening', today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
const cards = [['cal', allowed('calendar'), fmCard('fmcal', 'Today and tomorrow', '#/calendar', 'Calendar')], ['chores', allowed('chores'), fmCard('fmch', 'Chores today', '#/chores')], ['shop', allowed('recipes'), fmCard('fmsh', 'Shopping list', '#/recipes/shop')],
['notes', allowed('notes'), fmCard('fmnt', 'Family notes and lists', '#/notes')], ['chat', allowed('chat'), fmCard('fmct', 'Chat', '#/chat')], ['tasks', allowed('tasks'), fmCard('fmtk', 'Your tasks', '#/tasks')]].filter(c => c[1]);
$('#view').innerHTML = `<div class="wrap fmwrap"><header class="homehead"><div><h1>${greet}${ME ? ', ' + esc(ME.name.split(' ')[0]) : ''}</h1><p class="sub">${today}</p></div><div class="homeact">${inboxBell()}</div></header>
<div id="annc"></div><div class="fmgrid">${cards.map(c => c[2]).join('') || '<p class="empty">Nothing here yet. An admin can turn on Calendar, Chores, Notes, Chat and more for you.</p>'}</div></div>`;
inboxWire();
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
const done = new Set(cards.map(c => c[0])), run = (k, f) => { if (done.has(k)) f().catch(e => fmErr({ cal: 'fmcal', chores: 'fmch', shop: 'fmsh', notes: 'fmnt', chat: 'fmct', tasks: 'fmtk' }[k], e)); };
run('cal', fmEvents); run('chores', fmChores); run('shop', fmShopping); run('notes', fmNotes); run('chat', fmChat); run('tasks', fmTasks);
}
async function fmEvents() {
const d0 = new Date(), t0 = crYmd(d0), t1 = crShift(t0, 1), from = new Date(t0 + 'T00:00:00').getTime(), to = new Date(t1 + 'T23:59:59').getTime();
const r = await jfetch('/api/cal/events?' + new URLSearchParams({ from, to, fd: t0, td: t1 })), b = fmBody('fmcal'); if (!b) return;
const day = (ymdS, e) => e.allDay ? e.start <= ymdS && e.end > ymdS : crYmd(new Date(e.start)) === ymdS;
const section = (label, ymdS) => { const l = r.events.filter(e => day(ymdS, e)).sort((a, c) => (c.allDay - a.allDay) || (a.allDay ? 0 : a.start - c.start)); return `<div class="fmday"><small style="display:block">${label}</small>${l.map(e => `<div class="fmrow"><span class="fmdot" style="background:${esc(e.color || 'var(--accent)')}"></span><span class="grow"><b>${esc(e.title)}</b></span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || '<small class="sub" style="display:block;text-transform:none;letter-spacing:0;font-size:13px;margin:4px 0">Nothing</small>'}</div>`; };
b.innerHTML = section('Today', t0) + section('Tomorrow', t1);
}
async function fmChores() {
const d = await crApi('/?date=' + crYmd(new Date())), b = fmBody('fmch'); if (!b) return; const me = d.me.id;
const per = d.people.filter(p => p.board !== false).map(p => { const l = d.board.filter(i => i.user === p.id), dn = l.filter(i => i.done).length; return { p, l, dn }; }).filter(x => x.l.length), any = d.board.filter(i => i.user === null);
const mine = d.board.filter(i => i.user === me || i.user === null).filter(i => !i.done);
b.innerHTML = `${mine.length ? mine.slice(0, 5).map(i => `<label class="critem"><input type="checkbox" data-c="${esc(i.chore)}" data-u="${esc(i.user || '')}"><span class="grow"><b>${esc(i.title)}</b></span>${i.points ? `<em class="crpts">${i.points} pts</em>` : ''}</label>`).join('') : '<p class="empty" style="margin:6px 0">Nothing left for you today. 🎉</p>'}
${per.length ? `<div class="fmprog">${per.map(x => `<div class="fmpr"><span class="sgav" style="background:${sgColor(x.p.id)};width:26px;height:26px;font-size:12px">${esc(sgInitial(x.p.name))}</span><span class="grow"><small>${esc(x.p.name)} · ${x.dn} of ${x.l.length}</small><div class="crbar"><i style="width:${Math.round(x.dn / x.l.length * 100)}%"></i></div></span></div>`).join('')}</div>` : ''}${any.length ? `<small class="sub">${any.filter(i => !i.done).length} for anyone</small>` : ''}`;
b.querySelectorAll('.critem input').forEach(c => c.onchange = async () => { try { await crPost('/done', { chore: c.dataset.c, date: crYmd(new Date()), user: c.dataset.u || undefined }); toast('Nice work!'); fmChores(); } catch (e) { toast(e.message); c.checked = false; } });
}
async function fmShopping() {
const b0 = fmBody('fmsh'); const st = await jfetch('/api/recipes/status'); if (!st.enabled) { if (b0) b0.innerHTML = '<p class="sub" style="margin:6px 0">The shopping list is not set up yet.</p>'; return; }
const l = await jfetch('/api/recipes/shopping'), b = fmBody('fmsh'); if (!b) return; const open = l.items.filter(i => !i.checked);
b.innerHTML = `${open.slice(0, 8).map(i => `<label class="critem"><input type="checkbox" data-id="${esc(i.id)}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty" style="margin:6px 0">Nothing to buy.</p>'}${open.length > 8 ? `<small class="sub">+ ${open.length - 8} more</small>` : ''}
<form class="bar" id="fmshf" style="margin:8px 0 0;gap:8px;flex-wrap:nowrap"><input type="text" id="fmshi" placeholder="Add an item" maxlength="200" autocomplete="off" style="flex:1"><button class="btn ghost">Add</button></form>`;
b.querySelectorAll('input[data-id]').forEach(c => c.onchange = async () => { try { await jfetch('/api/recipes/shopping/check', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: c.dataset.id, checked: true }) }); fmShopping(); } catch (e) { toast(e.message); c.checked = false; } });
b.querySelector('#fmshf').onsubmit = async e => { e.preventDefault(); const i = b.querySelector('#fmshi'), t = i.value.trim(); if (!t) return; try { await jfetch('/api/recipes/shopping/add', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: t }) }); fmShopping(); } catch (x) { toast(x.message); } };
}
async function fmNotes() {
const r = await jfetch('/api/notes'), b = fmBody('fmnt'); if (!b) return; const l = r.notes.filter(n => !n.archived && n.share !== 'me').slice(0, 4);
b.innerHTML = l.map(n => `<a class="fmrow" href="#/notes/${esc(n.id)}" style="text-decoration:none;color:inherit"><span class="fmdot" style="background:${NT_COLORS[n.color] || 'var(--line)'}"></span><span class="grow"><b>${esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note'))}${n.pinned ? ' 📌' : ''}</b><small>${n.type === 'list' ? ntDone(n) + ' of ' + n.items.length + ' done' + (n.items.find(i => !i.done) ? ' · next: ' + esc(n.items.find(i => !i.done).text) : '') : esc(n.body.slice(0, 70))}</small></span></a>`).join('') || '<p class="empty" style="margin:6px 0">No shared notes yet.</p>';
}
async function fmChat() {
const r = await jfetch('/api/chat/convs'), b = fmBody('fmct'); if (!b) return; const l = r.convs.filter(c => !c.archived).slice(0, 4);
b.innerHTML = l.map(c => `<a class="fmrow" href="#/chat/${encodeURIComponent(c.id)}" style="text-decoration:none;color:inherit"><span class="sgav" style="background:${sgColor(c.id)};width:30px;height:30px;font-size:13px">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b>${esc(c.name)}</b><small>${esc(c.preview || '')}</small></span>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</a>`).join('') || '<p class="empty" style="margin:6px 0">No chats yet.</p>';
}
async function fmTasks() {
const r = await jfetch('/api/tasks/upcoming?today=' + crYmd(new Date())), b = fmBody('fmtk'); if (!b) return;
b.innerHTML = r.tasks.slice(0, 5).map(t => `<a class="fmrow" href="#/tasks" style="text-decoration:none;color:inherit"><span class="grow"><b>${esc(t.title)}</b></span><small style="${t.due < crYmd(new Date()) ? 'color:var(--danger)' : ''}">${t.due === crYmd(new Date()) ? 'Today' : esc(t.due)}</small></a>`).join('') || '<p class="empty" style="margin:6px 0">Nothing due this week. 🎉</p>';
}
+24
View File
@@ -0,0 +1,24 @@
'use strict';
// ---- Notifications: a bell with the count of things you have not seen yet (chat, reminders, announcements); tapping it opens the list.
let inboxN = 0;
const inboxBell = () => `<button class="btn ghost inboxbell" id="inboxbtn" aria-label="Notifications" title="Notifications">${icon('bell')}<i class="nbadge" id="inboxn" ${inboxN ? '' : 'hidden'}>${inboxN > 99 ? '99+' : inboxN}</i></button>`;
function inboxWire() { const b = document.getElementById('inboxbtn'); if (b) b.onclick = inboxOpen; inboxPaint(); inboxCount(); }
function inboxPaint() { const n = document.getElementById('inboxn'); if (n) { n.hidden = !inboxN; n.textContent = inboxN > 99 ? '99+' : inboxN; } }
async function inboxCount() { try { const r = await jfetch('/api/inbox'); inboxN = r.unread; inboxPaint(); } catch {} }
setInterval(() => { if (!document.hidden && typeof ME !== 'undefined' && ME && document.getElementById('inboxbtn')) inboxCount(); }, 45000);
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>');
let items = [];
const draw = () => {
const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length;
l.innerHTML = items.length ? `<div class="list">${items.map(i => { const [ic, col] = INBOX_ICON[i.kind] || ['bell', '#6a7280']; return `<a class="row" href="${esc(i.url || '#/home')}" data-id="${esc(i.id)}" style="${i.read ? 'opacity:.62' : ''}"><span class="ic" style="background:${col}">${icon(ic)}</span><span class="grow"><b>${esc(i.title)}${i.count > 1 ? ` <small>(${i.count})</small>` : ''}</b><small style="white-space:normal">${esc(i.body)}</small></span><small>${inboxAgo(i.at)}</small>${i.read ? '' : '<i class="nbadge" style="position:static;min-width:10px;height:10px;padding:0"></i>'}</a>`; }).join('')}</div>` : '<p class="empty">Nothing yet. Messages, reminders and announcements will show up here.</p>';
l.querySelectorAll('a.row').forEach(a => a.addEventListener('click', () => { const id = a.dataset.id; jfetch('/api/inbox/read', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then(r => { inboxN = r.unread; inboxPaint(); }).catch(() => {}); closeSheet(); }));
};
try { const r = await jfetch('/api/inbox'); items = r.items; inboxN = r.unread; inboxPaint(); } catch (e) { toast(e.message); }
draw();
const post = (p, b) => jfetch('/api/inbox/' + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) });
el.querySelector('#inboxall').onclick = async () => { try { const r = await post('read', { all: true }); items.forEach(i => i.read = true); inboxN = r.unread; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
el.querySelector('#inboxclr').onclick = async () => { try { await post('clear'); items = []; inboxN = 0; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
}
+30 -1
View File
@@ -49,7 +49,7 @@
<body>
<div id="shell">
<nav id="nav" aria-label="Main"></nav>
<div id="main"><div id="navbar" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
<div id="main"><div id="navbar" hidden></div><div id="subnav" class="seg" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
</div>
<div id="toast" role="status"></div>
<script src="/webauthn.js"></script>
@@ -59,23 +59,52 @@
<script src="/reminders.js"></script>
<script src="/file-history.js"></script>
<script src="/music-extras.js"></script>
<script src="/music-more.js"></script>
<script src="/screentime-view.js"></script>
<script src="/calendar-views.js"></script>
<script src="/app.js"></script>
<script src="/search-view.js"></script>
<script src="/today.js"></script>
<script src="/calendar.js"></script>
<script src="/tasks.js"></script>
<script src="/audiobooks.js"></script>
<script src="/music.js"></script>
<script src="/video.js"></script>
<script src="/office.js"></script>
<script src="/player.js"></script>
<script src="/offline.js"></script>
<script src="/podcast-view.js"></script>
<script src="/reader.js"></script>
<script src="/audiobooks-view.js"></script>
<script src="/librivox-view.js"></script>
<script src="/radio-view.js"></script>
<script src="/store-view.js"></script>
<script src="/recipes-view.js"></script>
<script src="/contacts-view.js"></script>
<script src="/bible-view.js"></script>
<script src="/save-to-files.js"></script>
<script src="/vbv-view.js"></script>
<script src="/lyrics-view.js"></script>
<script src="/visualizer.js"></script>
<script src="/settings.js"></script>
<script src="/files.js"></script>
<script src="/files-extra.js"></script>
<script src="/sheets.js"></script>
<script src="/file-actions.js"></script>
<script src="/signal-view.js"></script>
<script src="/chat-view.js"></script>
<script src="/notes-view.js"></script>
<script src="/chores-view.js"></script>
<script src="/family-view.js"></script>
<script src="/mail.js"></script>
<script src="/share.js"></script>
<script src="/cast.js"></script>
<script src="/rows.js"></script>
<script src="/keys.js"></script>
<script src="/inbox-view.js"></script>
<script src="/device-ui.js"></script>
<script src="/settings-family.js"></script>
<script src="/simple-mode.js"></script>
<script src="/boot.js"></script>
<script src="/shell.js"></script>
</body>
+27
View File
@@ -0,0 +1,27 @@
'use strict';
// Desktop keyboard shortcuts for Mail (the video and music players have their own, in video.js and player.js), and "?" for the list of all of them.
addEventListener('keydown', e => {
if (e.metaKey || e.ctrlKey || e.altKey || document.getElementById('sheet') || document.body.classList.contains('editing')) return;
const t = e.target, tag = t.tagName || '', k = e.key;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag) || t.isContentEditable) return;
if (k === '?') { e.preventDefault(); return keysHelp(); }
if (!location.hash.startsWith('#/mail') || typeof mail === 'undefined') return;
if (k === 'j' || k === 'k') { // next / previous message in the list
const rows = [...document.querySelectorAll('a.mrow')]; if (!rows.length) return;
const sel = rows.findIndex(a => a.classList.contains('sel')), i = Math.max(0, Math.min(rows.length - 1, sel < 0 ? 0 : sel + (k === 'j' ? 1 : -1)));
e.preventDefault(); location.hash = rows[i].getAttribute('href'); rows[i].scrollIntoView({ block: 'nearest' }); return;
}
if (!mail.msg) return;
const act = { e: 'archive', '#': 'delete', Delete: 'delete', r: 'reply', a: 'replyAll', f: 'forward', u: 'unread', s: 'flag' }[k];
if (act) { e.preventDefault(); mailAction(act); }
else if (k === 'Escape' || k === 'x') { location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); }
});
function keysHelp() {
const row = (k, d) => `<tr><td style="padding:4px 14px 4px 0"><kbd>${k}</kbd></td><td>${d}</td></tr>`;
const el = openSheet('Keyboard shortcuts', '', `<table style="width:100%;font-size:14px"><tbody>
<tr><th colspan="2" style="text-align:left;padding-top:4px">Anywhere</th></tr>${row('Ctrl/⌘ K', 'Search and commands')}${row('?', 'This list')}${row('Space', 'Play or pause music')}${row('← →', 'Back or forward 10 s')}${row('N / P', 'Next or previous track')}
<tr><th colspan="2" style="text-align:left;padding-top:10px">Mail</th></tr>${row('J / K', 'Next or previous message')}${row('E', 'Archive')}${row('# or Delete', 'Delete')}${row('R / A / F', 'Reply, reply all, forward')}${row('U', 'Mark unread')}${row('S', 'Flag')}${row('Esc or X', 'Back to the list')}
<tr><th colspan="2" style="text-align:left;padding-top:10px">Video player</th></tr>${row('Space or K', 'Play or pause')}${row('← → / J L', 'Back or forward 10 s (30 s with Shift or J/L)')}${row('↑ ↓', 'Volume')}${row('F', 'Full screen')}${row('M', 'Mute')}${row('N / P', 'Next or previous episode')}${row('0–9', 'Jump to 0–90% of the way through')}</tbody></table>
<div class="bar"><button class="btn ghost" id="kx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#kx').onclick = closeSheet;
}
+95
View File
@@ -0,0 +1,95 @@
'use strict';
// ---- Free audiobooks (LibriVox): search public-domain books read by volunteers, keep a shelf, and listen in the same player as Music. Where you got to in each
// book is kept on the server (per person), so it follows you between devices.
const lvApi = (p, opts) => jfetch('/api/ab/free' + p, opts);
const lvOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
const lvDur = s => { s = Math.round(+s || 0); if (!s) return ''; const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : Math.max(1, m) + ' min'; };
const lvImg = (b, sq) => `<span class="vimg" style="aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>`;
let lvProg = {}, lvQueued = {}, lvLast = 0, lvBook = null, lvLoading = '';
const lvPlaying = () => { const t = cur(), m = t && /^lv:(\d+):(\d+)$/.exec(String(t.key)); return m ? { id: m[1], n: +m[2] } : null; };
const lvOffsets = b => { let t = 0; return b.chapters.map(c => { const o = t; t += c.secs; return o; }); };
const lvTotal = b => b.chapters.reduce((a, c) => a + c.secs, 0) || b.secs;
const lvLoadProg = async () => { try { for (const [id, p] of Object.entries(await lvApi('/progress') || {})) if (!lvProg[id] || p.t > lvProg[id].t) lvProg[id] = p; } catch {} };
const lvPush = keepalive => {
if (!Object.keys(lvQueued).length) return;
const body = JSON.stringify(lvQueued); lvQueued = {};
fetch('/api/ab/free/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive }).then(r => { if (!r.ok) throw 0; }).catch(() => { lvQueued = { ...JSON.parse(body), ...lvQueued }; });
};
const lvSave = keepalive => {
const now = lvPlaying(), d = pl.a.duration; if (!now || !isFinite(d) || d <= 0) return;
if (!lvBook || lvBook.id !== now.id) { // the page was reloaded while a book was playing: fetch its chapter list once, then carry on saving
if (lvLoading !== now.id) { lvLoading = now.id; lvApi('/book/' + now.id).then(b => { lvBook = { id: b.id, offsets: lvOffsets(b), total: lvTotal(b) }; }).catch(() => {}).finally(() => { lvLoading = ''; }); }
return;
}
const key = 'lv:' + now.id; lvProg[key] = lvQueued[key] = { pos: (lvBook.offsets[now.n] || 0) + pl.a.currentTime, dur: lvBook.total, t: Date.now() }; lvPush(keepalive);
};
pl.a.addEventListener('timeupdate', () => { if (Date.now() - lvLast > 8000) { lvLast = Date.now(); lvSave(); } });
pl.a.addEventListener('pause', () => lvSave());
pl.a.addEventListener('ended', () => lvSave());
addEventListener('pagehide', () => lvSave(true));
let lvShelf = [];
/** Load the saved free books and where you got to in each (for this page and for the main Audiobooks list). Never throws. */
const lvPrime = async () => { try { [lvShelf] = await Promise.all([lvApi('/shelf'), lvLoadProg()]); } catch { lvShelf = []; } return lvShelf; };
const lvCard = (b, wide) => { const p = lvLeft(b.id), pct = p ? Math.min(100, p.pos / p.dur * 100) : 0; return `<a class="vcard${wide ? ' wide' : ''}" href="#" data-lv="${esc(b.id)}" ${wide ? 'style="width:140px;flex:none"' : ''}>${lvImg(b).replace('</span>', pct > 1 ? `<i class="vprog"><b style="width:${pct}%"></b></i></span>` : '</span>')}<b>${esc(b.title)}</b><small>${esc(b.author || '')}${b.secs ? (b.author ? ' · ' : '') + esc(lvDur(b.secs)) : ''}</small></a>`; };
const lvBind = (root, after, known) => root.querySelectorAll('[data-lv]').forEach(a => a.onclick = e => { e.preventDefault(); lvSheet(a.dataset.lv, after, known); });
const lvRecent = () => lvShelf.slice().sort((a, b) => ((lvProg['lv:' + b.id] || {}).t || b.added) - ((lvProg['lv:' + a.id] || {}).t || a.added));
const lvLeft = id => { const p = lvProg['lv:' + id]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; };
/** Play a book from `at` seconds into it (default: where you left off), as one queue of chapters so the next chapter follows by itself. */
function lvPlay(b, at) {
const offs = lvOffsets(b), p = lvLeft(b.id), pos = at != null ? at : p ? p.pos : 0;
let n = 0; for (let i = 0; i < offs.length; i++) if (offs[i] <= pos) n = i;
const now = lvPlaying(); lvBook = { id: b.id, offsets: offs, total: lvTotal(b) };
if (now && now.id === b.id && now.n === n && at == null) { if (pl.a.paused) pl.a.play().catch(() => {}); return; }
playQueue(b.chapters.map(c => ({ key: `lv:${b.id}:${c.n}`, title: c.title, artist: b.author, album: b.title, thumb: b.cover, src: `/api/ab/free/audio/${b.id}/${c.n}`, dur: c.secs * 1000, nocache: true })), n);
const within = Math.max(0, pos - offs[n]); if (within > 1) pl.a.addEventListener('loadedmetadata', () => { const t = lvPlaying(); if (t && t.id === b.id && t.n === n) pl.a.currentTime = within; }, { once: true });
lvApi('/shelf', lvOpt('POST', { id: b.id })).catch(() => {}); // a book you start is kept on your shelf
}
async function viewFreeBooks(host) { // host: { el, hash } when shown inside the Store tab
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty">Loading…</p></div>';
try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><p class="err">${esc(e.message)}</p></div>`; return; }
if (!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(() => {});
const card = b => lvCard(b);
v.innerHTML = `<div class="wrap">${host ? '' : `<div class="ptop"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><h1 style="margin:0">Free audiobooks</h1></div>`}
<p class="sub">Public-domain books read aloud by volunteers (LibriVox). Free, with no sign-in. A book you start is kept on your shelf.</p>
<input type="search" id="lvq" placeholder="Search by title or author" autocomplete="off" autocapitalize="off" style="width:100%"><div class="seg" id="lvgenres" aria-label="Genres"></div><div id="lvnote"></div><div id="lvbody"></div><div id="lvmore" class="bar" style="justify-content:center"></div></div>`;
const known = new Map(lvShelf.map(b => [b.id, b]));
const gchips = () => { const el = $('#lvgenres'); if (!el) return; el.innerHTML = [{ id: '', label: 'All genres' }, ...genreList.filter(g => g.count === undefined || g.count > 0)].map(g => `<a href="#" data-g="${esc(g.id)}" class="${S.genre === g.id ? 'on' : ''}">${esc(g.label)}</a>`).join(''); el.querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.genre = a.dataset.g; gchips(); run(true); }); };
const draw = () => {
const mine = lvRecent();
$('#lvbody').innerHTML = (!S.q && mine.length ? `<h2>My free audiobooks</h2><div class="vgrid">${mine.map(card).join('')}</div>` : '') +
(S.q ? (S.items.length ? `<h2>Results</h2><div class="vgrid">${S.items.map(card).join('')}</div>` : S.busy ? '' : '<p class="empty">Nothing found.</p>') : `<h2>${S.items.length ? 'Browse' : ''}</h2>${S.items.length ? `<div class="vgrid">${S.items.map(card).join('')}</div>` : S.busy ? '' : ''}`);
lvBind($('#lvbody'), draw, known);
$('#lvmore').innerHTML = S.busy ? '<span class="sub">Searching…</span>' : S.more ? '<button class="btn ghost" id="lvmb">Show more</button>' : '';
if ($('#lvmb')) $('#lvmb').onclick = () => run(false);
};
const run = async reset => {
const my = ++S.seq; if (reset) { S.page = 1; S.items = []; } else S.page++; S.busy = true; $('#lvnote').innerHTML = ''; draw();
try { const r = await lvApi(`/search?q=${encodeURIComponent(S.q)}&genre=${encodeURIComponent(S.genre)}&page=${S.page}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; if (r.building && !r.items.length) { $('#lvnote').innerHTML = '<p class="sub">Sorting the audiobooks by genre for the first time. This takes about a minute…</p>'; setTimeout(() => { if (my === S.seq && S.genre) run(true); }, 8000); } r.items.forEach(b => known.set(b.id, known.get(b.id) || b)); }
catch (e) { if (my === S.seq) $('#lvnote').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
if (my === S.seq) { S.busy = false; draw(); }
};
let t; $('#lvq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); run(true); }, 450); };
$('#lvq').onkeydown = e => { if (e.key === 'Enter') { clearTimeout(t); S.q = e.target.value.trim(); run(true); } };
draw(); run(true);
}
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>');
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);
el.querySelector('.empty').outerHTML = `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>
<div style="min-width:0"><p class="sub" style="margin:0">${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' · '))}</p><p class="sub" style="margin:4px 0 0">${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}</p></div></div>
${b.description ? `<p style="margin:10px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden">${esc(b.description)}</p>` : ''}
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button></div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
const go = at => { if (!b.chapters.length) return toast('This book has no audio'); lvPlay(b, at); closeSheet(); if (!onShelf()) lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); if (after) after(); };
el.querySelector('#lvp').onclick = () => go();
if (el.querySelector('#lvs')) el.querySelector('#lvs').onclick = () => go(0);
if (el.querySelector('#lvf')) el.querySelector('#lvf').onclick = () => { closeSheet(); saveToFiles(b.title, { kind: 'librivox-book', id }); };
el.querySelector('#lvc').onclick = () => { const offs = lvOffsets(b); el.querySelector('#lvch').innerHTML = b.chapters.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${esc([abFmt(c.secs), c.reader].filter(Boolean).join(' · '))}</small></span></button>`).join(''); el.querySelectorAll('#lvch [data-i]').forEach(x => x.onclick = () => go(offs[x.dataset.i])); };
el.querySelector('#lvk').onclick = async e => { const btn = e.currentTarget; try { if (onShelf()) { await lvApi('/shelf/' + id, lvOpt('DELETE')); lvShelf = lvShelf.filter(x => x.id !== id); } else { await lvApi('/shelf', lvOpt('POST', { id })); lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); } btn.textContent = onShelf() ? 'Remove from shelf' : 'Save to shelf'; if (after) after(); } catch (x) { toast(x.message); } };
}
+7
View File
@@ -49,6 +49,13 @@
.box img { width: 72px; height: 72px; border-radius: 18px; margin-bottom: 16px; }
.box .btn { width: 100%; justify-content: center; min-height: 52px; font-size: 17px; }
.box form { text-align: left; }
.box.wide { max-width: 560px; }
.tiles { display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; margin: 14px 0; }
.tile { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 104px; padding: 12px 6px; border-radius: 18px; background: var(--panel); border: 1px solid var(--line); color: var(--text); cursor: pointer; font: inherit; }
.tile:active { transform: scale(.97); } .tile .av { width: 60px; height: 60px; border-radius: 50%; color: #fff; display: grid; place-items: center; font-size: 26px; font-weight: 700; } .tile b { font-size: 16px; } .tile small { min-height: 14px; }
.kidtag { background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; }
.pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; max-width: 300px; margin: 10px auto 0; } .pad button { height: 64px; font-size: 26px; border-radius: 16px; background: var(--panel2); color: var(--text); border: 1px solid var(--line); font-family: inherit; }
.dots { display: flex; justify-content: center; gap: 12px; margin: 12px 0; min-height: 18px; } .dots i { width: 16px; height: 16px; border-radius: 50%; background: var(--line); } .dots i.on { background: var(--accent); }
</style>
</head>
<body>
+33 -1
View File
@@ -6,10 +6,42 @@ async function signIn() {
try { await passkeyLogin(); location.replace('/'); } catch (e) { fail(e); }
}
// ---- Name tiles: on a remembered device (a family screen or a person's own device) the sign-in page shows who lives here. Tap your name, then use your PIN or passkey.
const escH = t => String(t == null ? '' : t).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
async function tilesLogin() {
let r; try { r = await fetch('/api/login/people'); } catch { return false; } if (!r.ok) return false;
const { people } = await r.json(); if (!people.length) return false;
document.querySelector('.box').classList.add('wide'); $('#sub').textContent = "Who's signing in? Tap your name";
const av = p => `<span class="av" style="background:${p.color}">${escH((p.name.trim()[0] || '?').toUpperCase())}</span>`;
const home = () => { $('#err').textContent = ''; $('#sub').textContent = "Who's signing in? Tap your name";
$('#ui').innerHTML = `<div class="tiles">${people.map(p => `<button class="tile" data-id="${escH(p.id)}">${av(p)}<b>${escH(p.name.split(' ')[0])}</b><small>${p.kid ? '<span class="kidtag">KID</span>' : ''}</small></button>`).join('')}</div>
<p style="margin-top:6px"><a href="#" id="other" style="color:var(--accent)">Sign in another way (any passkey, invite or recovery code)</a></p>`;
$('#ui').querySelectorAll('.tile').forEach(t => t.onclick = () => who(people.find(p => p.id === t.dataset.id)));
$('#other').onclick = e => { e.preventDefault(); classicLogin(); }; };
const who = p => {
$('#err').textContent = ''; $('#sub').textContent = '';
const tile = `<div class="tile" style="margin:0 auto;border:0;background:none;cursor:default">${av(p)}<b>${escH(p.name)}</b>${p.kid ? '<span class="kidtag">KID</span>' : ''}</div>`;
const back = '<p><a href="#" id="back" style="color:var(--accent)">Not you? Back to the names</a></p>', passkeyBtn = p.passkey ? '<p><button class="btn ghost" id="pk">Use my passkey instead</button></p>' : '';
const done = () => location.replace('/'), pkgo = async () => { $('#err').textContent = ''; try { await passkeyLogin(p.id); done(); } catch (e) { fail(e); } };
const wire = () => { const b = $('#back'); if (b) b.onclick = e => { e.preventDefault(); home(); }; const k = $('#pk'); if (k) k.onclick = pkgo; };
if (p.kid && !p.pin) { $('#ui').innerHTML = `${tile}<p>Sign in as <b>${escH(p.name)}</b>?</p><button class="btn" id="yes">Yes, that's me</button>${passkeyBtn}${back}`; wire(); $('#yes').onclick = async () => { try { await pinPost(p.id); done(); } catch (e) { fail(e); } }; return; }
if (!p.pin) { $('#ui').innerHTML = `${tile}<p>${escH(p.name)} has not set a PIN yet.</p>${p.passkey ? '<button class="btn" id="pk">Sign in with my passkey</button>' : '<p class="sub">Set a PIN or add a passkey in Settings on a device where you are signed in.</p>'}${back}`; wire(); return; }
let pin = ''; $('#ui').innerHTML = `${tile}<p class="sub">Enter your PIN</p><div class="dots" id="dots"></div><div class="pad">${[1, 2, 3, 4, 5, 6, 7, 8, 9, '⌫', 0, '✓'].map(k => `<button data-k="${k}">${k}</button>`).join('')}</div>${passkeyBtn}${back}`; wire();
const paint = () => { $('#dots').innerHTML = Array.from({ length: Math.max(4, pin.length) }, (_, i) => `<i class="${i < pin.length ? 'on' : ''}"></i>`).join(''); }; paint();
$('#ui').querySelectorAll('.pad button').forEach(b => b.onclick = async () => { let k = b.dataset.k; $('#err').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 pinPost(p.id, pin); done(); return; } catch (e) { fail(e); pin = ''; } } paint(); });
};
const pinPost = async (uid, pin) => { const r = await fetch('/api/login/pin', { method: 'POST', body: JSON.stringify({ uid, pin }) }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not sign in'); };
home(); return true;
}
async function init() {
if (!window.PublicKeyCredential) { $('#sub').textContent = ''; return fail(new Error('This browser does not support passkeys.')); }
const st = await (await fetch('/api/auth/state')).json();
if (st.authed) return location.replace('/');
if (st.registered && await tilesLogin()) return; // (a remembered device: name tiles, and the PIN works even where passkeys do not)
if (!window.PublicKeyCredential) { $('#sub').textContent = ''; return fail(new Error('This browser does not support passkeys.')); }
classicLogin(st);
}
async function classicLogin(st) {
if (!st) st = await (await fetch('/api/auth/state')).json();
if (st.registered) {
$('#sub').textContent = 'Sign in with your passkey';
$('#ui').innerHTML = '<button class="btn" id="go">Sign in with passkey</button>';
+96 -18
View File
@@ -19,12 +19,20 @@ function lyrParseLrc(text) {
lines.forEach((l, i) => { l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; if (l.words) { const last = l.words[l.words.length - 1]; if (last.e == null) last.e = l.e; } });
return lines;
}
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 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 {} };
const lyrOffset = k => { try { return +localStorage.getItem('lyr_off:' + k) || 0; } catch { return 0; } };
// 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
// behind that, so radio starts 5 seconds early and the - and + buttons move it a second at a time (remembered for that station).
const lyrIsRadio = t => !!t && String(t.key).startsWith('radio:');
const lyrId = t => lyrIsRadio(t) ? t.key + '|' + t.artist + '|' + t.title : t.key; // (a station is one "track" that plays many songs)
const lyrStep = t => lyrIsRadio(t) ? 1000 : 500;
const lyrPos = t => lyrIsRadio(t) ? Date.now() - ((t.song && t.song.startedAt) || Date.now()) + lyrOffset(t.key) : pl.a.currentTime * 1000 + lyrOffset(t.key);
const lyrOffset = k => { try { const v = localStorage.getItem('lyr_off:' + k); return v === null ? (String(k).startsWith('radio:') ? -5000 : 0) : +v || 0; } catch { return 0; } };
const lyrSetOffset = (k, v) => { try { localStorage.setItem('lyr_off:' + k, String(v)); } catch {} };
const lyrQuery = t => { try { return JSON.parse(localStorage.getItem('lyr_q:' + t.key)) || { artist: t.artist, title: t.title }; } catch { return { artist: t.artist, title: t.title }; } };
const lyrQuery = t => { try { return JSON.parse(localStorage.getItem('lyr_q:' + lyrId(t))) || { artist: t.artist, title: t.title }; } catch { return { artist: t.artist, title: t.title }; } };
const lyrCleanTitle = s => String(s || '').replace(/\s*[\(\[](feat\.?|ft\.?|with|remaster(ed)?|live|deluxe|version|mono|stereo)[^\)\]]*[\)\]]/gi, '').replace(/\s+-\s+(remaster(ed)?|live|mono|stereo|single version).*$/i, '').trim();
async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc file next to it, or downloaded by Plex)
@@ -38,16 +46,35 @@ async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc
const lines = text.split(/\r?\n/).map(x => ({ text: x.trim() })); return lines.some(l => l.text) ? { found: true, source: 'Plex', sync: 'none', lines } : null;
} catch { return null; }
}
/** A transcript: the show's own if it publishes one, else (on request) one made on this server. Polls while that is being made. */
async function lyrSpoken(t, start) {
const q = new URLSearchParams({ key: t.key, src: t.src || '', title: t.title || '', artist: t.artist || '', thumb: t.thumb || '' });
for (let n = 0; ; n++) { // (n restarts at -1 after an automatic start, so the POST goes out once)
if (!lyr.el || lyr.key !== t.key) return null;
let r; try { r = await jfetch('/api/transcript?' + q, start && n <= 0 ? { method: 'POST' } : undefined); } catch (e) { return { found: false, error: e.message }; }
if (r.state === 'ready') { r.transcript.spoken = true; return r.transcript; }
if (r.state === 'error') return { found: false, error: r.error, canTranscribe: r.canTranscribe, none: true };
if (r.state === 'none') {
if (r.canTranscribe && !start && String(t.key).startsWith('pod:') && !lyrPref().noAuto) { start = true; n = -1; continue; } // nothing published: make one (an episode, not a whole book)
return { found: false, none: true, canTranscribe: r.canTranscribe, why: r.why };
}
const body = lyr.el.querySelector('#lyrbody'); if (body) body.innerHTML = `<p class="empty">Making a transcript on your server… ${Math.max(0, Math.round((Date.now() - r.since) / 60000))} min so far.<br><small>You can close this: it keeps going, and it is kept for next time.</small></p>`;
await new Promise(ok => setTimeout(ok, 4000));
}
}
const lyrForRadio = (t, res) => lyrIsRadio(t) && res && res.found && res.sync !== 'none' && !(t.song && t.song.startedAt) ? { ...res, sync: 'none' } : res; // a live station only has a clock when it says when the song began
async function lyrLoad(t, force) {
if (!force && lyr.cache.has(t.key)) return lyr.cache.get(t.key);
let res = force ? null : await lyrFromPlex(t);
if (!res && cfg.lyrics) {
const id = lyrId(t);
if (!force && lyr.cache.has(id)) return lyrForRadio(t, lyr.cache.get(id));
if (lyrIsRadio(t) && !t.song) return { found: false, error: 'This station does not say which song is playing, so there is nothing to look up.' };
let res = force || lyrIsRadio(t) ? null : await lyrFromPlex(t);
if (!res && cfg.lyrics && store.get('lookups', true) !== false) {
const q = lyrQuery(t), u = '/api/lyrics?' + new URLSearchParams({ artist: q.artist, title: lyrCleanTitle(q.title), duration: (t.dur || 0) / 1000 || '' });
try { res = await jfetch(u); if (!res.found && lyrCleanTitle(q.title) !== q.title) { /* the cleaned title is what we asked; nothing else to try */ } } catch (e) { res = { found: false, error: e.message }; }
}
res = res || { found: false };
if (!res.error) lyr.cache.set(t.key, res);
return res;
if (!res.error) lyr.cache.set(id, res);
return lyrForRadio(t, res);
}
function lyrOpen() {
@@ -64,7 +91,7 @@ function lyrClose() {
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
function lyrShell() {
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
lyr.el.className = pref.karaoke ? 'kar' : '';
lyr.el.className = pref.karaoke && !lyrSpokenKey(t) ? 'kar' : '';
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>
@@ -72,34 +99,42 @@ function lyrShell() {
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><span class="grow"></span>
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><button class="btn ghost" id="lyrsh">Share a line</button><span class="grow"></span>
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
const $l = q => lyr.el.querySelector(q);
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; $l('#lyrsh').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
$l('#lyrs').onclick = lyrSearchAgain;
$l('#lyrs').onclick = lyrSearchAgain; $l('#lyrsh').onclick = lyrShare;
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
$l('#lyrm').onclick = () => nudge(-500); $l('#lyrp').onclick = () => nudge(500);
$l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
syncUI();
}
async function lyrReload(force) {
const t = cur(); if (!t || !lyr.el) return; lyr.key = t.key; lyr.data = null; lyr.idx = -2;
const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2;
const body = lyr.el.querySelector('#lyrbody'); body.innerHTML = '<p class="empty">Looking for the lyrics…</p>';
const res = await lyrLoad(t, force); if (!lyr.el || lyr.key !== t.key) return;
const res = lyrSpokenKey(t) ? await lyrSpoken(t, force === 'start') : await lyrLoad(t, force); if (!res || !lyr.el || lyr.key !== lyrId(t)) return;
lyr.data = res; lyrDraw();
}
function lyrDraw() {
const t = cur(), d = lyr.data, body = lyr.el && lyr.el.querySelector('#lyrbody'); if (!body || !t) return;
const src = lyr.el.querySelector('#lyrsrc'), nudge = lyr.el.querySelector('#lyrnudge');
nudge.hidden = !(d && d.found && d.sync !== 'none'); lyr.el.querySelector('#lyroff').textContent = (lyrOffset(t.key) / 1000).toFixed(1) + ' s';
if (!d || !d.found) { src.textContent = ''; body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return; }
if (!d || !d.found) {
src.textContent = '';
if (lyrSpokenKey(t)) { // no transcript yet: offer to make one
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;
}
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced');
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') return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
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;
}
function lyrSearchAgain() {
@@ -107,12 +142,12 @@ function lyrSearchAgain() {
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">
<div class="bar"><button class="btn ghost" type="button" id="lyrc" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Search</button></div></form>`);
el.querySelector('#lyrc').onclick = closeSheet;
el.querySelector('#lyrf').onsubmit = e => { e.preventDefault(); try { localStorage.setItem('lyr_q:' + t.key, JSON.stringify({ artist: el.querySelector('#lyra').value.trim(), title: el.querySelector('#lyrti').value.trim() })); } catch {} closeSheet(); lyr.cache.delete(t.key); lyrReload(true); };
el.querySelector('#lyrf').onsubmit = e => { e.preventDefault(); try { localStorage.setItem('lyr_q:' + lyrId(t), JSON.stringify({ artist: el.querySelector('#lyra').value.trim(), title: el.querySelector('#lyrti').value.trim() })); } catch {} closeSheet(); lyr.cache.delete(lyrId(t)); lyrReload(true); };
}
function lyrTick() { // follows the song: which line is current, and how far into it we are
lyr.raf = requestAnimationFrame(lyrTick);
const d = lyr.data, t = cur(); if (!lyr.el || !d || !d.found || d.sync === 'none' || !t) return;
const pos = pl.a.currentTime * 1000 + lyrOffset(t.key), L = d.lines;
const pos = lyrPos(t), L = d.lines;
let lo = 0, hi = L.length - 1, idx = -1; while (lo <= hi) { const m = (lo + hi) >> 1; if (L[m].t <= pos) { idx = m; lo = m + 1; } else hi = m - 1; }
const body = lyr.el.querySelector('#lyrbody'), nodes = body.querySelectorAll('.ll');
if (idx !== lyr.idx) {
@@ -124,3 +159,46 @@ function lyrTick() { // follows the song: which line is current, and how far int
if (l.words) on.querySelectorAll('.w').forEach((w, j) => { const x = l.words[j]; if (!x) return; const p = pos >= x.e ? 1 : pos <= x.t ? 0 : (pos - x.t) / Math.max(1, x.e - x.t); w.classList.toggle('sung', p >= 1); w.style.setProperty('--p', p); });
else { const e = l.e && l.e > l.t ? l.e : l.t + 4000, p = Math.max(0, Math.min(1, (pos - l.t) / Math.max(400, Math.min(e - l.t, 7000)))); on.style.setProperty('--p', p); } // (no word timing: the colour sweeps across the line)
}
// Playing a podcast episode is also the signal to get its transcript going in the background (the published one if there is one, else one made on this
// server), so the words are usually ready, or well on the way, by the time Transcript is opened. Once per episode per visit; nothing for audiobooks.
const lyrAsked = new Set();
pl.a.addEventListener('playing', async () => {
const t = typeof cur === 'function' ? cur() : null;
if (!t || !String(t.key).startsWith('pod:') || !t.src || lyrAsked.has(t.key) || !cfg.transcribe || lyrPref().noAuto) return;
lyrAsked.add(t.key);
try {
const q = new URLSearchParams({ key: t.key, src: t.src, title: t.title || '', artist: t.artist || '', thumb: t.thumb || '' }), r = await jfetch('/api/transcript?' + q);
if (r.state === 'none' && r.canTranscribe) await jfetch('/api/transcript?' + q, { method: 'POST' });
} catch { lyrAsked.delete(t.key); }
});
// ---- Share a line: the lyric over the album cover, as a picture (shared through the phone's share sheet, or downloaded)
function lyrShare() {
const t = cur(), d = lyr.data; if (!t || !d || !d.found || d.instrumental || !d.lines) return toast('There are no lyrics to share yet');
const text = l => l.words ? l.words.map(w => w.text).join(' ') : l.text, rows = d.lines.map((l, i) => ({ i, s: (text(l) || '').trim() })).filter(x => x.s).slice(0, 250);
const now = d.sync !== 'none' && lyr.idx >= 0 ? lyr.idx : -1, el = openSheet('Share a line', 'Tick up to four lines for the picture.', `<div class="list" style="max-height:48dvh;overflow:auto">${rows.map(x => `<label class="row" style="cursor:pointer"><input type="checkbox" data-l="${x.i}"${x.i === now ? ' checked' : ''}><span class="grow" style="white-space:normal">${esc(x.s)}</span></label>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="shx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="shgo" style="flex:1;justify-content:center">Make the picture</button></div>`);
el.querySelector('#shx').onclick = closeSheet;
el.querySelectorAll('input[data-l]').forEach(c => c.onchange = () => { if (el.querySelectorAll('input[data-l]:checked').length > 4) c.checked = false; });
el.querySelector('#shgo').onclick = async () => {
const picked = [...el.querySelectorAll('input[data-l]:checked')].map(c => rows.find(x => x.i === +c.dataset.l).s); if (!picked.length) return toast('Tick at least one line');
closeSheet();
try { const blob = await lyrCard(t, picked), file = new File([blob], (t.title || 'lyric').replace(/[^\w -]+/g, '') + '.png', { type: 'image/png' });
if (navigator.canShare && navigator.canShare({ files: [file] })) await navigator.share({ files: [file], title: t.title, text: t.title + ' — ' + t.artist }).catch(e => { if (e.name !== 'AbortError') throw e; });
else { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); toast('Saved the picture'); } } catch (e) { toast(e.message || 'Could not make the picture'); }
};
}
async function lyrCard(t, lines) {
const W = 1080, H = 1350, cv = document.createElement('canvas'); cv.width = W; cv.height = H; const x = cv.getContext('2d');
const img = await new Promise(ok => { const im = new Image(); im.onload = () => ok(im); im.onerror = () => ok(null); im.src = px.art(t.thumb, 800); });
x.fillStyle = '#14161b'; x.fillRect(0, 0, W, H);
if (img) { x.save(); x.filter = 'blur(40px) saturate(1.3)'; const k = Math.max(W / img.width, H / img.height) * 1.2; x.drawImage(img, (W - img.width * k) / 2, (H - img.height * k) / 2, img.width * k, img.height * k); x.restore(); }
const g = x.createLinearGradient(0, 0, 0, H); g.addColorStop(0, 'rgba(0,0,0,.35)'); g.addColorStop(1, 'rgba(0,0,0,.75)'); x.fillStyle = g; x.fillRect(0, 0, W, H);
const text = lines.join('\n'), size = text.length > 160 ? 52 : text.length > 90 ? 62 : 74; x.fillStyle = '#fff'; x.font = `700 ${size}px system-ui, -apple-system, Segoe UI, sans-serif`; x.textAlign = 'center'; x.textBaseline = 'middle';
const wrapped = []; for (const ln of lines) { let cur1 = ''; for (const w of ln.split(/\s+/)) { const tryl = cur1 ? cur1 + ' ' + w : w; if (x.measureText(tryl).width > W - 160 && cur1) { wrapped.push(cur1); cur1 = w; } else cur1 = tryl; } wrapped.push(cur1); }
const lh = size * 1.3, y0 = H / 2 - ((wrapped.length - 1) * lh) / 2 - 40; wrapped.forEach((ln, i) => x.fillText(ln, W / 2, y0 + i * lh));
if (img) { const s = 150, ax = 100, ay = H - 250; x.save(); x.beginPath(); x.roundRect ? x.roundRect(ax, ay, s, s, 18) : x.rect(ax, ay, s, s); x.clip(); x.drawImage(img, ax, ay, s, s); x.restore(); }
x.textAlign = 'left'; x.font = '700 40px system-ui, sans-serif'; x.fillText(t.title.slice(0, 34), 280, H - 200); x.font = '400 34px system-ui, sans-serif'; x.fillStyle = 'rgba(255,255,255,.75)'; x.fillText((t.artist || '').slice(0, 40), 280, H - 150);
return new Promise((ok, no) => cv.toBlob(b => b ? ok(b) : no(new Error('Could not make the picture')), 'image/png'));
}
+234 -26
View File
@@ -16,7 +16,8 @@ function mcPut(k, v) {
localStorage.setItem(ix, JSON.stringify(list));
} catch { /* storage full or blocked: go without */ }
}
function mcClear() { try { for (const k of Object.keys(localStorage)) if (k.startsWith('mc:' + (ME ? ME.id : '') + ':')) localStorage.removeItem(k); } catch {} }
function mcClear() { if (typeof mbClear === 'function') mbClear();
try { for (const k of Object.keys(localStorage)) if (k.startsWith('mc:' + (ME ? ME.id : '') + ':')) localStorage.removeItem(k); } catch {} }
const mailFrom = f => (f && (f.name || f.address)) || '';
const mailWhen = iso => {
const d = new Date(iso), now = new Date(); if (isNaN(d)) return '';
@@ -32,12 +33,20 @@ function mailDoc(html, images) {
return `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><base target="_blank"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{margin:14px;font:15px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;color:#111;background:#fff;overflow-wrap:anywhere}img{max-width:100%;height:auto}table{max-width:100%}a{color:#1a5fd1}blockquote{margin:0 0 0 6px;padding-left:10px;border-left:3px solid #ccc;color:#555}</style>${html}`;
}
/** On a phone the message is as tall as its text and the page scrolls, so there is one scroll, not a box inside a page. (The box keeps its own scroll on a wide screen.) No scripts run in it: only same-origin so its height can be read. */
function mailFit(fr) {
if (!fr) return;
const fit = () => { if (!fr.isConnected) return; if (innerWidth >= 1024) { fr.style.height = ''; return; } try { const d = fr.contentDocument; d.documentElement.style.overflowY = 'hidden'; fr.style.height = Math.max(160, Math.ceil(d.documentElement.scrollHeight) + 2) + 'px'; } catch {} };
fr.addEventListener('load', () => { fit(); try { fr.contentDocument.addEventListener('load', fit, true); } catch {} setTimeout(fit, 300); setTimeout(fit, 1200); });
if (!mailFit.on) { mailFit.on = true; addEventListener('resize', () => { const f = document.querySelector('.mbody'); if (f) f.dispatchEvent(new Event('fit')); }); }
fr.addEventListener('fit', fit);
}
const mailHasRemote = html => /<img[^>]+src=["']?https?:|url\(\s*["']?https?:/i.test(html || '');
// ---- unread badge and Home card
async function mailPoll() {
if (!(allowed('mail') && cfg.mail)) return;
try { const s = await jfetch('/api/mail/status'); mail.st = s; mail.accts = s.accounts || mail.accts; const n = s.connected && s.unread ? s.unread : 0; if (n !== mailUnread) { mailUnread = n; renderNav(); } } catch {}
try { const s = await jfetch('/api/mail/status'); mail.st = s; mail.accts = s.accounts || mail.accts; const n = s.connected && s.unread ? s.unread : 0; if (n !== mailUnread) { mailUnread = n; renderNav(); } if (location.hash.startsWith('#/mail') && typeof mailDrawChips === 'function') mailDrawChips(); } catch {}
}
async function renderMailCard(el) {
if (!el) return;
@@ -138,6 +147,15 @@ async function mailAdminCard() {
async function viewMail(args = []) {
const v = $('#view');
if (!cfg.mail) { v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Mail is not available to you.</p></div>'; return; }
// Moving between messages (or back to the list) in the same folder changes only the reader and the highlighted row: the page is not rebuilt,
// so there is no flash, and the list keeps its place.
const shell = document.querySelector('.mailapp'), k0 = args[0], key0 = args[1];
if (shell && v.contains(shell) && mail.st && mail.st.connected && mail.accts.length && (k0 === 'm' || k0 === undefined) && (k0 !== 'm' || !String(key0).includes('~') || String(key0).split('~')[0] === mail.acct)) {
const view = $('#view');
if (k0 === 'm') { if (shell.dataset.mode !== 'read') mail.listScroll = view.scrollTop; shell.dataset.mode = 'read'; mailMarkSel(key0); mailOpen(key0); }
else { shell.dataset.mode = 'list'; mail.msg = null; mailDrawReader(); mailMarkSel(''); requestAnimationFrame(() => { if (mail.listScroll) view.scrollTop = mail.listScroll; }); }
return;
}
// The accounts come from the server without any network call to the mail providers ("light"), so the page can draw at once; the folders, the message
// list and the real unread counts then load side by side instead of one after another.
let st = mail.st;
@@ -170,12 +188,16 @@ async function viewMail(args = []) {
jfetch('/api/mail/folders?acct=' + encodeURIComponent(acct)).then(f => { if (mail.acct !== acct) return; mail.folders = f; mcPut('f:' + acct, { folders: f }); mailRefreshFolders(); }).catch(() => {});
mailPoll().then(() => { if (mail.st && location.hash.startsWith('#/mail')) mailRefreshFolders(); });
}
/** One button per account with its own unread count, so you can see which one has new mail without switching. */
const mailChips = () => `<div class="maccts" role="tablist" aria-label="Accounts">${mail.accts.map(a => `<button type="button" role="tab" data-a="${esc(a.id)}" aria-selected="${a.id === mail.acct}" class="${a.id === mail.acct ? 'on' : ''}${a.unread ? ' new' : ''}"><span class="mal">${esc(a.name || a.address)}</span><small>${esc(a.name ? a.address : '')}</small>${a.unread ? `<i class="nbadge" style="position:static">${a.unread > 99 ? '99+' : a.unread}</i>` : ''}</button>`).join('')}</div>`;
function mailBindChips() { document.querySelectorAll('.maccts button').forEach(b => b.onclick = () => { if (b.dataset.a === mail.acct) return; mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(b.dataset.a + '~inbox'); }); }
function mailDrawChips() { if (mail.accts.length < 2) return; document.querySelectorAll('.maccts').forEach(el => el.outerHTML = mailChips()); mailBindChips(); }
/** Redraw the folder pickers (names and unread counts) without touching the list or the open message. */
function mailRefreshFolders() {
mailDrawChips();
const fl = mail.folders; if (!fl.length) return;
const f = $('#mfsel'); if (f) f.innerHTML = fl.map(x => `<option value="${esc(x.id)}" ${x.id === mail.folder ? 'selected' : ''}>${esc(x.name)}${x.unread ? ' (' + x.unread + ')' : ''}</option>`).join('');
const side = document.querySelector('.mfold'); if (side) { const sel = side.querySelector('select'); side.innerHTML = (sel ? sel.outerHTML : '') + fl.map(x => `<a href="#/mail/f/${encodeURIComponent(x.id)}" class="${x.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(x.name)}</span>${x.unread ? `<i class="nbadge" style="position:static">${x.unread}</i>` : ''}</a>`).join(''); const s2 = side.querySelector('select'); if (s2) s2.onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value + '~inbox'); }; }
const acc = $('#macct'); if (acc && mail.accts.length) acc.innerHTML = mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}${a.unread ? ' (' + a.unread + ')' : ''}</option>`).join('');
const side = document.querySelector('.mfold'); if (side) { const sel = side.querySelector('select'); side.innerHTML = (mail.accts.length > 1 ? mailChips() : '') + fl.map(x => `<a href="#/mail/f/${encodeURIComponent(x.id)}" class="${x.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(x.name)}</span>${x.unread ? `<i class="nbadge" style="position:static">${x.unread}</i>` : ''}</a>`).join(''); mailBindChips(); }
}
function mailConnectScreen(st) {
@@ -192,21 +214,22 @@ function mailConnectScreen(st) {
}
function mailShell(mode) {
mail.sel = null;
const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }];
$('#view').innerHTML = `<div class="mailapp" data-mode="${mode}">
<aside class="mfold" aria-label="Folders">${mail.accts.length > 1 ? `<select id="macct2" aria-label="Account" style="margin-bottom:8px">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}</option>`).join('')}</select>` : ''}${fl.map(f => `<a href="#/mail/f/${encodeURIComponent(f.id)}" class="${f.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(f.name)}</span>${f.unread ? `<i class="nbadge" style="position:static">${f.unread}</i>` : ''}</a>`).join('')}</aside>
<aside class="mfold" aria-label="Folders">${mail.accts.length > 1 ? mailChips() : ''}${fl.map(f => `<a href="#/mail/f/${encodeURIComponent(f.id)}" class="${f.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(f.name)}</span>${f.unread ? `<i class="nbadge" style="position:static">${f.unread}</i>` : ''}</a>`).join('')}</aside>
<section class="mlist" aria-label="Messages">
${mail.accts.length > 1 ? `<div class="mtool" style="margin-bottom:4px"><select id="macct" aria-label="Account" style="max-width:100%;flex:1">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}${a.unread ? ' (' + a.unread + ')' : ''}</option>`).join('')}</select></div>` : ''}
${mail.accts.length > 1 ? mailChips() : ''}
<div class="mtool"><select id="mfsel" aria-label="Folder">${fl.map(f => `<option value="${esc(f.id)}" ${f.id === mail.folder ? 'selected' : ''}>${esc(f.name)}${f.unread ? ' (' + f.unread + ')' : ''}</option>`).join('')}</select>
<input type="search" id="mq" placeholder="Search mail" value="${esc(mail.q)}" autocomplete="off" aria-label="Search mail">
<button class="ibtn" id="mrf" aria-label="Refresh">${icon('refresh')}</button><button class="btn" id="mnew">${icon('edit')}New</button></div>
<div id="mrows" class="list"></div><div id="mmore"></div>
<button class="ibtn" id="msel" aria-label="Select messages" title="Select messages">${icon('check')}</button><button class="ibtn" id="mrf" aria-label="Refresh">${icon('refresh')}</button><button class="btn" id="mnew">${icon('edit')}New</button></div>
<div id="mrows" class="list"></div><div id="mmore"></div><div id="mselbar" class="mselbar" hidden></div>
</section>
<section class="mread" id="mread" aria-label="Message"></section></div>`;
$('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); };
if ($('#macct2')) $('#macct2').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value + '~inbox'); };
if ($('#macct')) $('#macct').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value + '~inbox'); };
mailBindChips();
$('#mnew').onclick = () => mailCompose();
$('#msel').onclick = () => mailSelMode(!mail.sel);
$('#mrf').onclick = () => { mailLoadList(true); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailPoll(); }).catch(() => {}); };
let t; $('#mq').oninput = e => { clearTimeout(t); t = setTimeout(() => { mail.q = e.target.value.trim(); mailLoadList(true); }, 400); };
}
@@ -223,38 +246,211 @@ async function mailLoadList(reset) {
try {
const r = await jfetch(`/api/mail/messages?folder=${encodeURIComponent(folder)}&skip=${showing ? 0 : mail.next}${mail.q ? '&q=' + encodeURIComponent(mail.q) : ''}`);
if (!$('#mrows') || mail.folder !== folder) return;
mail.items = showing ? r.items : mail.items.concat(r.items); mail.more = r.more; mail.next = r.next;
mail.items = showing ? mailApplyMine(r.items) : mail.items.concat(mailApplyMine(r.items)); mail.more = r.more; mail.next = r.next;
if (first) mcPut('l:' + folder, { items: r.items, more: r.more, next: r.next });
mailDrawList();
mailDrawList(); mailPrefetch();
} catch (e) { if (showing) return; const el = $('#mrows'); if (el) el.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; if (/Connect again/.test(e.message)) { mail.st = null; mcClear(); setTimeout(route, 1200); } }
}
function mailMarkSel(id) { document.querySelectorAll('a.mrow').forEach(a => a.classList.toggle('sel', !!id && decodeURIComponent(a.getAttribute('href').slice(9)) === id)); }
function mailDrawList() {
const rows = $('#mrows'); if (!rows) return;
const rows = $('#mrows'); if (!rows) return; mailSwipeInit(rows);
const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1];
rows.innerHTML = mail.items.length ? mail.items.map(m => `<a class="row mrow${m.read ? '' : ' unread'}${sel && decodeURIComponent(sel) === m.id ? ' sel' : ''}" href="#/mail/m/${encodeURIComponent(m.id)}">
rows.innerHTML = mail.items.length ? mail.items.map(m => `<a class="row mrow${m.read ? '' : ' unread'}${sel && !mail.sel && decodeURIComponent(sel) === m.id ? ' sel' : ''}${mail.sel && mail.sel.has(m.id) ? ' picked' : ''}" href="#/mail/m/${encodeURIComponent(m.id)}">${mail.sel ? `<span class="mchk${mail.sel.has(m.id) ? ' on' : ''}"></span>` : ''}
<span class="grow"><b>${esc(mailFrom(m.from))}</b><span class="msub">${esc(m.subject)}</span><small>${esc(m.preview)}</small></span>
<span class="mmeta"><small>${esc(mailWhen(m.date))}</small>${m.flagged ? icon('check') : ''}${m.attach ? icon('clip') : ''}</span></a>`).join('') : `<p class="empty" style="padding:12px">${mail.q ? 'Nothing matches.' : 'No messages here.'}</p>`;
<span class="mmeta"><small>${esc(mailWhen(m.date))}</small>${m.flagged ? `<span class="mflag" title="Flagged">${icon('bookmark')}</span>` : ''}${m.attach ? icon('clip') : ''}</span></a>`).join('') : `<p class="empty" style="padding:12px">${mail.q ? 'Nothing matches.' : 'No messages here.'}</p>`;
const more = $('#mmore'); more.innerHTML = mail.more ? '<div class="bar" style="justify-content:center"><button class="btn ghost" id="mmb">Load more</button></div>' : '';
if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false);
mailDrawSelBar();
}
// ---- Swipe a message in the list: left deletes it, right archives it (touch screens). The row follows your finger and the colour under it shows what will happen;
// let go past a third of the way (or flick it) and it leaves at once and finishes in the background, otherwise it springs back.
function mailSwipeInit(rows) {
if (rows.dataset.swipe) return; rows.dataset.swipe = '1';
let row = null, x0 = 0, y0 = 0, dx = 0, t0 = 0, mode = '', w = 0;
const reset = (anim) => { if (!row) return; const r = row; r.style.transition = anim ? 'transform .18s ease-out' : ''; r.style.transform = ''; setTimeout(() => { r.classList.remove('swl', 'swr'); r.style.transition = ''; r.style.removeProperty('--sww'); }, anim ? 200 : 0); };
rows.addEventListener('touchstart', e => {
const a = e.target.closest && e.target.closest('a.mrow'); if (!a || e.touches.length !== 1 || mail.sel) { row = null; return; }
row = a; x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; dx = 0; mode = ''; t0 = Date.now(); w = a.offsetWidth;
}, { passive: true });
rows.addEventListener('touchmove', e => {
if (!row) return; const t = e.touches[0], mx = t.clientX - x0, my = t.clientY - y0;
if (!mode) { if (Math.abs(my) > 12 && Math.abs(my) > Math.abs(mx)) { row = null; return; } if (Math.abs(mx) > 12 && Math.abs(mx) > Math.abs(my) * 1.4) { mode = 'swipe'; row.style.setProperty('--sww', w + 'px'); } else return; }
dx = mx; row.classList.toggle('swl', dx < 0); row.classList.toggle('swr', dx > 0); row.style.transition = ''; row.style.transform = `translateX(${dx}px)`;
row.dataset.sw = dx < 0 ? 'Delete' : 'Archive'; row.classList.toggle('swgo', Math.abs(dx) > w / 3);
}, { passive: true });
const end = () => {
if (!row) return; const r = row; row = null;
if (mode !== 'swipe') return;
const fast = Math.abs(dx) > 60 && Date.now() - t0 < 250; r.dataset.swiped = '1'; setTimeout(() => delete r.dataset.swiped, 400);
if (Math.abs(dx) > w / 3 || fast) {
const id = decodeURIComponent(r.getAttribute('href').slice(9)), kind = dx < 0 ? 'delete' : 'archive';
if (kind === 'delete' && /(^|~)deleteditems$/.test(mail.folder)) { row = r; reset(true); row = null; const it = mail.items.find(x => x.id === id); return confirmSheet('Delete for good?', it ? it.subject : '', 'Delete', () => mailRowAway(id, kind)); }
r.style.transition = 'transform .16s ease-in, opacity .16s'; r.style.transform = `translateX(${dx < 0 ? -w : w}px)`; r.style.opacity = '0.2';
setTimeout(() => mailRowAway(id, kind), 160);
} else { row = r; reset(true); row = null; }
};
rows.addEventListener('touchend', end); rows.addEventListener('touchcancel', () => { if (row) { reset(true); row = null; } });
rows.addEventListener('click', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.dataset.swiped) { e.preventDefault(); e.stopPropagation(); } }, true); // the finger lifting after a swipe is not a tap
// Selecting: while it is on, a tap picks or unpicks a message; a long press on a phone (or Ctrl/Cmd-click, Shift-click for a range, on a computer) starts it.
let lp = 0, lpRow = null, lx = 0, ly = 0, lpFired = false;
rows.addEventListener('touchstart', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (!a || e.touches.length !== 1) return; lpRow = a; lx = e.touches[0].clientX; ly = e.touches[0].clientY; clearTimeout(lp);
lpFired = false; lp = setTimeout(() => { if (!lpRow) return; const r = lpRow; lpRow = null; lpFired = true; if (navigator.vibrate) navigator.vibrate(15); mailSelMode(true, decodeURIComponent(r.getAttribute('href').slice(9))); }, 520); }, { passive: true });
rows.addEventListener('touchmove', e => { if (lpRow && (Math.abs(e.touches[0].clientX - lx) > 8 || Math.abs(e.touches[0].clientY - ly) > 8)) { clearTimeout(lp); lpRow = null; } }, { passive: true });
['touchend', 'touchcancel'].forEach(t => rows.addEventListener(t, e => { clearTimeout(lp); lpRow = null; if (lpFired) { lpFired = false; mail.quiet = Date.now() + 250; if (e.cancelable) e.preventDefault(); } })); // lifting the finger after a long press must not count as a tap on anything
rows.addEventListener('contextmenu', e => { if (e.target.closest && e.target.closest('a.mrow') && 'ontouchstart' in window) e.preventDefault(); });
rows.addEventListener('click', e => {
const a = e.target.closest && e.target.closest('a.mrow'); if (!a || a.dataset.swiped) return; if (Date.now() < (mail.quiet || 0)) { e.preventDefault(); e.stopPropagation(); return; } const id = decodeURIComponent(a.getAttribute('href').slice(9));
if (!mail.sel && !(e.ctrlKey || e.metaKey || e.shiftKey)) return;
e.preventDefault(); e.stopPropagation();
if (!mail.sel) mailSelMode(true, id);
else if (e.shiftKey && mail.selLast) { const ids = mail.items.map(x => x.id), i = ids.indexOf(mail.selLast), j = ids.indexOf(id); if (i >= 0 && j >= 0) ids.slice(Math.min(i, j), Math.max(i, j) + 1).forEach(x => mail.sel.add(x)); mailSelPaint(); }
else mailSelToggle(id);
}, true);
}
function mailSelMode(on, first) {
mail.sel = on ? new Set(first ? [first] : []) : null; mail.selLast = first || null;
if (on && /^#\/mail\/m\//.test(location.hash) && matchMedia('(max-width: 899px)').matches) location.hash = '#/mail/f/' + encodeURIComponent(mail.folder);
mailSelPaint();
}
/** Show who is picked without rebuilding the list (the row under a finger must stay in the page, or the finger's release never reaches it). */
function mailSelPaint() {
document.querySelectorAll('#mrows a.mrow').forEach(a => {
const id = decodeURIComponent(a.getAttribute('href').slice(9)), on = !!mail.sel && mail.sel.has(id); let c = a.querySelector(':scope > .mchk');
if (mail.sel && !c) { c = document.createElement('span'); c.className = 'mchk'; a.insertBefore(c, a.firstChild); } else if (!mail.sel && c) c.remove();
if (c) c.classList.toggle('on', on); a.classList.toggle('picked', on); if (mail.sel) a.classList.remove('sel');
});
mailDrawSelBar();
}
function mailSelToggle(id) { if (!mail.sel) return; if (mail.sel.has(id)) mail.sel.delete(id); else mail.sel.add(id); mail.selLast = id; mailSelPaint(); }
function mailDrawSelBar() {
const bar = $('#mselbar'); if (!bar) return;
document.body.classList.toggle('mselecting', !!mail.sel);
if (!mail.sel) { bar.hidden = true; bar.innerHTML = ''; return; }
const picked = mail.items.filter(m => mail.sel.has(m.id)), n = picked.length, anyUnread = picked.some(m => !m.read), anyUnflag = picked.some(m => !m.flagged), all = n && n === mail.items.length;
const b = (a, label, ic, cls = '') => `<button class="btn ${cls || 'ghost'} msb" data-s="${a}" ${n || a === 'all' || a === 'done' ? '' : 'disabled'}>${ic ? icon(ic) : ''}${label}</button>`;
bar.hidden = false;
bar.innerHTML = `<div class="msbtop"><b>${n ? n + ' selected' : 'Select messages'}</b><span class="grow"></span>${b('all', all ? 'Select none' : 'Select all', '')}${b('done', 'Done', '', 'btn')}</div>
<div class="msbrow">${b(anyUnread ? 'read' : 'unread', anyUnread ? 'Mark read' : 'Mark unread', 'mail')}${b(anyUnflag ? 'flag' : 'unflag', anyUnflag ? 'Flag' : 'Unflag', 'check')}${b('archive', 'Archive', 'files')}${b('move', 'Move', 'newdir')}${b('delete', 'Delete', 'trash', 'danger')}</div>`;
bar.querySelectorAll('[data-s]').forEach(x => x.onclick = () => mailSelAct(x.dataset.s));
}
function mailSelAct(a) {
if (Date.now() < (mail.quiet || 0)) return; // the finger-lift that ended a long press
if (a === 'done') return mailSelMode(false);
if (a === 'all') { if (mail.sel.size === mail.items.length) mail.sel.clear(); else mail.items.forEach(m => mail.sel.add(m.id)); return mailSelPaint(); }
const ids = [...mail.sel].filter(id => mail.items.some(m => m.id === id)); if (!ids.length) return;
if (a === 'move') {
const fl = mail.folders.filter(f => f.id !== mail.folder);
const el = openSheet('Move to', ids.length + (ids.length > 1 ? ' messages' : ' message'), `<div class="list">${fl.map(f => `<button class="row" data-f="${esc(f.id)}"><span class="grow"><b>${esc(f.name)}</b></span>${icon('chev', 'chev')}</button>`).join('') || '<p class="empty">No other folders.</p>'}</div>`);
return el.querySelectorAll('[data-f]').forEach(r => r.onclick = () => { closeSheet(); mailBatch(ids, 'move', r.dataset.f); });
}
if (a === 'delete' && /(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', ids.length + (ids.length > 1 ? ' messages' : ' message') + ' will be deleted for good.', 'Delete', () => mailBatch(ids, 'delete', true));
mailBatch(ids, a);
}
/** Change one message in the open list and in the saved copy of it. */
function mailPatchItem(id, patch) {
const it = mail.items.find(x => x.id === id); if (it) Object.assign(it, patch);
const c = mcGet('l:' + mail.folder); if (c && c.items) { const x = c.items.find(y => y.id === id); if (x) { Object.assign(x, patch); mcPut('l:' + mail.folder, { items: c.items, more: c.more, next: c.next }); } }
}
/** Do the same thing to several messages. The list changes at once; each message is then sent to the server (a few at a time), and any the server refuses go back as they were. */
async function mailBatch(ids, a, extra) {
const items = ids.map(id => mail.items.find(x => x.id === id)).filter(Boolean), before = new Map(items.map(m => [m.id, { ...m }])), at = new Map(items.map(m => [m.id, mail.items.indexOf(m)]));
const removes = ['archive', 'move', 'delete'].includes(a), inbox = mailInInbox(); let dUnread = 0;
mail.sel = null; mail.selLast = null;
if (removes) {
const gone = new Set(ids); mail.items = mail.items.filter(m => !gone.has(m.id)); dUnread = -items.filter(m => !m.read).length;
const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => !gone.has(x.id)), more: sv.more, next: sv.next });
if (mail.msg && gone.has(mail.msg.id)) { mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); }
} else if (a === 'read' || a === 'unread') { const read = a === 'read'; dUnread = items.filter(m => m.read !== read).length * (read ? -1 : 1); items.forEach(m => { mailSetRead(m.id, read); }); }
else if (a === 'flag' || a === 'unflag') items.forEach(m => mailPatchItem(m.id, { flagged: a === 'flag' }));
if (inbox && dUnread) { mailUnread = Math.max(0, mailUnread + dUnread); renderNav(); }
mailDrawList();
const call = m => a === 'move' ? jfetch('/api/mail/move', { method: 'POST', body: JSON.stringify({ id: m.id, to: extra }) })
: a === 'archive' ? jfetch('/api/mail/move', { method: 'POST', body: JSON.stringify({ id: m.id, to: 'archive' }) })
: a === 'delete' ? jfetch('/api/mail/delete', { method: 'POST', body: JSON.stringify({ id: m.id, ...(extra ? { permanent: true } : {}) }) })
: jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify(a === 'read' || a === 'unread' ? { id: m.id, read: a === 'read' } : { id: m.id, flagged: a === 'flag' }) });
const failed = []; let next = 0, lastErr = '';
await Promise.all([0, 1, 2, 3].map(async () => { while (next < items.length) { const m = items[next++]; try { await call(m); } catch (e) { failed.push(m); lastErr = e.message; } } }));
const word = { read: 'Marked read', unread: 'Marked unread', flag: 'Flagged', unflag: 'Unflagged', archive: 'Archived', move: 'Moved', delete: extra ? 'Deleted' : 'Moved to Deleted' }[a], ok = items.length - failed.length;
if (failed.length) {
for (const m of failed) { const o = before.get(m.id); if (removes) mail.items.splice(Math.min(at.get(m.id), mail.items.length), 0, o); else { if (a === 'read' || a === 'unread') mailSetRead(m.id, o.read); else mailPatchItem(m.id, { flagged: o.flagged }); } }
if (inbox) { jfetch('/api/mail/status').catch(() => {}); } mailDrawList();
toast(`${ok ? word + ' ' + ok + ', ' : ''}${failed.length} failed: ${lastErr}`);
} else toast(`${word}${items.length > 1 ? ' (' + items.length + ')' : ''}`);
mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {});
}
function mailRowAway(id, kind) {
const at = mail.items.findIndex(x => x.id === id), had = mail.items[at]; if (!had) return mailDrawList();
const permanent = kind === 'delete' && /(^|~)deleteditems$/.test(mail.folder);
mail.items.splice(at, 1);
const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== id), more: sv.more, next: sv.next });
if (mail.msg && mail.msg.id === id) { mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); }
mailDrawList();
if (!had.read && mailInInbox() && mailUnread > 0) { mailUnread--; renderNav(); }
toast(kind === 'archive' ? 'Archived' : permanent ? 'Deleted' : 'Moved to Deleted');
const refresh = () => { mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); };
jfetch('/api/mail/' + (kind === 'archive' ? 'move' : 'delete'), { method: 'POST', body: JSON.stringify(kind === 'archive' ? { id, to: 'archive' } : { id, ...(permanent ? { permanent: true } : {}) }) })
.then(refresh, e => { toast(e.message); if (!had.read && mailInInbox()) { mailUnread++; renderNav(); } mail.items.splice(Math.min(at, mail.items.length), 0, had); mailDrawList(); refresh(); });
}
// Opened messages are also kept on this device (IndexedDB, the newest 80), so one you have read opens at once, even offline. A message's text never
// changes, so only its read/flagged state is taken fresh (from the list). Cleared with the rest of the saved mail when you disconnect or sign out.
const mbDB = () => mbDB.p || (mbDB.p = new Promise(ok => { try { const r = indexedDB.open('hub-mail', 1); r.onupgradeneeded = () => r.result.createObjectStore('b'); r.onsuccess = () => ok(r.result); r.onerror = () => ok(null); } catch { ok(null); } }));
const mbKey = id => (ME ? ME.id : '') + ':' + id;
async function mbGet(id) { const db = await mbDB(); if (!db) return null; return new Promise(ok => { try { const r = db.transaction('b').objectStore('b').get(mbKey(id)); r.onsuccess = () => ok(r.result && r.result.m || null); r.onerror = () => ok(null); } catch { ok(null); } }); }
async function mbPut(id, m) {
const db = await mbDB(); if (!db || JSON.stringify(m).length > 1.5e6) return;
try {
const st = db.transaction('b', 'readwrite').objectStore('b'); st.put({ t: Date.now(), m }, mbKey(id));
const all = st.getAll(), keys = st.getAllKeys();
keys.onsuccess = () => { const rows = keys.result.map((k, i) => [k, all.result[i].t]).sort((x, y) => y[1] - x[1]); for (const [k] of rows.slice(80)) st.delete(k); }; // (all was asked first, so it is done when keys is)
} catch {}
}
async function mbClear() { const db = await mbDB(); if (db) try { db.transaction('b', 'readwrite').objectStore('b').clear(); } catch {} }
const mailCache = new Map(); // message id -> { t, p: promise }: a message you point at or tap is fetched once and kept for a minute
const mailFetch = id => { const c = mailCache.get(id); if (c && Date.now() - c.t < 60e3) return c.p; const p = jfetch('/api/mail/message?id=' + encodeURIComponent(id)); mailCache.set(id, { t: Date.now(), p }); p.catch(() => mailCache.delete(id)); if (mailCache.size > 30) mailCache.delete(mailCache.keys().next().value); return p; };
const mailFetch = id => {
const c = mailCache.get(id); if (c && Date.now() - c.t < 60e3) return c.p;
const p = mbGet(id).then(saved => {
if (saved) { const it = mail.items.find(x => x.id === id); return it ? { ...saved, read: it.read, flagged: it.flagged } : saved; } // (opens at once)
return jfetch('/api/mail/message?id=' + encodeURIComponent(id)).then(m => { mbPut(id, m); return m; });
});
mailCache.set(id, { t: Date.now(), p }); p.catch(() => mailCache.delete(id)); if (mailCache.size > 30) mailCache.delete(mailCache.keys().next().value); return p;
};
let mailPre = 0; // fetches the newest unread-or-recent messages of the open list one at a time, so they are already here when tapped
async function mailPrefetch() {
const run = ++mailPre, ids = mail.items.slice(0, 10).map(x => x.id);
for (const id of ids) { if (run !== mailPre || !location.hash.startsWith('#/mail')) return; try { if (!(await mbGet(id))) await mailFetch(id); } catch {} }
}
document.addEventListener('pointerover', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.getAttribute('href').startsWith('#/mail/m/')) mailFetch(decodeURIComponent(a.getAttribute('href').slice(9))).catch(() => {}); }, { passive: true });
document.addEventListener('touchstart', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.getAttribute('href').startsWith('#/mail/m/')) mailFetch(decodeURIComponent(a.getAttribute('href').slice(9))).catch(() => {}); }, { passive: true });
// What this device has just done to a message (read, unread), kept for a minute: a list that was already on its way from the server
// (it started before the change) must not put the old state back.
const mailMine = new Map();
const mailInInbox = () => /(^|~)inbox$/.test(mail.folder);
function mailSetRead(id, read) {
mailMine.set(id, { read, t: Date.now() });
const it = mail.items.find(x => x.id === id); if (it) it.read = read;
const c = mcGet('l:' + mail.folder); // the saved copy of this folder shows it too the next time Mail opens
if (c && c.items) { const x = c.items.find(y => y.id === id); if (x) { x.read = read; mcPut('l:' + mail.folder, { items: c.items, more: c.more, next: c.next }); } }
}
const mailApplyMine = items => { const now = Date.now(); for (const [k, v] of mailMine) if (now - v.t > 60e3) mailMine.delete(k); for (const m of items) { const o = mailMine.get(m.id); if (o) m.read = o.read; } return items; };
async function mailOpen(id) {
const rd = $('#mread'), it = mail.items.find(x => x.id === id);
if (rd) rd.innerHTML = `<div class="mrhead"><a class="btn ghost mback" href="#/mail/f/${encodeURIComponent(mail.folder)}">${icon('back')}${esc(mailFolderName(mail.folder))}</a>${it ? `<h2 class="msubj">${esc(it.subject)}</h2><p class="mfrom"><b>${esc(mailFrom(it.from))}</b> <small>${esc(it.from.address)}</small></p>` : ''}</div><p class="empty" style="padding:16px">Opening…</p>`; // (what the list already knows, at once)
mail.images = false;
try {
const m = await mailFetch(id);
if (!(location.hash.startsWith('#/mail/m/'))) return;
if (decodeURIComponent((location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1] || '') !== id) return; // you moved on to another message while this one loaded
mail.msg = m; mailDrawReader(); mailDrawList();
if (!m.read) { // opening a message marks it read
const inbox = mailInInbox();
jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify({ id, read: true }) }).then(() => {
const it = mail.items.find(x => x.id === id); if (it) it.read = true; m.read = true; mailDrawList();
mailUnread = Math.max(0, mailUnread - 1); renderNav(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {});
}).catch(() => {});
m.read = true; mailSetRead(id, true); mailDrawList();
if (inbox) { mailUnread = Math.max(0, mailUnread - 1); renderNav(); }
mailPoll(); // the real count (the server drops its saved one when a message changes)
jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {});
}).catch(e => toast('Could not mark it as read: ' + e.message));
}
} catch (e) { if ($('#mread')) $('#mread').innerHTML = `<p class="err" style="padding:16px">${esc(e.message)}</p>`; }
}
@@ -274,23 +470,33 @@ function mailDrawReader() {
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="unread">Mark unread</button><button class="btn ghost" data-a="flag">${m.flagged ? 'Unflag' : 'Flag'}</button><button class="btn danger" data-a="delete">Delete</button></div>
${m.attachments.length ? `<div class="matt">${m.attachments.map(a => `<a class="btn ghost" href="/api/mail/attachment?id=${encodeURIComponent(m.id)}&aid=${encodeURIComponent(a.id)}" download>${icon('clip')}${esc(a.name)} <small>${esc(fmtSize(a.size || 0))}</small></a>`).join('')}</div>` : ''}
${remote ? '<div class="mimg">Pictures are hidden to protect your privacy. <button class="btn ghost" id="mshow" style="min-height:32px">Show pictures</button></div>' : ''}</div>
<iframe class="mbody" title="Message" sandbox="allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
<iframe class="mbody" title="Message" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
if ($('#mshow')) $('#mshow').onclick = () => { mail.images = true; mailDrawReader(); };
mailFit(rd.querySelector('.mbody'));
if (typeof ctMailHook === 'function') ctMailHook(rd, m);
rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a));
}
async function mailAction(a) {
const m = mail.msg; if (!m) return;
const post = (p, body) => jfetch('/api/mail/' + p, { method: 'POST', body: JSON.stringify({ id: m.id, ...body }) });
const back = () => { mail.items = mail.items.filter(x => x.id !== m.id); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {}); };
const refresh = () => { mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); };
const gone = () => { mail.items = mail.items.filter(x => x.id !== m.id); const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== m.id), more: sv.more, next: sv.next }); };
const back = keep => { if (!keep) gone(); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); refresh(); };
// Archive and delete leave at once and finish in the background; if the server refuses, the message comes back
const away = (p, body, said) => {
const had = mail.items.find(x => x.id === m.id), at = mail.items.findIndex(x => x.id === m.id);
toast(said); back();
post(p, body).catch(e => { toast(e.message); if (had) { mail.items.splice(Math.min(at < 0 ? 0 : at, mail.items.length), 0, had); mailDrawList(); } refresh(); });
};
try {
if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m });
if (a === 'archive') { await post('move', { to: 'archive' }); toast('Archived'); return back(); }
if (a === 'unread') { await post('update', { read: false }); const it = mail.items.find(x => x.id === m.id); if (it) it.read = false; toast('Marked unread'); mailUnread++; renderNav(); return back(); }
if (a === 'archive') return away('move', { to: 'archive' }, 'Archived');
if (a === 'unread') { await post('update', { read: false }); m.read = false; mailSetRead(m.id, false); toast('Marked unread'); if (mailInInbox()) { mailUnread++; renderNav(); } return back(true); }
if (a === 'flag') { await post('update', { flagged: !m.flagged }); m.flagged = !m.flagged; const it = mail.items.find(x => x.id === m.id); if (it) it.flagged = m.flagged; mailDrawReader(); return mailDrawList(); }
if (a === 'delete') {
if (/(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', m.subject, 'Delete', async () => { try { await post('delete', { permanent: true }); toast('Deleted'); back(); } catch (e) { toast(e.message); } });
await post('delete', {}); toast('Moved to Deleted'); return back();
if (/(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', m.subject, 'Delete', () => away('delete', { permanent: true }, 'Deleted'));
return away('delete', {}, 'Moved to Deleted');
}
} catch (e) { toast(e.message); }
}
@@ -308,6 +514,8 @@ function mailCompose(o = {}) {
${reply ? '' : `<div class="bar" style="margin:8px 0 0;justify-content:flex-start"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('clip')}Attach<input type="file" id="mfile" multiple hidden></label><small id="mfl" style="color:var(--muted)"></small></div>`}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="msend" style="flex:1;justify-content:center">Send</button></div><div class="err" id="mer"></div></form>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
if (o.to && el.querySelector('#mto')) el.querySelector('#mto').value = o.to;
if (typeof ctSuggest === 'function') for (const id of ['#mto', '#mcc2', '#mbcc']) ctSuggest(el.querySelector(id));
const cc = el.querySelector('#mcc'); if (cc) cc.onclick = e => { e.preventDefault(); el.querySelector('#mcb').hidden = false; el.querySelector('#mccw').hidden = true; };
const fi = el.querySelector('#mfile');
const total = () => files.reduce((n, f) => n + f.size, 0);
+24 -1
View File
@@ -91,5 +91,28 @@
}
]
}
]
],
"dir": "ltr",
"prefers_related_applications": false,
"launch_handler": {
"client_mode": [
"navigate-existing",
"auto"
]
},
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{
"name": "files",
"accept": [
"*/*"
]
}
]
}
}
}
+5 -4
View File
@@ -29,6 +29,7 @@ async function addToPlaylist(tracks, suggest = '') {
}, suggest);
actionSheet('Add to playlist', keys.length + (keys.length === 1 ? ' track' : ' tracks'), [
{ label: 'New playlist…', icon: 'plus', run: newOne },
...(typeof addToFamily === 'function' ? [{ label: 'A Family playlist…', icon: 'music', run: () => addToFamily(tracks) }] : []),
...lists.map(p => ({ label: p.title, icon: 'music', run: async () => { try { await plx.add(p.ratingKey, keys); toast('Added to ' + p.title); if (location.hash === '#/music/playlist/' + p.ratingKey) route(); } catch (e) { toast(e.message); } } })),
]);
}
@@ -81,11 +82,11 @@ async function plexHomeBadge(st) {
}
function plexSwitchSheet(users) {
const el = openSheet('Who is listening?', 'People in your Plex Home. Playlists, ratings and what you have played follow the person you pick.',
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(u.thumb)}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected && !u.admin ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(stOk(u.thumb))}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => { const u = users[+b.dataset.u]; if (u.current) return closeSheet(); if (u.protected && !u.admin) plexPinSheet(u); else plexSwitchTo(u, ''); });
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => { const u = users[+b.dataset.u]; if (u.current) return closeSheet(); if (u.protected) plexPinSheet(u); else plexSwitchTo(u, ''); });
}
function plexPinSheet(u) {
const el = openSheet('PIN for ' + u.title, '', `<form id="ppf"><label for="ppi">PIN</label><input type="password" id="ppi" inputmode="numeric" autocomplete="off" maxlength="8" required>
@@ -97,7 +98,7 @@ function plexPinSheet(u) {
async function plexSwitchTo(u, pin, onError) {
try {
await jfetch('/api/plex/switch', { method: 'POST', body: JSON.stringify({ id: u.id, pin }) });
closeSheet(); plexHome.data = null; px.secs = null;
closeSheet(); plexHome.data = null; px.secs = null; wl.id = ''; try { for (const k of Object.keys(localStorage)) if (k.startsWith('vc:')) localStorage.removeItem(k); } catch {} // (what the last person had on their home screen)
toast('Now using Plex as ' + u.title); route();
} catch (e) { if (onError) onError(e.message); else toast(e.message); }
}
+319
View File
@@ -0,0 +1,319 @@
'use strict';
// ---- Music, more: history and stats, favorites, mixes (daily, most played, forgotten, moods, on this day, decades, genres), smart playlists, Family playlists, library radio,
// cover-art colors, offline albums, and a verse for the day. Uses the globals from music.js / player.js (px, pl, mapTrack, playQueue, listing helpers) and the hub's /api/music.
const mmApi = (p, o) => jfetch('/api/music' + p, o);
const mmOpt = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: b === undefined ? undefined : JSON.stringify(b) });
const mmPlex = k => /^\d{1,12}$/.test(String(k || ''));
const mmMeta = t => ({ key: t.key, title: t.title, artist: t.artist, album: t.album, thumb: t.thumb, artistKey: t.artistKey, albumKey: t.albumKey, dur: t.dur });
const mmPick = (a, n) => shuffled(a).slice(0, n);
const mmHours = m => m >= 90 ? (m / 60).toFixed(m >= 600 ? 0 : 1) + ' h' : m + ' min';
const mmNow = () => Math.floor(Date.now() / 1000);
// ---- what was played: a song that has been played for half its length (or four minutes) is counted, the same moment Plex is told
function musicPlayed(t, secs) {
if (!t || !mmPlex(t.key) || !allowed('music')) return;
fetch('/api/music/played', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...mmMeta(t), dur: secs ? secs * 1000 : t.dur }), keepalive: true }).catch(() => {});
}
// ---- favorites (kept by the hub, so they follow you between devices)
const mf = { keys: new Set(), list: null };
async function mfLoad(force) { if (mf.list && !force) return mf.list; try { mf.list = await mmApi('/favs'); mf.keys = new Set(mf.list.map(x => x.key)); } catch { mf.list = mf.list || []; } return mf.list; }
const mfIs = t => !!t && mf.keys.has(String(t.key));
async function mfToggle(t) {
if (!mmPlex(t.key)) return toast('Only songs from your music library can be favorites');
try {
if (mfIs(t)) { await mmApi('/favs/' + t.key, { method: 'DELETE' }); mf.keys.delete(String(t.key)); toast('Removed from Favorites'); }
else { await mmApi('/favs', mmOpt('POST', mmMeta(t))); mf.keys.add(String(t.key)); toast('Added to Favorites'); }
mf.list = null; mfPaint();
} catch (e) { toast(e.message); }
}
/** Hearts on screen (the Now Playing sheet and the track rows) follow what is a favorite. */
function mfPaint() {
const t = typeof cur === 'function' ? cur() : null;
document.querySelectorAll('#npfav, #cnfav').forEach(b => { const on = mfIs(t); b.classList.toggle('on', on); b.style.color = on ? 'var(--danger)' : ''; b.setAttribute('aria-pressed', on); });
document.querySelectorAll('[data-fv]').forEach(b => { const on = mf.keys.has(b.dataset.fv); b.style.color = on ? 'var(--danger)' : ''; b.setAttribute('aria-pressed', on); });
}
// ---- songs by their Plex keys (the hub remembers keys; the song itself, with its file, comes from Plex)
async function mmTracksByKeys(keys) {
keys = [...new Set(keys.map(String))].filter(mmPlex); const out = new Map();
for (let i = 0; i < keys.length; i += 40) {
try { const mc = await px.get('/library/metadata/' + keys.slice(i, i + 40).join(',')); for (const t of mc.Metadata || []) { const m = mapTrack(t, mc); if (m) out.set(String(t.ratingKey), m); } } catch { /* a missing song is simply left out */ }
}
return keys.map(k => out.get(k)).filter(Boolean);
}
const mmTracksOf = async albumKeys => (await Promise.all(albumKeys.map(async k => { try { const mc = await px.get(`/library/metadata/${k}/children`); return (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); } catch { return []; } }))).flat();
/** Random songs from the library, or only from the chosen playlists. */
async function mmRandomTracks(sec, n, pls = []) {
if (pls.length) { const lists = await Promise.all(pls.map(id => px.get(`/playlists/${id}/items`).catch(() => ({ Metadata: [] })))); return mmPick(lists.flatMap(l => (l.Metadata || []).map(t => mapTrack(t, l)).filter(Boolean)), n); }
try { return ((await px.get(`/library/sections/${sec}/all?type=10&sort=random&X-Plex-Container-Start=0&X-Plex-Container-Size=${n}`)).Metadata || []).map(t => mapTrack(t, {})).filter(Boolean); } catch { return []; }
}
// ---- a page of songs (a mix, a genre, favorites...): Play, Shuffle, Save as playlist, and the list
function mmPage(v, { title, sub, thumb, tracks, back, about, extra = '', onChange }) {
const hrefBack = back ? `<div class="bar"><a class="btn ghost" href="${back[0]}">${icon('back')}${esc(back[1])}</a></div>` : '';
v.innerHTML = `<div class="wrap">${hrefBack}<div class="mhead">${thumb ? `<img class="art" alt="" src="${px.art(thumb, 400)}">` : `<div class="art mixart" style="background:linear-gradient(135deg,hsl(${mmHue(title)} 55% 42%),hsl(${mmHue(title) + 40} 55% 26%))"></div>`}<div><h1>${esc(title)}</h1><p class="sub">${esc(sub || tracks.length + ' songs')}</p></div></div>
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}
<div class="bar"><button class="btn" id="mpa">${icon('play')}Play</button><button class="btn ghost" id="mps">${icon('shuffle')}Shuffle</button><button class="btn ghost" id="mpl">${icon('plus')}Save as playlist</button>${extra}</div>
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span><small>${fmtDur(t.dur)}</small></button>
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Song actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable songs</p>'}</div></div>`;
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => trackSheet(tracks[+b.dataset.m]));
v.querySelectorAll('button[data-fi]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.fi]; mfToggle(t).then(() => { if (onChange) onChange(); }); });
v.querySelector('#mpa').onclick = () => tracks.length && playQueue(tracks, 0);
v.querySelector('#mps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
v.querySelector('#mpl').onclick = () => tracks.length && addToPlaylist(tracks, title);
mfLoad().then(mfPaint); markPlaying();
}
const mmHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
const mmLoading = (v, title) => { v.innerHTML = `<div class="wrap"><h1>${esc(title)}</h1><p class="empty">Putting it together…</p></div>`; };
// ================================================================ the Mixes tab
const MOODS = [
['calm', 'Calm', /ambient|classical|acoustic|folk|instrumental|new age|piano|soft|easy|lullaby|worship|hymn/i],
['upbeat', 'Upbeat', /pop|dance|rock|gospel|praise|funk|disco|swing|country|r&b|soul|reggae|latin/i],
['focus', 'Focus', /instrumental|classical|ambient|jazz|soundtrack|score|piano|lo-?fi/i],
['worship', 'Worship', /worship|christian|gospel|hymn|praise|ccm|inspirational/i],
];
let mmGenres = null, mmAlbumsCache = null;
async function mmGenreList(sec) { if (mmGenres && mmGenres.sec === sec) return mmGenres.list; try { const l = ((await px.get(`/library/sections/${sec}/genre`)).Directory || []).map(g => ({ key: g.key, title: g.title })); mmGenres = { sec, list: l }; return l; } catch { return []; } }
async function mmAllAlbums(sec) { if (mmAlbumsCache && mmAlbumsCache.sec === sec && Date.now() - mmAlbumsCache.t < 600000) return mmAlbumsCache.list; const mc = await px.get(`/library/sections/${sec}/all?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=3000`); mmAlbumsCache = { sec, t: Date.now(), list: mc.Metadata || [] }; return mmAlbumsCache.list; }
/** Build the songs of a mix: { title, sub, tracks }. */
async function mmBuildMix(sec, id, arg) {
if (id === 'daily') {
const [top, favs, rnd] = await Promise.all([mmApi('/top?days=60&limit=40').catch(() => []), mfLoad(true), mmRandomTracks(sec, 24)]);
const keys = [...mmPick(top, 14).map(x => x.key), ...mmPick(favs, 12).map(x => x.key)], known = await mmTracksByKeys(keys), seen = new Set(known.map(t => t.key));
const tracks = shuffled([...known, ...rnd.filter(t => !seen.has(t.key))]);
return { title: 'Daily mix', sub: new Date().toLocaleDateString([], { weekday: 'long' }) + ' · ' + tracks.length + ' songs from what you play, your favorites and a few surprises', tracks };
}
if (id === 'mostplayed') { const top = await mmApi('/top?days=30&limit=50'); return { title: 'Most played this month', tracks: await mmTracksByKeys(top.map(x => x.key)), sub: top.length ? '' : 'Play a few songs and this fills in' }; }
if (id === 'forgotten') {
const cutoff = mmNow() - 90 * 86400; let l = [];
try { l = ((await px.get(`/library/sections/${sec}/all?type=10&viewCount>>=2&lastViewedAt<<=${cutoff}&sort=viewCount:desc&X-Plex-Container-Start=0&X-Plex-Container-Size=200`)).Metadata || []).map(t => mapTrack(t, {})).filter(Boolean); } catch { /* none */ }
const favs = await mfLoad(true), old = new Set(l.map(t => t.key)); const fav = (await mmTracksByKeys(mmPick(favs, 30).map(x => x.key))).filter(t => !old.has(t.key));
return { title: 'Forgotten favorites', sub: 'Songs you loved and have not played in a while', tracks: shuffled([...mmPick(l, 40), ...fav.slice(0, 15)]) };
}
if (id === 'mood') {
const mood = MOODS.find(m => m[0] === arg); if (!mood) throw new Error('Unknown mix');
const keys = (await mmGenreList(sec)).filter(g => mood[2].test(g.title)).map(g => g.key); if (!keys.length) throw new Error('No ' + mood[1].toLowerCase() + ' music was found in your genres');
const albums = ((await px.get(`/library/sections/${sec}/all?type=9&genre=${keys.join(',')}&sort=random&X-Plex-Container-Start=0&X-Plex-Container-Size=14`)).Metadata || []);
return { title: mood[1] + ' mix', sub: 'From your ' + mood[1].toLowerCase() + ' genres', tracks: mmPick(await mmTracksOf(albums.map(a => a.ratingKey)), 60) };
}
if (id === 'similar') { // more like an artist: the artists Plex says are similar, else the same genre
let artists = []; try { artists = ((await px.get(`/library/metadata/${arg}/similar`)).Metadata || []).slice(0, 8); } catch { /* none */ }
const me = ((await px.get(`/library/metadata/${arg}`)).Metadata || [])[0] || {};
if (!artists.length && me.Genre && me.Genre.length) { const g = ((await px.get(`/library/sections/${sec}/genre`)).Directory || []).find(d => d.title === me.Genre[0].tag); if (g) artists = ((await px.get(`/library/sections/${sec}/all?type=8&genre=${g.key}&sort=random&X-Plex-Container-Start=0&X-Plex-Container-Size=8`)).Metadata || []).filter(a => a.ratingKey !== arg); }
if (!artists.length) throw new Error('Plex has no similar artists for this one yet');
const albums = (await Promise.all(artists.slice(0, 6).map(async a => { try { return ((await px.get(`/library/metadata/${a.ratingKey}/children`)).Metadata || []).slice(0, 2); } catch { return []; } }))).flat();
return { title: 'More like ' + (me.title || 'this artist'), sub: artists.map(a => a.title).slice(0, 4).join(', '), tracks: mmPick(await mmTracksOf(albums.map(a => a.ratingKey)), 60) };
}
throw new Error('Unknown mix');
}
async function musicMixes({ sec, listing, v }) {
const [genres, smart] = await Promise.all([mmGenreList(sec), mmApi('/smart').catch(() => [])]);
const tile = (href, title, sub) => `<a class="acard mixtile" href="${href}"><span class="mixart" style="background:linear-gradient(135deg,hsl(${mmHue(title)} 55% 42%),hsl(${mmHue(title) + 40} 55% 26%))"><b>${esc(title)}</b></span><small>${esc(sub)}</small></a>`;
const moods = MOODS.filter(m => genres.some(g => m[2].test(g.title)));
const decades = [1960, 1970, 1980, 1990, 2000, 2010, 2020];
let today = [];
try { const d = new Date(), md = String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); today = (await mmAllAlbums(sec)).filter(a => (a.originallyAvailableAt || '').slice(5) === md && +(a.originallyAvailableAt || '0').slice(0, 4) < d.getFullYear()).sort((a, b) => (a.originallyAvailableAt < b.originallyAvailableAt ? -1 : 1)).slice(0, 12); } catch { /* skip it */ }
const card = (a, sub) => `<a class="acard" href="#/music/album/${a.ratingKey}"><img class="art" loading="lazy" alt="" src="${px.art(a.thumb)}"><b>${esc(a.title)}</b><small>${esc(sub)}</small></a>`;
listing('raw', `<div class="bar" style="justify-content:flex-start;flex-wrap:wrap;margin-top:0"><button class="btn" id="mxr">${icon('shuffle')}Library radio</button><small class="sub">An endless shuffle of your music. It uses the playlists chosen on Featured, if you chose any.</small></div>
<h2>Mixes for you</h2><div class="agrid">${tile('#/music/mix/daily', 'Daily mix', 'Your music, your favorites, a few surprises')}${tile('#/music/mix/mostplayed', 'Most played', 'This month')}${tile('#/music/mix/forgotten', 'Forgotten favorites', 'Not heard in a while')}${tile('#/music/favorites', 'Favorites', 'The songs you hearted')}</div>
${moods.length ? `<h2>Moods</h2><div class="agrid">${moods.map(m => tile('#/music/mix/mood/' + m[0], m[1], 'A mix of ' + m[1].toLowerCase() + ' music')).join('')}</div>` : ''}
${today.length ? `<h2>On this day</h2><p class="sub">Albums released on ${new Date().toLocaleDateString([], { month: 'long', day: 'numeric' })} in past years</p><div class="agrid">${today.map(a => card(a, (a.originallyAvailableAt || '').slice(0, 4) + ' · ' + (a.parentTitle || ''))).join('')}</div>` : ''}
<h2>Decades</h2><div class="seg">${decades.map(d => `<a href="#/music/decade/${d}">${d}s</a>`).join('')}</div>
${genres.length ? `<h2>Genres</h2><div class="seg" style="flex-wrap:wrap">${genres.slice(0, 40).map(g => `<a href="#/music/genre/${esc(g.key)}">${esc(g.title)}</a>`).join('')}</div>` : ''}
<h2>Smart playlists</h2><p class="sub">Rules that pick songs for you (for example: gospel from the 80s you have not played in a year).</p>
<div class="agrid">${smart.map(s => tile('#/music/smart/' + s.id, s.name, 'Smart playlist')).join('')}</div><div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="mxs">${icon('plus')}New smart playlist</button></div>`);
$('#mxr').onclick = () => mmRadioStart(sec);
$('#mxs').onclick = () => mmSmartEditor(sec, null, genres);
}
// ---- mix, genre, decade and smart playlist pages
async function musicMixPage({ sec, v }, id, arg, arg2) {
if (id === 'mood' || id === 'similar') { arg = arg; }
mmLoading(v, 'Mix');
try {
const m = await mmBuildMix(sec, id, id === 'mood' || id === 'similar' ? arg : arg);
if (!location.hash.startsWith('#/music/mix')) return;
mmPage(v, { title: m.title, sub: m.sub, tracks: m.tracks, back: ['#/music/mixes', 'Mixes'], extra: `<button class="btn ghost" id="mpr">${icon('repeat')}New ${esc(id === 'daily' ? 'mix' : 'pick')}</button>` });
v.querySelector('#mpr').onclick = () => musicMixPage({ sec, v }, id, arg);
} catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><h1>Mix</h1><p class="err">${esc(e.message)}</p></div>`; }
}
async function musicGenrePage({ sec, v, card }, key) {
mmLoading(v, 'Genre');
const genres = await mmGenreList(sec), g = genres.find(x => String(x.key) === String(key)); if (!g) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><p class="err">That genre is not in your library.</p></div>`; return; }
const albums = ((await px.get(`/library/sections/${sec}/all?type=9&genre=${encodeURIComponent(key)}&sort=titleSort&X-Plex-Container-Start=0&X-Plex-Container-Size=600`)).Metadata || []);
if (!location.hash.startsWith('#/music/genre')) return; const feat = albums.length ? albums[Math.floor(Math.random() * albums.length)] : null;
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><h1>${esc(g.title)}</h1><p class="sub">${albums.length} albums</p>
<div class="bar"><button class="btn" id="mgs">${icon('shuffle')}Shuffle ${esc(g.title)}</button></div>
${feat ? `<h2>Featured album</h2><div class="agrid"><a class="acard" href="#/music/album/${feat.ratingKey}"><img class="art" alt="" src="${px.art(feat.thumb)}"><b>${esc(feat.title)}</b><small>${esc(feat.parentTitle || '')}</small></a></div>` : ''}
<h2>Albums</h2><div class="agrid">${albums.map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')}</div></div>`;
$('#mgs').onclick = async e => { e.currentTarget.disabled = true; const t = mmPick(await mmTracksOf(mmPick(albums, 10).map(a => a.ratingKey)), 80); e.currentTarget.disabled = false; if (t.length) playQueue(shuffled(t), 0, true); else toast('No songs found'); };
}
async function musicDecadePage({ sec, v, card }, start) {
start = +start; if (!(start >= 1900 && start <= 2090)) return (location.hash = '#/music/mixes');
mmLoading(v, start + 's');
const albums = ((await px.get(`/library/sections/${sec}/all?type=9&year>>=${start}&year<<=${start + 9}&sort=year,titleSort&X-Plex-Container-Start=0&X-Plex-Container-Size=600`)).Metadata || []);
if (!location.hash.startsWith('#/music/decade')) return;
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><h1>${start}s</h1><p class="sub">${albums.length} albums</p>
<div class="bar"><button class="btn" id="mgs">${icon('shuffle')}Shuffle the ${start}s</button></div><div class="agrid">${albums.map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, (a.year || '') + ' · ' + (a.parentTitle || ''))).join('') || '<p class="empty">Nothing from this decade.</p>'}</div></div>`;
$('#mgs').onclick = async e => { e.currentTarget.disabled = true; const t = mmPick(await mmTracksOf(mmPick(albums, 10).map(a => a.ratingKey)), 80); e.currentTarget.disabled = false; if (t.length) playQueue(shuffled(t), 0, true); else toast('No songs found'); };
}
// ---- smart playlists
const SMART_ORDER = { random: 'random', newest: 'addedAt:desc', mostPlayed: 'viewCount:desc', leastRecent: 'lastViewedAt:asc' };
async function mmSmartTracks(sec, r) {
if (r.onlyFavorites) { const f = await mfLoad(true), keys = (r.order === 'random' ? shuffled(f) : f).slice(0, r.limit).map(x => x.key); return mmTracksByKeys(keys); }
const genres = await mmGenreList(sec), ids = r.genres.map(n => (genres.find(g => g.title === n) || {}).key).filter(Boolean);
const q = [`type=10`, `sort=${SMART_ORDER[r.order]}`, 'X-Plex-Container-Start=0', `X-Plex-Container-Size=${r.limit}`];
if (r.yearFrom) q.push(`year>>=${r.yearFrom}`); if (r.yearTo) q.push(`year<<=${r.yearTo}`); if (r.addedDays) q.push(`addedAt>>=${mmNow() - r.addedDays * 86400}`);
if (r.notPlayedDays) q.push(`lastViewedAt<<=${mmNow() - r.notPlayedDays * 86400}`); if (r.minPlays) q.push(`viewCount>>=${r.minPlays}`);
const run = async extra => ((await px.get(`/library/sections/${sec}/all?${[...q, ...extra].join('&')}`)).Metadata || []).map(t => mapTrack(t, {})).filter(Boolean);
if (!ids.length) return run([]);
let l = await run(['album.genre=' + ids.join(',')]); if (!l.length) l = await run(['artist.genre=' + ids.join(',')]); return l;
}
async function musicSmartPage({ sec, v }, id) {
mmLoading(v, 'Smart playlist');
const pl0 = (await mmApi('/smart')).find(s => s.id === id); if (!pl0) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><p class="err">That smart playlist is gone.</p></div>`; return; }
const tracks = await mmSmartTracks(sec, pl0.rules); if (!location.hash.startsWith('#/music/smart')) return;
mmPage(v, { title: pl0.name, sub: tracks.length + ' songs · a smart playlist', tracks, back: ['#/music/mixes', 'Mixes'], extra: `<button class="btn ghost" id="mse">Edit rules</button><button class="btn ghost" id="msd">Delete</button>` });
v.querySelector('#mse').onclick = async () => mmSmartEditor(sec, pl0, await mmGenreList(sec));
v.querySelector('#msd').onclick = () => confirmSheet('Delete this smart playlist?', pl0.name, 'Delete', async () => { try { await mmApi('/smart/' + id, { method: 'DELETE' }); toast('Deleted'); location.hash = '#/music/mixes'; } catch (e) { toast(e.message); } });
}
function mmSmartEditor(sec, sp, genres) {
const r = sp ? sp.rules : { genres: [], yearFrom: 0, yearTo: 0, addedDays: 0, notPlayedDays: 0, minPlays: 0, onlyFavorites: false, order: 'random', limit: 50 }, num = (id, label, val, ph) => `<label for="${id}">${label}</label><input type="number" id="${id}" min="0" value="${val || ''}" placeholder="${ph || 'any'}">`;
const el = openSheet(sp ? 'Edit smart playlist' : 'New smart playlist', 'Songs are picked by these rules each time you open it.', `<form id="msf"><label for="msn">Name</label><input type="text" id="msn" value="${esc(sp ? sp.name : '')}" maxlength="60" required autocomplete="off">
<label class="row" style="cursor:pointer"><span class="grow"><b>Only my favorites</b><small>Ignores the other rules except the order and the number of songs</small></span><input type="checkbox" id="msfv"${r.onlyFavorites ? ' checked' : ''}></label>
${genres.length ? `<label>Genres</label><div class="seg" id="msg" style="flex-wrap:wrap">${genres.slice(0, 40).map(g => `<a href="#" data-g="${esc(g.title)}" class="${r.genres.includes(g.title) ? 'on' : ''}">${esc(g.title)}</a>`).join('')}</div>` : ''}
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1">${num('msy1', 'From year', r.yearFrom, '1980')}</div><div style="flex:1">${num('msy2', 'To year', r.yearTo, '1989')}</div></div>
${num('msa', 'Added in the last (days)', r.addedDays)}${num('msnp', 'Not played in (days)', r.notPlayedDays)}${num('msmp', 'Played at least (times)', r.minPlays)}
<label for="mso">Order</label><select id="mso">${[['random', 'Random'], ['newest', 'Newest added first'], ['mostPlayed', 'Most played first'], ['leastRecent', 'Not played for longest first']].map(([k, l]) => `<option value="${k}"${r.order === k ? ' selected' : ''}>${l}</option>`).join('')}</select>
${num('msl', 'Number of songs', r.limit, '50')}<div class="err" id="mse2"></div>
<div class="bar"><button class="btn ghost" type="button" id="msx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#msx').onclick = closeSheet;
el.querySelectorAll('#msg [data-g]').forEach(a => a.onclick = e => { e.preventDefault(); a.classList.toggle('on'); });
el.querySelector('#msf').onsubmit = async e => {
e.preventDefault(); const g = id => +el.querySelector(id).value || 0;
const rules = { genres: [...el.querySelectorAll('#msg .on')].map(a => a.dataset.g), yearFrom: g('#msy1'), yearTo: g('#msy2'), addedDays: g('#msa'), notPlayedDays: g('#msnp'), minPlays: g('#msmp'), onlyFavorites: el.querySelector('#msfv').checked, order: el.querySelector('#mso').value, limit: g('#msl') || 50 };
try { const saved = await mmApi('/smart', mmOpt('POST', { id: sp && sp.id, name: el.querySelector('#msn').value, rules })); closeSheet(); toast('Saved'); location.hash = '#/music/smart/' + saved.id; if (sp) route(); } catch (x) { el.querySelector('#mse2').textContent = x.message; }
};
}
// ================================================================ Favorites, History and stats
async function musicFavorites({ v }) {
mmLoading(v, 'Favorites');
const list = await mfLoad(true), tracks = await mmTracksByKeys(list.map(x => x.key)); if (!location.hash.startsWith('#/music/favorites')) return;
mmPage(v, { title: 'Favorites', sub: tracks.length + ' songs you hearted', tracks, back: ['#/music/mixes', 'Mixes'], onChange: () => route() });
}
async function musicStats({ v, st }, range = 'month') {
mmLoading(v, 'Your listening');
let s, recent; try { [s, recent] = await Promise.all([mmApi('/stats?range=' + range), mmApi('/history?limit=30')]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Your listening</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/music/stats')) return;
const ranges = [['week', 'Week'], ['month', 'Month'], ['year', 'Year'], ['all', 'All time']], max = Math.max(1, ...s.series.map(x => x.minutes)), top = s.artists[0] ? s.artists[0].plays : 1;
v.innerHTML = `<div class="wrap"><h1>Your listening</h1><div class="seg">${ranges.map(([k, l]) => `<a href="#" data-r="${k}" class="${k === range ? 'on' : ''}">${l}</a>`).join('')}</div>
<div class="statgrid"><div class="stat"><b>${s.plays}</b><small>songs played</small></div><div class="stat"><b>${mmHours(s.minutes)}</b><small>listened</small></div><div class="stat"><b>${s.artistsHeard}</b><small>artists</small></div><div class="stat"><b>${s.streak}</b><small>day streak</small></div></div>
${s.plays ? `<h2>Minutes ${range === 'week' || range === 'month' ? 'per day' : 'per month'}</h2><div class="chart">${s.series.map(x => `<span title="${esc(x.d)}: ${x.minutes} min"><i style="height:${Math.max(2, Math.round(x.minutes / max * 100))}%"></i></span>`).join('')}</div>
<h2>Top artists</h2><div class="list">${s.artists.map(a => `<div class="row"><span class="grow"><b>${esc(a.name || 'Unknown')}</b><div class="statbar"><i style="width:${Math.round(a.plays / top * 100)}%"></i></div></span><small>${a.plays} plays · ${mmHours(a.minutes)}</small></div>`).join('')}</div>
<h2>Top songs</h2><div class="list">${s.songs.map((t, i) => `<button class="row" data-k="${esc(t.key)}"><span class="num">${i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span><small>${t.plays}×</small></button>`).join('')}</div>
${s.albums.length ? `<h2>Top albums</h2><div class="agrid">${s.albums.filter(a => a.key).map(a => `<a class="acard" href="#/music/album/${esc(a.key)}"><img class="art" loading="lazy" alt="" src="${px.art(a.thumb)}"><b>${esc(a.title)}</b><small>${esc(a.artist)} · ${a.plays} plays</small></a>`).join('')}</div>` : ''}` : '<p class="empty">Nothing yet. Songs you play from now on are counted here.</p>'}
<h2>Recently played</h2><div class="list">${recent.map((t, i) => `<button class="row" data-h="${i}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)} · ${esc(new Date(t.t).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }))}</small></span>${icon('play')}</button>`).join('') || '<p class="empty">Nothing played yet.</p>'}</div>
${s.plays || recent.length ? '<div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="mhc">Clear my history</button></div>' : ''}</div>`;
v.querySelectorAll('[data-r]').forEach(a => a.onclick = e => { e.preventDefault(); musicStats({ v, st }, a.dataset.r); });
const play = async keys => { const tr = await mmTracksByKeys(keys); if (tr.length) playQueue(tr, 0); else toast('That song is not in your library any more'); };
v.querySelectorAll('[data-k]').forEach(b => b.onclick = () => play([b.dataset.k]));
v.querySelectorAll('[data-h]').forEach(b => b.onclick = () => play([recent[+b.dataset.h].key]));
if ($('#mhc')) $('#mhc').onclick = () => confirmSheet('Clear your listening history?', 'The stats and the mixes that use it start over.', 'Clear', async () => { try { await mmApi('/history', { method: 'DELETE' }); toast('Cleared'); musicStats({ v, st }, range); } catch (e) { toast(e.message); } });
}
// ================================================================ Family playlists
const famCard = p => `<a class="acard" href="#/music/family/${p.id}"><span class="mixart" style="background:linear-gradient(135deg,hsl(${mmHue(p.name)} 55% 42%),hsl(${mmHue(p.name) + 40} 55% 26%))"><b>${esc(p.name)}</b></span><small>${p.count} songs · ${esc(p.ownerName)}</small></a>`;
async function musicFamilyPage({ v }, id) {
mmLoading(v, 'Family playlist');
let p; try { p = await mmApi('/family/' + id); } catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/playlists">${icon('back')}Playlists</a></div><p class="err">${esc(e.message)}</p></div>`; return; }
const tracks = await mmTracksByKeys(p.tracks.map(t => t.key)); if (!location.hash.startsWith('#/music/family')) return;
const mineOrAdmin = ME && (ME.role === 'admin' || ME.id === p.owner);
mmPage(v, { title: p.name, sub: `${tracks.length} songs · a Family playlist by ${p.ownerName}. Everyone can add and remove songs.`, tracks, back: ['#/music/playlists', 'Playlists'], extra: `${mineOrAdmin ? '<button class="btn ghost" id="mfr">Rename</button><button class="btn danger" id="mfd">Delete</button>' : ''}`, onChange: () => {} });
v.querySelectorAll('.row.it').forEach(r => { const t = tracks[+r.dataset.i], b = document.createElement('button'); b.className = 'ibtn'; b.setAttribute('aria-label', 'Remove from this playlist'); b.innerHTML = icon('trash'); b.onclick = async () => { try { await mmApi(`/family/${id}/tracks/${t.key}`, { method: 'DELETE' }); toast('Removed'); route(); } catch (e) { toast(e.message); } }; r.appendChild(b); });
if ($('#mfr')) $('#mfr').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await mmApi('/family/' + id, mmOpt('POST', { name: n })); route(); } catch (e) { toast(e.message); } }, p.name);
if ($('#mfd')) $('#mfd').onclick = () => confirmSheet('Delete this Family playlist?', 'It disappears for everyone.', 'Delete', async () => { try { await mmApi('/family/' + id, { method: 'DELETE' }); toast('Deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
}
/** "Add to a Family playlist…" from the song menus. */
async function addToFamily(tracks) {
let list = []; try { list = await mmApi('/family'); } catch (e) { return toast(e.message); }
const meta = tracks.map(mmMeta).filter(t => mmPlex(t.key));
const make = () => textSheet('New Family playlist', 'Everyone with Music can add to it', 'Create', async n => { try { await mmApi('/family', mmOpt('POST', { name: n, tracks: meta })); toast('Family playlist created'); if (location.hash.startsWith('#/music/playlists')) route(); } catch (e) { toast(e.message); } });
actionSheet('Add to a Family playlist', meta.length + (meta.length === 1 ? ' song' : ' songs'), [{ label: 'New Family playlist…', icon: 'plus', run: make }, ...list.map(p => ({ label: p.name + ' (' + p.count + ')', icon: 'music', run: async () => { try { const r = await mmApi(`/family/${p.id}/add`, mmOpt('POST', { tracks: meta })); toast(r.added ? 'Added to ' + p.name : 'Already in ' + p.name); } catch (e) { toast(e.message); } } }))]);
}
// ================================================================ Library radio: an endless shuffle that keeps adding songs as it plays
async function mmRadioStart(sec) {
const pls = store.get('music_feat_pls', []); toast('Starting library radio…');
const first = await mmRandomTracks(sec, 30, pls); if (!first.length) return toast('No songs found to play');
playQueue(first, 0); pl.radio = { sec, pls, seen: new Set(first.map(t => t.key)), busy: false };
toast('Library radio is on. It keeps going until you stop it.');
}
/** Called as each song starts: when the queue is nearly used up, add more. */
async function mmRadioTopUp() {
const r = pl.radio; if (!r || r.busy || pl.q.length - pl.i > 4) return; r.busy = true;
try {
let more = (await mmRandomTracks(r.sec, 30, r.pls)).filter(t => !r.seen.has(t.key));
if (!more.length) { r.seen = new Set(pl.q.slice(-6).map(t => t.key)); more = (await mmRandomTracks(r.sec, 30, r.pls)).filter(t => !r.seen.has(t.key)); } // (a small set of playlists has been played through: go round again)
more.forEach(t => r.seen.add(t.key)); if (pl.radio === r) { pl.q.push(...more); if (pl.q.length > 300 && pl.i > 100) { pl.q.splice(0, pl.i - 20); pl.i = 20; } npQueueCount(); saveState(); }
} catch { /* try again at the next song */ } finally { r.busy = false; }
}
// ================================================================ The player takes its colors from the album cover
const mmColors = new Map();
async function mmCoverTheme(t) {
const root = document.documentElement; if (!t || !t.thumb) return root.style.removeProperty('--np-a');
const src = px.art(t.thumb, 100); let c = mmColors.get(src);
if (c === undefined) {
c = await new Promise(ok => { const im = new Image(); im.onerror = () => ok(null);
im.onload = () => { try { const cv = document.createElement('canvas'); cv.width = cv.height = 16; const x = cv.getContext('2d', { willReadFrequently: true }); x.drawImage(im, 0, 0, 16, 16); const d = x.getImageData(0, 0, 16, 16).data; let r = 0, g = 0, b = 0, w = 0;
for (let i = 0; i < d.length; i += 4) { const mx = Math.max(d[i], d[i + 1], d[i + 2]), mn = Math.min(d[i], d[i + 1], d[i + 2]), k = (mx - mn) + 8; r += d[i] * k; g += d[i + 1] * k; b += d[i + 2] * k; w += k; }
r /= w; g /= w; b /= w; const mx = Math.max(r, g, b) / 255, mn = Math.min(r, g, b) / 255, l = (mx + mn) / 2, dl = mx - mn; let h = 0; const s = dl === 0 ? 0 : dl / (1 - Math.abs(2 * l - 1));
if (dl) { if (mx * 255 === r) h = ((g - b) / 255 / dl) % 6; else if (mx * 255 === g) h = (b - r) / 255 / dl + 2; else h = (r - g) / 255 / dl + 4; } h = Math.round((h * 60 + 360) % 360); ok(`hsl(${h} ${Math.round(Math.max(35, Math.min(75, s * 100)))}% 45%)`); } catch { ok(null); } };
im.src = src; });
mmColors.set(src, c); if (mmColors.size > 80) mmColors.delete(mmColors.keys().next().value);
}
if (typeof cur === 'function' && cur() !== t) return;
if (c) root.style.setProperty('--np-a', c); else root.style.removeProperty('--np-a');
}
// ================================================================ Albums saved on this device (they play with no signal)
const mmOff = {
async count(tracks) { try { const c = await caches.open(OFFLINE); let n = 0; for (const t of tracks) if (await c.match(t.src)) n++; return n; } catch { return 0; } },
async save(tracks, progress) {
if (!('caches' in window)) throw new Error('This browser cannot save for offline'); const c = await caches.open(OFFLINE); let n = 0;
for (const t of tracks) { if (!(await c.match(t.src))) { const r = await fetch(t.src, { credentials: 'same-origin' }); if (!r.ok) throw new Error('Could not download "' + t.title + '" (' + r.status + ')'); await c.put(t.src, r); } progress(++n); }
if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {});
},
async remove(tracks) { try { const c = await caches.open(OFFLINE); for (const t of tracks) await c.delete(t.src); } catch { /* nothing saved */ } },
};
/** The "Save offline" button on an album: saves every song, or takes them off the device again. */
async function mmOfflineButton(btn, tracks) {
if (!btn || !tracks.length) return; let busy = false;
const paint = async () => { const n = await mmOff.count(tracks); btn.dataset.n = n; btn.textContent = n === tracks.length ? 'Saved offline ✓' : n ? `Saved ${n} of ${tracks.length}` : 'Save offline'; btn.style.color = n === tracks.length ? 'var(--accent)' : ''; };
await paint();
btn.onclick = async () => {
if (busy) return; busy = true;
try {
if (+btn.dataset.n === tracks.length) { await mmOff.remove(tracks); toast('Removed from this device'); }
else { await mmOff.save(tracks, n => { btn.textContent = `Saving ${n} of ${tracks.length}…`; }); toast('Saved on this device'); }
} catch (e) { toast(e.name === 'QuotaExceededError' ? 'There is not enough room on this device' : e.message); }
busy = false; paint();
};
}
// ================================================================ A verse for the day, under Featured
const VERSES = [['JHN', 3, 16], ['PSA', 23, 1], ['PHP', 4, 13], ['ROM', 8, 28], ['ISA', 41, 10], ['PRO', 3, 5], ['JER', 29, 11], ['MAT', 11, 28], ['PSA', 46, 1], ['ROM', 12, 2], ['JOS', 1, 9], ['2TI', 1, 7], ['PSA', 119, 105], ['HEB', 11, 1], ['1CO', 13, 4], ['EPH', 2, 8], ['PSA', 27, 1], ['MAT', 6, 33], ['GAL', 5, 22], ['1JN', 4, 19], ['PSA', 121, 1], ['ISA', 40, 31], ['LAM', 3, 22], ['PHP', 4, 6], ['COL', 3, 23], ['PSA', 34, 8], ['JAS', 1, 5], ['1PE', 5, 7], ['ZEP', 3, 17], ['PSA', 100, 1]];
async function mmVerse(el) {
if (!el || !allowed('bible') || typeof bbLoad !== 'function') return;
try {
await bbLoad(); const [b, c, v] = VERSES[Math.floor(Date.now() / 864e5) % VERSES.length], text = bbVerseText(b, c, v); if (!text || !el.isConnected) return;
el.innerHTML = `<small class="sub">Verse of the day</small><blockquote class="mmverse">${esc(text)}</blockquote><small>${esc(bbRef(b, c, v))} · <a href="#/bible" style="color:var(--accent)">Open the Bible</a></small>`;
} catch { /* no Bible text: no verse */ }
}
+85 -13
View File
@@ -13,7 +13,7 @@ const px = {
if (!r.ok) throw new Error('Plex error ' + r.status);
return (await r.json()).MediaContainer || {};
},
art: (thumb, w = 300) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio|vbv|store)\//.test(thumb) ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
// All music libraries on the server; the chosen one is remembered per server.
async sections() {
if (px.secs) return px.secs;
@@ -32,7 +32,7 @@ const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor
const mapTrack = (t, mc) => {
const part = t.Media?.[0]?.Part?.[0];
return part && { key: t.ratingKey, title: t.title, artist: t.grandparentTitle || t.originalTitle || mc.title1 || '',
album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0 };
album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0, artistKey: t.grandparentRatingKey, albumKey: t.parentRatingKey };
};
async function viewMusic(args) {
@@ -110,36 +110,52 @@ async function plexPickServer() {
} catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p></div>`; }
}
async function musicRoute([kind = 'albums', key], st) {
async function musicRoute([kind = 'featured', key, key2], st) {
px.serverKey = st.server;
const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0];
const tabs = ['albums', 'artists', 'playlists', 'recent'];
const seg = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
const tabs = ['featured', 'mixes', 'albums', 'artists', 'playlists', 'favorites', 'stats', 'recent'], TAB_NAME = { recent: 'New', stats: 'Stats' };
const seg = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${TAB_NAME[t] || t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
const card = (href, thumb, title, sub, round) => `<a class="acard ${round ? 'round' : ''}" href="${href}" data-q="${esc((title + ' ' + sub).toLowerCase())}">
<img class="art" loading="lazy" alt="" src="${px.art(thumb)}"><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
const listing = (items, html) => {
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}<span id="phu"></span></p>${seg}<input type="search" id="mq" placeholder="Filter" autocomplete="off">
<div class="agrid" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}<span id="phu"></span></p>${seg}${items === 'raw' ? '' : '<input type="search" id="mq" placeholder="Filter" autocomplete="off">'}
<div class="${items === 'raw' ? '' : 'agrid'}" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
plexHomeBadge(st);
const pl2 = $('#plib');
if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music',
run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); };
$('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); };
if ($('#mq')) $('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); };
};
const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
const ctx = { sec, card, listing, st, v };
if (kind === 'featured') return musicFeatured({ sec, card, listing, st });
if (kind === 'mixes') return musicMixes(ctx);
if (kind === 'mix') return musicMixPage(ctx, key, key2, arguments[0][3]);
if (kind === 'genre') return musicGenrePage(ctx, key);
if (kind === 'decade') return musicDecadePage(ctx, key);
if (kind === 'smart') return musicSmartPage(ctx, key);
if (kind === 'family') return musicFamilyPage(ctx, key);
if (kind === 'favorites') return musicFavorites(ctx);
if (kind === 'stats') return musicStats(ctx);
if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
if (kind === 'recent') { const mc = await px.get(`/library/sections/${sec}/recentlyAdded?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=60`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
if (kind === 'artists') { const mc = await px.get(`/library/sections/${sec}/all?type=8${big}`);
return listing(0, (mc.Metadata || []).map(a => card('#/music/artist/' + a.ratingKey, a.thumb, a.title, '', true)).join('')); }
if (kind === 'playlists') { const mc = await px.get('/playlists?playlistType=audio');
return listing(0, (mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('')); }
if (kind === 'playlists') { const [mc, fam] = await Promise.all([px.get('/playlists?playlistType=audio'), mmApi('/family').catch(() => [])]);
listing('raw', `<h2 style="margin-top:0">Your playlists</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('') || '<p class="empty">No playlists yet.</p>'}</div>
<h2>Family playlists</h2><p class="sub">Shared with everyone who has Music. Anyone can add and remove songs.</p><div class="agrid">${fam.map(famCard).join('')}</div>
<div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="fnew">${icon('plus')}New Family playlist</button></div>`);
$('#fnew').onclick = () => textSheet('New Family playlist', 'Everyone with Music can add to it', 'Create', async n => { try { const r = await mmApi('/family', mmOpt('POST', { name: n })); location.hash = '#/music/family/' + r.id; } catch (e) { toast(e.message); } });
return; }
if (kind === 'artist') {
const mc = await px.get(`/library/metadata/${key}/children`);
const [mc, am] = await Promise.all([px.get(`/library/metadata/${key}/children`), px.get(`/library/metadata/${key}`).catch(() => ({}))]), about = ((am.Metadata || [])[0] || {}).summary || '';
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/music/mix/similar/${esc(key)}">${icon('shuffle')}More like this</a></div>
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
return;
}
@@ -154,10 +170,12 @@ async function musicRoute([kind = 'albums', key], st) {
const thumb = info.thumb || info.composite || mc.thumb;
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
${info.summary ? `<details class="mmabout"><summary>About</summary><p>${esc(info.summary).replace(/\n/g, '<br>')}</p></details>` : ''}
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
<button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
v.querySelectorAll('button[data-fi]').forEach(b => b.onclick = () => mfToggle(tracks[+b.dataset.fi])); mfLoad().then(mfPaint); if (album) mmOfflineButton($('#pof'), tracks);
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); });
if (mine) {
@@ -171,3 +189,57 @@ async function musicRoute([kind = 'albums', key], st) {
}
location.hash = '#/music/albums';
}
// ---- Featured: an artist and an album picked at random, either from the whole library or only from the playlists you choose. The pick stays for the day on this
// device (so Music does not change every time you open it); "Pick again" rolls new ones, and "Based on" chooses the playlists.
const featKey = () => 'music_feat:' + px.serverKey, featPls = () => store.get('music_feat_pls', []);
const pick = a => a[Math.floor(Math.random() * a.length)];
/** Two random choices: an artist { id, title, thumb } and an album { id, title, artist, thumb }. */
async function featuredPick(sec, pls) {
if (pls.length) { // only what is in the chosen playlists
const lists = await Promise.all(pls.map(id => px.get(`/playlists/${id}/items`).catch(() => ({ Metadata: [] }))));
const artists = new Map(), albums = new Map();
for (const t of lists.flatMap(l => l.Metadata || [])) {
if (t.grandparentRatingKey) artists.set(t.grandparentRatingKey, { id: t.grandparentRatingKey, title: t.grandparentTitle, thumb: t.grandparentThumb });
if (t.parentRatingKey) albums.set(t.parentRatingKey, { id: t.parentRatingKey, title: t.parentTitle, artist: t.grandparentTitle || '', thumb: t.parentThumb || t.thumb });
}
if (!artists.size && !albums.size) throw new Error('The playlists you chose have no songs in them yet');
return { artist: artists.size ? pick([...artists.values()]) : null, album: albums.size ? pick([...albums.values()]) : null };
}
const one = async type => { // a random item of a type: ask how many there are, then fetch one at a random place
const n = (await px.get(`/library/sections/${sec}/all?type=${type}&X-Plex-Container-Start=0&X-Plex-Container-Size=0`)).totalSize || 0; if (!n) return null;
const m = ((await px.get(`/library/sections/${sec}/all?type=${type}&X-Plex-Container-Start=${Math.floor(Math.random() * n)}&X-Plex-Container-Size=1`)).Metadata || [])[0];
return m ? { id: m.ratingKey, title: m.title, artist: m.parentTitle || '', thumb: m.thumb } : null;
};
const [artist, album] = await Promise.all([one(8), one(9)]); return { artist, album };
}
async function musicFeatured({ sec, card, listing, st }) {
const day = new Date().toDateString(), pls = featPls(); let saved = store.get(featKey(), null);
const fresh = !saved || saved.day !== day || saved.sec !== sec || JSON.stringify(saved.pls) !== JSON.stringify(pls);
let p = fresh ? null : saved.p, err = '';
if (!p) { try { p = await featuredPick(sec, pls); store.set(featKey(), { day, sec, pls, p }); } catch (e) { err = e.message; p = { artist: null, album: null }; } }
const names = pls.length ? (await px.get('/playlists?playlistType=audio').then(m => (m.Metadata || []).filter(x => pls.includes(x.ratingKey)).map(x => x.title)).catch(() => [])) : [];
const big = (kind, o, round) => `<div class="featcard"><a href="#/music/${kind}/${esc(o.id)}"><img class="art ${round ? 'round' : ''}" alt="" src="${px.art(o.thumb, 400)}"></a><div class="featinfo"><small class="sub">Featured ${kind}</small><h2>${esc(o.title)}</h2>${o.artist ? `<p class="sub">${esc(o.artist)}</p>` : ''}
<div class="bar" style="justify-content:flex-start"><button class="btn" data-fp="${kind}">${icon('play')}${kind === 'artist' ? 'Play mix' : 'Play'}</button><a class="btn ghost" href="#/music/${kind}/${esc(o.id)}">Open</a></div></div></div>`;
listing('raw', `<div class="bar" style="justify-content:flex-start;margin:0 0 10px;flex-wrap:wrap"><button class="btn ghost" id="fre">${icon('shuffle')}Pick again</button><button class="btn ghost" id="fpl">Based on: ${pls.length ? esc(names.slice(0, 2).join(', ') || pls.length + ' playlist' + (pls.length > 1 ? 's' : '')) + (names.length > 2 ? ' +' + (names.length - 2) : '') : 'Whole library'} ▾</button></div>
${err ? `<p class="err">${esc(err)}</p>` : ''}<div class="featgrid">${p.artist ? big('artist', p.artist, true) : ''}${p.album ? big('album', p.album) : ''}</div><div id="mverse" class="mmv"></div>`);
const v = $('#view'); mmVerse(document.getElementById('mverse'));
v.querySelectorAll('[data-fp]').forEach(b => b.onclick = async () => {
b.disabled = true;
try {
if (b.dataset.fp === 'album') { const mc = await px.get(`/library/metadata/${p.album.id}/children`), tr = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); if (tr.length) playQueue(tr, 0); }
else { const mc = await px.get(`/library/metadata/${p.artist.id}/allLeaves`), tr = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); if (tr.length) playQueue(shuffled(tr), 0, true); else toast('No songs found for this artist'); }
} catch (e) { toast(e.message); }
b.disabled = false;
});
$('#fre').onclick = () => { store.del(featKey()); route(); };
$('#fpl').onclick = async () => {
let all = []; try { all = ((await px.get('/playlists?playlistType=audio')).Metadata || []).filter(x => !x.smart || true); } catch (e) { return toast(e.message); }
if (!all.length) return toast('Make a playlist first, then choose it here');
const chosen = new Set(pls), el = openSheet('Based on', 'Pick the playlists the featured artist and album come from. None chosen means your whole library.', `<div class="list" style="max-height:50dvh;overflow:auto">${all.map(x => `<label class="row" style="cursor:pointer"><input type="checkbox" data-pl="${esc(x.ratingKey)}"${chosen.has(x.ratingKey) ? ' checked' : ''}><span class="grow"><b>${esc(x.title)}</b><small>${x.leafCount || 0} songs</small></span></label>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="fall" style="flex:1;justify-content:center">Whole library</button><button class="btn" id="fok" style="flex:1;justify-content:center">Use these</button></div>`);
const save = ids => { store.set('music_feat_pls', ids); store.del(featKey()); closeSheet(); route(); };
el.querySelector('#fok').onclick = () => save([...el.querySelectorAll('[data-pl]:checked')].map(c => c.dataset.pl));
el.querySelector('#fall').onclick = () => save([]);
};
}
+110
View File
@@ -0,0 +1,110 @@
'use strict';
// ---- Notes: notes and checklists that are private, shared with the family, or shared with chosen people (see notes.js). Checklist items change one at a time, so two
// people ticking the same list never overwrite each other, and the open list refreshes itself every few seconds.
const nt = { notes: [], people: [], filter: 'all', q: '', timer: null };
const ntApi = (p, o) => jfetch('/api/notes' + p, o);
const ntPost = (p, b) => ntApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
const NT_COLORS = { '': 'var(--panel)', red: '#e5484d', orange: '#f08a24', yellow: '#e5b800', green: '#30a46c', teal: '#12a594', blue: '#3e63dd', purple: '#8e4ec6', pink: '#d6409f' };
const NT_TEMPLATES = [['Grocery list', ['Milk', 'Eggs', 'Bread', 'Fruit', 'Vegetables', 'Coffee']], ['Packing list', ['Clothes', 'Toiletries', 'Chargers', 'Documents', 'Snacks']], ['Weekend to-do', ['Laundry', 'Groceries', 'Clean the kitchen', 'Mow the lawn']]];
const ntWho = n => n.share === 'family' ? 'Family' : n.share === 'me' || !n.share ? 'Just me' : 'Shared with ' + (n.sharedWith.length ? n.sharedWith.map(x => x.name).slice(0, 2).join(', ') + (n.sharedWith.length > 2 ? ' +' + (n.sharedWith.length - 2) : '') : 'no one');
const ntDone = n => n.items.filter(i => i.done).length;
function viewNotes(args = []) {
clearInterval(nt.timer); nt.timer = null;
return args[0] ? ntEditor(args[0]) : ntHome();
}
async function ntLoad() { const r = await ntApi('/'); nt.notes = r.notes; nt.people = r.people; return r; }
// ================================================================ the list of notes
async function ntHome() {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Notes</h1><p class="empty">Loading…</p></div>';
try { await ntLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Notes</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/notes')) return;
const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']];
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Notes</h1><span class="pspace grow"></span><button class="btn" id="ntnew">${icon('plus')}New</button></div>
<input type="search" id="ntq" placeholder="Search notes and lists" value="${esc(nt.q)}" autocomplete="off" aria-label="Search notes" style="width:100%"><div class="seg" id="ntf">${filters.map(([k, l]) => `<a href="#" data-f="${k}" class="${nt.filter === k ? 'on' : ''}">${l}</a>`).join('')}</div><div id="ntgrid" class="ntgrid"></div></div>`;
const draw = () => {
const q = nt.q.toLowerCase(), f = nt.filter;
const rows = nt.notes.filter(n => (f === 'archived' ? n.archived : !n.archived) && (f === 'list' ? n.type === 'list' : f === 'note' ? n.type === 'note' : f === 'mine' ? n.mine : f === 'shared' ? n.share !== 'me' : true)
&& (!q || (n.title + ' ' + n.body + ' ' + n.items.map(i => i.text).join(' ')).toLowerCase().includes(q)));
$('#ntgrid').innerHTML = rows.map(n => `<a class="ntcard" href="#/notes/${n.id}" style="--nc:${NT_COLORS[n.color] || 'var(--line)'}"><div class="nttop"><b>${esc(n.title || (n.type === 'list' ? 'Checklist' : 'Note'))}</b>${n.pinned ? icon('bookmark') : ''}</div>
${n.type === 'list' ? `<div class="ntitems">${n.items.slice(0, 5).map(i => `<span class="${i.done ? 'done' : ''}">${i.done ? '☑' : '☐'} ${esc(i.text)}</span>`).join('')}${n.items.length > 5 ? `<small>+ ${n.items.length - 5} more</small>` : ''}</div><small>${ntDone(n)} of ${n.items.length} done</small>` : `<p>${esc(n.body.slice(0, 180))}</p>`}
<small class="ntmeta">${esc(ntWho(n))}${n.mine ? '' : ' · ' + esc(n.ownerName)} · ${esc(sgTime(n.updated))}</small></a>`).join('') || `<p class="empty">${nt.notes.length ? 'Nothing matches.' : 'No notes yet. Tap New to start a note, a checklist, or a ready-made list.'}</p>`;
};
draw(); let t; $('#ntq').oninput = e => { nt.q = e.target.value.trim(); clearTimeout(t); t = setTimeout(draw, 200); };
$('#ntf').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); nt.filter = a.dataset.f; $('#ntf').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); draw(); });
$('#ntnew').onclick = ntNewSheet;
}
function ntNewSheet() {
const mk = async b => { try { const n = await ntPost('/', b); closeSheet(); location.hash = '#/notes/' + n.id; } catch (e) { toast(e.message); } };
const el = openSheet('New', '', `<div class="list"><button class="row" id="ntn">${icon('doc')}<span class="grow"><b>Note</b><small>Free writing</small></span></button><button class="row" id="ntl">${icon('check')}<span class="grow"><b>Checklist</b><small>Items to tick off</small></span></button></div>
<h2>Ready-made lists</h2><div class="list">${NT_TEMPLATES.map((t, i) => `<button class="row" data-t="${i}">${icon('list')}<span class="grow"><b>${esc(t[0])}</b><small>${esc(t[1].join(', '))}</small></span></button>`).join('')}</div>`);
el.querySelector('#ntn').onclick = () => mk({ type: 'note' }); el.querySelector('#ntl').onclick = () => mk({ type: 'list' });
el.querySelectorAll('[data-t]').forEach(b => b.onclick = () => { const t = NT_TEMPLATES[+b.dataset.t]; mk({ type: 'list', title: t[0], items: t[1], share: 'family' }); });
}
// ================================================================ one note or list
async function ntEditor(id) {
const v = $('#view'); v.innerHTML = '<div class="wrap"><p class="empty">Loading…</p></div>';
let n; try { await ntLoad(); n = nt.notes.find(x => x.id === id); if (!n) throw new Error('That note is not available (it may have been deleted, or is not shared with you).'); } catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a></div><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/notes/')) return;
let saveT, busy = false, dirty = false;
const render = () => {
const list = n.type === 'list';
v.innerHTML = `<div class="wrap ntedit" style="--nc:${NT_COLORS[n.color] || 'var(--line)'}"><div class="ptop"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a><span class="pspace grow"></span><button class="ibtn" id="ntpin" aria-label="${n.pinned ? 'Unpin' : 'Pin'}" title="${n.pinned ? 'Unpin' : 'Pin to the top'}" style="${n.pinned ? 'color:var(--accent)' : ''}">${icon('bookmark')}</button><button class="ibtn" id="ntmore" aria-label="More">${icon('more')}</button></div>
<input id="ntt" class="nttitle" type="text" value="${esc(n.title)}" placeholder="${list ? 'Checklist' : 'Title'}" maxlength="120" autocomplete="off">
<p class="sub" style="margin:4px 0 12px"><a href="#" id="ntsh" style="color:var(--accent)">${esc(ntWho(n))}</a>${n.mine ? '' : ' · by ' + esc(n.ownerName)}${n.updatedByName ? ' · last change by ' + esc(n.updatedByName) + ', ' + esc(sgTime(n.updated)) : ''}</p>
${list ? `<div class="ntlist">${n.items.map(i => `<div class="ntitem ${i.done ? 'done' : ''}" data-i="${i.id}"><input type="checkbox" ${i.done ? 'checked' : ''} aria-label="Done"><span class="nttext" contenteditable="plaintext-only" spellcheck="false">${esc(i.text)}</span>${i.done && i.byName ? `<small>${esc(i.byName)}</small>` : i.addedBy && n.share !== 'me' ? `<small>${esc(i.addedBy)}</small>` : ''}<button class="ibtn" data-x aria-label="Remove">${icon('x')}</button></div>`).join('')}</div>
<form id="ntadd" class="bar" style="margin:8px 0;gap:8px"><input type="text" id="ntnewitem" placeholder="Add an item" maxlength="300" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
<div class="bar" style="justify-content:space-between"><small class="sub">${ntDone(n)} of ${n.items.length} done</small>${ntDone(n) ? '<button class="btn ghost" id="ntclr">Clear the done ones</button>' : ''}</div>`
: `<textarea id="ntb" class="ntbody" placeholder="Write here" maxlength="50000">${esc(n.body)}</textarea>`}</div>`;
wire();
};
const wire = () => {
const tt = $('#ntt'); tt.oninput = () => { dirty = true; queue(); };
if (n.type === 'note') { const b = $('#ntb'); b.oninput = () => { dirty = true; queue(); }; }
$('#ntpin').onclick = () => patch({ pinned: !n.pinned }); $('#ntsh').onclick = e => { e.preventDefault(); ntShareSheet(n, patch); }; $('#ntmore').onclick = () => ntMore(n, patch);
if (n.type === 'list') {
const act = async b => { try { n = await ntPost('/' + n.id + '/items', b); render(); } catch (e) { toast(e.message); } };
v.querySelectorAll('.ntitem').forEach(r => { const iid = r.dataset.i;
r.querySelector('input[type=checkbox]').onchange = e => act({ toggle: iid, done: e.target.checked }); r.querySelector('[data-x]').onclick = () => act({ remove: iid });
const tx = r.querySelector('.nttext'), orig = n.items.find(i => i.id === iid).text; tx.onblur = () => { const t = tx.textContent.trim(); if (t && t !== orig) act({ edit: iid, text: t }); else if (!t) tx.textContent = orig; }; tx.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); tx.blur(); } }; });
$('#ntadd').onsubmit = async e => { e.preventDefault(); const i = $('#ntnewitem'), t = i.value.trim(); if (!t) return; i.value = ''; try { n = await ntPost('/' + n.id + '/items', { text: t }); render(); $('#ntnewitem').focus(); } catch (x) { toast(x.message); i.value = t; } };
if ($('#ntclr')) $('#ntclr').onclick = () => act({ clearDone: true });
}
};
const queue = () => { clearTimeout(saveT); saveT = setTimeout(save, 700); };
async function save() {
if (busy) return queue(); busy = true; const body = { id: n.id, title: $('#ntt') ? $('#ntt').value : n.title, rev: n.rev }; if (n.type === 'note' && $('#ntb')) body.body = $('#ntb').value;
try { n = await ntPost('/', body); dirty = false; }
catch (e) { if (e.status === 409 && e.message) { toast('Someone else changed this just now. Showing their version.'); const j = await fetch('/api/notes').then(r => r.json()).catch(() => null); const fresh = j && j.notes.find(x => x.id === n.id); if (fresh) { n = fresh; dirty = false; render(); } } else toast(e.message); }
busy = false;
}
const patch = async fields => { try { n = await ntPost('/', { id: n.id, ...fields }); if (location.hash.startsWith('#/notes/')) render(); if (fields.archived) { toast('Archived'); location.hash = '#/notes'; } } catch (e) { toast(e.message); } };
render();
// the open note follows what other people do to it
nt.timer = setInterval(async () => {
if (!location.hash.startsWith('#/notes/') || document.hidden || busy || dirty) return;
const a = document.activeElement; if (a && (a.id === 'ntt' || a.id === 'ntb' || a.classList.contains('nttext') || (a.id === 'ntnewitem' && a.value))) return; // (never while someone is typing; an empty add box does not count)
const refocus = !!(a && a.id === 'ntnewitem');
try { const r = await ntApi('/'); const fresh = r.notes.find(x => x.id === n.id); if (!fresh) { clearInterval(nt.timer); toast('This note was deleted'); location.hash = '#/notes'; return; } if (fresh.rev !== n.rev) { n = fresh; render(); if (refocus && $('#ntnewitem')) $('#ntnewitem').focus(); } } catch { /* try again */ }
}, 6000);
addEventListener('hashchange', () => { if (!location.hash.startsWith('#/notes/')) { clearInterval(nt.timer); nt.timer = null; } }, { once: true });
}
function ntShareSheet(n, patch) {
if (!n.mine && ME && ME.role !== 'admin') return toast('Only ' + n.ownerName + ' can change who this is shared with');
const chosen = new Set(Array.isArray(n.share) ? n.share : []);
const el = openSheet('Who can see this?', 'Anyone it is shared with can edit it.', `<div class="list"><button class="row" data-s="me"><span class="grow"><b>Just me</b></span>${n.share === 'me' ? icon('check') : ''}</button><button class="row" data-s="family"><span class="grow"><b>The whole family</b><small>Everyone with Notes</small></span>${n.share === 'family' ? icon('check') : ''}</button></div>
${nt.people.length ? `<h2>Or choose people</h2><div class="list">${nt.people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}"${chosen.has(p.id) ? ' checked' : ''}><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div><div class="bar"><button class="btn" id="ntps" style="width:100%;justify-content:center">Share with these people</button></div>` : ''}`);
el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { closeSheet(); patch({ share: b.dataset.s }); });
const ps = el.querySelector('#ntps'); if (ps) ps.onclick = () => { const ids = [...el.querySelectorAll('[data-p]:checked')].map(c => c.dataset.p); closeSheet(); patch({ share: ids.length ? ids : 'me' }); };
}
function ntMore(n, patch) {
const acts = [{ label: 'Colour', icon: 'cog', run: () => { const el = openSheet('Colour', '', `<div class="ntsw">${Object.entries(NT_COLORS).map(([k, c]) => `<button class="ntsw1" data-c="${k}" style="background:${c}" aria-label="${k || 'none'}">${n.color === k ? '✓' : ''}</button>`).join('')}</div>`); el.querySelectorAll('[data-c]').forEach(b => b.onclick = () => { closeSheet(); patch({ color: b.dataset.c }); }); } }];
if (allowed('chat')) acts.push({ label: 'Share to Chat', icon: 'send', run: () => shareToChat((n.title || 'Note') + (n.type === 'list' ? ': ' + n.items.filter(i => !i.done).map(i => i.text).slice(0, 8).join(', ') : '') + ' ' + location.origin + '/#/notes/' + n.id) });
if (n.type === 'list' && allowed('tasks') && n.items.some(i => !i.done)) acts.push({ label: 'Send the open items to Tasks', icon: 'check', run: async () => { try { for (const i of n.items.filter(x => !x.done)) await jfetch('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ list: 'local:inbox', fields: { title: i.text, notes: 'From ' + (n.title || 'a checklist'), due: '', done: false } }) }); toast('Sent to Tasks'); } catch (e) { toast(e.message); } } });
acts.push({ label: 'Archive', icon: 'files', run: () => patch({ archived: true }) });
if (n.archived) acts[acts.length - 1] = { label: 'Move out of the archive', icon: 'files', run: () => patch({ archived: false }) };
if (n.mine || (ME && ME.role === 'admin')) acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () => confirmSheet('Delete this ' + (n.type === 'list' ? 'checklist' : 'note') + '?', 'It is deleted for everyone it is shared with.', 'Delete', async () => { try { await jfetch('/api/notes/' + n.id, { method: 'DELETE' }); toast('Deleted'); location.hash = '#/notes'; } catch (e) { toast(e.message); } }) });
actionSheet(n.title || 'Note', ntWho(n), acts);
}
+99
View File
@@ -0,0 +1,99 @@
'use strict';
// Office: make, find and open documents, spreadsheets and presentations kept in Seafile (they open in OnlyOffice inside the app).
// Search, a filter by kind, sorting, pinned and recently opened documents, a saved copy of the list that shows at once, and upload by button or by dropping files.
const OFFICE_KINDS = {
doc: { label: 'Documents', one: 'Document', color: '#3d6bf0', exts: ['docx', 'doc', 'odt', 'rtf'] },
sheet: { label: 'Spreadsheets', one: 'Spreadsheet', color: '#2f9e6e', exts: ['xlsx', 'xls', 'ods', 'csv'] },
slide: { label: 'Presentations', one: 'Presentation', color: '#e0742b', exts: ['pptx', 'ppt', 'odp'] },
};
const ofKind = n => Object.keys(OFFICE_KINDS).find(k => OFFICE_KINDS[k].exts.includes(ext(n))) || 'doc';
const ofIsDoc = n => EDIT_EXT.has(ext(n)) && ext(n) !== 'txt';
const ofState = { q: '', kind: 'all', sort: store.get('office_sort', 'mtime'), limit: 40, docs: [], loc: null };
const ofKey = loc => 'oc:' + (ME ? ME.id : '') + ':' + loc.repo + ':' + loc.dir;
const ofPins = () => { const p = store.get('office_pins', []); return Array.isArray(p) ? p : []; };
const ofId = (repo, path) => repo + '|' + path;
const ofPath = f => (f.parent_dir || '/').replace(/\/?$/, '/') + f.name;
function ofOpen(repo, path, name) {
addRecent(repo, path, name);
if (cfg.editor) location.hash = officeHash(repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(repo)}/file${encP(path)}`, '_blank', 'noopener');
}
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>`; }
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>
${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()));
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: '' }))); };
const wrap = $('#ofwrap');
wrap.ondragover = e => { if ([...(e.dataTransfer.types || [])].includes('Files')) { e.preventDefault(); wrap.classList.add('dropt'); } };
wrap.ondragleave = e => { if (!wrap.contains(e.relatedTarget)) wrap.classList.remove('dropt'); };
wrap.ondrop = async e => { e.preventDefault(); wrap.classList.remove('dropt'); const files = (await droppedFiles(e.dataTransfer)).filter(f => ofIsDoc(f.file.name)); if (files.length) ofUpload(files); else toast('Drop Word, Excel or PowerPoint files here'); };
$('#oq').oninput = e => { ofState.q = e.target.value; ofState.limit = 40; ofDraw(); };
$('#osort').value = ofState.sort; $('#osort').onchange = e => { ofState.sort = e.target.value; store.set('office_sort', ofState.sort); ofDraw(); };
ofLoad();
}
async function ofUpload(entries) {
const loc = ofState.loc || await officeLocation(true); if (!loc) return;
await enqueueUploads(loc.repo, loc.dir, entries);
const t = setInterval(() => { if (!upActive()) { clearInterval(t); if (location.hash.startsWith('#/office') && $('#odocs')) ofLoad(true); } }, 1200);
setTimeout(() => clearInterval(t), 10 * 60e3);
}
async function ofLoad(quiet) {
try {
const loc = await officeLocation(false), el = $('#olib'); if (!el) return;
if (!loc) {
el.textContent = 'Choose where your documents are saved in Seafile.'; $('#odocs').innerHTML = '<div class="bar"><button class="btn" id="chl2">Choose location</button></div>';
$('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); return;
}
ofState.loc = loc;
el.innerHTML = `Saved in <b>${esc(locLabel(loc))}</b> · <a href="#" id="chl" style="color:var(--accent)">change</a>`;
$('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); ofState.docs = []; viewOffice(); } }); };
let saved = null; try { saved = JSON.parse(localStorage.getItem(ofKey(loc))); } catch {}
if (!quiet && saved && saved.docs) { ofState.docs = saved.docs; ofDraw(); } else if (!quiet) ofDraw(true);
const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`);
if (!location.hash.startsWith('#/office') || !$('#odocs')) return;
ofState.docs = all.filter(f => f.type === 'file' && ofIsDoc(f.name)).map(f => ({ name: f.name, parent_dir: f.parent_dir || '/', mtime: f.mtime, size: f.size || 0 }));
try { localStorage.setItem(ofKey(loc), JSON.stringify({ t: Date.now(), docs: ofState.docs.slice(0, 400) })); } catch {}
ofDraw();
} catch (e) {
const el = $('#olib'); if (!el) return;
if (ofState.docs.length) { el.innerHTML += ` · <span class="err">${esc(navigator.onLine ? e.message : 'offline')} (showing the saved list)</span>`; ofDraw(); }
else { el.innerHTML = `<span class="err">${esc(e.message)}</span> <a href="#" id="ofretry" style="color:var(--accent)">Try again</a>`; $('#ofretry').onclick = ev => { ev.preventDefault(); ofLoad(); }; }
}
}
function ofDraw(skeleton) {
const box = $('#odocs'), loc = ofState.loc; if (!box || !loc) return;
$('#oflt').hidden = !ofState.docs.length;
const pins = new Set(ofPins()), q = ofState.q.trim().toLowerCase(), counts = { all: ofState.docs.length };
for (const k of Object.keys(OFFICE_KINDS)) counts[k] = ofState.docs.filter(d => ofKind(d.name) === k).length;
$('#okinds').innerHTML = [['all', 'All']].concat(Object.entries(OFFICE_KINDS).map(([k, o]) => [k, o.label])).filter(([k]) => k === 'all' || counts[k]).map(([k, l]) => `<a href="#" role="tab" data-k="${k}" aria-selected="${k === ofState.kind}" class="${k === ofState.kind ? 'on' : ''}">${l}</a>`).join('');
$('#okinds').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); ofState.kind = a.dataset.k; ofState.limit = 40; ofDraw(); });
const cmp = { mtime: (a, b) => b.mtime - a.mtime, name: (a, b) => collator.compare(a.name, b.name), size: (a, b) => b.size - a.size }[ofState.sort] || ((a, b) => b.mtime - a.mtime);
const match = d => (ofState.kind === 'all' || ofKind(d.name) === ofState.kind) && (!q || (d.name + ' ' + d.parent_dir).toLowerCase().includes(q));
const list = ofState.docs.filter(match).sort(cmp), shown = list.slice(0, ofState.limit), pinned = shown.filter(d => pins.has(ofId(loc.repo, ofPath(d))));
const recents = !q && ofState.kind === 'all' ? store.get('recents', []).filter(r => r.repo === loc.repo && ofIsDoc(r.name)).slice(0, 6) : [];
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>` : '')
+ (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>`);
box.querySelectorAll('[data-o]').forEach(b => b.onclick = () => { const d = shown[+b.dataset.o]; if (d) ofOpen(loc.repo, ofPath(d), d.name); });
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const d = shown[+b.dataset.m]; if (d) fileSheet(loc.repo, ofPath(d), d.name, d.parent_dir === '/' ? '/' : d.parent_dir.replace(/\/$/, '')); });
box.querySelectorAll('[data-pin]').forEach(b => b.onclick = () => { const d = shown[+b.dataset.pin]; if (!d) return; const id = ofId(loc.repo, ofPath(d)), cur = ofPins(); store.set('office_pins', cur.includes(id) ? cur.filter(x => x !== id) : [id, ...cur].slice(0, 50)); ofDraw(); });
box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { const r = recents[+b.dataset.r]; if (r) ofOpen(r.repo, r.path, r.name); });
const more = $('#ofmore'); if (more) more.onclick = () => { ofState.limit += 40; ofDraw(); };
}
+16
View File
@@ -0,0 +1,16 @@
'use strict';
// Save podcast episodes on this device, so they play with no signal. They live in the browser's own storage (a cache the service worker answers from),
// so they are not synced between devices, and the browser may clear them if the phone runs low on space.
const OFFLINE = 'hub-offline';
const offUrl = id => '/api/podcast/podcasts/' + encodeURIComponent(id) + '/file';
const offHas = async id => { try { return !!(await (await caches.open(OFFLINE)).match(offUrl(id))); } catch { return false; } };
async function offSave(id, onDone) {
if (!('caches' in window)) { toast('This browser cannot save for offline'); return; }
toast('Saving…');
try {
const r = await fetch(offUrl(id), { credentials: 'same-origin' }); if (!r.ok) throw new Error('Could not download (' + r.status + ')');
await (await caches.open(OFFLINE)).put(offUrl(id), r); toast('Saved on this device'); if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {});
} catch (e) { try { await (await caches.open(OFFLINE)).delete(offUrl(id)); } catch {} toast(e.message || 'Could not save'); }
if (onDone) onDone();
}
async function offRemove(id, onDone) { try { await (await caches.open(OFFLINE)).delete(offUrl(id)); toast('Removed from this device'); } catch {} if (onDone) onDone(); }
+4 -1
View File
@@ -51,11 +51,14 @@ function openPalette() {
let items = [], sel = 0, seq = 0, timer, live = [];
const go = h => () => { location.hash = h; };
const base = () => {
const out = navTabs().map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === id)) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
const out = navTabs().flatMap(t => MERGED[t[0]] ? MERGED[t[0]][1].filter(allowed).map(id => [id, tabLabel(id)]) : [t]).map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === (leafOf(id) || id))) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
if (phone.matches) out.push({ label: 'Edit the bottom bar', hint: 'View', ic: 'menu', run: () => openPinsSheet(), kw: 'menu tabs pins more' });
if (allowed('tasks') && allowed('calendar')) out.push({ label: 'Go to Tasks', hint: 'Planner', ic: 'check', run: go('#/tasks'), kw: 'todo' }, { label: 'Go to Calendar', hint: 'Planner', ic: 'cal', run: go('#/calendar'), kw: 'agenda events' });
if (typeof stItems !== 'undefined' && allowed('store')) for (const i of stItems) { if (i.kind === 'extension' && stInstalled.includes(i.id)) out.push({ label: 'Open ' + i.title, hint: 'Extension', ic: 'cog', run: go('#/store/ext/' + i.id), kw: 'app extension ' + i.title.toLowerCase() }); else if (i.kind === 'link') out.push({ label: 'Open ' + i.title, hint: 'Site', ic: 'link', run: i.embed ? go('#/store/site/' + i.id) : () => window.open(i.url, '_blank', 'noopener,noreferrer'), kw: 'app site ' + i.title.toLowerCase() }); }
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' });
if (allowed('mail') && cfg.mail) out.push({ label: 'New email', hint: 'Create', ic: 'mail', run: () => { location.hash = '#/mail'; setTimeout(() => mailCompose(), 300); }, kw: 'compose write message' });
if (allowed('office')) for (const [k, o] of Object.entries(OFFICE_KINDS)) out.push({ label: 'New ' + o.one.toLowerCase(), hint: 'Create', ic: 'doc', run: () => { location.hash = '#/office'; setTimeout(() => newOfficeDoc(o.exts[0], o.one.toLowerCase()), 250); }, kw: 'office word excel powerpoint ' + o.exts.join(' ') });
if (allowed('links')) out.push({ label: 'New link', hint: 'Create', ic: 'plus', run: () => linkSheet(), kw: 'add bookmark' });
out.push({ label: 'Hide or show the menu', hint: 'View', ic: 'menu', run: () => setNav({ hidden: !navPref().hidden }), kw: 'sidebar' });
out.push({ label: 'Refresh the app', hint: 'App', ic: 'refresh', run: () => location.reload(), kw: 'reload update' });
+34 -18
View File
@@ -3,7 +3,7 @@
const shuffled = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; };
const pl = { q: [], i: -1, a: new Audio(), shuffle: false, repeat: 'off', orig: null, alt: new Set() };
const cur = () => pl.q[pl.i];
function playQueue(q, i = 0, shuffleOn = false) { pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); }
function playQueue(q, i = 0, shuffleOn = false) { pl.radio = null; pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); }
// Shuffle mixes only what is still to come, and turning it off puts the queue back in its original order.
function setShuffle(on) {
if (on === pl.shuffle || !pl.q.length) return;
@@ -22,7 +22,7 @@ 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)).map(t => t.src);
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)
function tcTrim() {
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
@@ -62,10 +62,13 @@ function loadCur(delay = 220) {
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();
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'); } });
tcKick();
const n = pl.q[pl.i + 1]; if (n) new Image().src = px.art(n.thumb, 600);
@@ -106,6 +109,12 @@ function trackSheet(t, extra) {
{ label: 'Add to queue', icon: 'files', run: () => queueTrack(t) },
{ label: 'Play now', icon: 'play', run: () => playQueue([t], 0) },
{ label: 'Add to playlist…', icon: 'plus', run: () => addToPlaylist([t]) },
...(typeof mmPlex === 'function' && mmPlex(t.key) ? [
{ label: mfIs(t) ? 'Remove from Favorites' : 'Add to Favorites', icon: 'heart', run: () => mfToggle(t) },
{ label: 'Add to a Family playlist…', icon: 'plus', run: () => addToFamily([t]) },
...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareToChat('♪ ' + t.title + ' — ' + t.artist + ' ' + location.origin + '/#/music/album/' + t.albumKey) }] : []),
...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []),
] : []),
...(extra || []),
]);
}
@@ -119,9 +128,9 @@ function restorePlayer() {
}
function openQueue() {
const upcoming = pl.q.map((t, i) => ({ t, i }));
const el = openSheet('Up next', pl.q.length + ' tracks', `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
const el = openSheet('Up next', pl.q.length + ' tracks' + (pl.radio ? ' · library radio is on' : ''), `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
${i > 0 ? `<button class="ibtn" data-u="${i}" aria-label="Move up" title="Move up">${icon('arrup')}</button>` : ''}${i < pl.q.length - 1 ? `<button class="ibtn" data-dn="${i}" aria-label="Move down" title="Move down">${icon('arrdn')}</button>` : ''}${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="qc" style="flex:1;justify-content:center">Clear up next</button><button class="btn ghost" id="qsp" style="flex:1;justify-content:center">Save as playlist</button></div>
<div class="bar" style="margin-top:0"><button class="btn ghost" id="qsl" style="flex:1;justify-content:center">${esc(sleepLabel())} timer</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
@@ -129,6 +138,8 @@ function openQueue() {
el.querySelector('#qsl').onclick = () => { closeSheet(); sleepSheet(); };
el.querySelector('#qc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); openQueue(); };
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); closeSheet(); });
const move = (k, d) => { const j = k + d; if (j < 0 || j >= pl.q.length) return; [pl.q[k], pl.q[j]] = [pl.q[j], pl.q[k]]; if (pl.i === k) pl.i = j; else if (pl.i === j) pl.i = k; pl.orig = null; saveState(); tcKick(); updateNow(); openQueue(); };
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => move(+b.dataset.u, -1)); el.querySelectorAll('[data-dn]').forEach(b => b.onclick = () => move(+b.dataset.dn, 1));
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); });
}
function advance(auto) {
@@ -143,7 +154,7 @@ const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cu
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;
const tlSend = (state, key, pos, dur) => fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {});
const tlSend = (state, key, pos, dur) => /^(pod|radio|lv|vbv):/.test(String(key)) ? 0 : fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {});
function tlReport(state) {
const t = cur(); if (!t) return;
if (tlKey && tlKey !== t.key) tlSend('stopped', tlKey, tlPos, tlDur); // the previous song is over
@@ -157,7 +168,8 @@ pl.a.addEventListener('timeupdate', () => {
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); }
const t = cur(), d = pl.a.duration;
if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex
if (t && String(t.key).startsWith('pod:')) { if (!pl.scrobbled && d && pl.a.currentTime > d * 0.9) { pl.scrobbled = true; fetch('/api/podcast/podcastitems/' + encodeURIComponent(t.key.slice(4)) + '/markPlayed').catch(() => {}); } } // a podcast episode counts as heard near its end
else if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); if (typeof musicPlayed === 'function') musicPlayed(t, d); } // counts as played in Plex
if (Date.now() - lastPos > 1000 && 'mediaSession' in navigator && pl.a.duration) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: pl.a.duration, position: pl.a.currentTime, playbackRate: pl.a.playbackRate }); } catch {} }
});
addEventListener('pagehide', saveState);
@@ -178,6 +190,7 @@ function markPlaying() {
const t = cur(); document.querySelectorAll('#view .row[data-i]').forEach(r => r.classList.toggle('playing', !!t && pl.q[+r.dataset.i]?.key === t.key));
}
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;
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)}">
@@ -202,7 +215,7 @@ function renderMini() {
let miniSwiped = false;
function stopPlayer() {
tlStop();
pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {}
pl.radio = null; pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {}
if ('mediaSession' in navigator) { navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = 'none'; }
tcKick(); saveState(); renderMini(); renderNpCard(); closeSheet(); markPlaying(); lyrClose();
}
@@ -212,20 +225,22 @@ function modeUI() {
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); });
}
function syncUI() {
const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
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;
const state = pl.buf && !a.paused ? 'buf' : String(a.paused), ic = state === 'buf' ? icon('spin') : icon(a.paused ? 'play' : 'pause');
const mt = $('#mt'); if (mt && mt.dataset.s !== state) { mt.innerHTML = ic; mt.dataset.s = state; }
const bf = $('#mbuf'); if (bf) { let e = 0; try { for (let i = 0; i < a.buffered.length; i++) if (a.buffered.start(i) <= a.currentTime + 1) e = Math.max(e, a.buffered.end(i)); } catch {} bf.style.width = d ? Math.min(100, (e / d) * 100) + '%' : '0'; }
const tm = $('#mtm'); if (tm) { const v = fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
const tm = $('#mtm'); if (tm) { const v = liveT ? 'LIVE' : fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
const t = cur(), sub = t ? (pl.buf ? (navigator.onLine ? 'Buffering…' : 'Waiting for connection…') : t.artist) : '';
const ms = $('#mo small'); if (ms && ms.textContent !== sub) ms.textContent = sub;
const ns = $('#npsub'); if (ns && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (ns.textContent !== v) ns.textContent = v; }
const nt = $('#npt'); if (nt && nt.dataset.s !== state) { nt.innerHTML = ic; nt.dataset.s = state; }
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = liveT ? 0 : a.currentTime; } if (sk) sk.hidden = liveT;
const c = $('#npc'); if (c) c.textContent = liveT ? '\u25CF LIVE' : fmtDur(a.currentTime * 1000);
const dd = $('#npd'); if (dd) dd.textContent = liveT ? '' : fmtDur(d * 1000);
for (const id of ['nps', 'npr', 'npq', 'npp', 'npn', 'mpv', 'mn', 'mseek']) { const e = $('#' + id); if (e) e.hidden = liveT; } // (a live station has no queue, no skipping and no seeking)
const lt = $('#lyrt'); if (lt && lt.dataset.s !== state) { lt.innerHTML = ic; lt.dataset.s = state; }
const vt = $('#vizt'); if (vt && vt.dataset.s !== state) { vt.innerHTML = ic; vt.dataset.s = state; }
// the Now Playing card on the Music tab
const ct = $('#cnt'); if (ct && ct.dataset.s !== state) { ct.innerHTML = ic; ct.dataset.s = state; }
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
@@ -243,15 +258,15 @@ function renderNpCard() {
el.innerHTML = `<button class="npc-art" id="cnart" aria-label="Open the full player"><img alt="" src="${px.art(t.thumb, 300)}"></button>
<div class="npc-text"><small class="npc-lbl">NOW PLAYING</small><b id="cnti">${esc(t.title)}</b><small id="cnsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
<button class="ibtn big" id="cnt" aria-label="Play or pause"></button><button class="ibtn" id="cnn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="cnr" aria-label="Repeat">${icon('repeat')}</button>
<button class="btn ghost npc-l" id="cnl">Lyrics</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
<button class="btn ghost npc-l" id="cnl">${lyrLabel()}</button><button class="btn ghost npc-l" id="cnv">Visuals</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
const sk = el.querySelector('#cnk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#cnc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen;
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen; el.querySelector('#cnv').onclick = vizOpen;
el.querySelector('#cnp').onclick = prev; el.querySelector('#cnn').onclick = next; el.querySelector('#cnt').onclick = toggle;
el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
modeUI(); syncUI();
}
// The Now Playing sheet stays open while tracks change, so refresh what it shows.
@@ -269,12 +284,13 @@ function openNow() {
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">Lyrics</button><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button><button class="btn ghost" id="npv">Visuals</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
const sk = el.querySelector('#npk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); };
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint);
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
updateNow(); syncUI();
+151
View File
@@ -0,0 +1,151 @@
'use strict';
// ---- Podcast: your shows and episodes from Podgrab, through the hub (the server holds the Podgrab sign-in). Episodes play in the same player as Music.
const pdApi = (p, opts) => jfetch('/api/podcast' + p, opts);
const pdOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
// Podgrab sends its field names with a capital letter (ID, Title, ...); accept either spelling.
const pdF = (o, k) => o == null ? undefined : o[k] !== undefined ? o[k] : o[k[0].toUpperCase() + k.slice(1)] !== undefined ? o[k[0].toUpperCase() + k.slice(1)] : o[k.toUpperCase()];
const pdId = o => pdF(o, 'id');
const pdImg = (kind, o, w) => `/api/podcast/${kind}/${encodeURIComponent(pdId(o))}/image`;
const pdDur = s => { s = Math.round(+s || 0); if (!s) return ''; const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : Math.max(1, m) + ' min'; };
const pdDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; };
const pdTime = d => Math.max(0, Date.parse(d) || 0);
const pdPlain = h => { const d = document.createElement('div'); d.innerHTML = String(h || ''); return (d.textContent || '').trim(); };
const pdTrack = (e, show) => ({ key: 'pod:' + pdId(e), title: pdF(e, 'title') || 'Episode', artist: pdF(show, 'title') || '', album: '', thumb: pdImg('podcasts', show), src: '/api/podcast/podcastitems/' + encodeURIComponent(pdId(e)) + '/file', nocache: true, dur: (+pdF(e, 'duration') || 0) * 1000 });
// Where you got to in each episode is kept on the server (per person), so it follows you between devices.
let pdProg = {}, pdLast = 0, pdQueued = {};
const pdPlaying = () => { const t = cur(); return t && String(t.key).startsWith('pod:') ? t.key.slice(4) : ''; };
const pdLoadProg = async () => { try { for (const [id, p] of Object.entries(await pdApi('/progress') || {})) if (!pdProg[id] || p.t > pdProg[id].t) pdProg[id] = p; } catch {} };
const pdPush = keepalive => {
if (!Object.keys(pdQueued).length) return;
const body = JSON.stringify(pdQueued); pdQueued = {};
fetch('/api/podcast/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive })
.then(r => { if (!r.ok) throw 0; }).catch(() => { pdQueued = { ...JSON.parse(body), ...pdQueued }; });
};
const pdSave = keepalive => {
const id = pdPlaying(), d = pl.a.duration; if (!id || !isFinite(d) || d <= 0) return;
pdProg[id] = pdQueued[id] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; pdPush(keepalive);
};
pl.a.addEventListener('timeupdate', () => { if (Date.now() - pdLast > 8000) { pdLast = Date.now(); pdSave(); } });
pl.a.addEventListener('pause', () => pdSave());
pl.a.addEventListener('ended', () => pdSave());
addEventListener('pagehide', () => pdSave(true));
const pdLeft = e => { const p = pdProg[pdId(e)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; }; // started but not finished
async function viewPodcast(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Podcast</h1><p class="empty">Loading…</p></div>';
try {
if (args[0] === 'show' && args[1]) return await pdShow(args[1]);
return await pdHome();
} catch (e) {
if (!location.hash.startsWith('#/podcast')) return;
v.innerHTML = `<div class="wrap"><h1>Podcast</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="pdretry">Try again</button><a class="btn ghost" href="#/podcast/vbv">Verse By Verse</a>${cfg.podcast ? `<a class="btn ghost" href="${esc(cfg.podcast)}" target="_blank" rel="noopener">Open Podgrab</a>` : ''}</div></div>`;
$('#pdretry').onclick = () => route();
}
}
async function pdHome() {
const list = await pdApi('/podcasts?sort=lastepisode&order=desc'), shows = Array.isArray(list) ? list : (list.podcasts || []);
if (!location.hash.startsWith('#/podcast')) return;
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Podcast</h1><span class="pspace grow"></span><a class="btn ghost" href="#/podcast/vbv">Verse By Verse</a><button class="btn ghost" id="pdtr">${icon('search')}Search what's said</button><button class="btn" id="pdadd">${icon('plus')}Add podcast</button></div>
${shows.length ? `<div class="vgrid">${shows.map(s => `<a class="vcard" href="#/podcast/show/${encodeURIComponent(pdId(s))}">
<span class="vimg" style="aspect-ratio:1/1"><img loading="lazy" alt="" src="${pdImg('podcasts', s)}" onerror="this.remove()"></span>
<b>${esc(pdF(s, 'title'))}</b><small>${esc([pdF(s, 'allEpisodesCount') ? pdF(s, 'allEpisodesCount') + ' episodes' : '', pdF(s, 'isPaused') ? 'paused' : ''].filter(Boolean).join(' · '))}</small></a>`).join('')}</div>`
: '<p class="empty">No podcasts on your list yet. Search for one, or paste its feed address.</p>'}</div>`;
$('#pdtr').onclick = pdTranscriptSearch;
$('#pdadd').onclick = () => {
const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', `<input type="search" id="pdq" placeholder="Search podcasts, or paste https://…/feed.xml" autocomplete="off" autocapitalize="off">
<div id="pdtopics" class="bar" style="flex-wrap:wrap;gap:6px;margin:8px 0 0"></div><div class="err" id="pde"></div><div id="pdres" class="list" style="margin-top:8px;max-height:50vh;overflow:auto"></div>
<p class="sub" style="margin:8px 0 0">Searching uses public podcast directories (Apple's, and Podcast Index when the server has a key), so the words you type are sent to them.</p>`);
const q = el.querySelector('#pdq'), res = el.querySelector('#pdres'), err = el.querySelector('#pde'); let timer, seq = 0, found = [];
const add = async (url, btn) => {
err.textContent = ''; if (btn) btn.disabled = true;
try { await pdApi('/podcasts', pdOpt('POST', { url })); closeSheet(); toast('Added; episodes will appear in a moment'); route(); }
catch (e) { err.textContent = e.status === 502 || e.status === 409 || e.status === 400 ? 'Podgrab could not add that (it may already be there)' : e.message; if (btn) btn.disabled = false; }
};
const show = list => {
found = list; res.innerHTML = found.length ? found.map((f, i) => `<div class="row"><img class="art" alt="" style="width:48px;height:48px;border-radius:8px;flex:none" src="${esc(stOk(f.image))}" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(f.title)}</b><small>${esc(f.author || '')}</small></span><button class="btn" data-i="${i}" style="min-height:36px">Add</button></div>`).join('') : '<p class="empty">Nothing found.</p>';
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b));
};
pdApi('/discover/topics').then(ts => { const box = el.querySelector('#pdtopics'); if (!box || !ts.length) return; box.innerHTML = ts.map(t => `<button class="btn ghost" data-t="${esc(t)}" style="min-height:32px;padding:0 12px">${esc(t)}</button>`).join('');
box.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { const my = ++seq; q.value = ''; err.textContent = ''; res.innerHTML = '<p class="empty">Looking…</p>'; try { const l = await pdApi('/discover?topic=' + encodeURIComponent(b.dataset.t)); if (my === seq) show(l); } catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; } }); }).catch(() => {});
const run = async () => {
const term = q.value.trim(), my = ++seq; err.textContent = '';
if (/^https?:\/\/\S+$/i.test(term)) { res.innerHTML = `<button class="row" id="pdfeed"><span class="grow"><b>Add this feed</b><small style="white-space:normal">${esc(term)}</small></span></button>`; res.querySelector('#pdfeed').onclick = e => add(term, e.currentTarget); return; }
if (term.length < 2) { res.innerHTML = ''; return; }
res.innerHTML = '<p class="empty">Searching…</p>';
try {
const l = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return; show(l);
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus();
};
}
// Search what has been said in every transcript you have: tap a result to play that episode from that moment.
const pdClock = ms => { const s = Math.floor(ms / 1000), h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); };
function pdTranscriptSearch() {
const el = openSheet("Search what's said", 'Finds a word or phrase in every transcript you have', `<input type="search" id="trq" placeholder="A word or phrase" autocomplete="off"><div class="err" id="tre"></div><div id="trres" class="list" style="margin-top:8px;max-height:60vh;overflow:auto"></div>`);
const q = el.querySelector('#trq'), res = el.querySelector('#trres'); let timer, seq = 0, found = [];
const hl = (text, w) => { const re = new RegExp('(' + w.split(/\s+/).filter(x => x.length > 1).map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'gi'); return esc(text).replace(re, '<mark>$1</mark>'); };
const play = (r, h) => {
closeSheet(); const key = r.key, track = { key, title: r.title, artist: r.artist, album: '', thumb: r.thumb, src: r.src, nocache: true };
if (pdPlaying() === key.slice(4) && String(cur().key) === key) { pl.a.currentTime = h.t / 1000; pl.a.play().catch(() => {}); return; }
playQueue([track], 0); pl.a.addEventListener('loadedmetadata', () => { const c = cur(); if (c && c.key === key) pl.a.currentTime = h.t / 1000; }, { once: true });
};
const run = async () => {
const term = q.value.trim(), my = ++seq; if (term.length < 2) { res.innerHTML = ''; return; }
res.innerHTML = '<p class="empty">Searching…</p>';
try {
const out = (await jfetch('/api/transcript/search?q=' + encodeURIComponent(term))).results; if (my !== seq) return; found = out;
res.innerHTML = out.length ? out.map((r, i) => `<div class="pdhit"><div class="row"><span class="grow"><b>${esc(r.title)}</b><small>${esc(r.artist)}${r.n > r.hits.length ? ' · ' + r.n + ' places' : ''}</small></span></div>
${r.hits.map((h, j) => `<button class="row" data-i="${i}" data-j="${j}"><span class="grow"><small style="white-space:normal">${hl(h.text, term)}</small></span><small>${pdClock(h.t)}</small></button>`).join('')}</div>`).join('')
: '<p class="empty">Nothing found. Only episodes that already have a transcript can be searched.</p>';
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => play(found[b.dataset.i], found[b.dataset.i].hits[b.dataset.j]));
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus();
}
async function pdShow(id) {
const [, show, items0] = await Promise.all([pdLoadProg(), pdApi('/podcasts/' + encodeURIComponent(id)), pdApi('/podcasts/' + encodeURIComponent(id) + '/items')]);
if (!location.hash.startsWith('#/podcast')) return;
const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate')));
const paused = !!pdF(show, 'isPaused'), ep = e => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : pdLeft(e) ? pdDur(pdLeft(e).dur - pdLeft(e).pos) + ' left' : ''].filter(Boolean).join(' · '))}</small></span>
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button><button class="ibtn" data-off aria-label="Save for offline" title="Save for offline">${icon('dl')}</button></div>`;
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Back</a></div>
<div style="display:flex;gap:16px;align-items:flex-start"><span class="vimg" style="width:120px;flex:none;aspect-ratio:1/1"><img alt="" src="${pdImg('podcasts', show)}" onerror="this.remove()"></span>
<div style="min-width:0"><h1 style="margin:0">${esc(pdF(show, 'title'))}</h1><p class="sub" style="margin:2px 0 0">${esc(pdF(show, 'author') || '')}</p>
<p class="sub" style="margin:6px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden">${esc(pdPlain(pdF(show, 'summary')))}</p></div></div>
<div class="bar">${items.length ? `<button class="btn" id="pdlatest">${icon('play')}Play latest</button>` : ''}<button class="btn ghost" id="pdmore">${icon('more')}More</button></div>
<h2>Episodes</h2><div class="list">${items.map(ep).join('') || '<p class="empty">No episodes yet. Podgrab may still be fetching the feed.</p>'}</div></div>`;
const byId = new Map(items.map(e => [String(pdId(e)), e]));
const playFrom = e => { // this one (from where you left off), then the newer ones after it
const i = items.indexOf(e), id = String(pdId(e)), p = pdLeft(e);
if (pdPlaying() === id) { if (pl.a.paused) pl.a.play().catch(() => {}); return; }
playQueue(items.slice(0, i + 1).reverse().map(x => pdTrack(x, show)), 0);
if (p) pl.a.addEventListener('loadedmetadata', () => { if (pdPlaying() === id) pl.a.currentTime = p.pos; }, { once: true });
};
document.querySelectorAll('.pdep').forEach(r => {
const e = byId.get(r.dataset.id);
r.querySelector('[data-play]').onclick = () => playFrom(e);
const ob = r.querySelector('[data-off]'), paint = async () => { const on = await offHas(pdId(e)); ob.style.color = on ? 'var(--accent)' : ''; ob.title = ob.ariaLabel = on ? 'Saved on this device (tap to remove)' : 'Save for offline'; return on; };
paint(); ob.onclick = async () => { const id = pdId(e); if (await offHas(id)) offRemove(id, paint); else offSave(id, paint); };
r.querySelector('[data-seen]').onclick = async () => {
try { await pdApi('/podcastitems/' + encodeURIComponent(pdId(e)) + (pdF(e, 'isPlayed') ? '/markUnplayed' : '/markPlayed')); route(); } catch (err) { toast(err.message); }
};
});
if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]);
$('#pdmore').onclick = () => {
const el = openSheet(pdF(show, 'title'), '', `<div class="list"><button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
<button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button>
<button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove from my podcasts</b><small>Only from your list; it is deleted from Podgrab when nobody else follows it</small></span></button></div>`);
const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } };
act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background');
act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused');
el.querySelector('#pdd').onclick = () => { closeSheet(); confirmSheet('Remove from your podcasts?', 'It leaves your list. Its downloaded episodes are only deleted when nobody else follows it.', 'Remove', async () => {
try { await pdApi('/podcasts/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Removed'); location.hash = '#/podcast'; } catch (e) { toast(e.message); } }); };
};
}
+96
View File
@@ -0,0 +1,96 @@
'use strict';
// ---- Radio: The Journey FM on top, your saved stations, and a search of thousands more (Radio Browser). The hub passes the stream on (and reads the song title
// out of it), so stations that only offer plain http still play, and everything plays in the same player as Music.
const rdoApi = (p, opts) => jfetch('/api/radio' + p, opts);
const rdoOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
const rdoArt = (s, song) => '/api/radio/art?s=' + encodeURIComponent(s.id) + (song && song.title && store.get('lookups', true) !== false ? '&artist=' + encodeURIComponent(song.artist || '') + '&title=' + encodeURIComponent(song.title) : '');
const rdoIcon = s => s.favicon ? '/api/radio/icon?u=' + encodeURIComponent(s.favicon) : '';
const rdoTrack = s => ({ key: 'radio:' + s.id, title: s.name, artist: 'Live radio', album: [s.state, s.country].filter(Boolean).join(', '), album0: [s.state, s.country].filter(Boolean).join(', '), thumb: rdoArt(s), src: '/api/radio/play/' + encodeURIComponent(s.id), nocache: true, dur: 0, station: s.name });
const rdoPlaying = () => { const t = cur(); return t && String(t.key).startsWith('radio:') ? t : null; };
const rdoMeta = s => [s.tags && s.tags.split(',').slice(0, 2).join(', '), s.state || s.country, s.codec && (s.codec + (s.bitrate ? ' ' + s.bitrate + 'k' : ''))].filter(Boolean).join(' · ');
const rdoChips = [['Christian', 'christian'], ['Worship', 'worship'], ['Gospel', 'gospel'], ['Talk', 'talk'], ['News', 'news'], ['Classical', 'classical'], ['Jazz', 'jazz'], ['Country', 'country'], ['Oldies', 'oldies']];
let rdoSaved = [], rdoNow = {};
/** While a station plays, ask what song is on every 20 seconds and show it in the player. */
async function rdoPoll() {
const t = rdoPlaying(); if (!t) return;
const id = t.key.slice(6);
try {
const n = await rdoApi('/now/' + encodeURIComponent(id)); if (!rdoPlaying() || rdoPlaying().key !== t.key) return;
rdoNow[id] = n; rdoApplyNow(t, n);
const el = document.getElementById('rdonow'); if (el && el.dataset.id === id) rdoDrawNow(el, n);
const sm = document.querySelector(`.rdorow[data-id="${id}"] small`); if (sm && n.title) sm.textContent = 'Now: ' + n.title + (n.artist ? ' \u2014 ' + n.artist : '');
} catch {}
}
/** What is on now goes into the player (the song as the title, the artist under it, the station as the album), the lock screen, and the lyrics if they are open. */
function rdoApplyNow(t, n) {
const was = t.song ? t.song.title + '|' + t.song.artist : '';
t.song = n.title ? { title: n.title, artist: n.artist || '', startedAt: n.startedAt || 0 } : null;
t.title = n.title || t.station; t.artist = n.title ? (n.artist || t.station) : 'Live radio'; t.album = n.title ? t.station : t.album0;
t.thumb = rdoArt({ id: t.key.slice(6) }, t.song); // the cover of the song that is on, else the station's logo
const now = t.song ? t.song.title + '|' + t.song.artist : '';
if (was === now && t.shown) return;
t.shown = true;
for (const f of ['setMeta', 'renderMini', 'updateNow', 'renderNpCard', 'syncUI']) { try { if (typeof window[f] === 'function') (f === 'setMeta' ? window[f](t) : window[f]()); } catch {} }
if (lyr.open && was !== now) { lyrShell(); lyrReload(); }
if (typeof vizTrackChanged === 'function' && was !== now) vizTrackChanged();
}
setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPlaying()) setTimeout(rdoPoll, 1500); });
function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `<small>Now playing</small><b>${esc(n.title)}</b>${n.artist ? '<span> · ' + esc(n.artist) + '</span>' : ''}` : '<small>Live</small>'; }
async function viewRadio(host) { // host: { el, hash } when shown inside the Store (the station finder); on the Radio tab itself only what was added from the Store is listed
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '<div class="wrap"><h1>Radio</h1><p class="empty">Loading…</p></div>';
let feat, mine, added;
try { ({ featured: feat, mine, added } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Radio</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!here()) return;
rdoSaved = mine; const inStore = !!host, finder = inStore || !allowed('store'), S = { q: '', tag: 'christian', items: [], more: false, busy: false, seq: 0, off: 0 }; // (someone without the Store tab can still find stations here)
const isFeat = id => feat.some(f => f.id === id), isAdded = id => isFeat(id) ? added.includes(id) : rdoSaved.some(x => x.id === id);
const j = feat[0], showJ = !inStore && added.includes(j.id);
const row = s => { const on = isAdded(s.id), label = on ? 'Remove from my stations' : 'Add to Radio';
const act = inStore ? `<button class="btn${on ? ' ghost' : ''}" data-fav style="min-height:34px;flex:none;margin-left:6px" title="${on ? 'Tap to remove it from Radio' : 'Show it in Radio under Listen'}">${on ? 'Added' : 'Add to Radio'}</button>`
: isFeat(s.id) ? '' : `<button class="ibtn" data-fav aria-label="${label}" title="${label}" style="${on ? 'color:var(--danger)' : ''}">${icon('heart')}</button>`;
return `<div class="row rdorow${rdoPlaying() && rdoPlaying().key === 'radio:' + s.id ? ' playing' : ''}" data-id="${esc(s.id)}"><button class="rdoplay" data-play aria-label="Play ${esc(s.name)}"><span class="art">${s.favicon ? `<img alt="" loading="lazy" src="${esc(rdoIcon(s))}" onerror="this.remove()">` : ''}</span>
<span class="grow"><b>${esc(s.name)}</b><small>${esc(rdoMeta(s))}</small></span>${icon('play')}</button>${act}</div>`; };
const empty = !inStore && !showJ && !rdoSaved.length;
const findHtml = finder ? `<h2>Find stations</h2><input type="search" id="rdoq" placeholder="Search stations by name" autocomplete="off" autocapitalize="off" style="width:100%">
<div class="seg" id="rdochips">${rdoChips.map(([l, t]) => `<a href="#" data-t="${t}" class="${t === S.tag ? 'on' : ''}">${l}</a>`).join('')}</div><div id="rdores" class="list"></div><div id="rdomore" class="bar" style="justify-content:center"></div>`
: empty ? '' : `<div class="bar" style="justify-content:flex-start"><a class="btn ghost" href="#/store/radio">${icon('search')}Find more stations in the Store</a></div>`;
v.innerHTML = `<div class="wrap">${host ? '' : '<h1>Radio</h1>'}
${inStore ? `<p class="sub">Add a station and it shows up in Radio under Listen.</p><h2>Featured</h2><div class="list" id="rdofeat"></div>` : showJ ? `<div class="card rdofeat" style="max-width:none"><div class="sub" style="margin:0">Featured</div><h2 style="margin:2px 0 0;font-size:22px;text-transform:none;letter-spacing:0;color:var(--text)">${esc(j.name)}</h2>
<p class="sub" style="margin:2px 0 10px">${esc(rdoMeta(j))}</p><div id="rdonow" data-id="${esc(j.id)}" class="rdonow"><small>Live</small></div>
<div class="bar"><button class="btn" id="rdojp">${icon('play')}Listen live</button><button class="btn ghost" id="rdojl">${icon('lyrics')}Lyrics</button><button class="btn ghost" id="rdojv">Visuals</button><a class="btn ghost" href="${esc(j.homepage)}" target="_blank" rel="noopener">${icon('ext')}Website</a><button class="btn ghost" id="rdojr">Recently played</button><button class="btn ghost" id="rdojx">Remove</button></div></div>` : ''}
${inStore ? '' : '<div id="rdomine"></div>'}${empty ? `<p class="empty">Nothing here yet. ${allowed('store') ? 'Add stations from the Store and they will show up here.' : 'Find a station below and tap the heart.'}</p>${allowed('store') ? `<div class="bar" style="justify-content:center"><a class="btn" href="#/store/radio">${icon('search')}Open the Store</a></div>` : ''}` : ''}
${findHtml}</div>`;
const playStation = s => { const t = rdoTrack(s); playQueue([t], 0); toast('Tuning in to ' + s.name); drawAll(); rdoApi('/now/' + encodeURIComponent(s.id)).then(n => { if (rdoPlaying() && rdoPlaying().key === t.key) { rdoNow[s.id] = n; rdoApplyNow(rdoPlaying(), n); const el = document.getElementById('rdonow'); if (el && el.dataset.id === s.id) rdoDrawNow(el, n); } }).catch(() => {}); setTimeout(rdoPoll, 2500); };
const toggle = async s => {
if (isAdded(s.id)) { await rdoApi('/stations/' + encodeURIComponent(s.id), rdoOpt('DELETE')); if (isFeat(s.id)) added = added.filter(x => x !== s.id); else rdoSaved = rdoSaved.filter(x => x.id !== s.id); }
else { await rdoApi('/stations', rdoOpt('POST', { id: s.id })); if (isFeat(s.id)) added.push(s.id); else rdoSaved.push(s); }
};
const bindRows = (root, byId) => { if (!root) return; root.querySelectorAll('.rdorow').forEach(r => { const s = byId(r.dataset.id); r.querySelector('[data-play]').onclick = () => playStation(s);
const f = r.querySelector('[data-fav]'); if (f) f.onclick = async () => { try { await toggle(s); drawAll(); } catch (e) { toast(e.message); } }; }); };
const drawFeat = () => { const el = $('#rdofeat'); if (!el) return; el.innerHTML = feat.map(row).join(''); bindRows(el, id => feat.find(x => x.id === id)); };
const drawMine = () => { const el = $('#rdomine'); if (!el) return; el.innerHTML = rdoSaved.length ? `<h2>My stations</h2><div class="list">${rdoSaved.map(row).join('')}</div>` : ''; bindRows(el, id => rdoSaved.find(x => x.id === id)); };
const drawRes = () => { const res = $('#rdores'); if (!res) return; res.innerHTML = S.items.length ? S.items.map(row).join('') : S.busy ? '<p class="empty">Searching…</p>' : '<p class="empty">No stations found.</p>'; bindRows(res, id => S.items.find(x => x.id === id));
$('#rdomore').innerHTML = S.more && !S.busy ? '<button class="btn ghost" id="rdomb">Show more</button>' : ''; if ($('#rdomb')) $('#rdomb').onclick = () => run(false); };
const drawAll = () => { drawFeat(); drawMine(); drawRes(); };
const run = async reset => {
const my = ++S.seq; if (reset) { S.off = 0; S.items = []; } S.busy = true; if (reset) drawRes();
try { const r = await rdoApi(`/search?q=${encodeURIComponent(S.q)}&tag=${encodeURIComponent(S.q ? '' : S.tag)}&offset=${S.off}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; S.off += 30; }
catch (e) { if (my === S.seq) { $('#rdores').innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; S.busy = false; return; } }
if (my === S.seq) { S.busy = false; drawRes(); }
};
if (showJ) {
$('#rdojp').onclick = () => playStation(j);
$('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); };
$('#rdojv').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof vizOpen === 'function') vizOpen(); }, 400); };
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty">Loading…</p>'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `<div class="list">${n.recent.map(x => `<div class="row"><span class="grow"><b>${esc(x.title)}</b><small>${esc(x.artist)}</small></span></div>`).join('')}</div>` : '<p class="empty">Nothing to show right now.</p>'; } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; } };
$('#rdojx').onclick = async () => { try { await rdoApi('/stations/' + encodeURIComponent(j.id), rdoOpt('DELETE')); toast('Removed from Radio'); viewRadio(host); } catch (e) { toast(e.message); } };
rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {});
}
if (finder) {
$('#rdochips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.tag = a.dataset.t; S.q = ''; $('#rdoq').value = ''; $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); run(true); });
let t; $('#rdoq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', !S.q && x.dataset.t === S.tag)); run(true); }, 450); };
run(true);
}
drawFeat(); drawMine();
}
+695
View File
@@ -0,0 +1,695 @@
'use strict';
// ---- Books: an EPUB, PDF and DOCX reader. The library, covers, reading position, bookmarks, notes and highlights live on the server (per person), so
// they follow you between devices. Reading uses epub.js, pdf.js and mammoth from /vendor/reader, loaded only when a book is opened or added.
const rdApi = (p, o) => jfetch('/api/read' + p, o);
const rdJson = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
const RD_LIBS = { epub: ['jszip.min.js', 'epub.min.js'], pdf: ['pdf.min.js'], docx: ['mammoth.browser.min.js'], mobi: ['mobi-parser.min.js'] };
const rdKind = ext => ['mobi', 'azw', 'azw3'].includes(ext) ? 'mobi' : ext;
const rdLoaded = {};
async function rdLibs(kind) {
for (const f of RD_LIBS[kind]) {
rdLoaded[f] = rdLoaded[f] || new Promise((ok, no) => { const s = document.createElement('script'); s.src = '/vendor/reader/' + f; s.onload = ok; s.onerror = () => { delete rdLoaded[f]; no(new Error('Could not load the ' + kind.toUpperCase() + ' reader')); }; document.head.appendChild(s); });
await rdLoaded[f];
}
if (kind === 'pdf') pdfjsLib.GlobalWorkerOptions.workerSrc = '/vendor/reader/pdf.worker.min.js';
}
const RD_THEMES = { light: { bg: '#ffffff', fg: '#1b1b1b', link: '#1a5fd0' }, sepia: { bg: '#f4ecd8', fg: '#4b3a2a', link: '#8a4b14' }, dark: { bg: '#15171b', fg: '#d5d8de', link: '#8fb0ff' } };
const RD_FONTS = { '': 'Publisher default', serif: 'Serif', sans: 'Sans-serif', mono: 'Monospace' };
const RD_FAMILY = { serif: 'Georgia, "Times New Roman", serif', sans: '-apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', mono: 'ui-monospace, Menlo, Consolas, monospace' };
const RD_COLORS = { yellow: '#ffd43b', green: '#69db7c', blue: '#74c0fc', pink: '#f783ac' };
const rdDefaults = { theme: 'auto', size: 19, font: '', lh: 1.55, margin: 2, flow: 'paged', zoom: 1 };
const rdPrefs = () => { try { return { ...rdDefaults, ...JSON.parse(localStorage.getItem('rd-prefs') || '{}') }; } catch { return { ...rdDefaults }; } };
const rdSavePrefs = p => { try { localStorage.setItem('rd-prefs', JSON.stringify(p)); } catch {} };
const rdTheme = p => RD_THEMES[p.theme] || (matchMedia('(prefers-color-scheme: dark)').matches ? RD_THEMES.dark : RD_THEMES.light);
const rdUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const rdTitleOf = f => f.replace(/\.[^.]+$/, '').replace(/[_]+/g, ' ');
// ======================================================= Library
async function viewReader(args = []) {
if (args[0] === 'open' && args[1]) return rdOpen(args[1]);
if (args[0] === 'store') return allowed('store') ? location.replace('#/store/books') : rdStore();
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Books</h1><p class="empty">Loading…</p></div>';
try { await rdLibrary(); } catch (e) {
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>`;
$('#rdretry').onclick = () => route();
}
}
async function rdLibrary() {
const books = await rdApi('/list');
if (!location.hash.startsWith('#/read')) return;
let q = '', sort = localStorage.getItem('rd-sort') || 'recent';
const v = $('#view');
const cover = b => `<span class="vimg rdcover" style="aspect-ratio:2/3">${b.cover ? `<img loading="lazy" alt="" src="/api/read/book/${b.id}/cover" onerror="this.remove()">` : ''}<em>${esc(b.title)}</em><span class="rdext">${esc(b.ext)}</span>${b.pct > 0 ? `<u class="rdprog"><s style="width:${Math.round(b.pct * 100)}%"></s></u>` : ''}</span>`;
const card = b => `<a class="vcard" href="#/read/open/${b.id}" data-id="${b.id}">${cover(b)}<b>${esc(b.title)}</b><small>${esc(b.author || b.ext.toUpperCase())}${b.pct > 0 ? ' · ' + Math.round(b.pct * 100) + '%' : ''}</small></a>`;
v.innerHTML = `<div class="wrap" id="rdlib"><div class="ptop"><h1>Books</h1><span class="pspace grow"></span><a class="btn ghost" href="#/read/store">${icon('search')}Book store</a><button class="btn" id="rdadd">${icon('plus')}Add books</button></div>
<input type="file" id="rdfile" accept=".epub,.pdf,.docx,.mobi,.azw,.azw3" multiple hidden>
<div id="rdbody"></div><p class="sub" id="rdhint" style="margin-top:16px">EPUB, PDF, DOCX, MOBI and AZW3 (without DRM). Add files with the button or drop them on this page. Your place, bookmarks and highlights follow you to every device.</p></div>`;
const draw = () => {
const f = q.trim().toLowerCase(), mine = books.filter(b => !f || (b.title + ' ' + b.author).toLowerCase().includes(f));
const sorted = [...mine].sort(sort === 'title' ? (a, b) => a.title.localeCompare(b.title) : sort === 'author' ? (a, b) => (a.author || '~').localeCompare(b.author || '~') || a.title.localeCompare(b.title) : sort === 'added' ? (a, b) => b.added - a.added : (a, b) => (b.read || b.added) - (a.read || a.added));
const cont = !f ? books.filter(b => b.read && b.pct < 0.98).sort((a, b) => b.read - a.read).slice(0, 8) : [];
$('#rdbody').innerHTML = !books.length ? `<p class="empty">No books yet. Add an EPUB, PDF or DOCX file to start reading.</p>` :
`<div class="bar" style="align-items:center"><input type="search" id="rdq" placeholder="Search your books" value="${esc(q)}" style="flex:1 1 200px;width:auto" autocomplete="off">
<select id="rdsort" aria-label="Sort"><option value="recent">Recently read</option><option value="added">Recently added</option><option value="title">Title</option><option value="author">Author</option></select></div>
${cont.length ? `<h2>Continue reading</h2><div class="vgrid">${cont.map(card).join('')}</div>` : ''}
<h2>${f ? 'Results' : 'Library'} <small style="text-transform:none;letter-spacing:0">(${sorted.length})</small></h2>
${sorted.length ? `<div class="vgrid">${sorted.map(card).join('')}</div>` : '<p class="empty">Nothing matches.</p>'}`;
const qi = $('#rdq'); if (qi) { qi.oninput = () => { q = qi.value; const pos = qi.selectionStart; draw(); const n = $('#rdq'); n.focus(); n.setSelectionRange(pos, pos); }; $('#rdsort').value = sort; $('#rdsort').onchange = e => { sort = e.target.value; try { localStorage.setItem('rd-sort', sort); } catch {} draw(); }; }
document.querySelectorAll('#rdbody .vcard').forEach(a => {
const open = e => { e.preventDefault(); rdBookMenu(books.find(b => b.id === a.dataset.id)); };
a.addEventListener('contextmenu', open);
let t; a.addEventListener('touchstart', () => { t = setTimeout(() => { t = 0; a.dataset.lp = '1'; open({ preventDefault() {} }); }, 600); }, { passive: true });
a.addEventListener('touchend', e => { clearTimeout(t); if (a.dataset.lp) { delete a.dataset.lp; e.preventDefault(); } });
a.addEventListener('touchmove', () => clearTimeout(t), { passive: true });
});
};
draw();
const input = $('#rdfile');
$('#rdadd').onclick = () => input.click();
input.onchange = () => { rdAddAll([...input.files]); input.value = ''; };
const wrap = $('#rdlib');
wrap.ondragover = e => { if ([...(e.dataTransfer.types || [])].includes('Files')) { e.preventDefault(); wrap.classList.add('drop'); } };
wrap.ondragleave = () => wrap.classList.remove('drop');
wrap.ondrop = e => { e.preventDefault(); wrap.classList.remove('drop'); rdAddAll([...e.dataTransfer.files]); };
$('#rdhint').insertAdjacentHTML('beforeend', ' Press and hold, or right-click, a book for more.');
}
function rdBookMenu(b) {
if (!b) return;
actionSheet(b.title, b.author || b.ext.toUpperCase(), [
{ icon: 'book', label: 'Open', run: () => { location.hash = '#/read/open/' + b.id; } },
{ icon: 'edit', label: 'Edit title and author', run: () => rdEditBook(b) },
...(allowed('files') ? [{ icon: 'files', label: 'Save to Files', run: () => saveToFiles(b.title, { kind: 'book', id: b.id }) }] : []),
{ icon: 'dl', label: 'Download', run: () => { const a = document.createElement('a'); a.href = `/api/read/book/${b.id}/file`; a.download = b.title + '.' + b.ext; document.body.appendChild(a); a.click(); a.remove(); } },
{ icon: 'trash', label: 'Delete from library', danger: true, run: () => confirmSheet('Delete this book?', `"${b.title}" and your bookmarks, notes and highlights in it are removed.`, 'Delete', async () => {
try { await rdApi('/book/' + b.id, { method: 'DELETE' }); toast('Deleted'); route(); } catch (e) { toast(e.message); } }) },
]);
}
function rdEditBook(b) {
const el = openSheet('Edit book', '', `<form><label>Title</label><input id="rdt" value="${esc(b.title)}" maxlength="200"><label>Author</label><input id="rda" value="${esc(b.author || '')}" maxlength="200">
<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">Save</button></div></form>`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('form').onsubmit = async e => { e.preventDefault(); try { await rdApi('/book/' + b.id, rdJson('PATCH', { title: el.querySelector('#rdt').value, author: el.querySelector('#rda').value })); closeSheet(); route(); } catch (err) { toast(err.message); } };
}
// ---- Adding books: the file goes up as it is; the browser reads the title, author and cover out of it afterwards.
async function rdAddAll(files) {
files = files.filter(f => /\.(epub|pdf|docx|mobi|azw3?)$/i.test(f.name));
if (!files.length) return toast('Choose EPUB, PDF, DOCX, MOBI or AZW3 files');
let added = 0, failed = 0, why = '';
for (const [i, f] of files.entries()) {
toast(`Adding ${i + 1} of ${files.length}: ${f.name}`);
try { if (!(await rdAddOne(f)).duplicate) added++; } catch (e) { failed++; why = f.name + ': ' + e.message; toast(why); await new Promise(r => setTimeout(r, 1800)); }
}
toast(added ? `Added ${added} book${added > 1 ? 's' : ''}${failed ? ', ' + failed + ' failed' : ''}` : failed ? (files.length === 1 ? why : 'Nothing was added') : 'Already in your library');
if (location.hash.startsWith('#/read') && !location.hash.startsWith('#/read/open')) route();
}
async function rdAddOne(file) {
const buf = await file.arrayBuffer(), ext = file.name.split('.').pop().toLowerCase();
const book = await rdApi('/upload?name=' + encodeURIComponent(file.name), { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: buf });
if (book.duplicate) return book;
try {
const meta = await rdMeta(ext, buf);
if (meta.title || meta.author) await rdApi('/book/' + book.id, rdJson('PATCH', { title: meta.title || undefined, author: meta.author || undefined }));
if (meta.cover) await rdApi('/book/' + book.id + '/cover', { method: 'PUT', headers: { 'content-type': meta.cover.type }, body: meta.cover });
} catch { /* the book is added anyway, with its file name as the title */ }
return book;
}
async function rdMeta(ext, buf) {
if (ext === 'epub') {
await rdLibs('epub'); const b = ePub(buf.slice(0)); const out = {};
try {
const md = await b.loaded.metadata; out.title = md.title; out.author = md.creator;
const cu = await b.coverUrl(); if (cu) { const blob = await (await fetch(cu)).blob(); if (/^image\/(jpeg|png|gif|webp)$/.test(blob.type)) out.cover = blob; }
} finally { try { b.destroy(); } catch {} }
return out;
}
if (ext === 'pdf') {
await rdLibs('pdf'); const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(buf.slice(0)) }).promise, out = {};
try {
const info = (await pdf.getMetadata().catch(() => ({}))).info || {}; out.title = info.Title; out.author = info.Author;
const page = await pdf.getPage(1), vp0 = page.getViewport({ scale: 1 }), vp = page.getViewport({ scale: 360 / vp0.width }), c = document.createElement('canvas');
c.width = vp.width; c.height = vp.height; await page.render({ canvasContext: c.getContext('2d'), viewport: vp, background: '#fff' }).promise;
out.cover = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.8));
} finally { pdf.destroy(); }
return out;
}
if (rdKind(ext) === 'mobi') {
await rdLibs('mobi'); const m = await rdMobiOpen(new Uint8Array(buf.slice(0))), out = {};
try { const md = m.getMetadata(); out.title = md.title; out.author = (md.author || []).join(', '); const cu = m.getCoverImage(); if (cu) { const blob = await (await fetch(cu)).blob(); if (/^image\/(jpeg|png|gif|webp)$/.test(blob.type)) out.cover = blob; } } finally { try { m.destroy(); } catch {} }
return out;
}
return {};
}
/** MOBI 6 and KF8 (AZW3) need different readers: try the newer format first, since the older reader can "open" a KF8 file and show nonsense. */
async function rdMobiOpen(u8) {
try { const k = await MobiParser.initKf8File(u8); if (k.getSpine().length) return k; try { k.destroy(); } catch {} } catch {}
return MobiParser.initMobiFile(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>';
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;
const key = b => (b.title + '|' + (b.author || '')).toLowerCase().replace(/\s+/g, ' ').trim(), have = new Map(mine.map(b => [key(b), b.id]));
const S = { q: '', source: 'all', genre: '', page: 1, items: [], more: false, seq: 0 };
v.innerHTML = `<div class="wrap" id="rdst"><div class="ptop">${host ? '' : `<a class="btn ghost" href="#/read">${icon('back')}Books</a><h1 style="margin:0">Book store</h1>`}<span class="pspace grow"></span>${ME && ME.role === 'admin' ? `<button class="btn ghost" id="rdcat">${icon('cog')}Catalogues</button>` : ''}</div>
<p class="sub">Free books without DRM, from public sources. Search them all at once, then add a book to your library in one tap.</p>
<input type="search" id="rdsq" placeholder="Search titles and authors" autocomplete="off" autocapitalize="off" style="width:100%">
<div class="seg" id="rdchips"></div><div class="seg" id="rdgenres" aria-label="Genres"></div><div id="rdnote"></div><div id="rdres"></div><div id="rdmore" class="bar" style="justify-content:center"></div></div>`;
const gchips = () => { const el = $('#rdgenres'); if (!el) return; el.innerHTML = [{ id: '', label: 'All genres' }, ...genreList].map(g => `<a href="#" data-g="${esc(g.id)}" class="${S.genre === g.id ? 'on' : ''}">${esc(g.label)}</a>`).join(''); el.querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.genre = a.dataset.g; gchips(); run(true); }); };
const chips = () => { $('#rdchips').innerHTML = [{ id: 'all', name: 'All' }, ...sources].map(x => `<a href="#" data-s="${esc(x.id)}" class="${S.source === x.id ? 'on' : ''}">${esc(x.name)}</a>`).join(''); $('#rdchips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.source = a.dataset.s; chips(); run(true); }); };
const draw = () => {
const res = $('#rdres'); if (!res) return;
res.innerHTML = S.items.length ? `<div class="vgrid">${S.items.map((b, i) => `<a class="vcard" href="#" data-i="${i}"><span class="vimg rdcover" style="aspect-ratio:2/3">${b.cover ? `<img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(b.cover)}" onerror="this.remove()">` : ''}<em>${esc(b.title)}</em><span class="rdext">${esc(b.format)}</span>${have.has(key(b)) ? '<span class="rdhave">In library</span>' : ''}</span><b>${esc(b.title)}</b><small>${esc(b.author || '')}${b.author ? ' · ' : ''}${esc(b.sourceName)}</small></a>`).join('')}</div>` : (S.busy ? '' : `<p class="empty">${S.q ? 'Nothing found.' : 'Search for a book or an author.'}</p>`);
res.querySelectorAll('.vcard').forEach(a => a.onclick = e => { e.preventDefault(); detail(S.items[a.dataset.i]); });
$('#rdmore').innerHTML = S.more && !S.busy ? '<button class="btn ghost" id="rdmb">Show more</button>' : S.busy ? '<span class="sub">Searching…</span>' : '';
if ($('#rdmb')) $('#rdmb').onclick = () => run(false);
};
const run = async reset => {
const my = ++S.seq; if (reset) { S.page = 1; S.items = []; } else S.page++;
S.busy = true; $('#rdnote').innerHTML = ''; draw();
try {
const r = await rdApi(`/store/search?q=${encodeURIComponent(S.q)}&source=${encodeURIComponent(S.source)}&genre=${encodeURIComponent(S.genre)}&page=${S.page}`); if (my !== S.seq) return;
S.items = S.items.concat(r.items); S.more = r.more;
const errs = Object.values(r.errors || {}).map(esc);
$('#rdnote').innerHTML = errs.length ? `<p class="sub" style="color:var(--danger)">${errs.join('<br>')}</p>` : '';
} catch (e) { if (my === S.seq) $('#rdnote').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
if (my === S.seq) { S.busy = false; draw(); }
};
const detail = b => {
const el = openSheet(b.title, b.author || '', `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg rdcover" style="width:96px;flex:none;aspect-ratio:2/3">${b.cover ? `<img alt="" referrerpolicy="no-referrer" src="${esc(b.cover)}" onerror="this.remove()">` : ''}<em>${esc(b.title)}</em></span>
<div style="min-width:0"><p class="sub" style="margin:0">${esc(b.sourceName)} · ${esc(b.format.toUpperCase())}</p>${b.summary ? `<p style="margin:8px 0 0;white-space:normal">${esc(b.summary)}</p>` : ''}</div></div>
<div class="err" id="rdae"></div><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" id="rdaa2" style="flex:1;justify-content:center">${have.has(key(b)) ? 'Open' : icon('plus') + 'Add to library'}</button></div>`);
el.querySelector('#sx').onclick = closeSheet;
const btn = el.querySelector('#rdaa2');
btn.onclick = async () => {
if (have.has(key(b))) { closeSheet(); location.hash = '#/read/open/' + have.get(key(b)); return; }
btn.disabled = true; btn.textContent = 'Adding…'; el.querySelector('#rdae').textContent = '';
try {
const r = await rdApi('/store/add', rdJson('POST', { source: b.source, ref: b.ref, title: b.title, author: b.author, cover: b.coverUrl, format: b.format }));
have.set(key(b), r.id); draw(); btn.disabled = false; btn.textContent = 'Open'; toast(r.duplicate ? 'Already in your library' : 'Added to your library');
} catch (e) { el.querySelector('#rdae').textContent = e.message; btn.disabled = false; btn.innerHTML = icon('plus') + 'Try again'; }
};
};
const catalogues = () => {
const el = openSheet('Catalogues', 'Anything that speaks OPDS: Calibre-Web, Kavita, Komga, Standard Ebooks and others', '<div id="rdcl"></div>');
const list = async () => { sources = await rdApi('/store/sources'); chips(); const sw = sources.find(x => x.id === 'smashwords'), acct = sw && sw.account; el.querySelector('#rdcl').innerHTML = `${sw ? `<div class="list"><div class="row"><span class="grow"><b>Smashwords account</b><small style="white-space:normal">${acct && acct.signedIn ? 'Signed in as ' + esc(acct.email) + '. Books are downloaded with this account.' : 'Free to make. Smashwords only lets signed-in members download, even free books. The sign-in is kept encrypted on this server.'}</small></span>${acct && acct.signedIn ? '<button class="btn danger" id="rdswout" style="min-height:36px">Remove</button>' : ''}</div></div>
${acct && acct.signedIn ? '' : '<form id="rdswf" style="margin:8px 0 16px"><input id="rdswe" type="email" placeholder="Smashwords email" autocomplete="off" autocapitalize="off" required><input id="rdswp" type="password" placeholder="Smashwords password" autocomplete="new-password" required style="margin-top:8px"><div class="err" id="rdswerr"></div><div class="bar"><button class="btn" style="flex:1;justify-content:center">Sign in to Smashwords</button></div></form>'}` : ''}<div class="list">${sources.map(x => `<div class="row"><span class="grow"><b>${esc(x.name)}</b><small>${x.builtin ? 'Built in' : 'Added by you'}</small></span>${x.builtin ? '' : `<button class="btn danger" data-d="${esc(x.id)}" style="min-height:36px">Remove</button>`}</div>`).join('')}</div>
<form id="rdcf"><label>Add a catalogue</label><input id="rdcn" placeholder="Name (for example My Calibre-Web)" maxlength="60" required><input id="rdcu" type="url" placeholder="Address, for example https://books.example.com/opds" required style="margin-top:8px">
<input id="rdcuser" placeholder="Username (if it needs one)" autocomplete="off" style="margin-top:8px"><input id="rdcp" type="password" placeholder="Password" autocomplete="new-password" style="margin-top:8px"><div class="err" id="rdce"></div>
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Add catalogue</button></div></form>`;
if (el.querySelector('#rdswout')) el.querySelector('#rdswout').onclick = async () => { try { await rdApi('/store/smashwords', { method: 'DELETE' }); toast('Smashwords sign-in removed'); await list(); } catch (e) { toast(e.message); } };
if (el.querySelector('#rdswf')) el.querySelector('#rdswf').onsubmit = async e => { e.preventDefault(); const err = el.querySelector('#rdswerr'), btn = e.submitter; err.textContent = ''; if (btn) { btn.disabled = true; btn.textContent = 'Signing in…'; }
try { await rdApi('/store/smashwords', rdJson('POST', { email: el.querySelector('#rdswe').value, pass: el.querySelector('#rdswp').value })); toast('Signed in to Smashwords'); await list(); } catch (x) { err.textContent = x.message; if (btn) { btn.disabled = false; btn.textContent = 'Sign in to Smashwords'; } } };
el.querySelectorAll('[data-d]').forEach(b => b.onclick = async () => { try { await rdApi('/store/sources/' + b.dataset.d, { method: 'DELETE' }); if (S.source === b.dataset.d) S.source = 'all'; await list(); } catch (e) { toast(e.message); } });
el.querySelector('#rdcf').onsubmit = async e => { e.preventDefault(); const err = el.querySelector('#rdce'), btn = e.submitter; err.textContent = ''; if (btn) btn.disabled = true;
try { await rdApi('/store/sources', rdJson('POST', { name: el.querySelector('#rdcn').value, url: el.querySelector('#rdcu').value, user: el.querySelector('#rdcuser').value, pass: el.querySelector('#rdcp').value })); toast('Catalogue added'); await list(); } catch (x) { err.textContent = x.message; if (btn) btn.disabled = false; } };
};
list();
};
chips(); gchips();
let t; $('#rdsq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); run(true); }, 450); };
$('#rdsq').onkeydown = e => { if (e.key === 'Enter') { clearTimeout(t); S.q = e.target.value.trim(); run(true); } };
if ($('#rdcat')) $('#rdcat').onclick = catalogues;
run(true);
}
// ======================================================= Reader
let rdCur = null; // the open book's reader, so leaving the page can close it
async function rdOpen(id) {
const v = $('#view');
document.body.classList.add('editing');
v.innerHTML = '<div class="rd"><p class="empty" style="margin:auto">Opening…</p></div>';
let book, notes;
try {
const list = await rdApi('/list'); book = list.find(b => b.id === id); if (!book) throw new Error('That book is no longer in your library');
notes = await rdApi('/book/' + id + '/notes');
} catch (e) { if (!location.hash.startsWith('#/read')) return; document.body.classList.remove('editing'); v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/read/open')) return;
if (typeof actLog === 'function') actLog('read', book.title || 'A book');
new RdReader(book, notes).start();
}
class RdReader {
constructor(book, notes) {
this.book = book; this.notes = notes || []; this.P = rdPrefs(); this.loc = book.loc; this.pct = book.pct || 0; this.bars = true; this.q = [];
rdCur?.destroy(); rdCur = this;
}
$(s) { return this.root.querySelector(s); }
async start() {
const b = this.book, v = $('#view');
v.innerHTML = `<div class="rd" id="rdroot"><header class="rdbar"><a class="ibtn" href="#/read" aria-label="Back to library" title="Back to library">${icon('back')}</a><span class="rdtitle grow"><b>${esc(b.title)}</b><small>${esc(b.author || '')}</small></span>
<button class="ibtn" id="rdtoc" aria-label="Contents" title="Contents">${icon('list')}</button><button class="ibtn" id="rdsrch" aria-label="Search in book" title="Search in book">${icon('search')}</button>
<button class="ibtn" id="rdbm" aria-label="Bookmark" title="Bookmark this place">${icon('bookmark')}</button><button class="ibtn" id="rdnotes" aria-label="Bookmarks and notes" title="Bookmarks, notes and highlights">${icon('edit')}</button>
<button class="ibtn" id="rdaa" aria-label="Appearance" title="Appearance">${icon('font')}</button></header>
<div class="rdmain"><div id="rdstage" class="rdstage ${b.ext}"></div><button class="rdzone l" id="rdprev" aria-label="Previous page"></button><button class="rdzone r" id="rdnext" aria-label="Next page"></button><div class="rdsel" id="rdsel" hidden></div><p class="rdload" id="rdload">Opening…</p></div>
<footer class="rdfoot"><span class="rdlabel" id="rdlabel"></span><input type="range" id="rdseek" min="0" max="1000" value="${Math.round(this.pct * 1000)}" aria-label="Position in book"><span class="rdpct" id="rdpct">${Math.round(this.pct * 100)}%</span></footer></div>`;
this.root = $('#rdroot'); this.applyChrome();
this.hookUi();
try {
await rdLibs(rdKind(b.ext));
this.A = b.ext === 'epub' ? new RdEpub(this) : b.ext === 'pdf' ? new RdPdf(this) : rdKind(b.ext) === 'mobi' ? new RdMobi(this) : new RdDocx(this);
await this.A.open(this.$('#rdstage'), this.loc);
this.$('#rdload').remove();
} catch (e) { if (this.dead) return; this.$('#rdload').innerHTML = `<span class="err">${esc(e.message || 'This book could not be opened')}</span><br><a class="btn ghost" href="#/read" style="margin-top:12px">Back to library</a>`; }
}
destroy() { if (this.dead) return; this.dead = true; this.save(true); try { this.A?.destroy(); } catch {} removeEventListener('keydown', this.keys, true); removeEventListener('pagehide', this.hide); removeEventListener('resize', this.onResize); if (rdCur === this) rdCur = null; }
applyChrome() { const t = rdTheme(this.P); this.root.style.setProperty('--rd-bg', t.bg); this.root.style.setProperty('--rd-fg', t.fg); this.root.style.setProperty('--rd-link', t.link); this.root.classList.toggle('dark', t === RD_THEMES.dark); this.root.classList.toggle('hidebars', !this.bars); }
hookUi() {
const A = () => this.A;
this.$('#rdprev').onclick = () => A()?.prev(); this.$('#rdnext').onclick = () => A()?.next();
this.$('#rdseek').oninput = e => { this.$('#rdpct').textContent = Math.round(e.target.value / 10) + '%'; clearTimeout(this.seekT); this.seekT = setTimeout(() => A()?.seek(e.target.value / 1000), 250); };
this.$('#rdtoc').onclick = () => this.showToc(); this.$('#rdsrch').onclick = () => this.showSearch(); this.$('#rdnotes').onclick = () => this.showNotes(); this.$('#rdaa').onclick = () => this.showAppearance();
this.$('#rdbm').onclick = () => this.toggleBookmark();
this.keys = e => {
if ((e.target.closest && e.target.closest('input,textarea,select,#sheet')) || document.getElementById('sheet')) return;
if (e.key === 'ArrowRight' || e.key === 'PageDown' || (e.key === ' ' && !e.shiftKey && this.book.ext !== 'pdf' && this.book.ext !== 'docx')) { A()?.next(); e.preventDefault(); }
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { A()?.prev(); e.preventDefault(); }
else if (e.key === 'Escape') location.hash = '#/read';
else if (e.key === 't') this.showToc(); else if (e.key === 'b') this.toggleBookmark(); else if (e.key === '/' ) { e.preventDefault(); this.showSearch(); }
};
addEventListener('keydown', this.keys, true);
this.hide = () => this.save(true); addEventListener('pagehide', this.hide);
this.onResize = () => this.A?.resize?.(); addEventListener('resize', this.onResize);
document.addEventListener('mousedown', e => { if (!e.target.closest('#rdsel')) this.hideSel(); }, { once: false, capture: true });
}
toggleBars() { this.bars = !this.bars; this.root.classList.toggle('hidebars', !this.bars); setTimeout(() => this.A?.resize?.(), 60); }
// ---- where you are: reported by the format reader, saved to the server a moment later
relocated({ loc, pct, label }) {
this.loc = loc; this.pct = Math.min(1, Math.max(0, pct || 0)); this.label = label || '';
if (this.root.isConnected) { this.$('#rdseek').value = Math.round(this.pct * 1000); this.$('#rdpct').textContent = Math.round(this.pct * 100) + '%'; this.$('#rdlabel').textContent = this.label; }
clearTimeout(this.saveT); this.saveT = setTimeout(() => this.save(), 1500);
}
save(keepalive) {
clearTimeout(this.saveT); if (!this.loc || this.savedLoc === this.loc + this.pct) return; this.savedLoc = this.loc + this.pct;
fetch(`/api/read/book/${this.book.id}/pos`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ loc: this.loc, pct: this.pct, t: Date.now() }), keepalive: !!keepalive }).catch(() => { this.savedLoc = ''; });
}
// ---- bookmarks, notes and highlights
async pushNote(n) {
n.t = Date.now(); const i = this.notes.findIndex(x => x.id === n.id); if (i >= 0) this.notes[i] = n; else this.notes.push(n);
try { this.notes = await rdApi(`/book/${this.book.id}/notes`, rdJson('POST', [n])); } catch (e) { toast('Could not save: ' + e.message); }
}
isMarked() { return this.notes.find(n => n.type === 'mark' && !n.del && this.A?.sameSpot?.(n)); }
async toggleBookmark() {
if (!this.A) return;
const have = this.isMarked();
if (have) { await this.pushNote({ ...have, del: true }); this.notes = this.notes.filter(n => !n.del); toast('Bookmark removed'); }
else { const here = this.A.here(); await this.pushNote({ id: rdUid(), type: 'mark', loc: here.loc, pct: this.pct, text: here.text || '', page: here.page || 0, color: 'yellow' }); toast('Bookmarked'); }
this.$('#rdbm').classList.toggle('on', !!this.isMarked());
}
// The selection bar: pick a colour to highlight, add a note, or copy.
showSel(sel) { // sel = { loc, text, page, clear() }
this.sel = sel; const el = this.$('#rdsel');
el.innerHTML = Object.entries(RD_COLORS).map(([c, hex]) => `<button class="rdcol" data-c="${c}" style="background:${hex}" aria-label="Highlight ${c}"></button>`).join('') + `<button class="rdsb" data-a="note">${icon('edit')}Note</button><button class="rdsb" data-a="copy">${icon('copy')}Copy</button>`;
el.hidden = false;
el.querySelectorAll('.rdcol').forEach(b => b.onclick = async () => { await this.addHighlight(b.dataset.c, ''); });
el.querySelector('[data-a=note]').onclick = () => this.noteDialog(null);
el.querySelector('[data-a=copy]').onclick = () => { navigator.clipboard?.writeText(sel.text).then(() => toast('Copied'), () => toast('Could not copy')); this.hideSel(true); };
}
hideSel(clear) { const el = this.$('#rdsel'); if (el) el.hidden = true; if (clear && this.sel) { try { this.sel.clear(); } catch {} } if (clear) this.sel = null; }
async addHighlight(color, note) {
const s = this.sel; if (!s) return; this.hideSel(true);
const n = { id: rdUid(), type: note ? 'note' : 'hl', loc: s.loc, pct: this.pct, text: s.text.slice(0, 2000), note, color, page: s.page || 0 };
await this.pushNote(n); this.A.drawNote?.(n);
}
noteDialog(existing) { // a new note on the current selection, or edit one that exists
const s = this.sel, n = existing, color = n ? n.color : 'yellow';
const el = openSheet(n ? 'Highlight' : 'Add a note', '', `${(n || s).text ? `<blockquote class="rdq">${esc(((n || s).text || '').slice(0, 400))}</blockquote>` : ''}<label>Note</label><textarea id="rdnt" rows="4" maxlength="5000" placeholder="Write a note">${esc(n ? n.note : '')}</textarea>
<div class="rdcolors" id="rdcols">${Object.entries(RD_COLORS).map(([c, hex]) => `<button type="button" class="rdcol${c === color ? ' on' : ''}" data-c="${c}" style="background:${hex}" aria-label="${c}"></button>`).join('')}</div>
<div class="bar">${n ? '<button class="btn danger" id="rdnd" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" id="rdns" style="flex:1;justify-content:center">Save</button></div>`);
let col = color; el.querySelectorAll('#rdcols .rdcol').forEach(b => b.onclick = () => { col = b.dataset.c; el.querySelectorAll('#rdcols .rdcol').forEach(x => x.classList.toggle('on', x === b)); });
if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#rdns').onclick = async () => {
const text = el.querySelector('#rdnt').value.trim(); closeSheet();
if (n) { const upd = { ...n, note: text, color: col, type: text ? 'note' : 'hl' }; this.A.eraseNote?.(n); await this.pushNote(upd); this.A.drawNote?.(upd); this.refreshNotes?.(); }
else if (this.sel) await this.addHighlight(col, text);
};
if (n) el.querySelector('#rdnd').onclick = () => { closeSheet(); this.deleteNote(n); };
}
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 = [];
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); });
}
showSearch() {
const el = openSheet('Search in book', '', `<form><input type="search" id="rdq" placeholder="Search for a word or phrase" autocomplete="off" value="${esc(this.lastQ || '')}"></form><div id="rdres" class="list rdlist" style="margin-top:10px"></div>`);
const res = el.querySelector('#rdres'), inp = el.querySelector('#rdq'); let seq = 0;
const run = async () => {
const term = inp.value.trim(), my = ++seq; this.lastQ = term; if (term.length < 2) { res.innerHTML = ''; return; }
res.innerHTML = '<p class="empty">Searching…</p>';
try {
const out = await this.A.search(term, () => my !== seq); if (my !== seq) return;
res.innerHTML = out.length ? out.map((r, i) => `<button class="row" data-i="${i}"><span class="grow"><b style="white-space:normal;font-weight:400">${r.excerpt}</b><small>${esc(r.where || '')}</small></span></button>`).join('') : '<p class="empty">Nothing found.</p>';
res.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(out[b.dataset.i].loc, term); });
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
el.querySelector('form').onsubmit = e => { e.preventDefault(); run(); }; let t; inp.oninput = () => { clearTimeout(t); t = setTimeout(run, 450); }; inp.focus(); if (inp.value) run();
}
showNotes() {
const el = openSheet('Bookmarks and notes', '', '<div id="rdnl"></div>');
const draw = () => {
const l = this.notes.filter(n => !n.del).sort((a, b) => a.pct - b.pct), box = el.querySelector('#rdnl');
box.innerHTML = l.length ? `<div class="list rdlist">${l.map(n => `<div class="row rdnote" data-id="${n.id}"><button class="grow rdnj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0">
<b style="white-space:normal">${n.type === 'mark' ? icon('bookmark') + ' Bookmark' : `<span class="rddot" style="background:${RD_COLORS[n.color] || RD_COLORS.yellow}"></span>${n.type === 'note' ? 'Note' : 'Highlight'}`} <small style="display:inline">· ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}</small></b>
${n.text ? `<small style="white-space:normal;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden">${esc(n.text)}</small>` : ''}${n.note ? `<span style="white-space:pre-wrap;display:block;margin-top:2px">${esc(n.note)}</span>` : ''}</button>
${n.type === 'mark' ? '' : `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>`}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="rdexp">${icon('copy')}Copy as text</button></div>` : '<p class="empty">Nothing yet. Select text to highlight it or add a note; tap the bookmark button to mark a place.</p>';
box.querySelectorAll('.rdnote').forEach(r => { const n = this.notes.find(x => x.id === r.dataset.id);
r.querySelector('.rdnj').onclick = () => { closeSheet(); this.A.goto(n.loc, null, n); };
const e = r.querySelector('[data-e]'); if (e) e.onclick = () => { closeSheet(); this.noteDialog(n); };
r.querySelector('[data-d]').onclick = async () => { await this.deleteNote(n); draw(); }; });
const ex = box.querySelector('#rdexp'); if (ex) ex.onclick = () => {
const txt = `${this.book.title}${this.book.author ? ' - ' + this.book.author : ''}\n\n` + l.map(n => (n.type === 'mark' ? '[Bookmark] ' + (n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%') : `"${n.text}"${n.note ? '\n Note: ' + n.note : ''}`)).join('\n\n');
navigator.clipboard?.writeText(txt).then(() => toast('Copied'), () => toast('Could not copy'));
};
};
this.refreshNotes = () => { if (document.body.contains(el)) draw(); }; draw();
}
showAppearance() {
const P = this.P, pdf = this.book.ext === 'pdf', docx = this.book.ext === 'docx';
const seg = (name, opts, cur) => `<div class="rdseg" data-k="${name}">${opts.map(([v, l]) => `<button type="button" data-v="${v}" class="${String(v) === String(cur) ? 'on' : ''}">${l}</button>`).join('')}</div>`;
const el = openSheet('Appearance', '', `<label>Theme</label>${seg('theme', [['auto', 'Auto'], ['light', 'Light'], ['sepia', 'Sepia'], ['dark', 'Dark']], P.theme)}
${pdf ? `<label>Zoom</label><div class="rdstep"><button type="button" class="btn ghost" data-z="-0.15">−</button><span id="rdzv">${Math.round(P.zoom * 100)}%</span><button type="button" class="btn ghost" data-z="0.15">+</button></div>` : `
<label>Text size</label><div class="rdstep"><button type="button" class="btn ghost" data-s="-1">A−</button><span id="rdsv">${P.size}</span><button type="button" class="btn ghost" data-s="1">A+</button></div>
<label>Font</label>${seg('font', Object.entries(RD_FONTS).filter(([k]) => !(docx && !k)).map(([k, l]) => [k, l]), P.font)}
<label>Line spacing</label>${seg('lh', [[1.3, 'Tight'], [1.55, 'Normal'], [1.85, 'Loose']], P.lh)}
<label>Margins</label>${seg('margin', [[0, 'Narrow'], [2, 'Normal'], [4, 'Wide']], P.margin)}
${docx ? '' : `<label>Layout</label>${seg('flow', [['paged', 'Pages'], ['scroll', 'Scroll']], P.flow)}`}`}
<div class="bar"><button class="btn" id="rdok" style="flex:1;justify-content:center">Done</button></div>`);
const apply = (k, v) => { P[k] = v; rdSavePrefs(P); this.applyChrome(); this.A?.restyle(k); };
el.querySelectorAll('.rdseg').forEach(g => g.querySelectorAll('button').forEach(b => b.onclick = () => { g.querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b)); const k = g.dataset.k; apply(k, ['lh', 'margin'].includes(k) ? +b.dataset.v : b.dataset.v); }));
el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { apply('size', Math.min(40, Math.max(12, P.size + +b.dataset.s))); el.querySelector('#rdsv').textContent = P.size; });
el.querySelectorAll('[data-z]').forEach(b => b.onclick = () => { apply('zoom', Math.min(3, Math.max(0.5, Math.round((P.zoom + +b.dataset.z) * 100) / 100))); el.querySelector('#rdzv').textContent = Math.round(P.zoom * 100) + '%'; });
el.querySelector('#rdok').onclick = closeSheet;
}
}
// Shared helpers for the format readers
const rdEsc = esc;
function rdExcerpt(text, i, len) { const a = Math.max(0, i - 40), b = Math.min(text.length, i + len + 60); return (a ? '…' : '') + esc(text.slice(a, i)) + '<mark>' + esc(text.slice(i, i + len)) + '</mark>' + esc(text.slice(i + len, b)) + (b < text.length ? '…' : ''); }
const rdFetchBook = async (id, ext) => { const r = await fetch(`/api/read/book/${id}/file?inline=1`); if (!r.ok) throw new Error('Could not download the book (' + r.status + ')'); return r.arrayBuffer(); };
const rdCss = (P, t) => { const fam = RD_FAMILY[P.font]; return `html,body{background:${t.bg}!important;color:${t.fg}!important}body{${fam ? `font-family:${fam}!important;` : ''}font-size:${P.size}px;line-height:${P.lh};padding:0 ${P.margin * 3}%!important}a{color:${t.link}!important}p,li,div,span,td,h1,h2,h3,h4,h5,h6,blockquote{color:inherit!important;${fam ? 'font-family:inherit!important;' : ''}}p,li{line-height:${P.lh}!important}img,svg{max-width:100%!important;height:auto}${P.theme === 'dark' || t === RD_THEMES.dark ? 'img{filter:brightness(.85)}' : ''}`; };
// ======================================================= EPUB
class RdEpub {
constructor(R) { this.R = R; this.hl = new Map(); }
async open(stage, loc) {
const R = this.R; this.stage = stage;
this.b = ePub(await rdFetchBook(R.book.id, 'epub'));
await this.b.ready;
const cached = (() => { try { return localStorage.getItem('rd-loc-' + R.book.id); } catch {} })();
this.locs = new Promise(ok => { const done = () => { this.locsReady = true; ok(); this.refreshPct(); }; if (cached) { try { this.b.locations.load(cached); return done(); } catch {} } this.b.locations.generate(1200).then(() => { try { localStorage.setItem('rd-loc-' + R.book.id, this.b.locations.save()); } catch {} done(); }).catch(ok); });
await this.build(loc);
R.$('#rdbm').classList.toggle('on', !!R.isMarked());
}
async build(loc) {
const R = this.R, P = R.P; if (this.rend) { try { this.rend.destroy(); } catch {} this.stage.innerHTML = ''; this.hl.clear(); }
const scroll = P.flow === 'scroll';
this.rend = this.b.renderTo(this.stage, scroll ? { width: '100%', height: '100%', flow: 'scrolled', manager: 'continuous', allowScriptedContent: false } : { width: '100%', height: '100%', flow: 'paginated', spread: 'auto', minSpreadWidth: 900, allowScriptedContent: false });
this.restyle();
this.rend.on('relocated', l => { this.at = l; this.refreshPct(); R.$('#rdbm').classList.toggle('on', !!R.isMarked()); });
this.rend.on('rendered', (sec) => { const c = this.rend.getContents()[0]; if (!c || c.__rd) return; });
this.rend.on('selected', async (range, contents) => {
try {
const r = await this.b.getRange(range), text = r.toString().trim(); if (!text) return;
R.showSel({ loc: range, text, clear: () => { for (const c of this.rend.getContents()) c.window.getSelection().removeAllRanges(); } });
} catch {}
});
this.rend.hooks.content.register(contents => {
const doc = contents.document; let x0 = 0, y0 = 0, t0 = 0;
doc.addEventListener('keydown', e => R.keys(e));
doc.addEventListener('touchstart', e => { x0 = e.changedTouches[0].screenX; y0 = e.changedTouches[0].screenY; t0 = Date.now(); }, { passive: true });
doc.addEventListener('touchend', e => { const dx = e.changedTouches[0].screenX - x0, dy = e.changedTouches[0].screenY - y0; if (this.R.P.flow === 'paged' && Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - t0 < 700) dx < 0 ? this.next() : this.prev(); });
doc.addEventListener('click', e => { if (e.target.closest && e.target.closest('a[href]')) return; const sel = contents.window.getSelection(); if (sel && !sel.isCollapsed) return; if (this.R.P.flow !== 'paged') return R.toggleBars(); const x = this.tapX(contents, e.clientX); if (x < 0.22) this.prev(); else if (x > 0.78) this.next(); else R.toggleBars(); });
doc.addEventListener('mousedown', () => R.hideSel());
});
const start = loc && !/^\d+$/.test(loc) ? loc : undefined;
try { await this.rend.display(start); } catch { await this.rend.display(); }
for (const n of R.notes) this.drawNote(n);
}
/** Where a tap landed as a fraction of the reading area. The page frame is wider than the screen (the pages sit side by side inside it), so its own coordinates say nothing about the screen. */
tapX(contents, clientX) { const f = contents.window.frameElement.getBoundingClientRect(), s = this.stage.getBoundingClientRect(); return (f.left + clientX - s.left) / s.width; }
restyle(k) {
const R = this.R, P = R.P, t = rdTheme(P);
if (k === 'flow') { const loc = this.at && this.at.start.cfi; return this.build(loc); }
const th = this.rend.themes; th.override('color', t.fg); th.override('background', t.bg);
th.fontSize(P.size + 'px'); th.override('line-height', String(P.lh)); th.override('padding', `0 ${P.margin * 3}%`);
if (P.font) th.font(RD_FAMILY[P.font]); else th.override('font-family', 'inherit');
th.default({ a: { color: t.link + ' !important' }, 'p,li,div,span,td,h1,h2,h3,h4,blockquote': { color: 'inherit !important' }, img: { 'max-width': '100% !important' } });
R.root.style.setProperty('--rd-bg', t.bg);
if (k === 'theme') this.R.applyChrome();
if (this.at && ['size', 'font', 'lh', 'margin'].includes(k)) setTimeout(() => { try { this.rend.display(this.at.start.cfi); } catch {} }, 80);
}
refreshPct() {
const l = this.at; if (!l) return; let pct;
if (this.locsReady) pct = this.b.locations.percentageFromCfi(l.start.cfi); else pct = this.b.spine.length ? (l.start.index || 0) / this.b.spine.length : 0;
if (l.atEnd) pct = 1;
const sec = this.b.navigation && this.b.navigation.get && l.start.href ? this.b.navigation.get(l.start.href) : null;
this.R.relocated({ loc: l.start.cfi, pct, label: sec && sec.label ? sec.label.trim() : '' });
}
next() { return this.rend.next(); } prev() { return this.rend.prev(); }
async seek(f) { if (!this.locsReady) await this.locs; const cfi = this.b.locations.cfiFromPercentage(Math.min(0.9999, f)); if (cfi) this.rend.display(cfi); }
here() { const l = this.at; return { loc: l ? l.start.cfi : '', text: '' }; }
sameSpot(n) { const l = this.at; if (!l) return false; try { const c = new ePub.CFI(); return c.compare(n.loc, l.start.cfi) >= 0 && c.compare(n.loc, l.end.cfi) <= 0; } catch { return n.loc === l.start.cfi; } }
async toc() { const nav = await this.b.loaded.navigation, out = []; const walk = (items, d) => items.forEach(i => { out.push({ label: (i.label || '').trim() || 'Untitled', loc: i.href, depth: d }); if (i.subitems) walk(i.subitems, d + 1); }); walk(nav.toc, 0); return out; }
async goto(loc, term, note) {
try { await this.rend.display(loc); } catch { return toast('Could not go there'); }
const flash = note && note.type !== 'mark' ? note.loc : term ? loc : null;
if (flash && /^epubcfi/.test(flash) && !note) { try { this.rend.annotations.highlight(flash, {}, null, 'rd-flash', { fill: '#ffd43b', 'fill-opacity': '.5' }); setTimeout(() => { try { this.rend.annotations.remove(flash, 'highlight'); } catch {} }, 2500); } catch {} }
}
async search(q, stale) {
const out = [], re = q.toLowerCase();
for (const item of this.b.spine.spineItems) {
if (stale()) return out;
try {
await item.load(this.b.load.bind(this.b));
const found = item.find(q) || []; // [{cfi, excerpt}]
const nav = this.b.navigation && this.b.navigation.get ? this.b.navigation.get(item.href) : null;
for (const f of found) { const ex = String(f.excerpt || ''), i = ex.toLowerCase().indexOf(re); out.push({ loc: f.cfi, excerpt: i >= 0 ? rdExcerpt(ex, i, q.length) : esc(ex), where: nav && nav.label ? nav.label.trim() : '' }); if (out.length >= 150) { item.unload(); return out; } }
item.unload();
} catch { try { item.unload(); } catch {} }
}
return out;
}
drawNote(n) {
if (n.type === 'mark' || n.del || !/^epubcfi/.test(n.loc || '') || this.hl.has(n.id)) return;
const hex = RD_COLORS[n.color] || RD_COLORS.yellow;
try { this.rend.annotations.highlight(n.loc, { id: n.id }, () => { const cur = this.R.notes.find(x => x.id === n.id); if (cur) this.R.noteDialog(cur); }, 'rd-hl', { fill: hex, 'fill-opacity': '.4', 'mix-blend-mode': 'multiply', stroke: 'none' }); this.hl.set(n.id, n.loc); } catch {}
}
eraseNote(n) { const loc = this.hl.get(n.id); if (loc) { try { this.rend.annotations.remove(loc, 'highlight'); } catch {} this.hl.delete(n.id); } }
resize() { try { this.rend.resize(); } catch {} }
destroy() { try { this.b.destroy(); } catch {} }
}
// ======================================================= PDF
class RdPdf {
constructor(R) { this.R = R; this.texts = {}; this.pages = []; }
async open(stage, loc) {
const R = this.R; this.stage = stage;
this.pdf = await pdfjsLib.getDocument({ url: `/api/read/book/${R.book.id}/file?inline=1` }).promise;
this.n = this.pdf.numPages; const p1 = await this.pdf.getPage(1); this.v1 = p1.getViewport({ scale: 1 });
stage.innerHTML = '<div class="rdpages"></div>'; this.box = stage.firstChild;
this.layout();
stage.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.onScroll(); }); });
this.io = new IntersectionObserver(es => es.forEach(e => e.isIntersecting ? this.render(+e.target.dataset.n) : this.release(+e.target.dataset.n)), { root: stage, rootMargin: '150% 0px' });
this.pages.forEach(p => this.io.observe(p.el));
const sel = () => setTimeout(() => this.checkSel(), 30);
stage.addEventListener('mouseup', sel); stage.addEventListener('touchend', sel); stage.addEventListener('keyup', sel);
stage.addEventListener('click', e => { const s = getSelection(); if (s && !s.isCollapsed) return; const r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width; if (x < 0.15 && this.R.P.zoom <= 1.01) this.prev(); else if (x > 0.85 && this.R.P.zoom <= 1.01) this.next(); else R.toggleBars(); });
const page = /^pg:(\d+)/.exec(loc || '');
this.goto(page ? 'pg:' + page[1] : 'pg:1'); this.onScroll();
R.$('#rdbm').classList.toggle('on', !!R.isMarked());
}
scale() { return Math.max(0.3, (this.stage.clientWidth - 16) / this.v1.width * this.R.P.zoom); }
layout() {
const s = this.scale(), cur = this.page || 1; this.s = s; this.box.innerHTML = ''; this.pages = [];
for (let i = 1; i <= this.n; i++) {
const el = document.createElement('div'); el.className = 'pg'; el.dataset.n = i; el.style.width = this.v1.width * s + 'px'; el.style.height = this.v1.height * s + 'px'; this.box.appendChild(el); this.pages.push({ el, rendered: false });
}
if (this.io) this.pages.forEach(p => this.io.observe(p.el));
this.stage.scrollTop = this.pages[cur - 1].el.offsetTop - 8;
this.restyle('theme');
}
async render(n) {
const p = this.pages[n - 1]; if (!p || p.rendered || p.busy) return; p.busy = true;
try {
const page = await this.pdf.getPage(n), vp = page.getViewport({ scale: this.s }), dpr = Math.min(2, devicePixelRatio || 1);
if (p.gone) { p.busy = false; return; }
const c = document.createElement('canvas'); c.width = Math.floor(vp.width * dpr); c.height = Math.floor(vp.height * dpr); c.style.width = vp.width + 'px'; c.style.height = vp.height + 'px';
p.el.style.width = vp.width + 'px'; p.el.style.height = vp.height + 'px';
await page.render({ canvasContext: c.getContext('2d'), viewport: vp, transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null, background: 'rgba(255,255,255,1)' }).promise;
const tl = document.createElement('div'); tl.className = 'textLayer'; tl.style.setProperty('--scale-factor', vp.scale);
const hl = document.createElement('div'); hl.className = 'rdhls';
p.el.replaceChildren(c, hl, tl); p.rendered = true; p.vp = vp;
pdfjsLib.renderTextLayer({ textContentSource: page.streamTextContent(), container: tl, viewport: vp, textDivs: [] });
this.R.notes.forEach(nt => { if (nt.page === n) this.drawNote(nt); });
} catch { /* a page that fails to draw stays blank */ }
p.busy = false;
}
release(n) { const p = this.pages[n - 1]; if (!p) return; p.gone = true; if (p.rendered) { p.el.replaceChildren(); p.rendered = false; } setTimeout(() => { p.gone = false; }, 0); }
onScroll() {
const top = this.stage.scrollTop + this.stage.clientHeight * 0.3; let lo = 0, hi = this.n - 1;
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (this.pages[mid].el.offsetTop <= top) lo = mid; else hi = mid - 1; }
const n = lo + 1; this.page = n; this.R.relocated({ loc: 'pg:' + n, pct: this.n > 1 ? n / this.n : 1, label: `Page ${n} of ${this.n}` });
this.R.$('#rdbm').classList.toggle('on', !!this.R.isMarked());
}
goto(loc, term, note) {
const m = /^pg:(\d+)/.exec(String(loc)) || /^r:(\d+):/.exec(String(loc)); if (!m) return;
const n = Math.min(this.n, Math.max(1, +m[1])), el = this.pages[n - 1].el; this.stage.scrollTop = el.offsetTop - 8; this.render(n);
if (note && note.loc.startsWith('r:')) { const l = el.querySelector('.rdhls'); if (l) setTimeout(() => { el.querySelectorAll(`[data-nid="${note.id}"]`).forEach(d => { d.classList.add('flash'); setTimeout(() => d.classList.remove('flash'), 2000); }); }, 400); }
if (term) this.flashTerm = { n, term };
}
next() { this.stage.scrollBy({ top: this.stage.clientHeight * 0.9, behavior: 'smooth' }); } prev() { this.stage.scrollBy({ top: -this.stage.clientHeight * 0.9, behavior: 'smooth' }); }
seek(f) { this.goto('pg:' + Math.max(1, Math.min(this.n, Math.round(f * this.n) || 1))); }
here() { return { loc: 'pg:' + (this.page || 1), page: this.page || 1, text: '' }; }
sameSpot(n) { return n.type === 'mark' && n.loc === 'pg:' + this.page; }
restyle(k) {
if (k === 'zoom') { this.layout(); this.pages.forEach(p => this.io.observe(p.el)); return; }
const R = this.R, t = rdTheme(R.P), dark = t === RD_THEMES.dark, sepia = t === RD_THEMES.sepia;
this.stage.style.background = t.bg; this.box.style.filter = ''; this.box.classList.toggle('pdark', dark); this.box.classList.toggle('psepia', sepia);
}
resize() { const w = this.stage.clientWidth; if (this.lastW && Math.abs(w - this.lastW) < 4) return; this.lastW = w; this.layout(); }
async toc() {
const out = [], ol = await this.pdf.getOutline().catch(() => null); if (!ol) return out;
const walk = async (items, d) => { for (const i of items) {
let page = 0; try { let dest = i.dest; if (typeof dest === 'string') dest = await this.pdf.getDestination(dest); if (Array.isArray(dest)) page = (await this.pdf.getPageIndex(dest[0])) + 1; } catch {}
if (page) out.push({ label: i.title.trim() || 'Untitled', loc: 'pg:' + page, depth: d }); if (i.items && i.items.length) await walk(i.items, d + 1); } };
await walk(ol, 0); return out;
}
async pageText(n) { if (this.texts[n] == null) { const tc = await (await this.pdf.getPage(n)).getTextContent(); this.texts[n] = tc.items.map(i => i.str).join(' ').replace(/\s+/g, ' '); } return this.texts[n]; }
async search(q, stale) {
const out = [], lq = q.toLowerCase();
for (let n = 1; n <= this.n; n++) {
if (stale()) return out; const t = await this.pageText(n), low = t.toLowerCase(); let i = -1, c = 0;
while ((i = low.indexOf(lq, i + 1)) >= 0 && c++ < 5) { out.push({ loc: 'pg:' + n, excerpt: rdExcerpt(t, i, q.length), where: 'Page ' + n }); if (out.length >= 200) return out; }
}
return out;
}
// ---- highlights are drawn as rectangles on the page; they are stored as fractions of the page size so they hold at any zoom
checkSel() {
const s = getSelection(); if (!s || s.isCollapsed || !s.rangeCount) return;
const r = s.getRangeAt(0), host = (r.commonAncestorContainer.nodeType === 1 ? r.commonAncestorContainer : r.commonAncestorContainer.parentElement);
if (!host || !host.closest || !host.closest('.textLayer') && !host.closest('.pg')) return;
const pg = (host.closest('.pg') || r.startContainer.parentElement.closest('.pg')); if (!pg) return;
const text = s.toString().replace(/\s+/g, ' ').trim(); if (!text) return;
const n = +pg.dataset.n, box = pg.getBoundingClientRect();
let rects = [...r.getClientRects()].filter(x => x.width > 1 && x.height > 1).map(x => ({ x: (x.left - box.left) / box.width, y: (x.top - box.top) / box.height, w: x.width / box.width, h: x.height / box.height }));
rects.sort((a, b) => a.y - b.y || a.x - b.x); const merged = [];
for (const q of rects) { const l = merged[merged.length - 1]; if (l && Math.abs(l.y - q.y) < 0.006 && q.x <= l.x + l.w + 0.01) { const right = Math.max(l.x + l.w, q.x + q.w); l.x = Math.min(l.x, q.x); l.w = right - l.x; l.h = Math.max(l.h, q.h); } else merged.push({ ...q }); }
let loc = ''; for (const q of merged) { const part = [q.x, q.y, q.w, q.h].map(v => Math.round(v * 10000)).join(','); if ((loc + part).length > 2900) break; loc += (loc ? ';' : '') + part; }
this.R.showSel({ loc: `r:${n}:` + loc, text, page: n, clear: () => s.removeAllRanges() });
}
drawNote(n) {
const m = /^r:(\d+):(.+)$/.exec(n.loc || ''); if (!m || n.del) return; const p = this.pages[+m[1] - 1]; if (!p || !p.rendered) return;
const layer = p.el.querySelector('.rdhls'); if (!layer || layer.querySelector(`[data-nid="${n.id}"]`)) return;
for (const part of m[2].split(';')) { const [x, y, w, h] = part.split(',').map(v => +v / 10000); const d = document.createElement('div'); d.className = 'rdhl'; d.dataset.nid = n.id; d.style.cssText = `left:${x * 100}%;top:${y * 100}%;width:${w * 100}%;height:${h * 100}%;background:${RD_COLORS[n.color] || RD_COLORS.yellow}`; layer.appendChild(d); }
}
eraseNote(n) { this.box.querySelectorAll(`[data-nid="${n.id}"]`).forEach(d => d.remove()); }
destroy() { try { this.io && this.io.disconnect(); this.pdf && this.pdf.destroy(); } catch {} }
}
// ======================================================= DOCX (converted to HTML in the browser; shown in a frame that cannot run scripts)
class RdDocx {
constructor(R) { this.R = R; }
async convert() {
const buf = await rdFetchBook(this.R.book.id, 'docx');
return { html: (await mammoth.convertToHtml({ arrayBuffer: buf }, { styleMap: ['p[style-name="Title"] => h1.title:fresh', 'p[style-name="Subtitle"] => p.subtitle:fresh'] })).value, head: '' };
}
async open(stage, loc) {
const R = this.R, res = await this.convert();
this.f = document.createElement('iframe'); this.f.setAttribute('sandbox', 'allow-same-origin'); this.f.title = R.book.title; this.f.className = 'rdframe';
stage.appendChild(this.f);
this.f.srcdoc = `<!doctype html><html><head><meta charset="utf-8"><style id="base">body{max-width:46em;margin:0 auto;padding:1.2em 6%;overflow-wrap:break-word}table{border-collapse:collapse}td,th{border:1px solid #8888;padding:.3em .5em}h1,h2,h3{line-height:1.25}mark.rdh{border-radius:2px;color:inherit;padding:0}mark.rdh.flash{outline:2px solid #e8590c}mark.rdq{background:#ffd43b88;color:inherit}</style><style id="theme"></style>${res.head}</head><body><article>${res.html}</article></body></html>`;
await new Promise(ok => { this.f.onload = ok; });
this.d = this.f.contentDocument; this.w = this.f.contentWindow; this.root = this.d.documentElement;
this.restyle();
this.d.addEventListener('keydown', e => R.keys(e));
this.d.addEventListener('mousedown', () => R.hideSel());
const sel = () => setTimeout(() => this.checkSel(), 30);
this.d.addEventListener('mouseup', sel); this.d.addEventListener('touchend', sel); this.d.addEventListener('keyup', sel);
this.d.addEventListener('click', e => { const s = this.w.getSelection(); if (s && !s.isCollapsed) return; if (e.target.closest('a[href]')) { e.preventDefault(); const a = e.target.closest('a[href]'); if (!this.link(a)) this.d.getElementById((a.getAttribute('href') || '').replace(/^#/, ''))?.scrollIntoView(); return; } R.toggleBars(); });
this.d.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.report(); }); }, { passive: true });
this.w.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.report(); }); }, { passive: true });
for (const n of R.notes) this.drawNote(n);
const m = /^o:(\d+)/.exec(loc || ''); if (m) this.gotoOffset(+m[1]); this.report();
}
link() { return false; }
scroller() { return this.d.scrollingElement || this.root; }
restyle(k) {
const R = this.R, t = rdTheme(R.P); this.d.getElementById('theme').textContent = rdCss(R.P, t); R.root.style.setProperty('--rd-bg', t.bg);
if (k === 'theme') R.applyChrome();
if (this.at != null && k !== 'theme') setTimeout(() => this.gotoOffset(this.at), 50);
}
textNodes() { const w = this.d.createTreeWalker(this.d.body, NodeFilter.SHOW_TEXT), out = []; let n; while ((n = w.nextNode())) out.push(n); return out; }
offsetTo(node, off) { let total = 0; for (const t of this.textNodes()) { if (t === node) return total + off; total += t.nodeValue.length; } return total; }
nodeAt(offset) { let total = 0; const all = this.textNodes(); for (const t of all) { const len = t.nodeValue.length; if (offset <= total + len) return [t, Math.max(0, offset - total)]; total += len; } const l = all[all.length - 1]; return l ? [l, l.nodeValue.length] : [this.d.body, 0]; }
gotoOffset(o) { const [n, off] = this.nodeAt(o), r = this.d.createRange(); r.setStart(n, Math.min(off, n.nodeValue ? n.nodeValue.length : 0)); r.collapse(true); const rc = r.getBoundingClientRect(); this.scroller().scrollTop += rc.top - 24; }
topOffset() {
const cp = this.d.caretRangeFromPoint ? this.d.caretRangeFromPoint(this.w.innerWidth / 2, 16) : (this.d.caretPositionFromPoint ? (p => p && { startContainer: p.offsetNode, startOffset: p.offset })(this.d.caretPositionFromPoint(this.w.innerWidth / 2, 16)) : null);
return cp && cp.startContainer ? this.offsetTo(cp.startContainer, cp.startOffset) : 0;
}
report() { const s = this.scroller(), max = s.scrollHeight - s.clientHeight, off = this.topOffset(); this.at = off; this.R.relocated({ loc: 'o:' + off, pct: max > 0 ? Math.min(1, s.scrollTop / max) : 1, label: '' }); this.R.$('#rdbm').classList.toggle('on', !!this.R.isMarked()); }
next() { this.scroller().scrollBy({ top: this.w.innerHeight * 0.9, behavior: 'smooth' }); } prev() { this.scroller().scrollBy({ top: -this.w.innerHeight * 0.9, behavior: 'smooth' }); }
seek(f) { const s = this.scroller(); s.scrollTop = f * (s.scrollHeight - s.clientHeight); }
here() { const o = this.topOffset(), t = this.d.body.textContent.slice(o, o + 120).replace(/\s+/g, ' ').trim(); return { loc: 'o:' + o, text: t }; }
sameSpot(n) { if (n.type !== 'mark') return false; const m = /^o:(\d+)/.exec(n.loc), o = this.topOffset(); return !!m && Math.abs(+m[1] - o) < 400 && this.w.innerHeight > 0 && Math.abs(+m[1] - o) < this.d.body.textContent.length; }
async toc() { const out = []; this.d.querySelectorAll('h1,h2,h3,h4').forEach(h => { const t = h.textContent.trim(); if (!t) return; const w = this.d.createTreeWalker(h, NodeFilter.SHOW_TEXT), first = w.nextNode(); out.push({ label: t.slice(0, 120), loc: 'o:' + (first ? this.offsetTo(first, 0) : 0), depth: Math.min(3, +h.tagName[1] - 1) }); }); return out; }
async search(q, stale) {
const text = this.d.body.textContent, low = text.toLowerCase(), lq = q.toLowerCase(), out = []; let i = -1;
while ((i = low.indexOf(lq, i + 1)) >= 0 && out.length < 200) out.push({ loc: 'o:' + i, excerpt: rdExcerpt(text.replace(/\s/g, ' '), i, q.length), where: Math.round(i / Math.max(1, text.length) * 100) + '% into the document' });
return out;
}
goto(loc, term, note) {
const m = /^o:(\d+)(?:-(\d+))?$/.exec(loc); if (!m) return; this.gotoOffset(+m[1]);
if (term || note) { const a = +m[1], b = m[2] ? +m[2] : a + (term ? term.length : 0); this.wrap(a, b, 'rdq flash', null); setTimeout(() => this.d.querySelectorAll('mark.rdq').forEach(k => { k.replaceWith(...k.childNodes); }), 2200); this.d.body.normalize(); }
}
checkSel() {
const s = this.w.getSelection(); if (!s || s.isCollapsed || !s.rangeCount) return; const r = s.getRangeAt(0), a = this.offsetTo(r.startContainer, r.startOffset), b = this.offsetTo(r.endContainer, r.endOffset);
const text = s.toString().replace(/\s+/g, ' ').trim(); if (!text || b <= a) return;
this.R.showSel({ loc: `o:${a}-${b}`, text, clear: () => s.removeAllRanges() });
}
wrap(a, b, cls, id, color) { // wrap the text between two offsets in <mark> elements, one per text node it touches
let total = 0; const todo = [];
for (const t of this.textNodes()) { const len = t.nodeValue.length, s = Math.max(a, total), e = Math.min(b, total + len); if (e > s) todo.push([t, s - total, e - total]); total += len; if (total >= b) break; }
for (const [t, s, e] of todo) { const mid = t.splitText(s); mid.splitText(e - s); const k = this.d.createElement('mark'); k.className = cls; if (id) { k.dataset.nid = id; k.style.background = (RD_COLORS[color] || RD_COLORS.yellow) + 'a8'; k.style.cursor = 'pointer'; k.onclick = () => { const cur = this.R.notes.find(x => x.id === id); if (cur && this.w.getSelection().isCollapsed) this.R.noteDialog(cur); }; } mid.parentNode.replaceChild(k, mid); k.appendChild(mid); }
}
drawNote(n) { const m = /^o:(\d+)-(\d+)$/.exec(n.loc || ''); if (!m || n.del || n.type === 'mark' || this.d.querySelector(`mark[data-nid="${n.id}"]`)) return; this.wrap(+m[1], +m[2], 'rdh', n.id, n.color); }
eraseNote(n) { this.d.querySelectorAll(`mark[data-nid="${n.id}"]`).forEach(k => k.replaceWith(...k.childNodes)); this.d.body.normalize(); }
resize() {}
destroy() {}
}
// ======================================================= MOBI / AZW / AZW3 (DRM-free): every chapter is joined into one page, then read like a DOCX
class RdMobi extends RdDocx {
async convert() {
const buf = new Uint8Array(await rdFetchBook(this.R.book.id, 'mobi'));
this.m = await rdMobiOpen(buf); const spine = this.m.getSpine(), css = new Set(), parts = [];
for (const ch of spine) { const c = this.m.loadChapter(ch.id); if (!c) continue; (c.css || []).forEach(x => css.add(x.href)); parts.push(`<section class="rdch" id="rdch-${esc(ch.id)}" data-ch="${esc(ch.id)}">${c.html}</section>`); }
if (!parts.length) throw new Error('This book has no readable text. It may have DRM.');
return { html: parts.join(''), head: [...css].map(h => `<link rel="stylesheet" href="${esc(h)}">`).join('') };
}
/** a link inside the book: the parser says which chapter (and where in it) it points to */
link(a) {
const href = a.getAttribute('href') || ''; if (/^(https?:|mailto:)/i.test(href)) { window.open(href, '_blank', 'noopener'); return true; }
let r; try { r = this.m.resolveHref(href); } catch {} if (!r) return false;
const sec = this.d.getElementById('rdch-' + r.id); let el = sec; if (sec && r.selector) { try { el = sec.querySelector(r.selector) || sec; } catch {} }
if (el) { el.scrollIntoView(); return true; } return false;
}
async toc() {
const out = [], walk = (items, d) => (items || []).forEach(t => {
let loc = null; try { const r = this.m.resolveHref(t.href); const sec = r && this.d.getElementById('rdch-' + r.id); let el = sec; if (sec && r.selector) { try { el = sec.querySelector(r.selector) || sec; } catch {} } if (el) { const w = this.d.createTreeWalker(el, NodeFilter.SHOW_TEXT), first = w.nextNode(); if (first) loc = 'o:' + this.offsetTo(first, 0); } } catch {}
if (loc) out.push({ label: String(t.label || '').trim().slice(0, 120) || 'Untitled', loc, depth: d }); walk(t.children, d + 1); });
walk(this.m.getToc(), 0);
return out.length ? out : super.toc();
}
destroy() { try { this.m && this.m.destroy(); } catch {} }
}
+104
View File
@@ -0,0 +1,104 @@
'use strict';
// ---- Recipes: your Mealie recipes and shopping list, through the hub (the server holds the Mealie token). A recipe's ingredients go on one shopping list, which is
// also kept in Tasks (a "Shopping" list there), so it can be ticked off from either place.
const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts);
const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; };
const rcTabs = on => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
let rcQuery = '', rcPage = 1;
async function viewRecipes(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Recipes</h1><p class="empty">Loading…</p></div>';
try {
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
if (args[0] === 'shop') return await rcShop();
return await rcHome();
} catch (e) {
if (!location.hash.startsWith('#/recipes')) return;
v.innerHTML = `<div class="wrap"><h1>Recipes</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rcretry">Try again</button></div></div>`;
$('#rcretry').onclick = () => route();
}
}
async function rcHome() {
const draw = (items, more, page) => {
const g = $('#rcgrid'); if (!g) return;
const html = items.map(r => `<a class="vcard" href="#/recipes/r/${encodeURIComponent(r.slug)}"><span class="vimg" style="aspect-ratio:4/3">${r.image ? `<img loading="lazy" alt="" src="/api/recipes/image/${encodeURIComponent(r.id)}" onerror="this.remove()">` : ''}</span>
<b>${esc(r.name)}</b><small>${esc([rcTime(r.minutes), r.rating ? '★'.repeat(Math.round(r.rating)) : ''].filter(Boolean).join(' · '))}</small></a>`).join('');
if (page > 1) g.insertAdjacentHTML('beforeend', html); else g.innerHTML = html || '<p class="empty">No recipes found.</p>';
const mb = $('#rcmore'); if (mb) mb.hidden = !more;
};
const first = await rcApi('/?page=1&q=' + encodeURIComponent(rcQuery)); if (!location.hash.startsWith('#/recipes')) return;
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1><span class="pspace grow"></span><button class="btn" id="rcimp">${icon('plus')}Import from a link</button></div>${rcTabs('all')}
<input type="search" id="rcq" placeholder="Search your recipes" value="${esc(rcQuery)}" autocomplete="off" aria-label="Search recipes" style="width:100%;margin-bottom:12px">
<div class="vgrid" id="rcgrid"></div><div class="bar"><button class="btn ghost" id="rcmore" hidden>Show more</button></div></div>`;
rcPage = 1; draw(first.items, first.more, 1);
let timer, seq = 0;
const load = async page => { const my = ++seq; try { const j = await rcApi('/?page=' + page + '&q=' + encodeURIComponent(rcQuery)); if (my === seq) { rcPage = page; draw(j.items, j.more, page); } } catch (e) { toast(e.message); } };
$('#rcq').oninput = e => { clearTimeout(timer); timer = setTimeout(() => { rcQuery = e.target.value.trim(); load(1); }, 350); };
$('#rcmore').onclick = () => load(rcPage + 1);
$('#rcimp').onclick = () => {
const el = openSheet('Import a recipe', 'Mealie reads the recipe from the web page', `<input type="url" id="rcurl" placeholder="https://…" autocomplete="off" autocapitalize="off" spellcheck="false"><div class="err" id="rce"></div>
<div class="bar"><button class="btn ghost" id="rcx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="rcgo" style="flex:1;justify-content:center">Import</button></div>`);
el.querySelector('#rcx').onclick = closeSheet; const go = el.querySelector('#rcgo'), err = el.querySelector('#rce'), inp = el.querySelector('#rcurl'); inp.focus();
const run = async () => {
err.textContent = ''; go.disabled = true; go.textContent = 'Importing…';
try { const r = await rcApi('/import', rcOpt('POST', { url: inp.value.trim() })); closeSheet(); toast('Imported'); location.hash = '#/recipes/r/' + encodeURIComponent(r.slug); }
catch (e) { err.textContent = e.message; go.disabled = false; go.textContent = 'Import'; }
};
go.onclick = run; inp.onkeydown = e => { if (e.key === 'Enter') run(); };
};
}
async function rcOne(slug) {
const r = await rcApi('/r/' + encodeURIComponent(slug)); if (!location.hash.startsWith('#/recipes')) return;
const facts = [['Prep', rcTime(r.prep)], ['Cook', rcTime(r.cook)], ['Total', rcTime(r.total)], ['Serves', r.servings]].filter(f => f[1]);
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/recipes">${icon('back')}Recipes</a></div>
<div class="rchero">${r.image ? `<span class="vimg" style="width:min(240px,40%);flex:none;aspect-ratio:4/3"><img alt="" src="/api/recipes/image/${encodeURIComponent(r.id)}?big=1" onerror="this.remove()"></span>` : ''}
<div style="min-width:0"><h1 style="margin:0">${esc(r.name)}</h1>${r.description ? `<p class="sub" style="margin:6px 0 0;white-space:normal">${esc(r.description)}</p>` : ''}
${facts.length ? `<p class="sub" style="margin:8px 0 0">${facts.map(f => `<b>${esc(f[0])}</b> ${esc(f[1])}`).join(' · ')}</p>` : ''}
${r.orgUrl ? `<p class="sub" style="margin:4px 0 0"><a href="${esc(r.orgUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Original page</a></p>` : ''}</div></div>
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
<div class="rccols"><section><div class="rchead"><h2>Ingredients</h2><button class="btn ghost" id="rcreset" style="min-height:32px">Uncheck all</button></div>
<div class="list">${r.ingredients.map((i, n) => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<label class="row rcing" style="cursor:pointer"><input type="checkbox" data-n="${n}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty">No ingredients.</p>'}</div></section>
<section><div class="rchead"><h2>Steps</h2></div><ol class="rcsteps">${r.steps.map((s, n) => `<li data-s="${n}">${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol></section></div>
${r.notes.length ? `<h2>Notes</h2>${r.notes.map(n => `<p>${n.title ? `<b>${esc(n.title)}</b><br>` : ''}${esc(n.text).replace(/\n/g, '<br>')}</p>`).join('')}` : ''}</div>`;
// What you have used and done is remembered on this device for each recipe, so you can leave and come back while cooking.
const sk = 'rc_done:' + r.id; let done = store.get(sk, { i: [], s: [] }); if (!done || !Array.isArray(done.i)) done = { i: [], s: [] };
const paint = () => {
document.querySelectorAll('.rcing input').forEach(c => { const on = done.i.includes(+c.dataset.n); c.checked = on; c.nextElementSibling.style.cssText = on ? 'text-decoration:line-through;color:var(--muted)' : ''; });
document.querySelectorAll('.rcsteps li[data-s]').forEach(li => li.classList.toggle('done', done.s.includes(+li.dataset.s)));
$('#rcreset').hidden = !done.i.length && !done.s.length;
};
const flip = (list, n) => { const k = done[list].indexOf(n); if (k < 0) done[list].push(n); else done[list].splice(k, 1); if (done.i.length || done.s.length) store.set(sk, done); else store.del(sk); paint(); };
document.querySelectorAll('.rcing input').forEach(c => c.onchange = () => flip('i', +c.dataset.n));
document.querySelectorAll('.rcsteps li[data-s]').forEach(li => li.onclick = () => flip('s', +li.dataset.s));
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
paint();
if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
$('#rcadd').onclick = async e => {
const b = e.currentTarget; b.disabled = true;
try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; }
catch (err) { toast(err.message); b.disabled = false; }
};
}
async function rcShop() {
const draw = async l => {
if (!location.hash.startsWith('#/recipes/shop')) return;
const open = l.items.filter(i => !i.checked), done = l.items.filter(i => i.checked), row = i => `<label class="row rcitem" style="cursor:pointer"><input type="checkbox" data-id="${esc(i.id)}"${i.checked ? ' checked' : ''}><span class="grow"${i.checked ? ' style="text-decoration:line-through;color:var(--muted)"' : ''}>${esc(i.text)}</span></label>`;
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Shopping list</h1></div>${rcTabs('shop')}
<form id="rcnew" class="bar" style="margin:0 0 12px;gap:8px"><input type="text" id="rctext" placeholder="Add an item" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
<div class="list">${open.map(row).join('') || '<p class="empty">Nothing to buy. Open a recipe and add its ingredients.</p>'}</div>
${done.length ? `<h2>Got it</h2><div class="list">${done.map(row).join('')}</div><div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="rcclear">Remove ticked items</button></div>` : ''}
<p class="sub" style="margin-top:16px">Also in Tasks as the “Shopping” list: ticking an item there ticks it here.</p></div>`;
document.querySelectorAll('.rcitem input').forEach(c => c.onchange = async () => {
c.disabled = true; try { await rcApi('/shopping/check', rcOpt('POST', { id: c.dataset.id, checked: c.checked })); draw(await rcApi('/shopping')); } catch (e) { toast(e.message); c.checked = !c.checked; c.disabled = false; }
});
$('#rcnew').onsubmit = async e => { e.preventDefault(); const t = $('#rctext').value.trim(); if (!t) return; try { await rcApi('/shopping/add', rcOpt('POST', { text: t })); draw(await rcApi('/shopping')); } catch (err) { toast(err.message); } };
if ($('#rcclear')) $('#rcclear').onclick = async () => { try { await rcApi('/shopping/clear', rcOpt('POST', {})); draw(await rcApi('/shopping')); } catch (e) { toast(e.message); } };
};
try { await rcApi('/shopping/sync', rcOpt('POST', {})); } catch {} // (opening the list also brings Tasks up to date)
await draw(await rcApi('/shopping'));
}
+14 -3
View File
@@ -13,15 +13,19 @@ async function remindersInit() {
if (!(allowed('tasks') || allowed('calendar'))) return document.getElementById('sec-rem').remove();
if (!remSupported()) { box.innerHTML = `<div class="row"><span class="grow"><b>Not available here</b><small style="white-space:normal">${/iPhone|iPad/.test(navigator.userAgent) ? 'On iPhone and iPad, add NE Hub to the Home Screen first (Share → Add to Home Screen), then open it from there.' : 'This browser does not support push notifications.'}</small></span></div>`; return; }
let info; try { info = await jfetch('/api/push/key'); } catch (e) { box.innerHTML = `<div class="row"><span class="grow"><small>${esc(e.message)}</small></span></div>`; return; }
const reg = await navigator.serviceWorker.ready, sub = await reg.pushManager.getSubscription();
const reg = await Promise.race([navigator.serviceWorker.ready, new Promise(ok => setTimeout(ok, 4000))]); // (never ready: the browser refused the service worker)
if (!reg) { box.innerHTML = '<div class="row"><span class="grow"><b>Not available here</b><small style="white-space:normal">The browser did not start the background service reminders need. Reload the page, or open NE Hub over https in a regular browser.</small></span></div>'; return; }
const sub = await reg.pushManager.getSubscription();
const on = !!sub && Notification.permission === 'granted';
const post = (p, body) => jfetch('/api/push/' + p, { method: 'POST', body: JSON.stringify({ tz: myTz(), ...body }) });
box.innerHTML = `<label class="row" style="cursor:pointer"><span class="grow"><b>Reminders on this device</b><small id="remst" style="white-space:normal">${Notification.permission === 'denied' ? 'Blocked in the browser settings for this site; allow notifications there first' : on ? 'On' : 'Off'}${info.devices ? ' · ' + info.devices + (info.devices === 1 ? ' device' : ' devices') + ' in total' : ''}</small></span><input type="checkbox" id="remon" ${on ? 'checked' : ''} ${Notification.permission === 'denied' ? 'disabled' : ''}></label>
${allowed('tasks') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Tasks due today</b><small>One summary each morning</small></span><input type="checkbox" id="remt" ${info.tasks ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>Morning time</b></span><input type="time" id="remat" value="${esc(info.tasksAt)}" style="width:auto;min-width:130px"></div>` : ''}
${allowed('mail') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>New mail</b><small>When a new unread message arrives (checked every few minutes)</small></span><input type="checkbox" id="remmail" ${info.mail ? 'checked' : ''}></label>` : ''}
${allowed('calendar') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Upcoming events</b><small>Shortly before they start</small></span><input type="checkbox" id="reme" ${info.events ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''}
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>`;
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>
<div class="row"><span class="grow"><small>Dismiss every notification from NE Hub still showing on this device</small></span><button class="btn ghost" id="remclear" style="min-height:36px">Clear all</button></div>`;
const on1 = document.getElementById('remon');
on1.onchange = async () => {
try {
@@ -39,6 +43,13 @@ async function remindersInit() {
} catch (e) { on1.checked = !on1.checked; toast(e.message); }
};
const pref = (id, key, get) => { const el = document.getElementById(id); if (el) el.onchange = () => post('prefs', { [key]: get(el) }).catch(e => toast(e.message)); };
pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value);
pref('remmail', 'mail', e => e.checked); pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value);
document.getElementById('remclear').onclick = async () => {
try {
const list = await reg.getNotifications(); list.forEach(n => n.close());
try { navigator.clearAppBadge && await navigator.clearAppBadge(); } catch {}
toast(list.length ? 'Cleared ' + list.length + (list.length === 1 ? ' notification' : ' notifications') : 'No notifications to clear');
} catch (e) { toast(e.message); }
};
document.getElementById('remtest').onclick = () => post('test', {}).then(() => toast('Test sent')).catch(e => toast(e.message));
}
+23
View File
@@ -0,0 +1,23 @@
'use strict';
// Sideways rows (Continue watching, Collections, Recently added...): arrow buttons at the ends, and click-and-drag with a mouse, so nobody has to
// reach for the scrollbar. Touch and trackpads scroll them natively. Any .vrow that shows up on the page gets these.
(function () {
const enhance = row => {
if (row.dataset.sl) return; row.dataset.sl = '1';
const wrap = document.createElement('div'); wrap.className = 'vslide'; row.parentNode.insertBefore(wrap, row); wrap.appendChild(row);
const mk = (cls, label, d) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'vsl ' + cls; b.setAttribute('aria-label', label); b.innerHTML = d < 0 ? '‹' : '›'; b.onclick = () => row.scrollBy({ left: d * Math.max(200, row.clientWidth * .85), behavior: 'smooth' }); wrap.appendChild(b); return b; };
const l = mk('l', 'Scroll left', -1), r = mk('r', 'Scroll right', 1);
const sync = () => { l.hidden = row.scrollLeft < 8; r.hidden = row.scrollLeft + row.clientWidth >= row.scrollWidth - 8; };
row.addEventListener('scroll', sync, { passive: true }); addEventListener('resize', sync); new ResizeObserver(sync).observe(row); sync(); setTimeout(sync, 400); setTimeout(sync, 1500); // (posters arrive after the row is drawn)
let down = null, moved = false;
row.addEventListener('pointerdown', e => { if (e.pointerType !== 'mouse' || e.button !== 0) return; down = { x: e.clientX, left: row.scrollLeft }; moved = false; });
addEventListener('pointermove', e => { if (!down || !row.isConnected) return; const dx = e.clientX - down.x; if (Math.abs(dx) > 5) { moved = true; row.classList.add('drag'); } if (moved) row.scrollLeft = down.left - dx; });
addEventListener('pointerup', () => { if (down) { down = null; setTimeout(() => row.classList.remove('drag'), 0); } });
row.addEventListener('click', e => { if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; } }, true); // (a drag is not a tap on a poster)
row.addEventListener('dragstart', e => e.preventDefault());
};
let queued = 0;
const scan = () => { queued = 0; document.querySelectorAll('.vrow:not([data-sl])').forEach(enhance); };
new MutationObserver(() => { if (!queued) queued = requestAnimationFrame(scan); }).observe(document.getElementById('view') || document.body, { childList: true, subtree: true });
scan();
})();
+37
View File
@@ -0,0 +1,37 @@
'use strict';
// ---- "Save to Files": copy a book, free audiobook or Verse By Verse lesson into your Seafile. You pick the library and folder; the hub does the downloading and
// uploading (a whole study keeps going in the background), skips what is already there, and tells you when it is done.
const sfLoc = () => { try { return JSON.parse(localStorage.getItem('save_loc:' + (ME ? ME.id : ''))); } catch { return null; } };
async function saveToFiles(title, body) {
if (!allowed('files')) return toast('Your account does not have Files');
if (!sf.token()) return toast('Open Files and sign in first', { label: 'Files', run: () => { location.hash = '#/files'; } });
const dst = await chooseFolder('Save “' + title + '” in…', sfLoc(), { newFolder: true }); if (!dst) return;
try { localStorage.setItem('save_loc:' + (ME ? ME.id : ''), JSON.stringify(dst)); } catch {}
let job; try { job = await jfetch('/api/save/', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, repo: dst.repo, dir: dst.dir }) }); } catch (e) { return toast(e.message); }
sfProgress(job, dst);
}
function sfProgress(job, dst) {
let hidden = false, el = null;
const where = () => (dst.repoName || 'Files') + (dst.dir === '/' ? '' : dst.dir) + '/' + job.where;
const open = () => { location.hash = '#/files/' + encodeURIComponent(dst.repo) + (dst.dir + '/' + job.where).split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); };
const draw = () => {
if (!el || !document.body.contains(el)) return; const n = job.done + job.skipped + job.missing + job.failed, pct = job.total ? Math.round(n / job.total * 100) : 100;
el.querySelector('#sfb').innerHTML = `<p class="sub" style="margin:0 0 8px;word-break:break-word">${esc(where())}</p><div style="height:8px;border-radius:99px;background:var(--panel2);overflow:hidden"><div style="height:100%;width:${pct}%;background:var(--accent);transition:width .3s"></div></div>
<p style="margin:10px 0 0">${job.state === 'running' ? `Saving ${Math.min(n + 1, job.total)} of ${job.total}…` : job.state === 'done' ? 'Done' : job.state === 'cancelled' ? 'Stopped' : 'Could not finish'}</p>${job.current && job.state === 'running' ? `<p class="sub" style="margin:2px 0 0;word-break:break-word">${esc(job.current)}</p>` : ''}
<p class="sub" style="margin:6px 0 0">${esc(sfSummary(job))}</p>${job.error ? `<div class="err">${esc(job.error)}</div>` : ''}${job.errors.length && !job.error ? `<div class="err" style="font-size:13px">${job.errors.map(esc).join('<br>')}</div>` : ''}`;
const b = el.querySelector('#sfx'); b.textContent = job.state === 'running' ? 'Stop' : 'Close'; el.querySelector('#sfh').hidden = job.state !== 'running'; el.querySelector('#sfo').hidden = job.state === 'running' || !job.done && !job.skipped;
};
el = openSheet('Saving to Files', job.label, '<div id="sfb"></div><div class="bar"><button class="btn ghost" id="sfx" style="flex:1;justify-content:center">Stop</button><button class="btn ghost" id="sfh" style="flex:1;justify-content:center">Hide</button><button class="btn" id="sfo" hidden style="flex:1;justify-content:center">Open folder</button></div>');
el.querySelector('#sfx').onclick = async () => { if (job.state === 'running') { try { await fetch('/api/save/job/' + job.id, { method: 'DELETE' }); } catch {} } else closeSheet(); };
el.querySelector('#sfh').onclick = () => { hidden = true; closeSheet(); toast('Saving in the background'); };
el.querySelector('#sfo').onclick = () => { closeSheet(); open(); };
draw();
const tick = async () => {
try { job = await jfetch('/api/save/job/' + job.id); } catch (e) { if (e.status === 404) return; setTimeout(tick, 4000); return; }
draw();
if (job.state === 'running') return setTimeout(tick, 1200);
if (hidden || !document.body.contains(el)) toast(job.state === 'done' ? `Saved ${job.done} file${job.done === 1 ? '' : 's'} to Files` + (job.failed ? `, ${job.failed} failed` : '') : job.error || 'Saving stopped', job.done || job.skipped ? { label: 'Open', run: open } : undefined);
};
setTimeout(tick, 800);
}
const sfSummary = j => [j.done && `${j.done} saved`, j.skipped && `${j.skipped} already there`, j.missing && `${j.missing} with no handout`, j.failed && `${j.failed} failed`].filter(Boolean).join(' · ');
+31
View File
@@ -0,0 +1,31 @@
'use strict';
// ---- iPad and Screen Time (admin): which web addresses the app needs, the ones devices were seen reaching, and a check you run on the device itself.
const stOk = u => /^https?:\/\//i.test(u || '') ? '/api/img?u=' + encodeURIComponent(u) : (u || ''); // a picture from another site, passed through the hub so the browser only talks to the hub
async function screenTimeSheet() {
let d; try { d = await jfetch('/api/screentime'); } catch (e) { return toast(e.message); }
const row = (x, extra = '') => `<div class="row"><span class="grow"><b>${esc(x.host)}</b><small style="white-space:normal">${esc(x.why || ((x.what || []).join(', ') + ' · seen ' + x.n + (x.n === 1 ? ' time' : ' times')))}</small></span>${extra}<span class="stres" data-h="${esc(x.host)}"></span><button class="ibtn" data-c="${esc(x.host)}" aria-label="Copy ${esc(x.host)}" title="Copy">${icon('copy')}</button></div>`;
const hub = d.required[0].host;
const el = openSheet('iPad and Screen Time', 'Let the hub through Screen Time without adding each site one by one', `
<p style="margin:0 0 8px"><b>The simple way.</b> On the iPad: <b>Settings → Screen Time → Content &amp; Privacy Restrictions → Content Restrictions → Web Content</b>. Choose <b>Limit Adult Websites</b>, then under <b>Always Allow</b> tap <b>Add Website</b> and enter <b>${esc(hub)}</b>. Nothing else needs adding: the hub carries the music, books, podcasts and pictures itself.</p>
<p class="sub" style="white-space:normal">Only if you use <b>Allowed Websites Only</b> do you need to add every address below.</p>
<h2>Addresses the app needs</h2><div class="list">${d.required.map(x => row(x)).join('')}</div>
${d.seen.length ? `<h2>Others your devices reached</h2><p class="sub" style="white-space:normal">Seen while people used the app (a podcast cover, a weather extension, a site you added). Add only what you want available.</p><div class="list">${d.seen.map(x => row(x)).join('')}</div>` : '<p class="sub" style="white-space:normal">No other addresses have been seen yet. Use the app for a day on the iPad and any others will show up here.</p>'}
<h2>Only sometimes</h2><div class="list">${d.optional.map(x => row(x)).join('')}</div>
<div class="bar"><button class="btn ghost" id="stcopy" style="flex:1;justify-content:center">Copy the list</button><button class="btn" id="stchk" style="flex:1;justify-content:center">Check this device</button></div>
<p class="sub" style="white-space:normal">“Check this device” tries each address from the device you are on, and shows which ones Screen Time (or the network) is blocking. Run it on the iPad.</p>
${d.seen.length ? '<div class="bar"><button class="btn ghost" id="stclr">Clear the seen list</button></div>' : ''}`);
el.querySelector('.sheet').classList.add('wide');
el.querySelectorAll('[data-c]').forEach(b => b.onclick = () => { navigator.clipboard.writeText(b.dataset.c).then(() => toast('Copied ' + b.dataset.c), () => toast(b.dataset.c)); });
const all = [...d.required, ...d.seen, ...d.optional].map(x => x.host);
el.querySelector('#stcopy').onclick = () => navigator.clipboard.writeText(all.join('\n')).then(() => toast('Copied ' + all.length + ' addresses'), () => toast('Could not copy'));
el.querySelector('#stchk').onclick = async e => {
const b = e.currentTarget; b.disabled = true; b.textContent = 'Checking…';
await Promise.all(all.map(async h => {
const mark = el.querySelector(`.stres[data-h="${CSS.escape(h)}"]`); if (!mark) return;
try { await fetch(h === location.hostname ? '/manifest.webmanifest' : 'https://' + h + '/', { mode: 'no-cors', cache: 'no-store', signal: AbortSignal.timeout(8000) }); mark.textContent = ' ✓'; mark.style.color = 'var(--accent)'; mark.title = 'Reachable'; }
catch { mark.textContent = ' ✗'; mark.style.color = 'var(--danger)'; mark.title = 'Blocked or not reachable from this device'; }
}));
b.disabled = false; b.textContent = 'Check again';
};
if (el.querySelector('#stclr')) el.querySelector('#stclr').onclick = async () => { try { await jfetch('/api/screentime', { method: 'DELETE' }); closeSheet(); toast('Cleared'); } catch (x) { toast(x.message); } };
}
+56 -11
View File
@@ -25,10 +25,30 @@ async function searchMusic(term) {
return items.slice(0, 16);
}
// Plex movies and shows (and episodes) from the server-wide search.
async function searchVideo(term) {
const mc = await px.get(`/hubs/search?query=${encodeURIComponent(term)}&limit=12`), items = [];
for (const hub of mc.Hub || []) for (const m of hub.Metadata || []) if (['movie', 'show', 'episode'].includes(m.type)) items.push(m);
return items.slice(0, 24);
}
// Podgrab: the podcasts you follow, matched on title and author.
async function searchPodcasts(term) {
const list = await jfetch('/api/podcast/podcasts'), lower = term.toLowerCase();
return (Array.isArray(list) ? list : []).filter(s => ((pdF(s, 'title') || '') + ' ' + (pdF(s, 'author') || '')).toLowerCase().includes(lower)).slice(0, 12);
}
// The audiobook library, matched on title, author and series.
async function searchBooks(term) {
const [data] = await Promise.all([jfetch('/api/ab/books'), abLoadProg()]), all = Array.isArray(data) ? data : (data.books || Object.values(data)), lower = term.toLowerCase();
return all.filter(b => (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(lower)).slice(0, 10);
}
function viewSearch() {
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Tasks, events, files, music…" autocomplete="off" autocapitalize="off">
const kidAcct = !!(ME && ME.kid && ME.kid.on); // (a child's web search is the restricted Learn search, and there are no links out to the open web)
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Mail, chat, notes, tasks, events, files, music, recipes, contacts, books…" autocomplete="off" autocapitalize="off">
<p class="sub" id="src" style="margin:8px 0 0"></p>
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
<div class="bar" id="web" ${kidAcct ? 'hidden' : ''}><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
<div id="res"></div></div>`;
const q = $('#q'); q.focus();
@@ -41,12 +61,12 @@ function viewSearch() {
const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓';
const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)}`;
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)} · Movies & TV ${part(r.video, musicOff)} · Podcasts ${part(r.pods, '✓')} · Audiobooks ${part(r.books, '✓')}${allowed('mail') && cfg.mail ? ' · Mail ' + part(r.mail, '✓') : ''}`;
};
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
showSources();
// Duck.ai and DuckDuckGo refuse to be shown inside other apps, so these hand the question over in a new tab.
const webLinks = () => { const t = encodeURIComponent(q.value.trim()); $('#duckai').href = 'https://duck.ai/' + (t ? '?q=' + t : ''); $('#ddg').href = 'https://duckduckgo.com/' + (t ? '?q=' + t : ''); };
const webLinks = () => { if (kidAcct) return; const t = encodeURIComponent(q.value.trim()); $('#duckai').href = 'https://duck.ai/' + (t ? '?q=' + t : ''); $('#ddg').href = 'https://duckduckgo.com/' + (t ? '?q=' + t : ''); };
q.oninput = () => { webLinks(); clearTimeout(timer); timer = setTimeout(() => runSearch(q.value.trim(), ++seq), 350); };
async function runSearch(term, mySeq) {
const res = $('#res');
@@ -55,12 +75,17 @@ function viewSearch() {
const enc = encodeURIComponent, lower = term.toLowerCase();
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
const [libs, files, music, ddg, hub] = await Promise.allSettled([
const [libs, files, music, video, pods, books, ddg, hub, mailR, myBooks] = await Promise.allSettled([
libsP,
libsP.then(l => searchFiles(term, l)),
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
jfetch('/api/ddg?q=' + enc(term)),
plexReady ? searchVideo(term) : Promise.reject(new Error('not connected')),
allowed('podcast') ? searchPodcasts(term) : Promise.reject(new Error('not allowed')),
allowed('audio') ? searchBooks(term) : Promise.reject(new Error('not allowed')),
jfetch((kidAcct ? '/api/kidsearch?q=' : '/api/ddg?q=') + enc(term)),
jfetch('/api/search?q=' + enc(term)),
allowed('mail') && cfg.mail ? jfetch('/api/mail/messages?folder=inbox&q=' + enc(term)) : Promise.reject(new Error('not allowed')),
allowed('read') ? jfetch('/api/read/list') : Promise.reject(new Error('not allowed')),
]);
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
@@ -78,13 +103,27 @@ function viewSearch() {
else if (m.type === 'album') musicHtml += `<a class="row" href="#/music/album/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Album · ${esc(m.parentTitle || '')}</small></span></a>`;
else if (m.type === 'track') { const tr = mapTrack(m, {}); if (tr) musicHtml += `<button class="row" data-t="${esc(JSON.stringify(tr))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb || m.parentThumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Track · ${esc(m.grandparentTitle || '')}</small></span></button>`; }
}
const d = ddg.status === 'fulfilled' ? ddg.value : {};
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
const videoHtml = (video.status === 'fulfilled' ? video.value : []).map(m => vdCard(m)).join('');
const podHtml = (pods.status === 'fulfilled' ? pods.value : []).map(s => `<a class="row" href="#/podcast/show/${enc(pdId(s))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${pdImg('podcasts', s)}" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(pdF(s, 'title'))}</b><small>Podcast${pdF(s, 'author') ? ' · ' + esc(pdF(s, 'author')) : ''}</small></span></a>`).join('');
const bookHtml = (books.status === 'fulfilled' ? books.value : []).map(b => `<a class="row" href="#/audio" data-book="${esc(abId(b))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${abCover(b)}" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(abTitle(b))}</b><small>Audiobook${abAuthor(b) ? ' · ' + esc(abAuthor(b)) : ''}</small></span></a>`).join('');
const mailHtml = (mailR.status === 'fulfilled' ? mailR.value.items || [] : []).slice(0, 8).map(m => `<a class="row" href="#/mail/m/${enc(m.id)}"><span class="ic" style="background:#d4392f">${icon('mail')}</span><span class="grow"><b>${esc(m.subject)}</b><small>${esc((m.from && (m.from.name || m.from.address)) || '')} · ${esc(new Date(m.date).toLocaleDateString())}</small></span></a>`).join('');
const d = ddg.status === 'fulfilled' && !kidAcct ? ddg.value : {}, kd = ddg.status === 'fulfilled' && kidAcct ? ddg.value : {};
const kidCard = !kidAcct ? '' : kd.blocked ? `<p class="sub" style="margin-top:14px">${esc(kd.message || 'That search is not available.')}</p>` : (kd.results || []).length ? `<h2>Learn</h2><div class="list">${kd.results.map((r, i) => `<button class="row" data-ks="${i}"><span class="ic" style="background:#2f9e6e">${icon('book')}</span><span class="grow"><b>${esc(r.title)}</b><small style="white-space:normal">${esc(r.extract.slice(0, 170))}</small></span></button>`).join('')}</div><small class="sub">From Simple English Wikipedia</small>` : '';
const card = kidAcct ? kidCard : (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
${d.image ? `<img alt="" src="${esc(stOk(d.image))}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
<div style="min-width:0"><small style="color:var(--muted)">From DuckDuckGo${d.source ? ' · ' + esc(d.source) : ''}</small>
${d.heading ? `<div style="font-weight:700;margin-top:2px">${esc(d.heading)}</div>` : ''}${d.answer ? `<div style="font-size:18px;margin-top:2px">${esc(d.answer)}</div>` : ''}
${d.text ? `<p style="margin:4px 0 0;font-size:14px">${esc(d.text)}</p>` : ''}${d.url ? `<a href="${esc(d.url)}" target="_blank" rel="noopener" style="color:var(--accent);font-size:14px">Read more</a>` : ''}</div></div>` : '';
const h = hub.status === 'fulfilled' ? hub.value : { tasks: [], events: [], errors: [] };
// the family parts: notes, chat, contacts, chores, recipes, and the books on your shelf
const row = (href, color, ic, title, sub) => `<a class="row" href="${href}"><span class="ic" style="background:${color}">${icon(ic)}</span><span class="grow"><b>${esc(title)}</b><small style="white-space:normal">${esc(sub)}</small></span></a>`;
const myBookHtml = (myBooks.status === 'fulfilled' && Array.isArray(myBooks.value) ? myBooks.value : []).filter(b => ((b.title || '') + ' ' + (b.author || '')).toLowerCase().includes(lower)).slice(0, 8).map(b => row('#/read/open/' + enc(b.id), '#2b8a5e', 'book', b.title, b.author || 'Your books')).join('');
const famHtml = sec('Notes and lists', (h.notes || []).map(n => row('#/notes/' + enc(n.id), '#e5b800', n.type === 'list' ? 'check' : 'doc', n.title, n.share + (n.snippet ? ' · ' + n.snippet : ''))).join(''))
+ sec('Chat', (h.chat || []).map(c => row('#/chat/' + enc(c.conv), '#2aa6a0', 'chat', c.convName, c.msg.name + ': ' + c.msg.text)).join(''))
+ sec('Contacts', (h.contacts || []).map(c => row('#/contacts/c/' + enc(c.id), '#5b8def', 'people', c.name, [c.org, c.email, c.phone].filter(Boolean).join(' · '))).join(''))
+ sec('Chores', (h.chores || []).map(c => row('#/chores', '#2f9e6e', 'check', c.title, [c.repeat, c.who, c.points ? c.points + ' pts' : ''].filter(Boolean).join(' · '))).join(''))
+ sec('Recipes', (h.recipes || []).map(r => row('#/recipes/r/' + enc(r.slug), '#e0742b', 'chef', r.name, r.minutes ? r.minutes + ' min' : 'Recipe')).join(''))
+ sec('Your books', myBookHtml);
evStore.length = 0;
const taskHtml = (h.tasks || []).map(t => { const du = dueInfo(t.due);
return `<button class="row" data-tl="${esc(t.list)}"><span class="ic" style="background:#4f7cff">${icon('check')}</span><span class="grow"><b${t.done ? ' style="text-decoration:line-through;color:var(--muted)"' : ''}>${esc(t.title)}</b><small>${esc(t.listName)}${du ? ' · ' + esc(du.text) : ''}${t.done ? ' · done' : ''}</small></span></button>`; }).join('');
@@ -96,12 +135,18 @@ function viewSearch() {
events: hub.status === 'fulfilled' ? { n: (h.events || []).length } : { err: hub.reason && hub.reason.message },
files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' },
music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message },
pods: !allowed('podcast') ? undefined : pods.status === 'fulfilled' ? { n: pods.value.length } : { err: pods.reason && pods.reason.message },
books: !allowed('audio') ? undefined : books.status === 'fulfilled' ? { n: books.value.length } : { err: books.reason && books.reason.message },
mail: !(allowed('mail') && cfg.mail) ? undefined : mailR.status === 'fulfilled' ? { n: (mailR.value.items || []).length } : { err: mailR.reason && mailR.reason.message },
video: !plexReady ? undefined : video.status === 'fulfilled' ? { n: video.value.length } : { err: video.reason && video.reason.message },
});
const notes = [...(h.errors || [])].map(e => `<p class="sub">${esc(e)}</p>`).join('');
const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).join('') : '';
res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + notes;
if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
res.innerHTML = card + famHtml + sec('Mail', mailHtml) + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `<h2>Movies & TV</h2><div class="vgrid">${videoHtml}</div>` : '') + notes;
if (!famHtml && !taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !mailHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
res.querySelectorAll('[data-ks]').forEach(b => b.onclick = async () => { const r = kd.results[+b.dataset.ks]; try { const pg = await jfetch('/api/kidsearch/page?title=' + enc(r.title)); openSheet(pg.title, 'From Simple English Wikipedia', `<div style="white-space:pre-wrap;max-height:62dvh;overflow:auto;line-height:1.5">${esc(pg.text)}</div>`); } catch (e) { toast(e.message); } });
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
res.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (books.value || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; });
}
+59
View File
@@ -0,0 +1,59 @@
'use strict';
// ---- Settings > Family: the family's settings in one place (chat, your PIN, chores and rewards, who is on the family board, shared screens).
// Each row opens the same sheet that used to live inside that part of the hub.
const famRow = (id, ic, title, sub) => `<button class="row" id="${id}"><span class="ic" style="background:#6a7280">${icon(ic)}</span><span class="grow"><b>${esc(title)}</b><small style="white-space:normal">${esc(sub)}</small></span>${icon('chev', 'chev')}</button>`;
async function famManageChores() {
try { cr.data = await crApi('/?date=' + crYmd(new Date())); } catch (e) { return toast(e.message); }
cr.afterEdit = famManageChores; crManage();
}
async function famSettings(box) {
if (!box) return; const admin = ME && ME.role === 'admin', can = t => typeof allowed === 'function' && allowed(t);
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>' : ''}`;
const c = box.querySelector('#fs-chat'); if (c) c.onclick = () => chSettings();
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; }
if (admin) try {
const r = await jfetch('/api/users'), b = box.querySelector('#fs-board'); if (!b) return;
b.innerHTML = r.users.filter(u => !u.disabled).map(u => `<label class="row" style="cursor:pointer"><span class="ic" style="background:${u.role === 'admin' ? '#4f7cff' : '#6a7280'}">${esc(u.name.slice(0, 1).toUpperCase())}</span><span class="grow"><b>${esc(u.name)}</b>${u.kid ? '<small>Child</small>' : ''}</span><input type="checkbox" data-u="${esc(u.id)}" ${u.board === false ? '' : 'checked'}></label>`).join('');
b.querySelectorAll('input[data-u]').forEach(i => i.onchange = async () => { try { await jfetch('/api/users/' + encodeURIComponent(i.dataset.u), { method: 'PUT', body: JSON.stringify({ board: i.checked }) }); } catch (e) { toast(e.message); i.checked = !i.checked; } });
} catch (e) { const b = box.querySelector('#fs-board'); if (b) b.innerHTML = `<div class="row"><span class="grow"><small>${esc(e.message)}</small></span></div>`; }
}
// ---- Requests from children: what they asked for, with Yes / No.
async function famRequests(box) {
if (!box) return; let r; try { r = await jfetch('/api/kid/requests'); } catch { box.innerHTML = ''; return; }
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); } });
}
// ---- One row per child: their week (minutes by area, what they played, searched and tried), and Plex locking.
async function famKids(box) {
if (!box) return; let r; try { r = await jfetch('/api/users'); } catch (e) { box.innerHTML = `<div class="row"><span class="grow"><small>${esc(e.message)}</small></span></div>`; return; }
const kids = r.users.filter(u => u.kid && u.kid.on && !u.disabled);
box.innerHTML = kids.map(u => `<button class="row" data-k="${esc(u.id)}"><span class="ic" style="background:#2f9e6e">${esc(u.name.slice(0, 1).toUpperCase())}</span><span class="grow"><b>${esc(u.name)}</b><small>Activity and Plex</small></span>${icon('chev', 'chev')}</button>`).join('') || '<div class="row"><span class="grow"><small>No child accounts yet. Turn on <b>This is a child</b> for a person under Admin → People.</small></span></div>';
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;
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));
const day = d => new Date(d + 'T12:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
el.querySelector('#fcb').innerHTML = `<div class="list"><div class="row"><span class="grow"><b>${h(total)} in the app</b><small style="white-space:normal">${Object.entries(by).filter(([k, m]) => m > 0 && a.labels[k]).map(([k, m]) => esc(a.labels[k]) + ' ' + h(m)).join(' · ') || 'No time recorded yet'}</small></span></div>
${a.days.map(d => `<div class="row"><span class="grow"><small>${esc(day(d.day))}</small><div class="statbar"><i style="width:${Math.round(d.minutes / max * 100)}%"></i></div></span><small>${h(d.minutes)}</small></div>`).join('')}</div>
<h2 style="margin:14px 0 6px">Plex</h2><div class="list"><div class="row"><span class="grow"><b>${px.connected ? (px.profile ? 'Using Plex as ' + esc(px.profile) : 'Connected to Plex') : 'Not connected to Plex'}</b><small style="white-space:normal">${px.locked ? 'Locked to this profile: they cannot switch profiles or servers.' : 'They can switch Plex profiles if the other profile has no PIN. Lock it once you are on their managed profile.'}</small></span><button class="btn ${px.locked ? 'ghost' : ''}" id="fcpl">${px.locked ? 'Unlock' : 'Lock'}</button></div></div>
<h2 style="margin:14px 0 6px">What they did</h2><div class="list" style="max-height:38dvh;overflow:auto">${a.recent.map(e => `<div class="row"><span style="font-size:20px">${icn[e.kind] || '•'}</span><span class="grow"><b style="white-space:normal">${esc(e.title || e.kind)}</b><small>${esc(new Date(e.t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' }))}</small></span></div>`).join('') || '<p class="empty" style="margin:8px">Nothing yet.</p>'}</div>`;
el.querySelector('#fcpl').onclick = async () => { try { await jfetch('/api/kid/plex-lock', { method: 'POST', body: JSON.stringify({ user: u.id, locked: !px.locked }) }); toast(px.locked ? 'Unlocked' : 'Locked to this profile'); closeSheet(); famChild(u); } catch (e) { toast(e.message); } };
}
// ---- The words a parent blocks from a child's search (on top of a built-in list).
async function famWords() {
let r; try { r = await jfetch('/api/kidsearch/words'); } catch (e) { return toast(e.message); }
const el = openSheet('Blocked search words', 'A child searches Simple English Wikipedia only. Any search or result with one of these words (or a built-in blocked word) is hidden. One per line or separated by commas.', `<form id="fwf"><textarea id="fww" rows="6" style="width:100%" placeholder="a word you want blocked">${esc(r.words.join('\n'))}</textarea><div class="bar"><button type="button" class="btn ghost" id="fwx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
el.querySelector('#fwx').onclick = closeSheet; el.querySelector('#fwf').onsubmit = async e => { e.preventDefault(); try { await jfetch('/api/kidsearch/words', { method: 'POST', body: JSON.stringify({ words: el.querySelector('#fww').value }) }); closeSheet(); toast('Saved'); } catch (x) { toast(x.message); } };
}
+90 -20
View File
@@ -42,8 +42,16 @@ async function addPersonSheet() {
function personSheet(u) {
const self = u.id === ME.id, admin = u.role === 'admin';
const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `<form id="uf"><label for="un">Name</label><input type="text" id="un" value="${esc(u.name)}" autocomplete="off" required>
${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)}`}
const el = openSheet(u.name, admin ? 'Admin' : u.disabled ? 'Turned off' : 'User', `<form id="uf"><label for="un">Name</label><input type="text" id="un" value="${esc(u.name)}" autocomplete="off" required><label class="row" style="cursor:pointer"><span class="grow"><b>Simple mode</b><small style="white-space:normal">No menus: just a Home screen of big buttons for what they can use. Good for young children or grandparents.</small></span><input type="checkbox" id="usm"${u.simple ? ' checked' : ''}></label><label class="row" style="cursor:pointer"><span class="grow"><b>Show on the family board</b><small style="white-space:normal">Their name appears on the family screen, and their chores in the family progress</small></span><input type="checkbox" id="ubd"${u.board === false ? '' : ' checked'}></label>
${admin ? '<p class="sub">Admins can use everything.</p>' : `<label>What can they use?</label>${tabBoxes(u.tabsChosen)}
<h2 style="margin-top:14px">Child account</h2><label class="row" style="cursor:pointer"><span class="grow"><b>This is a child</b><small style="white-space:normal">Adds a daily time limit and a bedtime, and a safer Store and podcast search.</small></span><input type="checkbox" id="kon"${u.kid && u.kid.on ? ' checked' : ''}></label>
<div id="kopt" ${u.kid && u.kid.on ? '' : 'hidden'}><label for="klim">Time per day, in minutes (empty for no limit)</label><input type="number" id="klim" min="0" max="720" value="${u.kid && u.kid.limitMin ? u.kid.limitMin : ''}" placeholder="120">
<label for="kwe">Weekends, in minutes (empty: same as other days)</label><input type="number" id="kwe" min="0" max="720" value="${u.kid && u.kid.weekendMin ? u.kid.weekendMin : ''}" placeholder="180">
<label>Limit one part of the app (minutes a day, empty for no limit)</label><div class="list" id="kal">${[['video', 'Movies and TV'], ['music', 'Music'], ['listen', 'Podcasts, audiobooks, radio'], ['read', 'Books'], ['chat', 'Chat']].map(([k, l]) => `<div class="row"><span class="grow"><b>${l}</b></span><input type="number" data-a="${k}" min="0" max="720" style="width:90px" value="${u.kid && u.kid.areaLimits && u.kid.areaLimits[k] ? u.kid.areaLimits[k] : ''}" placeholder="none"></div>`).join('')}</div>
<label class="row" style="cursor:pointer"><span class="grow"><b>Bedtime</b><small>The app is locked in these hours</small></span><input type="checkbox" id="kbo"${u.kid && u.kid.bedtime && u.kid.bedtime.on ? ' checked' : ''}></label>
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="kbf">From</label><input type="time" id="kbf" value="${esc((u.kid && u.kid.bedtime && u.kid.bedtime.from) || '20:30')}"></div><div style="flex:1"><label for="kbt">To</label><input type="time" id="kbt" value="${esc((u.kid && u.kid.bedtime && u.kid.bedtime.to) || '07:00')}"></div></div>
<label class="row" style="cursor:pointer"><span class="grow"><b>Safer Store and podcast search</b><small style="white-space:normal">Books and audiobooks from a few kid-friendly sources and genres, and no podcasts marked explicit</small></span><input type="checkbox" id="ksf"${!u.kid || u.kid.safe !== false ? ' checked' : ''}></label>
<div class="row" id="kuse"><span class="grow"><small>Today</small><b id="kuset">…</b></span><button type="button" class="btn ghost" data-m="15">+15 min</button><button type="button" class="btn ghost" data-m="30">+30 min</button><button type="button" class="btn ghost" data-m="60">+1 h</button></div></div>`}
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Save</button></div><div class="err" id="ue"></div></form>
<div class="list" style="background:var(--panel2);border:0;margin-top:6px">
<button class="row" id="u-inv">${icon('copy')}<span class="grow"><b>Invite code for a new device</b><small>Also the way back in if they lose their device</small></span></button>
@@ -54,7 +62,12 @@ function personSheet(u) {
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
const act = async (fn, msg) => { try { await fn(); if (msg) toast(msg); closeSheet(); loadPeople(); } catch (err) { toast(err.message); } };
const put = body => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'PUT', body: JSON.stringify(body) });
el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, ...(admin ? {} : { tabs: pickedTabs(el) }) }), 'Saved'); };
const kon = el.querySelector('#kon'); if (kon) {
const fillUse = async () => { try { const r = await jfetch('/api/kid/usage?user=' + encodeURIComponent(u.id)); el.querySelector('#kuset').textContent = r.on ? r.used + ' min used' + (r.limit ? ' of ' + r.limit : ' (no limit)') + (r.locked ? ' · locked: ' + (r.locked === 'bedtime' ? 'bedtime' : 'time is up') : '') : 'Not saved as a child account yet'; } catch { el.querySelector('#kuset').textContent = ''; } };
kon.onchange = () => { el.querySelector('#kopt').hidden = !kon.checked; }; fillUse();
el.querySelectorAll('#kuse [data-m]').forEach(b => b.onclick = async () => { try { await jfetch('/api/kid/extra', { method: 'POST', body: JSON.stringify({ user: u.id, minutes: +b.dataset.m }) }); toast('Added ' + b.dataset.m + ' minutes for today'); fillUse(); } catch (err) { toast(err.message); } });
}
el.querySelector('#uf').onsubmit = e => { e.preventDefault(); act(() => put({ name: el.querySelector('#un').value, board: el.querySelector('#ubd').checked, simple: admin ? undefined : el.querySelector('#usm').checked, ...(admin ? {} : { tabs: pickedTabs(el), kid: { on: el.querySelector('#kon').checked, limitMin: +el.querySelector('#klim').value || 0, weekendMin: +el.querySelector('#kwe').value || 0, areaLimits: Object.fromEntries([...el.querySelectorAll('#kal [data-a]')].map(i => [i.dataset.a, +i.value || 0])), bedtime: { on: el.querySelector('#kbo').checked, from: el.querySelector('#kbf').value, to: el.querySelector('#kbt').value }, safe: el.querySelector('#ksf').checked } }) }), 'Saved'); };
el.querySelector('#u-inv').onclick = async () => { try { const r = await jfetch('/api/users/' + encodeURIComponent(u.id) + '/invite', { method: 'POST', body: '{}' }); codeSheet(u.name, r.code, r.minutes); } catch (err) { toast(err.message); } };
el.querySelector('#u-out').onclick = () => act(() => put({ signOutEverywhere: true }), u.name + ' was signed out everywhere');
const off = el.querySelector('#u-off'); if (off) off.onclick = () => act(() => put({ disabled: !u.disabled }), u.disabled ? 'Turned back on' : 'Turned off');
@@ -62,42 +75,53 @@ function personSheet(u) {
const del = el.querySelector('#u-del'); if (del) del.onclick = () => confirmSheet('Delete ' + u.name + '?', 'Their tasks, events, settings and sign-ins are permanently deleted.', 'Delete', () => act(() => jfetch('/api/users/' + encodeURIComponent(u.id), { method: 'DELETE' }), 'Deleted'));
}
function viewSettings() {
function viewSettings(args) {
if (args && args[0]) { try { store.set('settings_tab', args[0]); } catch {} } // (a link such as #/settings/family opens that tab)
const signed = !!sf.token();
const isAdmin = ME && ME.role === 'admin';
const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : '';
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
${ME ? `<h2>Signed in as</h2><div class="list"><div class="row"><span class="grow"><b>${esc(ME.name)}</b><small style="white-space:normal">${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}</small></span></div></div>` : ''}
${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<h2>Recovery codes</h2><div class="list"><div class="row"><span class="grow"><b id="rcn">Checking…</b><small style="white-space:normal">One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper).</small></span><button class="btn ghost" id="rcgen">Make codes</button></div></div>
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
<div id="sec-conn"><h2>Connections</h2><div class="list">
<div class="sgrp" id="settabs" role="tablist" aria-label="Settings sections">${[['general', 'General'], ['connections', 'Connections'], ['family', 'Family'], ['security', 'Security'], ...(isAdmin ? [['admin', 'Admin']] : [])].map(([k, l]) => `<a href="#" role="tab" data-g="${k}">${l}</a>`).join('')}</div>
<div class="sgroup" data-g="general">${ME ? `<h2>Signed in as</h2><div class="list"><div class="row"><span class="grow"><b>${esc(ME.name)}</b><small style="white-space:normal">${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}</small></span></div></div>` : ''}
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2>${installHtml()}</div>
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
<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') || 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>
<div id="sec-mail"><h2>Mail</h2><div class="list" id="mailst"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>
<div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy button on the Audiobooks tab to paste them into the site's sign-in form.</p>
<div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Your OpenAudible username and password (the server's audiobook library login). Saved on your server (encrypted) and shared with all your devices. The Audiobooks tab uses them to load your library and play books.</p>
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
<label for="ab-p">Password</label>
<input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div>
</div><div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
</div></div>
<div class="sgroup" data-g="family"><div id="famset"><p class="sub">Loading…</p></div></div>
<div class="sgroup" data-g="security"><h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<h2>Recovery codes</h2><div class="list"><div class="row"><span class="grow"><b id="rcn">Checking…</b><small style="white-space:normal">One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper).</small></span><button class="btn ghost" id="rcgen">Make codes</button></div></div>
</div>
<div class="sgroup" data-g="admin">${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
<h2>Health</h2><div class="list" id="health"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
<h2>iPad and Screen Time</h2><p class="sub" style="white-space:normal">Allow the hub on an iPad with Screen Time web limits, without adding every site one by one.</p><div class="bar"><button class="btn ghost" id="stbtn">Set up Screen Time</button></div>
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
</div></div>`;
remindersInit();
installInit();
// only show what this person may use
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
hide(!((allowed('mail') && cfg.mail) || cfg.mailAdmin), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
settingsTabs();
if ($('#sga-s')) {
const a = sgAlerts(), ns = $('#sga-ns'); $('#sga-s').checked = a.sound; $('#sga-n').checked = a.notify && 'Notification' in window && Notification.permission === 'granted';
if (!('Notification' in window)) { $('#sga-n').disabled = true; ns.textContent = 'This browser does not support notifications'; }
@@ -121,7 +145,20 @@ function viewSettings() {
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'; });
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
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 => {
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${bad ? '⚠ ' : '✓ '}${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
const gb = n => (n / 1e9).toFixed(1) + ' GB', days = t => Math.floor((Date.now() - t) / 864e5);
const up = h.uptime > 86400 ? Math.floor(h.uptime / 86400) + ' days' : h.uptime > 3600 ? Math.floor(h.uptime / 3600) + ' hours' : Math.max(1, Math.floor(h.uptime / 60)) + ' min';
$('#health').innerHTML =
(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.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.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) : '')
+ row('Server', 'Up ' + up + ' · Node ' + esc(h.node) + ' · lyrics ' + (h.lyrics ? 'on' : 'off') + ' · Signal ' + (h.signal ? 'on' : 'off'), false);
}).catch(() => { const e = $('#health'); if (e) e.innerHTML = '<div class="row"><small>Could not read the health report</small></div>'; });
if (isAdmin) jfetch('/api/system').then(i => {
const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable;
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
@@ -162,6 +199,7 @@ function viewSettings() {
const el = $('#audit'); if (!el) return;
el.innerHTML = events.length ? events.slice(0, 40).map(e => `<div class="row"><span class="grow"><b>${esc(e.event)}${e.who ? ' · ' + esc(e.who) : ''}</b><small style="white-space:normal">${esc(new Date(e.t).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${e.detail ? ' · ' + esc(e.detail) : ''}${e.ip ? ' · ' + esc(e.ip) : ''}</small></span></div>`).join('') : '<div class="row"><span class="grow"><small>Nothing yet</small></span></div>';
}).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '<div class="row"><span class="grow"><small>Could not load</small></span></div>'; });
if (typeof famSettings === 'function') famSettings($('#famset'));
$('#invpk').onclick = async () => {
try {
const { code, minutes } = await authPost('/api/auth/invite');
@@ -173,3 +211,35 @@ function viewSettings() {
$('#hubout').onclick = async () => { try { mcClear(); for (const k of Object.keys(localStorage)) if (k.startsWith('dc:')) localStorage.removeItem(k); } catch {} await authPost('/api/auth/logout'); location.replace('/login'); };
const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); };
}
// ---- Install: the browser's own prompt where it offers one (Chrome, Edge, Android); iPhone/iPad have to use Share
let installEvt = null;
addEventListener('beforeinstallprompt', e => { e.preventDefault(); installEvt = e; const b = document.getElementById('install-btn'); if (b) installInit(); });
addEventListener('appinstalled', () => { installEvt = null; const b = document.getElementById('install-box'); if (b) b.innerHTML = '<p class="sub">NE Hub is installed on this device.</p>'; });
const isInstalled = () => matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
function installHtml() {
const body = isInstalled() ? '<p class="sub">NE Hub is installed on this device.</p>'
: '<p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p><div class="bar"><button class="btn" id="install-btn" hidden>Install NE Hub</button></div>';
return '<div id="install-box">' + body + '</div>';
}
function installInit() {
const b = document.getElementById('install-btn'); if (!b || !installEvt) return;
b.hidden = false;
b.onclick = async () => { const ev = installEvt; installEvt = null; b.hidden = true; try { await ev.prompt(); } catch {} };
}
// ---- Settings sections: one group at a time (General, Connections, Security, Admin), the last one is remembered on this device
function settingsTabs() {
const tabs = [...document.querySelectorAll('#settabs a')], groups = [...document.querySelectorAll('.sgroup')];
const empty = g => !g.textContent.trim() && !g.querySelector('input, button');
for (const g of groups) if (empty(g)) { g.remove(); const a = tabs.find(x => x.dataset.g === g.dataset.g); if (a) a.remove(); }
const left = [...document.querySelectorAll('#settabs a')]; if (left.length < 2) { const b = $('#settabs'); if (b) b.hidden = true; }
const show = k => {
if (!left.some(a => a.dataset.g === k)) k = left[0] && left[0].dataset.g;
left.forEach(a => { a.classList.toggle('on', a.dataset.g === k); a.setAttribute('aria-selected', a.dataset.g === k); });
document.querySelectorAll('.sgroup').forEach(g => { g.hidden = g.dataset.g !== k; });
try { store.set('settings_tab', k); } catch {}
};
left.forEach(a => a.onclick = e => { e.preventDefault(); show(a.dataset.g); $('#view').scrollTop = 0; });
show(store.get('settings_tab', 'general'));
}
+17
View File
@@ -0,0 +1,17 @@
'use strict';
// Files shared into NE Hub from other apps (the manifest's share target; sw.js keeps them): choose a Seafile folder, then upload.
async function shareCheck() {
if (!('caches' in window)) return;
let c, keys;
try { c = await caches.open('hub-share'); keys = await c.keys(); } catch { return; }
if (!keys.length) return;
const clear = async () => { for (const k of await c.keys()) await c.delete(k); };
if (!allowed('files')) { await clear(); toast('You do not have the Files tab, so shared files were discarded'); return; }
const files = [];
for (const k of keys) { const r = await c.match(k); if (r) files.push(new File([await r.blob()], decodeURIComponent(r.headers.get('x-name') || 'file'), { type: r.headers.get('content-type') || '' })); }
const loc = store.get('office_loc');
const dst = await chooseFolder('Save ' + (files.length === 1 ? '“' + files[0].name + '”' : files.length + ' shared files') + ' to…', loc ? { repo: loc.repo, repoName: loc.repoName || '', dir: loc.dir } : null, { okLabel: 'Upload here', newFolder: true });
await clear();
if (!dst) return toast('Shared files discarded');
enqueueUploads(dst.repo, dst.dir, files.map(file => ({ file })));
}
+16 -2
View File
@@ -4,10 +4,24 @@
// ---- Offline notice
const bar = document.createElement('div');
bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true;
bar.textContent = 'You are offline. Showing what was saved on this device; changes will not sync until you are back online.';
bar.innerHTML = 'You are offline. <button type="button" id="offinfo" style="background:none;border:0;color:inherit;text-decoration:underline;font:inherit;cursor:pointer;padding:0 0 0 6px;min-height:0">What still works?</button>';
document.body.appendChild(bar);
const sync = () => { bar.hidden = navigator.onLine; };
addEventListener('online', sync); addEventListener('offline', sync); sync();
bar.querySelector('#offinfo').onclick = () => {
const el = openSheet('You are offline', 'The app opens, and these still work from what this device has saved:', `<ul style="line-height:1.7;padding-left:18px">
<li><b>Mail</b>: your last message lists and every message you have opened. Deleting and replying wait until you are back online.</li>
<li><b>Files</b>: folders you have opened, and the saved list of documents.</li>
<li><b>Calendar, tasks, reminders</b>: what was loaded last time.</li>
<li><b>Music, video, Signal</b>: need the connection (the song or movie streams from your server).</li></ul>
<p class="sub">Nothing is lost: it all catches up when you reconnect.</p><div class="bar"><button class="btn ghost" id="offx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#offx').onclick = closeSheet;
};
// Back online, or coming back to the app after a while away: refresh what is on screen quietly.
let hiddenAt = 0;
const refresh = () => { try { if (typeof mailPoll === 'function') mailPoll(); if (location.hash.startsWith('#/mail') && typeof mailLoadList === 'function' && typeof mail !== 'undefined' && mail.st && $('#mrows') && !mail.msg) mailLoadList(true); else if (/^#\/(home|planner|calendar|tasks)?$/.test(location.hash) && typeof route === 'function' && !document.getElementById('sheet')) route(); } catch {} };
addEventListener('online', () => { sync(); refresh(); });
document.addEventListener('visibilitychange', () => { if (document.hidden) hiddenAt = Date.now(); else if (hiddenAt && Date.now() - hiddenAt > 60e3 && navigator.onLine) { hiddenAt = 0; refresh(); } });
addEventListener('offline', sync); sync();
// ---- Skip link and landmarks
const skip = document.createElement('a');
+21 -10
View File
@@ -28,9 +28,10 @@ function sgChime() {
g.gain.setValueAtTime(0.0001, t + d); g.gain.exponentialRampToValueAtTime(0.22, t + d + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + d + 0.4);
o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); });
}
function sgBadgeEverywhere() {
document.title = (sg.unread ? '(' + (sg.unread > 99 ? '99+' : sg.unread) + ') ' : '') + 'NE Hub';
try { if (navigator.setAppBadge) { sg.unread ? navigator.setAppBadge(sg.unread) : navigator.clearAppBadge(); } } catch {}
function sgBadgeEverywhere() { // Signal chats, unread mail and tasks due: one number on the tab title and the installed app's icon
const n = (sg.unread || 0) + (typeof ch === 'object' ? ch.unread || 0 : 0) + (typeof mailUnread === 'number' ? mailUnread : 0) + (typeof taskDue === 'number' ? taskDue : 0);
document.title = (n ? '(' + (n > 99 ? '99+' : n) + ') ' : '') + 'NE Hub';
try { if (navigator.setAppBadge) { n ? navigator.setAppBadge(n) : navigator.clearAppBadge(); } } catch {}
}
function sgAlert(ev) { // a message from someone else arrived and you are not looking at that chat
const a = sgAlerts();
@@ -103,7 +104,7 @@ function sgBubble(m, c, prev) {
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-ts="${m.ts}">${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name || nm(m.from))}</small>` : ''}
${m.quote ? `<div class="sgq"><b>${esc(nm(m.quote.author))}</b><span>${esc(m.quote.text || '')}</span></div>` : ''}${att}
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${sgLink(esc(m.text)).replace(/\n/g, '<br>')}</div>` : ''}
<span class="sgmeta">${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ` <span class="${m.status === 'read' ? 'rd' : ''}">${tick}</span>` : ''}</span>
<span class="sgmeta">${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ` <span class="${m.status === 'read' ? 'sgrd' : ''}">${tick}</span>` : ''}</span>
${Object.keys(reacts).length ? `<span class="sgre">${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')}</span>` : ''}</div>`;
}
async function sgDrawThread(first) {
@@ -153,15 +154,25 @@ function sgWireComposer() {
sgq('#sgat').onclick = () => sgq('#sgfile').click();
sgq('#sgfile').onchange = e => { sg.tray.push(...e.target.files); e.target.value = ''; sgDrawTray(); };
ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { sg.tray.push(...fs); sgDrawTray(); } });
// The message shows up in the chat at once, dimmed as "Sending…"; Signal takes a few seconds to take it, and that happens in the background.
// If it fails, the text (and files) go back into the box so nothing is lost.
sgq('#sgform').onsubmit = async e => {
e.preventDefault(); const text = ta.value, files = [...sg.tray]; if (!text.trim() && !files.length) return;
const btn = sgq('#sgsend'); btn.disabled = true;
e.preventDefault(); const text = ta.value, files = [...sg.tray], reply = sg.reply, to = sg.cur; if (!text.trim() && !files.length) return;
ta.value = ''; grow(); sg.tray = []; sg.reply = null; sgDrawTray(); sgDrawReply(); ta.focus();
const list = sgq('#sgmsgs'), tmp = document.createElement('div');
tmp.className = 'sgb out sgpending'; tmp.style.opacity = '.65';
tmp.innerHTML = `${esc(text) || 'Attachment'}<div class="sgmeta"><small>Sending…</small></div>`;
if (list) { list.appendChild(tmp); list.scrollTop = list.scrollHeight; }
sg.sendQ = (sg.sendQ || Promise.resolve()).then(async () => { // one at a time, so quick messages arrive in the order you wrote them
try {
const attachments = []; for (const f of files) attachments.push({ name: f.name, type: f.type || 'application/octet-stream', data: await sgB64(f) });
await sgPost('/send', { to: sg.cur, text, attachments, quote: sg.reply ? sg.reply.ts : undefined });
ta.value = ''; grow(); sg.tray = []; sg.reply = null; sgDrawTray(); sgDrawReply(); await sgRefresh(); const el = sgq('#sgmsgs'); if (el) el.scrollTop = el.scrollHeight;
} catch (err) { toast(err.message); }
btn.disabled = false; ta.focus();
await sgPost('/send', { to, text, attachments, quote: reply ? reply.ts : undefined });
await sgRefresh(); tmp.remove(); const el = sgq('#sgmsgs'); if (el) el.scrollTop = el.scrollHeight;
} catch (err) {
tmp.remove(); toast(err.message || 'Could not send');
if (sg.cur === to && !ta.value) { ta.value = text; grow(); sg.tray = files; sg.reply = reply; sgDrawTray(); sgDrawReply(); }
}
});
};
}
// ---- editing contacts and removing chats
+12
View File
@@ -0,0 +1,12 @@
'use strict';
// ---- Simple mode: for young children or grandparents. A parent turns it on for a person; the app then has no menus, just a Home screen of big buttons for the few
// things they can use, and a big Home button to get back.
function simpleStart() {
if (!ME || !ME.simple) return; document.body.classList.add('simple');
if (!document.getElementById('simplehome')) { const b = document.createElement('a'); b.id = 'simplehome'; b.href = '#/home'; b.innerHTML = icon('home') + '<span>Home</span>'; document.body.appendChild(b); }
const sync = () => { const h = document.getElementById('simplehome'); if (h) h.hidden = !location.hash || location.hash === '#/' || location.hash === '#/home'; }; addEventListener('hashchange', sync); sync();
}
function viewSimple() {
const tabs = leafTabs().filter(t => !['settings', 'search', 'family', 'files', 'office', 'links', 'planner'].includes(t[0]) && t[0] !== 'home'), h = new Date().getHours(), greet = h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
$('#view').innerHTML = `<div class="wrap simplewrap"><h1>${greet}${ME ? ', ' + esc(ME.name.split(' ')[0]) : ''}!</h1><div class="simplegrid">${tabs.map(([id, label]) => `<a class="simpletile" href="#/${id}" style="background:${TAB_COLOR[id] || '#4f7cff'}">${icon(TAB_ICON[id] || 'menu')}<span>${esc(label)}</span></a>`).join('') || '<p class="empty">Nothing is turned on yet. Ask a parent.</p>'}</div></div>`;
}
+234
View File
@@ -0,0 +1,234 @@
'use strict';
// ---- Store: everything you can get, in one place. Books (free sources), free audiobooks, radio stations, and My Store: content the admin adds themselves (books,
// music, apps to download, and extensions that add features to the hub). The first three are the same screens as before, shown inside this one.
const stApi = (p, opts) => jfetch('/api/store/my' + p, opts);
const stOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
const ST_KINDS = [['book', 'Books', 'book'], ['music', 'Music', 'music'], ['app', 'Apps', 'files'], ['extension', 'Extensions', 'cog'], ['link', 'Sites', 'link']];
const stKind = k => ST_KINDS.find(x => x[0] === k) || ST_KINDS[2];
const stSize = b => fmtSize(+b || 0);
const stCover = it => it.cover ? `/api/store/my/items/${it.id}/cover?v=${it.updated}` : '';
const stHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
let stItems = [], stInstalled = [], stAdmin = false, stFilter = 'all';
/** The sections this person may open (each needs its own tab's permission, except My Store). */
const stSections = () => [['books', 'Books', allowed('read')], ['audiobooks', 'Audiobooks', allowed('audio')], ['radio', 'Radio', allowed('radio')], ['mine', 'My Store', true]].filter(s => s[2]);
async function viewStore(args = []) {
const secs = stSections(), want = args[0] === 'ext' || args[0] === 'site' ? 'mine' : args[0], sec = (secs.find(s => s[0] === want) || secs[0])[0];
if (!args[0] || (!secs.some(s => s[0] === args[0]) && args[0] !== 'ext' && args[0] !== 'site')) return location.replace('#/store/' + sec); // (a bare #/store opens the first section by its own address, so its page knows it is the one showing)
const v = $('#view');
v.innerHTML = `<div class="wrap" id="stwrap"><div class="ptop"><h1>Store</h1></div><div class="seg">${secs.map(s => `<a href="#/store/${s[0]}" class="${s[0] === sec ? 'on' : ''}">${esc(s[1])}</a>`).join('')}</div><div id="stbody"></div></div>`;
const el = $('#stbody'), host = { el, hash: '#/store/' + sec };
if (sec === 'books') return rdStore(host);
if (sec === 'audiobooks') return viewFreeBooks(host);
if (sec === 'radio') return viewRadio(host);
if (args[0] === 'ext' && args[1]) return stRunExt(el, args[1]);
if (args[0] === 'site' && args[1]) return stRunSite(el, args[1]);
return stMine(el);
}
// ---------------------------------------------------------------- My Store
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stAdmin = r.admin; return r; }
async function stMine(el) {
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const draw = () => {
if (!$('#stbody')) return;
const mine = stItems.filter(i => i.kind === 'extension' && stInstalled.includes(i.id)), shown = stItems.filter(i => stFilter === 'all' || i.kind === stFilter);
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><p class="sub" style="margin:0;flex:1">Books, music, apps and extensions added just for this hub.</p>${stAdmin ? `<button class="btn" id="stadd">${icon('plus')}Add</button>` : ''}</div>
${mine.length ? `<h2>My extensions</h2><div class="vgrid">${mine.map(i => stCard(i, true)).join('')}</div>` : ''}
<div class="seg" id="stkinds"><a href="#" data-k="all" class="${stFilter === 'all' ? 'on' : ''}">All</a>${ST_KINDS.map(k => `<a href="#" data-k="${k[0]}" class="${stFilter === k[0] ? 'on' : ''}">${k[1]}</a>`).join('')}</div>
${shown.length ? `<div class="vgrid">${shown.map(i => stCard(i)).join('')}</div>` : `<p class="empty">${stItems.length ? 'Nothing of that kind yet.' : stAdmin ? 'Nothing here yet. Add your first book, album, app or extension.' : 'Nothing has been added yet.'}</p>`}`;
el.querySelectorAll('#stkinds a').forEach(a => a.onclick = e => { e.preventDefault(); stFilter = a.dataset.k; draw(); });
el.querySelectorAll('[data-it]').forEach(a => a.onclick = e => { e.preventDefault(); const it = stItems.find(x => x.id === a.dataset.it); if (a.dataset.run) location.hash = '#/store/ext/' + it.id; else stDetail(it, draw); });
if ($('#stadd')) $('#stadd').onclick = () => stEdit(null, draw);
};
draw();
}
const stCard = (it, run) => `<a class="vcard" href="#" data-it="${esc(it.id)}"${run ? ' data-run="1"' : ''}><span class="vimg" style="aspect-ratio:${it.kind === 'book' ? '2/3' : '1/1'};background:linear-gradient(135deg,hsl(${stHue(it.title)} 55% 40%),hsl(${stHue(it.title) + 40} 55% 28%));display:grid;place-items:center;color:#fff;font-size:40px">${it.cover ? `<img loading="lazy" alt="" src="${stCover(it)}" onerror="this.remove()">` : esc(((it.kind === 'extension' || it.kind === 'link') && it.icon) || (it.title.match(/\p{L}|\p{N}/u) || ['?'])[0].toUpperCase())}</span>
<b>${esc(it.title)}</b><small>${esc([stKind(it.kind)[1].replace(/s$/, ''), it.creator].filter(Boolean).join(' · '))}</small></a>`;
function stDetail(it, redraw) {
const k = it.kind, f0 = it.files[0], isMine = k === 'extension' && stInstalled.includes(it.id);
const url = f => `/api/store/my/items/${it.id}/files/${f.id}`;
const el = openSheet(it.title, [stKind(k)[1].replace(/s$/, ''), it.creator, it.version && 'v' + it.version].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start">${it.cover ? `<span class="vimg" style="width:96px;flex:none;aspect-ratio:${k === 'book' ? '2/3' : '1/1'}"><img alt="" src="${stCover(it)}"></span>` : ''}
<div style="min-width:0">${it.description ? `<p style="margin:0;white-space:normal">${esc(it.description).replace(/\n/g, '<br>')}</p>` : '<p class="sub" style="margin:0">No description.</p>'}</div></div>
${k === 'music' || k === 'app' ? `<div class="list" style="margin-top:12px">${it.files.map((f, i) => `<div class="row"><span class="grow"><b>${esc(f.name)}</b><small>${esc(stSize(f.size))}</small></span>${k === 'music' ? `<button class="ibtn" data-play="${i}" aria-label="Play">${icon('play')}</button>` : ''}<a class="ibtn" href="${url(f)}?dl=1" download aria-label="Download">${icon('download')}</a></div>`).join('') || '<p class="empty">No files.</p>'}</div>` : ''}
${k === 'link' ? `<p class="sub" style="margin:10px 0 0;word-break:break-all">${esc(it.url)}</p>` : ''}<div class="err" id="sterr"></div><div class="bar" id="stbar" style="flex-wrap:wrap">
${k === 'book' && f0 ? `${allowed('read') ? '<button class="btn" id="stlib">Add to my library</button>' : ''}<a class="btn ghost" href="${url(f0)}?dl=1" download>Download</a>` : ''}
${k === 'music' && it.files.length ? `<button class="btn" id="stplay">${icon('play')}Play</button>` : ''}
${k === 'app' && it.url ? `<a class="btn" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">Open link</a>` : ''}
${k === 'link' ? `<button class="btn" id="stopen">Open</button><a class="btn ghost" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">New tab</a>` : ''}
${k === 'extension' && f0 ? `<button class="btn" id="stinst">${isMine ? 'Open' : 'Install'}</button>${isMine ? '<button class="btn ghost" id="stun">Remove</button>' : ''}` : ''}
${stAdmin ? '<button class="btn ghost" id="stedit">Edit</button>' : ''}</div>`);
const err = t => { el.querySelector('#sterr').textContent = t; };
const tracks = () => it.files.map(f => ({ key: `my:${it.id}:${f.id}`, title: f.name.replace(/\.[^.]+$/, '').replace(/^\d+[\s._-]+/, ''), artist: it.creator || '', album: it.title, thumb: stCover(it), src: url(f), nocache: true }));
const play = i => { playQueue(tracks(), i || 0); closeSheet(); };
if (el.querySelector('#stplay')) el.querySelector('#stplay').onclick = () => play(0);
el.querySelectorAll('[data-play]').forEach(b => b.onclick = () => play(+b.dataset.play));
if (el.querySelector('#stlib')) el.querySelector('#stlib').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const r = await stApi(`/items/${it.id}/to-library`, stOpt('POST')); closeSheet(); toast(r.duplicate ? 'Already in your library' : 'Added to your library', { label: 'Open', run: () => { location.hash = '#/read/open/' + r.id; } }); } catch (x) { err(x.message); b.disabled = false; } };
if (el.querySelector('#stopen')) el.querySelector('#stopen').onclick = () => { closeSheet(); if (it.embed) location.hash = '#/store/site/' + it.id; else window.open(it.url, '_blank', 'noopener,noreferrer'); };
if (el.querySelector('#stinst')) el.querySelector('#stinst').onclick = async e => { const b = e.currentTarget; try { if (!isMine) { b.disabled = true; stInstalled = (await stApi(`/ext/${it.id}/install`, stOpt('POST'))).installed; } closeSheet(); location.hash = '#/store/ext/' + it.id; } catch (x) { err(x.message); b.disabled = false; } };
if (el.querySelector('#stun')) el.querySelector('#stun').onclick = () => { closeSheet(); confirmSheet('Remove this extension?', 'Its saved settings are forgotten too.', 'Remove', async () => { try { stInstalled = (await stApi(`/ext/${it.id}/uninstall`, stOpt('POST'))).installed; toast('Removed'); redraw(); } catch (x) { toast(x.message); } }); };
if (el.querySelector('#stedit')) el.querySelector('#stedit').onclick = () => { closeSheet(); stEdit(it, redraw); };
}
// ---- adding and changing (admin)
function stEdit(it, redraw) {
const x = it || { kind: 'book', title: '', creator: '', description: '', version: '', url: '', icon: '', files: [], cover: false };
let created = false;
const el = openSheet(it ? 'Edit item' : 'Add to My Store', '', `<form id="stf">
${it ? '' : `<label for="stk">What is it?</label><select id="stk">${ST_KINDS.map(k => `<option value="${k[0]}">${k[1].replace(/s$/, '')}</option>`).join('')}</select>`}
<label for="stt">Title</label><input type="text" id="stt" value="${esc(x.title)}" autocomplete="off" required>
<label for="stc" id="stcl">Author</label><input type="text" id="stc" value="${esc(x.creator)}" autocomplete="off">
<label for="std">Description</label><textarea id="std" rows="3">${esc(x.description)}</textarea>
<div id="stver" hidden><label for="stv">Version</label><input type="text" id="stv" value="${esc(x.version)}" autocomplete="off"></div>
<div id="stlnk" hidden><label for="stu" id="stul">Link (optional)</label><input type="url" id="stu" value="${esc(x.url)}" placeholder="https://…" autocapitalize="off"></div>
<label id="stemb" class="row" style="cursor:pointer" hidden><span class="grow"><b>Open inside the hub</b><small>Some sites refuse to be shown this way; they open in a new tab instead</small></span><input type="checkbox" id="ste"${x.embed ? ' checked' : ''}></label>
<div id="stext" hidden><label for="sti">Icon (one letter or emoji)</label><input type="text" id="sti" value="${esc(x.icon || '')}" maxlength="2" style="width:80px"></div>
<div id="stfiles"><label>Files</label><div id="stfl" class="list"></div>
<div class="bar" style="justify-content:flex-start;margin:6px 0"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('plus')}Upload<input type="file" id="stup" hidden multiple></label>${allowed('files') ? `<button type="button" class="btn ghost" id="stsf">${icon('files')}From Files</button>` : ''}</div></div>
<label>Cover picture</label><div class="bar" style="justify-content:flex-start;margin:4px 0"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('plus')}Choose<input type="file" id="stcv" accept="image/jpeg,image/png,image/gif,image/webp" hidden></label><small id="stcvn" style="color:var(--muted)">${x.cover ? 'A cover is set' : ''}</small></div>
<div class="err" id="sterr"></div><div class="bar">${it ? '<button type="button" class="btn danger" id="stdel">Delete</button>' : ''}<button type="button" class="btn ghost" id="stx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="stok" style="flex:1;justify-content:center">${it ? 'Save' : 'Add'}</button></div></form>`);
const $e = s => el.querySelector(s), err = t => { $e('#sterr').textContent = t; };
let kind = x.kind; const files = [], existing = [...x.files]; let cover = null;
const kindUi = () => { kind = it ? x.kind : $e('#stk').value; $e('#stcl').textContent = kind === 'music' ? 'Artist' : kind === 'book' ? 'Author' : 'Made by'; $e('#stver').hidden = kind !== 'app' && kind !== 'extension'; $e('#stlnk').hidden = kind !== 'app' && kind !== 'link'; $e('#stul').textContent = kind === 'link' ? 'Address of the site' : 'Link (optional)'; $e('#stemb').hidden = kind !== 'link'; $e('#stext').hidden = kind !== 'extension' && kind !== 'link'; $e('#stfiles').hidden = kind === 'link';
$e('#stup').accept = kind === 'book' ? '.epub,.pdf,.mobi,.azw3,.azw,.docx' : kind === 'music' ? 'audio/*,.mp3,.m4a,.flac,.ogg,.opus,.wav' : kind === 'extension' ? '.html' : ''; $e('#stup').multiple = kind === 'music' || kind === 'app'; };
const drawFiles = () => { $e('#stfl').innerHTML = [...existing.map((f, i) => [f.name, stSize(f.size), 'e', i]), ...files.map((f, i) => [f.name || f.path.split('/').pop(), f.size != null ? stSize(f.size) : 'from Files', 'n', i])].map(([n, s, t, i]) => `<div class="row"><span class="grow"><b>${esc(n)}</b><small>${esc(s)}</small></span><button type="button" class="ibtn" data-t="${t}" data-i="${i}" aria-label="Remove">${icon('trash')}</button></div>`).join('') || '<p class="sub" style="margin:8px 12px">No files yet.</p>';
el.querySelectorAll('#stfl [data-t]').forEach(b => b.onclick = () => { (b.dataset.t === 'e' ? existing : files).splice(+b.dataset.i, 1); drawFiles(); }); };
if (!it) $e('#stk').onchange = kindUi; kindUi(); drawFiles();
$e('#stup').onchange = e => { const single = kind !== 'music' && kind !== 'app'; if (single) { files.length = 0; existing.length = 0; } for (const f of e.target.files) files.push(f); e.target.value = ''; drawFiles(); };
if ($e('#stsf')) $e('#stsf').onclick = async () => { const p = await stPickFile(); if (!p) return; if (kind !== 'music' && kind !== 'app') { files.length = 0; existing.length = 0; } files.push({ path: p.path, repo: p.repo, name: p.path.split('/').pop() }); drawFiles(); };
$e('#stcv').onchange = e => { cover = e.target.files[0] || null; $e('#stcvn').textContent = cover ? cover.name : ''; };
$e('#stx').onclick = closeSheet;
if ($e('#stdel')) $e('#stdel').onclick = () => { closeSheet(); confirmSheet('Delete this item?', it.title, 'Delete', async () => { try { await stApi('/items/' + it.id, { method: 'DELETE' }); toast('Deleted'); await stLoad(); redraw(); } catch (e) { toast(e.message); } }); };
$e('#stf').onsubmit = async e => {
e.preventDefault(); const ok = $e('#stok'); ok.disabled = true; err('');
try {
if (!files.length && !existing.length && kind !== 'app' && kind !== 'link') throw new Error('Add the file first');
let saved = await stApi('/items', stOpt('POST', { id: it && it.id, kind, title: $e('#stt').value, creator: $e('#stc').value, description: $e('#std').value, version: $e('#stv').value, url: $e('#stu').value, embed: !!$e('#ste').checked, icon: $e('#sti') ? $e('#sti').value : '' }));
if (!it) created = true;
for (const f of x.files) if (!existing.some(y => y.id === f.id)) saved = await stApi(`/items/${saved.id}/files/${f.id}`, { method: 'DELETE' });
let n = 0;
for (const f of files) {
ok.textContent = `Adding ${++n} of ${files.length}…`;
saved = f instanceof File ? await stApi(`/items/${saved.id}/files?name=${encodeURIComponent(f.name)}`, { method: 'POST', body: f, headers: { 'content-type': 'application/octet-stream' } }) : await stApi(`/items/${saved.id}/files`, stOpt('POST', { repo: f.repo, path: f.path }));
}
if (cover) saved = await stApi(`/items/${saved.id}/cover`, { method: 'POST', body: cover, headers: { 'content-type': cover.type } });
closeSheet(); toast(it ? 'Saved' : 'Added'); await stLoad(); redraw();
} catch (x2) { err(x2.message + (created ? ' The item was created without it: open it to finish.' : '')); ok.disabled = false; ok.textContent = it ? 'Save' : 'Add'; }
};
}
/** Pick one file from your Seafile libraries. Resolves { repo, path } or null. */
function stPickFile() {
return new Promise(resolve => {
if (!sf.token()) { toast('Open Files and sign in first'); return resolve(null); }
let repo = null, dir = '/', done = false;
const el = openSheet('Choose a file from Files', '', '<div id="stpk"></div>'); const finish = v => { done = true; closeSheet(); resolve(v); };
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
const draw = async () => {
const box = el.querySelector('#stpk'); box.innerHTML = '<p class="empty">Loading…</p>';
try {
if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted).sort((a, b) => a.name.localeCompare(b.name));
box.innerHTML = `<div class="list" style="max-height:50dvh;overflow:auto">${repos.map(r => `<button class="row" data-r="${esc(r.id)}">${icon('files')}<span class="grow"><b>${esc(r.name)}</b></span></button>`).join('')}</div><div class="bar"><button class="btn ghost" id="pcx" style="flex:1;justify-content:center">Cancel</button></div>`;
box.querySelectorAll('[data-r]').forEach(b => b.onclick = () => { repo = b.dataset.r; dir = '/'; draw(); });
} else {
const items = (await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).sort((a, b) => (a.type === b.type ? 0 : a.type === 'dir' ? -1 : 1) || a.name.localeCompare(b.name));
box.innerHTML = `<p class="sub" style="word-break:break-all">${esc(dir)}</p><div class="list" style="max-height:50dvh;overflow:auto"><button class="row" data-up="1">${icon('back')}<span class="grow"><b>${dir === '/' ? 'All libraries' : 'Up'}</b></span></button>
${items.map(x => `<button class="row" data-${x.type === 'dir' ? 'd' : 'f'}="${esc(x.name)}">${icon(x.type === 'dir' ? 'files' : 'doc')}<span class="grow"><b>${esc(x.name)}</b>${x.type === 'dir' ? '' : `<small>${esc(stSize(x.size))}</small>`}</span></button>`).join('')}</div><div class="bar"><button class="btn ghost" id="pcx" style="flex:1;justify-content:center">Cancel</button></div>`;
box.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { dir = (dir === '/' ? '' : dir) + '/' + b.dataset.d; draw(); });
box.querySelectorAll('[data-f]').forEach(b => b.onclick = () => finish({ repo, path: (dir === '/' ? '' : dir) + '/' + b.dataset.f }));
box.querySelector('[data-up]').onclick = () => { if (dir === '/') repo = null; else dir = dir.slice(0, dir.lastIndexOf('/')) || '/'; draw(); };
}
box.querySelector('#pcx').onclick = () => finish(null);
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
draw();
});
}
// ---------------------------------------------------------------- running an extension
// The extension is one HTML file in a sandboxed frame (no access to your sign-in or the hub's data). It talks to the hub only through the messages below.
async function stRunExt(el, id) {
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const it = stItems.find(x => x.id === id && x.kind === 'extension');
if (!it) { el.innerHTML = '<p class="empty">That extension is not in the store any more.</p>'; return; }
if (!stInstalled.includes(id)) { el.innerHTML = `<p class="empty">Install “${esc(it.title)}” from My Store first.</p>`; return; }
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}</b></div>
<iframe id="stframe" title="${esc(it.title)}" sandbox="allow-scripts allow-forms allow-popups allow-modals" referrerpolicy="no-referrer" style="width:100%;height:calc(100dvh - 190px);min-height:360px;border:1px solid var(--line);border-radius:12px;background:#fff"></iframe>`;
const frame = el.querySelector('#stframe'); let data = null, timer;
// (the page is fetched by the hub and shown from here, so the frame needs no sign-in of its own; its sandbox keeps it from touching anything of the hub's)
fetch(`/api/store/my/ext/${id}/page`).then(r => r.ok ? r.text() : Promise.reject(new Error('Could not load it'))).then(h => { frame.srcdoc = h; }).catch(e => { el.querySelector('#stframe').outerHTML = `<p class="err">${esc(e.message)}</p>`; });
const load = async () => data || (data = await stApi(`/ext/${id}/data`).catch(() => ({})));
const save = () => { clearTimeout(timer); timer = setTimeout(() => stApi(`/ext/${id}/data`, stOpt('POST', data)).catch(e => toast(e.message)), 400); };
// What an extension may ask the hub for. Each kind of access is asked of the person the first time and remembered until the extension is removed.
const WHY = { 'tasks.read': 'see your tasks', 'tasks.write': 'add tasks and mark them done', 'contacts.read': 'see your contacts', 'contacts.write': 'add contacts', 'calendar.read': 'see your calendar', 'calendar.write': 'add events to your calendar' };
let grants = null; const refused = new Set();
const allowedTo = async perm => {
if (!grants) grants = (await stApi(`/ext/${id}/grants`).catch(() => ({ grants: [] }))).grants;
if (grants.includes(perm)) return true; if (refused.has(perm) || !allowed(perm.split('.')[0] === 'calendar' ? 'calendar' : perm.split('.')[0])) return false;
const yes = await new Promise(ok => { let done = false; const el2 = openSheet('Allow access?', it.title, `<p>“${esc(it.title)}” would like to <b>${esc(WHY[perm])}</b>.</p><div class="bar"><button class="btn ghost" id="pno" style="flex:1;justify-content:center">Don't allow</button><button class="btn" id="pyes" style="flex:1;justify-content:center">Allow</button></div>`);
const fin = v => { if (!done) { done = true; closeSheet(); ok(v); } }; el2.querySelector('#pyes').onclick = () => fin(true); el2.querySelector('#pno').onclick = () => fin(false); el2.querySelector('.scrim').addEventListener('click', () => fin(false)); });
if (!yes) { refused.add(perm); return false; }
grants = [...grants, perm]; stApi(`/ext/${id}/grants`, stOpt('POST', { grants })).catch(() => {}); return true;
};
const jf = (p, o) => jfetch(p, o), pj = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) });
const txt = (v, n) => String(v == null ? '' : v).slice(0, n), ymd = v => /^\d{4}-\d{2}-\d{2}$/.test(String(v)) ? String(v) : '';
const hubCall = async (method, a = {}) => {
const need = { 'tasks.lists': 'tasks.read', 'tasks.list': 'tasks.read', 'tasks.add': 'tasks.write', 'tasks.complete': 'tasks.write', 'contacts.list': 'contacts.read', 'contacts.add': 'contacts.write', 'calendar.events': 'calendar.read', 'calendar.add': 'calendar.write' }[method];
if (!need) throw new Error('Unknown request'); if (!a || typeof a !== 'object') a = {};
if (!(await allowedTo(need))) throw new Error('The person did not allow that');
const localList = async name => { const l = (await jf('/api/tasks/lists')).lists.filter(x => x.local), n = txt(name, 60).toLowerCase(); return (n && l.find(x => x.name.toLowerCase() === n)) || l.find(x => x.id === 'local:inbox') || l[0]; };
if (method === 'tasks.lists') return (await jf('/api/tasks/lists')).lists.map(l => ({ id: l.id, name: l.name }));
if (method === 'tasks.list') return (await jf('/api/tasks?list=' + encodeURIComponent(txt(a.list, 80)))).map(t => ({ id: t.id, title: t.title, notes: t.notes || '', due: t.due || '', done: !!t.done }));
if (method === 'tasks.add') { const l = await localList(a.list); if (!l) throw new Error('No task list'); await jf('/api/tasks', pj('POST', { list: l.id, fields: { title: txt(a.title, 200), notes: txt(a.notes, 2000), due: ymd(a.due), done: false } })); return { ok: true, list: l.name }; }
if (method === 'tasks.complete') { const list = txt(a.list, 80), t = (await jf('/api/tasks?list=' + encodeURIComponent(list))).find(x => x.id === a.id); if (!t) throw new Error('Task not found'); await jf('/api/tasks', pj('POST', { list, id: t.id, fields: { title: t.title, notes: t.notes || '', due: t.due || '', done: true } })); return { ok: true }; }
if (method === 'contacts.list') return (await ctApi('/')).map(c => ({ id: c.id, name: c.name, org: c.org, emails: c.emails.map(e => e.value), phones: c.phones.map(p => p.value), birthday: c.birthday }));
if (method === 'contacts.add') { const c = await ctApi('/', pj('POST', { name: txt(a.name, 120), org: txt(a.org, 120), emails: a.email ? [{ label: '', value: txt(a.email, 120) }] : [], phones: a.phone ? [{ label: '', value: txt(a.phone, 60) }] : [], birthday: txt(a.birthday, 12), notes: txt(a.notes, 2000) })); ctList = null; return { id: c.id }; }
if (method === 'calendar.events') { const f = ymd(a.from), t = ymd(a.to); if (!f || !t) throw new Error('Give from and to as YYYY-MM-DD'); const q = new URLSearchParams({ from: new Date(f + 'T00:00:00').getTime(), to: new Date(t + 'T23:59:59').getTime(), fd: f, td: t });
return (await jf('/api/cal/events?' + q)).events.slice(0, 500).map(e => ({ title: e.title, start: e.start, end: e.end, allDay: !!e.allDay, calendar: e.cal })); }
if (method === 'calendar.add') { const cals = await jf('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('No calendar to add to'); const allDay = !!a.allDay, re = allDay ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
if (!re.test(a.start || '')) throw new Error('start must be ' + (allDay ? 'YYYY-MM-DD' : 'YYYY-MM-DDTHH:MM')); const end = re.test(a.end || '') ? a.end : a.start;
await jf('/api/cal/event', pj('POST', { cal: c.id, fields: { title: txt(a.title, 200), location: txt(a.location, 200), notes: txt(a.notes, 2000), allDay, start: a.start, end, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, repeat: 'none' } })); return { ok: true }; }
};
const onMsg = async e => {
if (!document.body.contains(frame)) return removeEventListener('message', onMsg);
if (e.source !== frame.contentWindow || !e.data || !e.data.hub) return;
const { id: mid, method, args = [] } = e.data; let result, error;
try {
if (method === 'storage.get') result = (await load())[String(args[0])] ?? null;
else if (method === 'storage.all') result = { ...(await load()) };
else if (method === 'storage.set') { const d = await load(); d[String(args[0]).slice(0, 100)] = args[1]; if (JSON.stringify(d).length > 256 * 1024) { delete d[String(args[0]).slice(0, 100)]; throw new Error('Out of space (256 KB)'); } save(); result = true; }
else if (method === 'storage.remove') { delete (await load())[String(args[0])]; save(); result = true; }
else if (method === 'toast') { toast(String(args[0]).slice(0, 200)); result = true; }
else if (method === 'user') result = { name: ME ? ME.name : '' };
else if (method.startsWith('tasks.') || method.startsWith('contacts.') || method.startsWith('calendar.')) result = await hubCall(method, args[0]);
else if (method === 'open') { if (/^https?:\/\//i.test(String(args[0]))) window.open(String(args[0]), '_blank', 'noopener,noreferrer'); result = true; }
else throw new Error('Unknown request');
} catch (x) { error = x.message; }
frame.contentWindow.postMessage({ hub: 1, id: mid, result, error }, '*');
};
addEventListener('message', onMsg);
}
/** A site shown inside the hub. It is another website in a frame of its own, so it cannot see anything of the hub's. */
async function stRunSite(el, id) {
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const it = stItems.find(x => x.id === id && x.kind === 'link');
if (!it) { el.innerHTML = '<p class="empty">That site is not in the store any more.</p>'; return; }
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><a class="btn ghost" href="#/store/mine">${icon('back')}My Store</a><b style="flex:1">${esc(it.title)}</b><a class="btn ghost" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">New tab</a></div>
<iframe title="${esc(it.title)}" src="${esc(it.url)}" sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" style="width:100%;height:calc(100dvh - 190px);min-height:360px;border:1px solid var(--line);border-radius:12px;background:#fff"></iframe>
<p class="sub" style="margin-top:8px">If the page stays blank, that site does not allow being shown here. Use New tab.</p>`;
}
/** On Home, under the app tiles: the extensions this person installed, and the Sites that were added to My Store, one tap away. */
async function stHomeExt(el) {
if (!el) return;
try { await stLoad(); } catch { return; }
if (!el.isConnected) return;
const mine = stItems.filter(i => (i.kind === 'extension' && stInstalled.includes(i.id)) || i.kind === 'link');
if (!mine.length) { el.innerHTML = ''; return; }
const tileOf = i => { const href = i.kind === 'extension' ? '#/store/ext/' + i.id : i.embed ? '#/store/site/' + i.id : i.url, ext = i.kind === 'link' && !i.embed;
return `<a class="tile" href="${esc(href)}" ${ext ? 'target="_blank" rel="noopener noreferrer"' : ''}><span class="ic" style="background:hsl(${stHue(i.title)} 55% 42%);font-weight:700;font-size:20px">${esc(i.icon || (i.title.match(/\p{L}|\p{N}/u) || ['?'])[0].toUpperCase())}</span><b>${esc(i.title)}</b><small>${i.kind === 'extension' ? 'Extension' : 'Site'}</small></a>`; };
el.innerHTML = `<h2>My apps</h2><div class="grid">${mine.map(tileOf).join('')}</div>`;
}
+426 -17
View File
@@ -32,7 +32,7 @@ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: v
.sub { color: var(--muted); margin: 0 0 8px; }
.wrap { max-width: 1100px; margin: 0 auto; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(2, 1fr); }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.tile { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px;
display: flex; flex-direction: column; gap: 10px; min-height: 108px; text-align: left; }
.tile:active, .row:active { background: var(--panel2); }
@@ -241,7 +241,7 @@ body.onaudio #view { flex: none; overflow: visible; padding-bottom: 0; }
.cday .chips small { color: var(--muted); font-size: 11px; text-align: left; padding-left: 6px; }
}
select, textarea, input[type=date], input[type=datetime-local], input[type=time] { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 16px; color: var(--text); }
select, textarea, input[type=number], input[type=date], input[type=datetime-local], input[type=time] { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-size: 16px; color: var(--text); }
textarea { padding: 12px 14px; resize: vertical; font-family: inherit; }
.chk { display: flex; align-items: center; gap: 10px; color: var(--text); font-size: 16px; margin: 14px 0 0; }
.chk input { width: 22px; height: 22px; }
@@ -310,17 +310,25 @@ body:not(.editing) #view { padding-top: 8px; }
.navtoggle { display: none; }
.navcol { display: none; }
@media (min-width: 768px) {
#nav { overflow-y: auto; scrollbar-width: none; }
#nav { overflow: hidden; } /* the menu never scrolls: when the window is short its entries shrink to fit instead */
#nav a { flex: 1 1 0; min-height: 28px; max-height: 64px; }
#nav .ngrp { flex: none; }
.navtoggle { display: grid; }
body.nav-hidden #nav { display: none; }
body.nav-hidden #navbar { padding-left: 12px; }
}
@media (min-width: 1024px) { body:not(.nav-mini) #nav a { max-height: 48px; } }
@media (min-width: 768px) and (max-height: 700px) { /* a short window: tighter spacing so every entry still fits without scrolling */
#nav { padding-top: calc(var(--sat) + 8px); padding-bottom: 6px; gap: 0; }
#nav a { min-height: 26px; font-size: 13px; } #nav a svg { width: 18px; height: 18px; }
.brand { margin-bottom: 6px !important; } #nav .ngrp { margin-top: 2px; padding-top: 2px; } #nav .ngrp span { display: none; } .navcol { min-height: 30px; }
}
@media (min-width: 1024px) {
.navcol { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 14px; border: 0; background: none; border-radius: 12px; color: var(--muted); font-size: 14px; }
.navcol:hover { background: var(--panel2); color: var(--text); }
.navcol svg { width: 20px; height: 20px; }
body.nav-mini #nav { width: 76px; padding: calc(var(--sat) + 12px) 0 12px var(--sal); }
body.nav-mini #nav a { flex-direction: column; justify-content: center; gap: 2px; padding: 0; font-size: 10px; min-height: 50px; border-radius: 0; flex: none; }
body.nav-mini #nav a { flex-direction: column; justify-content: center; gap: 2px; padding: 0; font-size: 10px; min-height: 0; max-height: 50px; border-radius: 0; flex: 1 1 0; }
body.nav-mini #nav a svg { width: 22px; height: 22px; }
body.nav-mini #nav a.on { background: none; }
body.nav-mini .brand { display: block; width: 36px; height: 36px; margin: 4px auto 12px; }
@@ -361,8 +369,8 @@ body:not(.editing) #view { padding-top: 8px; }
.sgb .who { display: block; font-weight: 700; font-size: 12px; margin-bottom: 2px; }
.sgt a { color: inherit; text-decoration: underline; }
.sgmeta { display: block; text-align: right; font-size: 11px; opacity: .7; margin-top: 2px; }
.sgmeta .rd { color: #8be28b; font-weight: 700; }
.sgb.out .sgmeta .rd { color: #fff; }
.sgmeta .sgrd { color: #8be28b; font-weight: 700; }
.sgb.out .sgmeta .sgrd { color: #fff; }
.sgimg { display: block; max-width: 100%; max-height: 320px; border-radius: 10px; margin-bottom: 4px; }
.sgb audio, .sgb video { max-width: 100%; display: block; margin-bottom: 4px; }
.sgfile { display: flex; align-items: center; gap: 8px; text-decoration: underline; margin-bottom: 4px; }
@@ -452,8 +460,8 @@ body.lyropen { overflow: hidden; }
.ll.on { opacity: 1; transform: scale(1.04); }
.lgap { height: 24px; }
.ll .lt { color: inherit; }
.ll.on .lt { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; }
.ll.on .lt:has(.w) { background: none; color: inherit; }
.ll.on .lt { color: var(--accent); } /* (a line-synced song shows the whole current line; only real word timing sweeps across words) */
.ll.on .lt:has(.w) { color: inherit; }
.ll .w { color: inherit; }
.ll.on .w { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; white-space: pre-wrap; }
.ll.on .w.sung { background: none; color: var(--accent); }
@@ -539,14 +547,14 @@ body.lyropen { overflow: hidden; }
.mact, .matt { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
.mact .btn, .matt .btn { min-height: 38px; }
.mimg { background: var(--panel2); border-radius: 10px; padding: 8px 12px; font-size: 14px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.mbody { width: 100%; flex: 1; min-height: 55dvh; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
.mbody { width: 100%; flex: none; height: 60dvh; min-height: 0; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
@media (min-width: 1024px) {
.mailapp { grid-template-columns: 340px minmax(0, 1fr); height: calc(100dvh - 40px); min-height: 0; }
.mailapp[data-mode] .mlist, .mailapp[data-mode] .mread { display: block; }
.mailapp[data-mode] .mread { display: flex; }
.mlist, .mread { overflow-y: auto; min-height: 0; }
.mback { display: none; }
.mbody { min-height: 0; }
.mbody { flex: 1; height: auto; }
}
@media (min-width: 1500px) {
.mailapp { grid-template-columns: 190px 340px minmax(0, 1fr); }
@@ -655,13 +663,414 @@ body.nav-mini .ngrp span { display: none; }
@media (max-width: 700px) { .vtop .vtc { display: none; } .vnav .sgrp { margin-left: 0; } }
/* Video mini player */
#vmini { position: fixed; right: 14px; bottom: calc(var(--nav-h) + var(--sab) + 76px); width: 300px; max-width: calc(100vw - 28px); z-index: 45; border-radius: 12px; overflow: hidden; background: #000; box-shadow: 0 8px 28px rgba(0, 0, 0, .45); border: 1px solid var(--line); }
#vmini { position: fixed; right: 14px; bottom: calc(var(--nav-h) + var(--sab) + 76px); width: 340px; max-width: calc(100vw - 28px); z-index: 45; border-radius: 12px; overflow: hidden; background: #000; box-shadow: 0 8px 28px rgba(0, 0, 0, .45); border: 1px solid var(--line); }
@media (min-width: 768px) { #vmini { bottom: 84px; } }
#vmini[hidden] { display: none; }
.vmslot { cursor: pointer; } .vmslot video { width: 100%; display: block; aspect-ratio: 16 / 9; background: #000; }
.vmover { position: absolute; left: 0; right: 0; top: 0; padding: 6px 8px; display: flex; align-items: center; gap: 8px; color: #fff; background: linear-gradient(rgba(0, 0, 0, .75), transparent); opacity: 0; transition: opacity .2s; }
#vmini:hover .vmover, #vmini:focus-within .vmover { opacity: 1; }
@media (hover: none) { .vmover { opacity: 1; } }
.vmtitle { flex: 1; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vmbtns { display: flex; gap: 2px; } .vmbtns button { border: 0; background: rgba(255, 255, 255, .15); color: #fff; width: 32px; height: 32px; border-radius: 8px; display: grid; place-items: center; cursor: pointer; } .vmbtns button svg { width: 18px; height: 18px; }
.vmprog { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255, 255, 255, .25); display: block; } .vmprog b { display: block; height: 100%; background: var(--accent); }
/* Office */
.ocreate { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-top: 4px; }
.octile { display: flex; align-items: center; gap: 12px; text-align: left; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: var(--panel); min-width: 0; }
.octile:hover { border-color: var(--c, var(--accent)); }
.octile b, .octile small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .octile small { color: var(--muted); }
.octile > span:last-child { min-width: 0; }
.ofic { position: relative; } .ofic em { position: absolute; bottom: 2px; font-style: normal; font-size: 8px; font-weight: 700; letter-spacing: .02em; color: #fff; }
.ofic svg { margin-top: -4px; }
.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; }
.orow .rmain { min-width: 0; }
#ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; }
.vmctl { background: var(--panel); color: var(--text); padding: 6px 10px 8px; }
.vmhead { display: flex; align-items: center; gap: 4px; }
.vmtitle { flex: 1; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vmctl input[type=range] { width: 100%; margin: 2px 0; accent-color: var(--accent); cursor: pointer; }
.vmrow { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.vmmid { display: flex; align-items: center; gap: 2px; }
.vmb { border: 0; background: transparent; color: var(--text); min-width: 34px; height: 34px; border-radius: 8px; display: inline-grid; place-items: center; cursor: pointer; }
.vmb:hover { background: var(--panel2); } .vmb:disabled { opacity: .35; cursor: default; } .vmb[hidden] { display: none; }
.vmb svg { width: 20px; height: 20px; } .vmb.big { width: 40px; height: 40px; background: var(--accent); color: var(--accent-t); border-radius: 50%; } .vmb.big svg { width: 22px; height: 22px; }
.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; }
.mini .vskip { position: relative; display: grid; place-items: center; } .mini .vskip em { position: absolute; font-style: normal; font-size: 8px; font-weight: 700; top: 7px; }
@media (max-width: 700px) { .vtop .vright input[type=range], .vtop #vmute, .vtop #vspd { display: none; } .vtop .vtitle { display: none; } }
/* calendar colors */
.cdot{width:22px;height:22px;min-height:0;padding:0;border-radius:50%;border:2px solid var(--panel2);box-shadow:0 0 0 1px var(--line);flex:none;cursor:pointer}
.swatches{display:flex;flex-wrap:wrap;gap:10px;margin:8px 0 4px}
.sw{width:30px;height:30px;min-height:0;padding:0;border-radius:50%;border:2px solid transparent;cursor:pointer;position:relative;box-shadow:0 0 0 1px var(--line)}
.sw.on{border-color:var(--text)}
.sw.custom{background:conic-gradient(#d9453a,#e5a00d,#2f9e6e,#4f7cff,#8a5cf0,#d9453a);overflow:hidden}
.sw.custom input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer}
/* Plex details editor: image choices */
.vimgs{display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin:6px 0}
.vimgs.arts{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
.vpick{padding:0;min-height:0;border:2px solid transparent;border-radius:8px;overflow:hidden;background:var(--panel2);cursor:pointer;line-height:0}
.vpick.on{border-color:var(--accent)}
.vpick img{width:100%;height:auto;display:block}
/* Home: header with the quick actions beside it, today's cards in two columns on wide screens, apps as a compact launcher */
.homehead { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.homehead h1 { margin: 0; }
.homehead .sub { margin: 2px 0 0; }
.homeact { display: flex; gap: 8px; flex-wrap: wrap; }
.homeact .btn { margin: 0; }
.home .searchbar { margin: 14px 0 4px; }
.homecols { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0 24px; align-items: start; }
.hcol:empty, .hcol > :empty { display: none; }
.home .homeapps h2:first-child { margin-top: 24px; }
.home .tile { flex-direction: row; align-items: center; gap: 12px; min-height: 0; padding: 12px 14px; border-radius: 14px; }
.home .tile .ic { flex: none; width: 36px; height: 36px; border-radius: 10px; }
.home .tile b, .home .tile small { display: block; line-height: 1.25; }
.home .tile small { font-size: 12px; word-break: normal; overflow-wrap: anywhere; }
@media (min-width: 1024px) {
.homecols { grid-template-columns: 1.2fr 1fr; }
.home .grid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
}
@media (max-width: 560px) { .homeact .btn small { display: none; } }
@media (max-width: 767px) { /* phone: the compact app launcher comes before the cards (the bottom tabs reach the same places) */
.home { display: flex; flex-direction: column; }
.homecols { order: 5; }
.homeapps { order: 4; }
.homeapps h2:first-child { margin-top: 18px; }
}
#settabs { margin: 12px 0 0; }
.sgroup[hidden] { display: none; }
.sgroup > h2:first-child, .sgroup > div:first-child > h2:first-child { margin-top: 20px; }
.home .tile small { overflow-wrap: break-word; }
.home .tile > span:not(.ic) { min-width: 0; }
.home .tile { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; column-gap: 12px; row-gap: 0; align-content: center; }
.home .tile .ic { grid-row: 1 / span 2; align-self: center; }
.home .tile b { align-self: end; }
.home .tile small { align-self: start; }
@media (max-width: 560px) { .homehead { flex-wrap: nowrap; align-items: center; } #qpal { display: none; } } /* the command palette is a keyboard thing: Quick add sits beside the greeting on a phone */
@media (max-width: 560px) { .homehead h1 { font-size: 24px; } .homeact .btn { white-space: nowrap; } #settabs { display: flex; } #settabs a { flex: 1; justify-content: center; padding: 6px 8px; } }
/* Home never scrolls sideways: wide content shrinks or wraps instead */
.home, .home .hcol, .home .homeapps { min-width: 0; max-width: 100%; }
#view { overflow-x: hidden; }
/* Movies & TV: remove from Continue watching, filters */
.vdeckc { position: relative; flex: none; }
.vx { position: absolute; top: 6px; left: 6px; width: 28px; height: 28px; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; cursor: pointer; opacity: .85; }
.vx svg { width: 14px; height: 14px; } .vx:hover, .vx:focus-visible { opacity: 1; background: rgba(0, 0, 0, .85); }
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { min-height: 40px; max-width: 48vw; }
.vunw { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; } .vunw input { width: 18px; height: 18px; }
.vmark { position: absolute; right: 16px; bottom: 96px; z-index: 6; background: rgba(0, 0, 0, .7); color: #fff; border: 1px solid rgba(255, 255, 255, .5); }
.vdeckc .vcard.wide { width: 250px; }
/* Movies & TV polish */
.vimg { box-shadow: 0 2px 8px rgba(0, 0, 0, .18); transition: transform .18s ease, box-shadow .18s ease; }
.vimg img { animation: vfade .35s ease both; }
@keyframes vfade { from { opacity: 0; } to { opacity: 1; } }
@media (hover: hover) {
.vcard:hover .vimg, .vcard:focus-visible .vimg { transform: translateY(-3px) scale(1.025); box-shadow: 0 10px 24px rgba(0, 0, 0, .32); }
.vcard.wide .vimg::after, .vcard .vimg::after { content: ''; position: absolute; inset: 0; background: linear-gradient(transparent 55%, rgba(0, 0, 0, .45)); opacity: 0; transition: opacity .18s; pointer-events: none; }
.vcard:hover .vimg::after { opacity: 1; }
.vx { opacity: 0; transition: opacity .15s; } .vdeckc:hover .vx, .vx:focus-visible { opacity: .95; }
}
.vrow { scroll-snap-type: x proximity; padding: 4px 2px 10px; margin: 0 -2px; }
.vrow > * { scroll-snap-align: start; }
.vcard b { font-weight: 650; letter-spacing: -.005em; }
.vprog { height: 5px; border-radius: 0 3px 3px 0; overflow: hidden; } .vprog b { border-radius: 0 3px 3px 0; box-shadow: 0 0 6px var(--accent); }
#view h2 { letter-spacing: -.01em; }
.vclear { vertical-align: middle; font-size: 13px; }
.vfilt select { border-radius: 10px; border: 1px solid var(--line); background: var(--panel); padding: 0 10px; color: var(--text); }
.vsk { aspect-ratio: 2/3; border-radius: 10px; background: linear-gradient(100deg, var(--panel2) 30%, var(--panel) 50%, var(--panel2) 70%); background-size: 300% 100%; animation: vshim 1.3s linear infinite; }
@keyframes vshim { from { background-position: 100% 0; } to { background-position: 0 0; } }
.vhero .vbg { opacity: .3; height: 300px; filter: saturate(1.2); }
.vpost { box-shadow: 0 10px 30px rgba(0, 0, 0, .35); border-radius: 12px; }
.vsum { font-size: 15.5px; }
.vmark { border-radius: 10px; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); animation: vfade .25s ease both; font-weight: 650; }
/* Lyrics polish */
.ll { text-shadow: 0 1px 12px color-mix(in srgb, var(--bg) 70%, transparent); }
.ll.on { text-shadow: 0 0 28px color-mix(in srgb, var(--accent) 35%, transparent); }
.lyrart { box-shadow: 0 4px 14px rgba(0, 0, 0, .3); }
.vban { position: relative; display: block; height: clamp(170px, 30vw, 280px); border-radius: 16px; overflow: hidden; margin: 6px 0 14px; background: var(--panel2); color: #fff; box-shadow: 0 12px 32px rgba(0, 0, 0, .3); }
.vban img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; animation: vfade .5s ease both; }
.vban::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(0, 0, 0, .78), rgba(0, 0, 0, .15) 70%), linear-gradient(transparent 50%, rgba(0, 0, 0, .5)); }
.vbant { position: absolute; z-index: 1; left: 20px; bottom: 18px; right: 20px; display: flex; flex-direction: column; gap: 4px; align-items: flex-start; }
.vbant b { font-size: clamp(20px, 4vw, 32px); line-height: 1.15; letter-spacing: -.02em; } .vbant small { opacity: .85; font-size: 13px; }
.vbant .btn { margin-top: 8px; display: inline-flex; align-items: center; gap: 6px; font-style: normal; min-height: 44px; }
.vhero { --vtint: transparent; }
.vhero::before { content: ''; position: absolute; inset: -8px -16px 0 -16px; z-index: -1; background: radial-gradient(120% 90% at 20% 0, color-mix(in srgb, var(--vtint) 45%, transparent), transparent 70%); pointer-events: none; transition: background .5s; }
.vempty { grid-column: 1 / -1; display: grid; justify-items: center; gap: 8px; padding: 36px 12px; color: var(--muted); } .vempty svg { width: 40px; height: 40px; opacity: .6; }
kbd { display: inline-block; padding: 1px 7px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 6px; background: var(--panel2); font: 12px ui-monospace, Menlo, monospace; }
/* every poster in a row is the same size (a row has no grid to size them) */
.vrow > .vcard:not(.wide) { flex: none; width: 130px; }
@media (min-width: 768px) { .vrow > .vcard:not(.wide) { width: 150px; } }
.vrow > .vcard:not(.wide) .vimg { aspect-ratio: 2/3; }
/* sideways rows: arrows and drag */
.vslide { position: relative; }
.vrow { scrollbar-width: none; } .vrow::-webkit-scrollbar { display: none; }
.vrow.drag { cursor: grabbing; user-select: none; scroll-snap-type: none; } .vrow.drag * { pointer-events: none; }
.vsl { position: absolute; top: 28%; z-index: 4; width: 38px; height: 38px; min-height: 0; padding: 0; border-radius: 50%; border: 1px solid var(--line); background: color-mix(in srgb, var(--panel) 92%, transparent); color: var(--text); font-size: 24px; line-height: 1; display: grid; place-items: center; cursor: pointer; box-shadow: 0 4px 14px rgba(0, 0, 0, .3); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
.vsl[hidden] { display: none; } .vsl.l { left: -6px; } .vsl.r { right: -6px; }
@media (hover: none) { .vsl { display: none; } }
/* Mail: one button per account, each with its own unread count */
.maccts { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; margin-bottom: 6px; padding-bottom: 2px; }
.maccts::-webkit-scrollbar { display: none; }
.maccts button { flex: none; display: flex; align-items: center; gap: 8px; max-width: 220px; min-height: 40px; padding: 4px 12px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--muted); text-align: left; cursor: pointer; }
.maccts button.on { background: var(--accent); color: var(--accent-t); border-color: transparent; }
.maccts button.new:not(.on) { color: var(--text); border-color: var(--accent); }
.maccts .mal { font-weight: 650; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.maccts small { display: none; }
.maccts .nbadge { margin-left: 2px; }
.mfold .maccts { flex-direction: column; overflow: visible; margin-bottom: 10px; } .mfold .maccts button { max-width: none; } .mfold .maccts small { display: block; opacity: .75; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mfold .maccts .mal, .mfold .maccts small { display: block; } .mfold .maccts button > .mal { max-width: 100%; }
/* Books: library and reader */
.rdcover { position: relative; display: block; background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
.rdcover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.rdcover em { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 10px; text-align: center; font: 600 13px/1.25 Georgia, serif; color: var(--muted); overflow: hidden; z-index: 0; }
.rdcover img + em { display: none; }
.rdext { position: absolute; top: 6px; right: 6px; z-index: 1; font: 700 9px/1 system-ui; text-transform: uppercase; letter-spacing: .06em; background: rgba(0,0,0,.6); color: #fff; padding: 3px 5px; border-radius: 4px; }
.rdprog { position: absolute; left: 0; right: 0; bottom: 0; height: 4px; background: rgba(0,0,0,.25); z-index: 1; }
.rdprog s { display: block; height: 100%; background: var(--accent); }
#rdlib.drop { outline: 2px dashed var(--accent); outline-offset: 6px; border-radius: 12px; }
#rdbody select { min-height: 44px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--text); padding: 0 10px; font-size: 16px; }
.rd { --rd-bg: #fff; --rd-fg: #1b1b1b; --rd-link: #1a5fd0; position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; background: var(--rd-bg); color: var(--rd-fg); }
.rd.dark { color-scheme: dark; }
.rdbar, .rdfoot { display: flex; align-items: center; gap: 2px; padding: 4px 6px; background: var(--rd-bg); color: var(--rd-fg); border-color: rgba(128,128,128,.25); flex: none; z-index: 5; }
.rdbar { padding-top: calc(var(--sat, 0px) + 4px); border-bottom: 1px solid rgba(128,128,128,.25); }
.rdfoot { gap: 10px; padding: 6px 14px calc(var(--sab, 0px) + 6px); border-top: 1px solid rgba(128,128,128,.25); }
.rd .ibtn { color: inherit; flex: none; }
.rd .ibtn.on svg { fill: currentColor; color: var(--accent); }
.rdtitle { min-width: 0; padding: 0 6px; } .rdtitle b, .rdtitle small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rdtitle small { opacity: .65; font-size: 12px; }
.rd.hidebars .rdbar, .rd.hidebars .rdfoot { display: none; }
.rdmain { position: relative; flex: 1; min-height: 0; display: flex; }
.rdstage { flex: 1; min-width: 0; min-height: 0; position: relative; overflow: hidden; background: var(--rd-bg); }
.rdstage.pdf { overflow-y: auto; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.rdstage.pdf .rdpages { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 8px 0; min-width: min-content; }
.rdframe { width: 100%; height: 100%; border: 0; display: block; background: var(--rd-bg); }
.rdzone { position: absolute; top: 0; bottom: 0; width: 56px; background: none; border: 0; z-index: 2; opacity: 0; display: none; }
.rdzone.l { left: 0; } .rdzone.r { right: 0; }
@media (min-width: 768px) and (hover: hover) { .rdzone { display: block; } .rdzone:hover { opacity: 1; background: linear-gradient(90deg, rgba(128,128,128,.15), transparent); } .rdzone.r:hover { background: linear-gradient(270deg, rgba(128,128,128,.15), transparent); } }
.rdload { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; margin: 0; color: var(--rd-fg); opacity: .8; text-align: center; padding: 20px; z-index: 3; }
.rdload .err { opacity: 1; }
.rdlabel { flex: 0 1 30%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; opacity: .7; }
.rdfoot input[type=range] { flex: 1; min-width: 60px; accent-color: var(--accent); }
.rdpct { font-size: 12px; opacity: .7; min-width: 34px; text-align: right; }
.pg { position: relative; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.25); flex: none; overflow: hidden; }
.pg canvas { display: block; }
.pdark .pg canvas { filter: invert(.9) hue-rotate(180deg); } .psepia .pg canvas { filter: sepia(.45) contrast(.95); }
.pdark .pg { background: #111; }
.textLayer { position: absolute; inset: 0; overflow: hidden; line-height: 1; text-size-adjust: none; z-index: 2; }
.textLayer > span, .textLayer br { color: transparent; position: absolute; white-space: pre; cursor: text; transform-origin: 0% 0%; }
.textLayer ::selection { background: rgba(60,120,255,.35); }
.rdhls { position: absolute; inset: 0; z-index: 1; pointer-events: none; mix-blend-mode: multiply; opacity: .5; }
.pdark .rdhls { mix-blend-mode: screen; opacity: .45; }
.rdhl { position: absolute; border-radius: 2px; } .rdhl.flash { outline: 2px solid #e8590c; }
.rdsel { position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); z-index: 6; display: flex; align-items: center; gap: 8px; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 999px; padding: 6px 10px; box-shadow: 0 6px 24px rgba(0,0,0,.3); max-width: calc(100% - 16px); }
.rdsel[hidden] { display: none; }
.rdcol { width: 30px; height: 30px; border-radius: 50%; border: 2px solid transparent; flex: none; } .rdcol.on { border-color: var(--text); }
.rdsb { display: flex; align-items: center; gap: 4px; background: none; border: 0; color: var(--text); font-size: 14px; min-height: 36px; padding: 0 6px; } .rdsb svg { width: 18px; height: 18px; }
.rdcolors { display: flex; gap: 10px; margin: 12px 0; }
.rddot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
.rdq { margin: 0 0 8px; padding: 6px 12px; border-left: 3px solid var(--accent); color: var(--muted); }
.rdlist { max-height: 60vh; overflow: auto; }
.rdlist mark, .rdlist .row mark { background: #ffd43b88; color: inherit; border-radius: 2px; }
.rdseg { display: flex; gap: 6px; flex-wrap: wrap; } .rdseg button { flex: 1; min-height: 40px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 0 10px; white-space: nowrap; }
.rdseg button.on { background: var(--accent); color: var(--accent-t); border-color: transparent; font-weight: 600; }
.rdstep { display: flex; align-items: center; gap: 12px; } .rdstep span { min-width: 56px; text-align: center; font-weight: 600; } .rdstep .btn { min-width: 64px; justify-content: center; }
/* Mail: swipe a message left to delete, right to archive */
.mrow { position: relative; touch-action: pan-y; will-change: transform; background: var(--panel); }
.mrow.swl { box-shadow: var(--sww, 100vw) 0 0 var(--danger); } .mrow.swr { box-shadow: calc(-1 * var(--sww, 100vw)) 0 0 #2f9e6e; }
.mrow.swl.swgo { box-shadow: var(--sww, 100vw) 0 0 #b3261e; }
.mrow.swl::after, .mrow.swr::after { content: attr(data-sw); position: absolute; top: 0; bottom: 0; display: flex; align-items: center; color: #fff; font-weight: 600; padding: 0 20px; white-space: nowrap; }
.mrow.swl::after { left: 100%; } .mrow.swr::after { right: 100%; }
/* Mail: choosing several messages */
.mchk { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--muted); flex: none; margin-top: 1px; position: relative; }
.mchk.on { background: var(--accent); border-color: var(--accent); }
.mchk.on::after { content: ''; position: absolute; left: 6px; top: 2px; width: 5px; height: 10px; border: solid var(--accent-t); border-width: 0 2px 2px 0; transform: rotate(45deg); }
.mrow.picked { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); }
.mselbar { position: sticky; bottom: 8px; z-index: 4; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 8px 10px; margin: 10px 0 0; box-shadow: 0 4px 16px rgba(0,0,0,.12); }
.msbtop { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .msbtop .btn { min-height: 36px; }
.msbrow { display: flex; gap: 6px; flex-wrap: wrap; } .msb { min-height: 40px; flex: 1 1 auto; justify-content: center; padding: 0 10px; font-size: 14px; } .msb svg { width: 18px; height: 18px; margin-right: 4px; }
.msb:disabled { opacity: .45; }
body.mselecting .mrow { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
.mflag { color: var(--danger); } .mflag svg { fill: currentColor; }
.rdhave { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1; background: rgba(47,158,110,.92); color: #fff; font: 600 11px/1 system-ui; text-align: center; padding: 5px 0; }
/* Radio */
.rdorow { padding: 0; } .rdorow.playing { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); }
.rdoplay { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 10px 12px 10px 16px; background: none; border: 0; text-align: left; color: inherit; min-height: 56px; }
.rdoplay > svg { width: 20px; height: 20px; color: var(--accent); flex: none; }
.rdoplay .art { width: 44px; height: 44px; border-radius: 10px; background: var(--panel2); flex: none; overflow: hidden; display: block; } .rdoplay .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.rdorow .ibtn { margin-right: 6px; }
.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::-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; } }
#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; }
#sheet .sheet.launcher h2 { margin: 16px 0 10px; }
.lgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px 6px; }
.lt { display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; color: inherit; min-width: 0; }
.lti { position: relative; width: 58px; height: 58px; border-radius: 17px; display: grid; place-items: center; color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
.lti svg { width: 27px; height: 27px; } .lt:active .lti { transform: scale(.94); }
.ltl { font-size: 12px; line-height: 1.2; max-width: 100%; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pinno { width: 26px; height: 26px; border-radius: 50%; border: 2px solid var(--line); display: grid; place-items: center; font-weight: 700; font-size: 13px; flex: none; } .pinno.on { background: var(--accent); border-color: var(--accent); color: var(--accent-t); }
/* Bible */
.bbwrap { max-width: 42rem; }
.bbbar { display: flex; align-items: center; gap: 6px; position: sticky; top: -8px; z-index: 3; background: var(--bg); padding: 8px 0; margin-top: -8px; }
.bbbar .btn b { margin-left: 6px; }
.bbtext { line-height: 1.75; padding-bottom: 8px; } .bbtext.serif { font-family: Georgia, "Times New Roman", serif; }
.bbtext .bh { font-size: .82em; font-family: system-ui, sans-serif; text-transform: none; letter-spacing: 0; margin: 1.5em 0 .4em; color: var(--muted); }
.bbtext .bsub { font-style: italic; color: var(--muted); margin: .2em 0 .8em; } .bbtext .bp { margin: 0 0 .9em; }
.bbtext .bpoem { margin: 0 0 .5em; } .bbtext .pl { display: block; padding-left: 1.2em; text-indent: -1.2em; } .bbtext .pl.l2 { padding-left: 2.4em; }
.bbtext .vn { font-size: .6em; font-weight: 700; color: var(--accent); margin-right: .25em; vertical-align: super; font-family: system-ui, sans-serif; }
.bbtext .fn { font-size: .6em; font-weight: 700; color: var(--accent); margin: 0 .1em; cursor: pointer; vertical-align: super; font-family: system-ui, sans-serif; }
.bbtext .vs { border-radius: 3px; -webkit-box-decoration-break: clone; box-decoration-break: clone; cursor: pointer; }
.bbtext .vs.pick { background: rgba(61,107,240,.25); } .bbtext .vs.flash { background: rgba(232,89,12,.28); } .bbtext .vs.hasnote { border-bottom: 2px dotted var(--accent); }
.bbtext .hl-yellow { background: #ffd43b66; } .bbtext .hl-green { background: #69db7c66; } .bbtext .hl-blue { background: #74c0fc66; } .bbtext .hl-pink { background: #f783ac66; }
.bbnav { display: flex; justify-content: space-between; gap: 8px; margin-top: 24px; }
.bbsel.rdsel { position: fixed; bottom: calc(env(safe-area-inset-bottom) + 84px); flex-wrap: wrap; justify-content: center; border-radius: 20px; z-index: 15; }
.bbref { font-weight: 600; font-size: 13px; white-space: nowrap; width: 100%; text-align: center; }
.bbooks { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 4px; max-height: 58dvh; overflow: auto; }
.bbooks button { min-height: 44px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 0 10px; text-align: left; } .bbooks button.on { background: var(--accent); color: var(--accent-t); }
.bgrid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; max-height: 58dvh; overflow: auto; }
.bgrid a { display: grid; place-items: center; height: 46px; border-radius: 10px; background: var(--panel2); color: inherit; font-weight: 600; } .bgrid a.on { background: var(--accent); color: var(--accent-t); }
/* Music visualizer: a full-screen picture that moves with the sound */
#viz { position: fixed; inset: 0; z-index: 40; background: #000; color: #fff; display: flex; flex-direction: column; justify-content: space-between; touch-action: manipulation; user-select: none; }
body.vizopen { overflow: hidden; }
#viz canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#viz > .viztop, #viz > .vizfoot { position: relative; z-index: 1; display: flex; align-items: center; gap: 10px; padding: calc(var(--sat, 0px) + 12px) 16px 12px; transition: opacity .4s; background: linear-gradient(rgba(0,0,0,.55), transparent); }
#viz > .vizfoot { justify-content: center; gap: 22px; padding: 12px 16px calc(var(--sab, 0px) + 20px); background: linear-gradient(transparent, rgba(0,0,0,.55)); }
#viz .viztop .grow { min-width: 0; } #viz .viztop b, #viz .viztop small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-shadow: 0 1px 4px rgba(0,0,0,.6); }
#viz .viztop small { opacity: .75; font-size: 12px; }
#viz .btn, #viz .ibtn { color: #fff; border-color: rgba(255,255,255,.35); background: rgba(0,0,0,.35); }
#viz.quiet > .viztop, #viz.quiet > .vizfoot { opacity: 0; pointer-events: none; }
/* Recipes and Contacts */
.rchero { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 12px; }
@media (max-width: 560px) { .rchero { flex-direction: column; } .rchero .vimg { width: 100% !important; } }
.rcsteps { padding-left: 22px; line-height: 1.5; } .rcsteps li { margin: 0 0 12px; cursor: pointer; } .rcsteps li.done { color: var(--muted); text-decoration: line-through; }
.rchead { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .rchead h2 { margin: 16px 0 8px; }
.rccols { display: block; }
@media (min-width: 900px) { .rccols { display: grid; grid-template-columns: minmax(280px, 2fr) 3fr; gap: 28px; align-items: start; } .rccols > section:first-child { position: sticky; top: 8px; } }
.rcitem input { width: 22px; height: 22px; flex: none; }
.ctgrp { margin: 14px 0 4px; font-size: 13px; font-weight: 600; color: var(--muted); }
.ctav { width: 40px; height: 40px; border-radius: 50%; flex: none; display: grid; place-items: center; color: #fff; font-weight: 600; }
.ctsug { position: absolute; z-index: 30; left: 0; right: 0; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 6px 20px rgba(0,0,0,.15); max-height: 220px; overflow: auto; }
.ctsug button { display: block; width: 100%; text-align: left; padding: 8px 12px; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; } .ctsug button:hover, .ctsug button.on { background: rgba(127,127,127,.15); }
.pdhit { margin-bottom: 8px; } mark { background: rgba(255, 200, 0, .45); color: inherit; border-radius: 3px; }
/* Store */
#stbody .wrap { padding: 0; max-width: none; }
#stbody .vimg img { width: 100%; height: 100%; object-fit: cover; }
/* Music: featured artist and album */
.featgrid { display: grid; grid-template-columns: 1fr; gap: 16px; }
@media (min-width: 760px) { .featgrid { grid-template-columns: 1fr 1fr; } }
.featcard { display: flex; gap: 16px; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 14px; }
.featcard .art { width: 150px; height: 150px; border-radius: 12px; object-fit: cover; flex: none; display: block; } .featcard .art.round { border-radius: 50%; }
.featinfo { min-width: 0; } .featinfo h2 { margin: 2px 0 0; font-size: 22px; text-transform: none; letter-spacing: 0; color: var(--text); overflow-wrap: anywhere; }
@media (max-width: 420px) { .featcard { flex-direction: column; align-items: flex-start; } .featcard .art { width: 100%; height: auto; aspect-ratio: 1 / 1; } }
/* Music: mixes, stats, notes, cover colors */
.npcard, .np { background: linear-gradient(160deg, color-mix(in srgb, var(--np-a, transparent) 30%, var(--panel)), var(--panel)); transition: background .6s; }
.mixtile .mixart, .mixart { display: flex; align-items: flex-end; width: 100%; aspect-ratio: 1 / 1; border-radius: 12px; padding: 12px; box-sizing: border-box; color: #fff; }
.mixart b { font-size: 18px; line-height: 1.15; text-shadow: 0 1px 6px rgba(0,0,0,.4); }
.mhead .mixart { width: 120px; height: 120px; flex: none; aspect-ratio: auto; }
.mmabout { margin: 8px 0 12px; } .mmabout summary { cursor: pointer; color: var(--accent); } .mmabout p { margin: 8px 0 0; line-height: 1.5; white-space: normal; max-width: 70ch; }
.mmv { margin-top: 18px; padding: 14px 16px; border-radius: 14px; background: var(--panel); border: 1px solid var(--line); } .mmv:empty { display: none; }
.mmverse { margin: 6px 0; font-size: 18px; line-height: 1.5; font-style: italic; }
.statgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 8px 0 4px; } @media (min-width: 640px) { .statgrid { grid-template-columns: repeat(4, 1fr); } }
.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px; } .stat b { display: block; font-size: 26px; } .stat small { color: var(--muted); }
.chart { display: flex; align-items: flex-end; gap: 3px; height: 120px; padding: 6px 0; } .chart span { flex: 1; height: 100%; display: flex; align-items: flex-end; } .chart i { display: block; width: 100%; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; }
.statbar { height: 6px; border-radius: 3px; background: var(--panel2); margin-top: 6px; overflow: hidden; } .statbar i { display: block; height: 100%; background: var(--accent); }
[data-fv][aria-pressed="true"] svg, #npfav.on svg, #cnfav.on svg { fill: currentColor; }
/* Chat: pins, notices, mentions, forwarded */
.sgpin { display: flex; gap: 10px; align-items: center; width: 100%; padding: 8px 14px; border: 0; border-bottom: 1px solid var(--line); background: var(--panel2); color: inherit; font: inherit; text-align: left; cursor: pointer; } .sgpin[hidden] { display: none; }
.sgpin b, .sgpin small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgpin small { color: var(--accent); }
.sgsys { font-size: 12px; color: var(--muted); font-style: italic; }
.mention { color: var(--accent); } .sgb.out .mention { color: inherit; text-decoration: underline; }
.sgfwd { display: block; font-size: 12px; opacity: .7; font-style: italic; margin-bottom: 2px; }
.sgb.flash { animation: chflash 1.2s; } @keyframes chflash { 0%, 60% { box-shadow: 0 0 0 3px var(--accent); } 100% { box-shadow: 0 0 0 0 transparent; } }
/* Chat: voice messages, polls, link previews, emoji */
.sgvoice { display: flex; align-items: center; gap: 8px; min-width: 200px; cursor: pointer; } .sgvoice .sgvp { flex: none; background: rgba(255,255,255,.18); border-radius: 50%; width: 36px; height: 36px; }
.sgbars { flex: 1; display: flex; align-items: center; gap: 2px; height: 30px; } .sgbars i { flex: 1; min-width: 2px; background: currentColor; opacity: .35; border-radius: 2px; } .sgbars i.on { opacity: 1; } .sgvt { font-size: 12px; opacity: .8; min-width: 32px; text-align: right; }
.sgpoll { min-width: 220px; } .sgpoll b { display: block; margin-bottom: 2px; cursor: pointer; } .sgpoll > small { display: block; opacity: .75; margin: 2px 0 6px; }
.sgopt { position: relative; display: flex; align-items: center; gap: 8px; width: 100%; margin: 4px 0; padding: 8px 10px; border: 1px solid rgba(127,127,127,.35); border-radius: 10px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; overflow: hidden; }
.sgopt i { position: absolute; inset: 0 auto 0 0; background: rgba(127,127,127,.25); z-index: 0; } .sgopt span, .sgopt small { position: relative; z-index: 1; } .sgopt span { flex: 1; } .sgopt.on { border-color: currentColor; font-weight: 600; } .sgopt:disabled { cursor: default; opacity: .8; }
.sgprev { display: flex; gap: 10px; margin: 6px 0 2px; padding: 8px; border-radius: 10px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgprev img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; flex: none; }
.sgprev span { min-width: 0; } .sgprev small, .sgprev b, .sgprev em { display: block; overflow: hidden; text-overflow: ellipsis; } .sgprev small { opacity: .7; } .sgprev b { white-space: nowrap; } .sgprev em { font-style: normal; opacity: .8; font-size: 13px; max-height: 2.6em; }
.chemo { position: absolute; bottom: 100%; left: 0; right: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 2px; max-height: 190px; overflow: auto; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px 12px 0 0; z-index: 25; }
.chemo button { border: 0; background: none; font-size: 24px; padding: 4px; cursor: pointer; border-radius: 8px; } .chemo button:hover { background: var(--panel2); }
.sgc { position: relative; } .sgc.rec > :not(.chrecbar) { visibility: hidden; } .chrecbar { position: absolute; inset: 0; display: flex; align-items: center; gap: 10px; padding: 0 10px; background: var(--panel); } .recdot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); animation: chblink 1s infinite; } @keyframes chblink { 50% { opacity: .2; } }
.annc { display: flex; align-items: center; gap: 12px; margin: 0 0 14px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, var(--accent) 14%, var(--panel)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); }
.annc .ic { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; color: var(--accent-t); flex: none; } .annc small { display: block; color: var(--muted); } .annc b { display: block; font-size: 17px; overflow-wrap: anywhere; }
/* Notes */
.ntgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-top: 4px; }
.ntcard { display: flex; flex-direction: column; gap: 6px; padding: 14px; border-radius: 14px; background: color-mix(in srgb, var(--nc, var(--line)) 12%, var(--panel)); border: 1px solid var(--line); border-top: 4px solid var(--nc, var(--line)); color: inherit; text-decoration: none; min-height: 110px; }
.ntcard .nttop { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; } .ntcard .nttop svg { width: 16px; height: 16px; color: var(--accent); flex: none; } .ntcard p { margin: 0; white-space: pre-wrap; overflow: hidden; max-height: 5.5em; color: var(--muted); }
.ntitems { display: flex; flex-direction: column; gap: 2px; } .ntitems span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ntitems span.done { text-decoration: line-through; color: var(--muted); } .ntmeta { margin-top: auto; color: var(--muted); }
.ntedit { border-top: 5px solid var(--nc, var(--line)); padding-top: 8px; } .nttitle { width: 100%; font-size: 24px; font-weight: 700; background: transparent; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 8px 2px; }
.ntbody { width: 100%; min-height: 55vh; font: inherit; line-height: 1.6; resize: vertical; }
.ntitem { display: flex; align-items: center; gap: 10px; padding: 6px 2px; border-bottom: 1px solid var(--line); } .ntitem input[type=checkbox] { width: 22px; height: 22px; flex: none; } .ntitem .nttext { flex: 1; outline: 0; padding: 4px 2px; overflow-wrap: anywhere; } .ntitem.done .nttext { text-decoration: line-through; color: var(--muted); } .ntitem small { color: var(--muted); }
.ntsw { display: flex; gap: 10px; flex-wrap: wrap; } .ntsw1 { width: 44px; height: 44px; border-radius: 50%; border: 2px solid var(--line); cursor: pointer; color: #fff; font-weight: 700; }
/* The narrow menu rail (a tablet, or the menu collapsed) has no room for a label under every icon once there are many entries: below this height it shows the icons alone (the name is in the tooltip) */
@media (min-width: 768px) and (max-width: 1023px) and (max-height: 1100px) { #nav a > span:not(.nbadge), #nav .ngrp span { display: none; } #nav a { min-height: 34px; justify-content: center; } #nav a svg { width: 22px; height: 22px; } #nav .ngrp { margin-top: 3px; padding-top: 3px; } #nav { gap: 1px; } }
@media (min-width: 1024px) and (max-height: 1100px) { body.nav-mini #nav a > span:not(.nbadge), body.nav-mini #nav .ngrp span { display: none; } body.nav-mini #nav a { min-height: 34px; justify-content: center; } body.nav-mini #nav a svg { width: 22px; height: 22px; } body.nav-mini #nav { gap: 1px; } }
/* Chores */
.crcard { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 4px 12px; } .crgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; } .crgrid .crperson { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 10px 12px; }
.critem { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); cursor: pointer; } .critem:last-child { border-bottom: 0; } .critem input { width: 24px; height: 24px; flex: none; } .critem small { display: block; color: var(--muted); } .critem.done b { text-decoration: line-through; color: var(--muted); } .critem input:disabled { opacity: .5; }
.crpts { font-style: normal; font-size: 12px; padding: 3px 8px; border-radius: 99px; background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); white-space: nowrap; }
.crph { display: flex; align-items: center; gap: 10px; padding: 4px 0; } .crph small { margin-left: auto; color: var(--muted); } .crbar { height: 6px; border-radius: 3px; background: var(--panel2); overflow: hidden; margin: 2px 0 4px; } .crbar i { display: block; height: 100%; background: var(--accent); }
.crrank { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); } .crrank:last-child { border-bottom: 0; } .crrank .num { width: 22px; color: var(--muted); }
/* Family page */
.fmgrid { display: grid; grid-template-columns: 1fr; gap: 14px; } @media (min-width: 760px) { .fmgrid { grid-template-columns: 1fr 1fr; } } @media (min-width: 1200px) { .fmgrid { grid-template-columns: repeat(3, 1fr); } }
.fmcard { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 14px 16px; min-width: 0; } .fmh { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } .fmh h2 { margin: 0 0 6px; } .fmmore { font-size: 13px; color: var(--accent); }
.fmrow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); } .fmrow:last-child { border-bottom: 0; } .fmrow small { color: var(--muted); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .fmrow b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fmdot { width: 10px; height: 10px; border-radius: 50%; flex: none; } .fmday { margin-bottom: 8px; } .fmday > small { color: var(--muted); text-transform: uppercase; letter-spacing: .06em; font-size: 11px; }
.fmprog { margin-top: 10px; display: grid; gap: 8px; } .fmpr { display: flex; gap: 10px; align-items: center; } .fmpr .grow { min-width: 0; } .fmpr small { color: var(--muted); }
/* Child account: time left and the lock screen */
#kidchip { position: fixed; right: 12px; bottom: calc(12px + var(--ph, 0px)); z-index: 40; padding: 6px 12px; border-radius: 99px; background: var(--panel2); border: 1px solid var(--line); font-size: 13px; box-shadow: 0 2px 10px rgba(0,0,0,.25); } #kidchip.low { background: var(--danger); color: #fff; border-color: transparent; }
#kidlock { position: fixed; inset: 0; z-index: 999; background: var(--bg); display: grid; place-items: center; padding: 24px; text-align: center; } .kidbox { max-width: 420px; } .kidbox h1 { margin: 8px 0; }
.inboxbell { position: relative; }
.inboxbell .nbadge { position: absolute; top: -6px; right: -6px; }
.fmrow .grow { min-width: 0; }
#devchip { position: fixed; top: calc(env(safe-area-inset-top, 0px) + 8px); right: 10px; z-index: 60; display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 22px; padding: 4px 4px 4px 14px; box-shadow: 0 4px 14px rgba(0,0,0,.3); }
#devchip .btn { min-height: 34px; padding: 0 14px; border-radius: 18px; } .devwho { display: flex; align-items: center; gap: 6px; } .devkid { font-style: normal; background: #2f9e6e; color: #fff; border-radius: 8px; padding: 1px 7px; font-size: 11px; font-weight: 700; }
.crtick { position: absolute; top: 0; width: 2px; height: 100%; background: var(--text); opacity: .55; } .crearn { display: flex; align-items: center; gap: 10px; margin: 6px 0 0 52px; padding: 8px 10px; border-radius: 12px; background: var(--panel2); } .crearn .btn { min-height: 34px; padding: 0 12px; }
.crdet { margin: 6px 0 0 52px; } .crdet summary { cursor: pointer; color: var(--muted); font-size: 14px; } .crwk { padding: 8px 0; border-bottom: 1px solid var(--line); } .crwk:last-child { border-bottom: 0; }
#kidreq { position: fixed; left: 12px; bottom: calc(env(safe-area-inset-bottom, 0px) + 74px); z-index: 55; min-height: 40px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font: inherit; font-weight: 600; box-shadow: 0 4px 14px rgba(0,0,0,.3); cursor: pointer; }
@media (min-width: 900px) { #kidreq { bottom: 16px; left: 236px; } }
/* simple mode: no menus, big buttons */
body.simple #nav, body.simple #navbar, body.simple #subnav, body.simple #chatfab, body.simple .fab { display: none !important; }
body.simple #shell { flex-direction: column; }
.simplewrap { max-width: 920px; } .simplewrap h1 { font-size: 34px; margin: 12px 0 18px; }
.simplegrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 16px; }
.simpletile { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 170px; border-radius: 28px; color: #fff; text-decoration: none; font-size: 24px; font-weight: 700; box-shadow: 0 6px 18px rgba(0,0,0,.3); } .simpletile svg { width: 64px; height: 64px; }
#simplehome { position: fixed; left: 14px; bottom: calc(env(safe-area-inset-bottom, 0px) + 14px); z-index: 60; display: flex; align-items: center; gap: 10px; padding: 14px 24px; border-radius: 32px; background: var(--accent); color: var(--accent-t, #fff); font-size: 22px; font-weight: 700; text-decoration: none; box-shadow: 0 6px 18px rgba(0,0,0,.4); } #simplehome[hidden] { display: none; } #simplehome svg { width: 30px; height: 30px; }
+25 -1
View File
@@ -1,5 +1,6 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v8';
const CACHE = 'hub-v86';
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'];
// Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline.
@@ -21,8 +22,31 @@ self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
self.clients.claim();
});
// Podcast episodes saved on this device (see offline.js): answer from them, including the byte ranges an audio player asks for; otherwise use the network as normal.
async function offlineAudio(req) {
const hit = await (await caches.open('hub-offline')).match(req.url);
if (!hit) return fetch(req);
const blob = await hit.blob(), size = blob.size, type = hit.headers.get('content-type') || 'audio/mpeg', m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get('range') || '');
if (!m) return new Response(blob, { status: 200, headers: { 'content-type': type, 'content-length': size, 'accept-ranges': 'bytes' } });
let a = m[1] === '' ? Math.max(0, size - +m[2]) : +m[1], b = m[1] === '' || m[2] === '' ? size - 1 : Math.min(+m[2], size - 1);
if (a > b || a >= size) return new Response(null, { status: 416, headers: { 'content-range': 'bytes */' + size } });
return new Response(blob.slice(a, b + 1), { status: 206, headers: { 'content-type': type, 'content-length': b - a + 1, 'content-range': `bytes ${a}-${b}/${size}`, 'accept-ranges': 'bytes' } });
}
// Share target: other apps send files here (POST); keep them and open the app, which asks where to put them.
async function takeShare(request) {
try {
const form = await request.formData(), c = await caches.open(SHARE);
for (const k of await c.keys()) await c.delete(k);
let i = 0;
for (const f of form.getAll('files')) if (f && f.name) await c.put('/share-files/' + (i++), new Response(f, { headers: { 'x-name': encodeURIComponent(f.name), 'content-type': f.type || 'application/octet-stream' } }));
} catch { /* nothing usable was shared */ }
return Response.redirect(self.location.origin + '/#/files', 303);
}
self.addEventListener('fetch', e => {
const u = new URL(e.request.url);
if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/podcast\/podcasts\/[^/]+\/file$/.test(u.pathname)) return e.respondWith(offlineAudio(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/plex\/library\/parts\//.test(u.pathname)) return e.respondWith(offlineAudio(e.request)); // (albums saved on this device)
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
e.respondWith(fetch(e.request).then(r => {
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
+39 -59
View File
@@ -3,18 +3,21 @@
const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
const taskStore = []; // tasks on screen, so a tap can open the right one
let taskLists = []; // the lists (NE Hub and iCloud), so a task seen in "All" opens with its own list's options
async function viewTasks() {
const v = $('#view');
v.innerHTML = '<div class="wrap">' + plannerHead('tasks') + '<p class="empty">Loading…</p></div>';
try {
const { lists, icloud } = await jfetch('/api/tasks/lists');
let { lists, icloud } = await jfetch('/api/tasks/lists');
if (lists.some(l => l.mealie) && allowed('recipes')) { try { await jfetch('/api/recipes/shopping/sync', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); } catch {} } // (the Shopping list is shared with Mealie: bring it up to date first)
if (!location.hash.startsWith('#/tasks')) return;
let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id;
const list = lists.find(l => l.id === cur);
taskLists = lists;
let cur = store.get('task_list'); if (cur !== 'all' && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together)
const list = cur === 'all' ? lists.find(l => l.id === 'local:inbox') || lists.find(l => l.local) || lists[0] : lists.find(l => l.id === cur); // (where a task typed into All goes)
v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
<div class="seg">${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="Add a task" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
<div class="seg">${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="${cur === 'all' ? '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>
<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.'
@@ -23,7 +26,7 @@ async function viewTasks() {
$('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); };
$('#qa').onsubmit = async e => {
e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return;
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: list.id, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
catch (err) { toast(err.message); }
};
loadTaskList(cur, list);
@@ -33,7 +36,9 @@ async function viewTasks() {
async function loadTaskList(cur, list) {
const box = $('#tl'); if (!box) return;
try {
const tasks = await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
const all = cur === 'all', byId = Object.fromEntries(taskLists.map(l => [l.id, l]));
const tasks = all ? (await Promise.all(taskLists.map(l => jfetch('/api/tasks?list=' + encodeURIComponent(l.id)).then(r => r.map(t => ({ ...t, list: l.id, _ln: l.name + (l.icloud ? ' (iCloud)' : '') }))).catch(() => []))))
.flat() : await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
if (!$('#tl')) return;
taskStore.length = 0;
const ids = new Set(tasks.map(t => t.id)), byParent = {};
@@ -43,7 +48,7 @@ async function loadTaskList(cur, list) {
const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' };
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
const extra = [rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
const extra = [all && t._ln ? t._ln : '', t.by && t.list === 'local:family' ? 'Added by ' + t.by : '', rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
return `<div class="row it${t.done ? ' donetask' : ''}${sub ? ' subtask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes || extra ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && (extra || t.notes) ? ' · ' : ''}${esc(extra)}${extra && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
const showDone = store.get('task_show_done', false);
@@ -52,14 +57,15 @@ async function loadTaskList(cur, list) {
const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
const t = taskStore[+b.dataset.t]; b.disabled = true;
try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); loadTaskList(cur, list); }
try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: t.list, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); loadTaskList(cur, list); }
catch (err) { b.disabled = false; toast(err.message); }
});
box.querySelectorAll('button[data-e]').forEach(b => b.onclick = () => taskSheet(taskStore[+b.dataset.e], cur, list));
box.querySelectorAll('button[data-e]').forEach(b => b.onclick = () => { const t = taskStore[+b.dataset.e]; taskSheet(t, t.list, byId[t.list] || list, cur, list); });
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
}
function taskSheet(t, cur, list) {
function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task's own list; viewCur and viewList: what the page is showing, to redraw)
const redraw = () => loadTaskList(viewCur || cur, viewList || list);
const el = openSheet('Task', list.name, `<form id="tf"><input type="text" id="tt" value="${esc(t.title)}" autocomplete="off" required style="margin-top:6px">
<label for="tdue">Due date</label><div style="display:flex;gap:8px"><input type="date" id="tdue" value="${esc(t.due)}" style="flex:1"><button type="button" class="btn ghost" id="tclr">Clear</button></div>
<label for="tnote">Notes</label><textarea id="tnote" rows="3">${esc(t.notes)}</textarea>
@@ -73,24 +79,24 @@ function taskSheet(t, cur, list) {
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; };
el.querySelector('#tf').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true;
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); loadTaskList(cur, list); }
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); redraw(); }
catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; }
};
if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => {
const inp = el.querySelector('#tsub'), title = inp.value.trim(); if (!title) return;
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title, notes: '', due: '', done: false, parent: t.id } }) });
el.querySelector('#tsubs').insertAdjacentHTML('beforeend', `<div class="row"><span class="grow"><b>${esc(title)}</b></span></div>`); inp.value = ''; inp.focus(); loadTaskList(cur, list); }
el.querySelector('#tsubs').insertAdjacentHTML('beforeend', `<div class="row"><span class="grow"><b>${esc(title)}</b></span></div>`); inp.value = ''; inp.focus(); redraw(); }
catch (err) { el.querySelector('#te').textContent = err.message; }
};
if (el.querySelector('#tsub')) el.querySelector('#tsub').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); el.querySelector('#tsubadd').click(); } };
el.querySelector('#td').onclick = () => confirmSheet('Delete task?', t.title + (taskStore.some(k => k.parent === t.id) ? ' and its subtasks' : ''), 'Delete', async () => {
try { await jfetch('/api/tasks/delete', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, etag: t.etag }) }); toast('Task deleted'); loadTaskList(cur, list); }
try { await jfetch('/api/tasks/delete', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, etag: t.etag }) }); toast('Task deleted'); redraw(); }
catch (err) { toast(err.message); }
});
}
function newListSheet(lists, list) {
const mine = lists.filter(l => l.local && l.id !== 'local:inbox');
const mine = lists.filter(l => l.local && l.id !== 'local:inbox' && !l.shared);
const el = openSheet('Lists', '', `${mine.length ? `<div class="list" style="background:var(--panel2);border:0">${mine.map(l => `<div class="row"><span class="grow"><b>${esc(l.name)}</b></span><button class="btn danger" data-rm="${esc(l.id)}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
<form id="lf"><label for="ln">New list</label><input type="text" id="ln" placeholder="Groceries" autocomplete="off" required>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" style="flex:1;justify-content:center">Add list</button></div><div class="err" id="le"></div></form>`);
@@ -99,58 +105,32 @@ function newListSheet(lists, list) {
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); }));
}
// Home: a card only when something is overdue or due today
// Overdue and due-today tasks, for the icon badge
let taskDue = 0;
async function taskPoll() {
if (!allowed('tasks')) return;
try { const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = (s.overdue || 0) + (s.today || 0); if (n !== taskDue) { taskDue = n; renderNav(); } } catch {}
}
// Home: the tasks that are overdue, due today or due this week, from every list (NE Hub and iCloud), with a tick to finish each
async function renderTasksCard(el) {
if (!el) return;
try {
const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = s.overdue + s.today;
if (!n || !document.body.contains(el)) return;
el.innerHTML = `<h2>Tasks</h2><a class="row card2" href="#/tasks">${icon('check')}<span class="grow"><b>${n} task${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention</b><small>${s.overdue ? s.overdue + ' overdue' : ''}${s.overdue && s.today ? ' · ' : ''}${s.today ? s.today + ' due today' : ''}</small></span>${icon('chev', 'chev')}</a>`;
const r = await jfetch('/api/tasks/upcoming?today=' + ymd(new Date()));
if (!document.body.contains(el)) return; if (!r.tasks.length) { el.innerHTML = ''; return; }
const rows = r.tasks.map((t, i) => { const du = dueInfo(t.due);
return `<div class="row it"><button class="ibtn tick" data-h="${i}" aria-label="Mark done">${icon('check')}</button><a class="rmain grow" href="#/tasks" style="color:inherit"><span class="grow"><b>${esc(t.title)}</b><small><span style="color:${du.color}">${esc(du.text)}</span>${t.listName ? ' · ' + esc(t.listName) : ''}</small></span></a></div>`; }).join('');
el.innerHTML = `<h2>Tasks</h2><div class="list">${rows}</div><p style="margin:6px 2px 0"><a href="#/tasks" style="color:var(--accent)">${r.total > r.tasks.length ? 'All ' + r.total + ' coming up' : 'All tasks'}</a></p>`;
el.querySelectorAll('button[data-h]').forEach(b => b.onclick = async () => {
const t = r.tasks[+b.dataset.h]; b.disabled = true;
try { await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: t.list, id: t.id, href: t.href, done: true }) }); taskPoll(); renderTasksCard(el); }
catch (err) { b.disabled = false; toast(err.message); }
});
} catch {}
}
const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join('');
function viewOffice(args = []) {
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
$('#view').innerHTML = `<div class="wrap"><h1>Office</h1><p class="sub">Create, open and edit documents without leaving this tab. Everything is saved in Seafile.</p>
<div class="grid">
<button class="tile" data-x="docx" data-l="document"><span class="ic" style="background:#3d6bf0">${icon('doc')}</span><b>Document</b><small>Word (.docx)</small></button>
<button class="tile" data-x="xlsx" data-l="spreadsheet"><span class="ic" style="background:#2f9e6e">${icon('doc')}</span><b>Spreadsheet</b><small>Excel (.xlsx)</small></button>
<button class="tile" data-x="pptx" data-l="presentation"><span class="ic" style="background:#e0742b">${icon('doc')}</span><b>Presentation</b><small>PowerPoint (.pptx)</small></button>
</div>
<p class="sub" id="olib" style="margin-top:12px">${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
<div id="odocs"></div>
<div class="bar"><a class="btn ghost" href="#/files">Browse all files</a>${sf.token() ? '' : '<a class="btn" href="#/files">Sign in</a>'}</div></div>`;
$('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l));
if (!sf.token()) return;
(async () => {
try {
const loc = await officeLocation(false), el = $('#olib'); if (!el) return;
if (!loc) {
el.textContent = 'Choose where your documents are saved in Seafile.';
$('#odocs').innerHTML = '<div class="bar"><button class="btn" id="chl2">Choose location</button></div>';
$('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } });
return;
}
el.innerHTML = `Saved in <b>${esc(locLabel(loc))}</b> · <a href="#" id="chl" style="color:var(--accent)">change</a>`;
$('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); };
const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`);
const docs = all.filter(f => f.type === 'file' && EDIT_EXT.has(ext(f.name)) && ext(f.name) !== 'txt').sort((a, b) => b.mtime - a.mtime).slice(0, 60);
if (!location.hash.startsWith('#/office') || !$('#odocs')) return;
$('#odocs').innerHTML = docs.length ? `<h2>Documents</h2><div class="list">${docs.map((f, i) => {
const color = { docx: '#3d6bf0', doc: '#3d6bf0', odt: '#3d6bf0', rtf: '#3d6bf0', xlsx: '#2f9e6e', xls: '#2f9e6e', ods: '#2f9e6e', csv: '#2f9e6e' }[ext(f.name)] || '#e0742b';
return `<button class="row" data-i="${i}"><span class="ic" style="background:${color}">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.parent_dir || '/')} · ${fmtDate(f.mtime)}</small></span></button>`; }).join('')}</div>`
: '<p class="empty">No documents here yet. Create one above.</p>';
$('#odocs').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => {
const f = docs[b.dataset.i], path = (f.parent_dir || '/').replace(/\/?$/, '/') + f.name;
addRecent(loc.repo, path, f.name);
if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
});
} catch (e) { const el = $('#olib'); if (el) el.innerHTML = `<span class="err">${esc(e.message)}</span>`; }
})();
}
const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / '));
// The saved Office location, if it still exists (the library may have been deleted).
async function officeLocation(ask) {
+30
View File
@@ -0,0 +1,30 @@
'use strict';
// Home: "Pick up where you left off" (Continue watching and Continue listening), shown above the day's agenda in the evening and below it in the morning.
async function homeResume(el) {
if (!el) return;
const bits = await Promise.allSettled([
allowed('video') ? (async () => {
const st = await jfetch('/api/plex/status'); if (!st.signedIn || !st.server) return '';
px.serverKey = st.server;
const items = ((await px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=8')).Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode').slice(0, 6);
return items.length ? `<h3 class="sub" style="margin:10px 0 4px">Continue watching</h3><div class="vrow">${items.map(m => vdCard(m, true)).join('')}</div>` : '';
})() : '',
allowed('audio') ? (async () => {
const [data] = await Promise.all([abApi('/books'), abLoadProg()]), books = Array.isArray(data) ? data : (data.books || Object.values(data));
const cont = books.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, 6);
today.books = cont;
return cont.length ? `<h3 class="sub" style="margin:10px 0 4px">Continue listening</h3><div class="vrow">${cont.map(b => { const p = abProg[abId(b)], pc = Math.min(100, p.pos / p.dur * 100);
return `<a class="vcard" href="#/audio" data-book="${esc(abId(b))}" style="flex:none;width:130px"><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()">${pc > 1 ? `<i class="vprog"><b style="width:${pc}%"></b></i>` : ''}</span><b>${esc(abTitle(b))}</b><small>${esc(abAuthor(b))}</small></a>`; }).join('')}</div>` : '';
})() : '',
allowed('read') ? (async () => {
const list = await jfetch('/api/read/list');
const cont = list.filter(b => b.pct > 0.01 && b.pct < 0.98).sort((a, b) => b.read - a.read).slice(0, 6);
return cont.length ? `<h3 class="sub" style="margin:10px 0 4px">Continue reading</h3><div class="vrow">${cont.map(b => `<a class="vcard" href="#/read/open/${esc(b.id)}" style="flex:none;width:110px"><span class="vimg rdcover" style="aspect-ratio:2/3">${b.cover ? `<img loading="lazy" alt="" src="/api/read/book/${esc(b.id)}/cover" onerror="this.remove()">` : ''}<em>${esc(b.title)}</em><u class="rdprog"><s style="width:${Math.round(b.pct * 100)}%"></s></u></span><b>${esc(b.title)}</b><small>${Math.round(b.pct * 100)}%</small></a>`).join('')}</div>` : '';
})() : '',
]);
const html = bits.map(b => b.status === 'fulfilled' ? b.value : '').join('');
if (!html || !el.isConnected) return;
el.innerHTML = `<h2>Pick up where you left off</h2>${html}`;
el.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (today.books || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
}
const today = { books: [] };
+75
View File
@@ -0,0 +1,75 @@
'use strict';
// ---- Verse By Verse Ministry (Podcast -> Verse By Verse): free verse-by-verse Bible studies from versebyverseministry.org. Pick a study, play a lesson (the rest of the
// study follows), open its handout. Where you got to in each lesson is kept on the server, so it follows you between devices.
const vbApi = (p, opts) => jfetch('/api/vbv' + p, opts);
const vbOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
let vbProg = {}, vbQueued = {}, vbLast = 0;
const vbPlaying = () => { const t = cur(), m = t && /^vbv:([\w-]+):(\d+)$/.exec(String(t.key)); return m ? { slug: m[1], n: +m[2], key: t.key } : null; };
const vbLoadProg = async () => { try { for (const [k, p] of Object.entries(await vbApi('/progress') || {})) if (!vbProg[k] || p.t > vbProg[k].t) vbProg[k] = p; } catch {} };
const vbPush = keepalive => {
if (!Object.keys(vbQueued).length) return;
const body = JSON.stringify(vbQueued); vbQueued = {};
fetch('/api/vbv/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive }).then(r => { if (!r.ok) throw 0; }).catch(() => { vbQueued = { ...JSON.parse(body), ...vbQueued }; });
};
const vbSave = keepalive => { const n = vbPlaying(), d = pl.a.duration; if (!n || !isFinite(d) || d <= 0) return; vbProg[n.key] = vbQueued[n.key] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; vbPush(keepalive); };
pl.a.addEventListener('timeupdate', () => { if (Date.now() - vbLast > 8000) { vbLast = Date.now(); vbSave(); } });
pl.a.addEventListener('pause', () => vbSave()); pl.a.addEventListener('ended', () => vbSave()); addEventListener('pagehide', () => vbSave(true));
const vbLeft = key => { const p = vbProg[key]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; };
const vbDone = key => { const p = vbProg[key]; return !!(p && p.dur && p.pos >= p.dur - 30); };
const vbDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; };
const vbCover = (s, big) => `<span class="vimg" style="aspect-ratio:1/1;background:var(--panel2)"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(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>';
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(); }
}
async function vbHome() {
const all = await vbApi('/studies'); if (!location.hash.startsWith('#/podcast/vbv')) return;
const S = { q: '', cat: '' }, bySlug = new Map(all.studies.map(s => [s.slug, s]));
const recent = Object.entries(vbProg).map(([k, p]) => { const m = /^vbv:([\w-]+):(\d+)$/.exec(k); return m && { slug: m[1], n: +m[2], key: k, t: p.t }; }).filter(x => x && bySlug.has(x.slug) && vbLeft(x.key)).sort((a, b) => b.t - a.t)
.filter((x, i, a) => a.findIndex(y => y.slug === x.slug) === i).slice(0, 8);
const card = s => `<a class="vcard" href="#/podcast/vbv/study/${esc(s.slug)}">${vbCover(s)}<b>${esc(s.title)}</b><small>${esc([s.lessons ? s.lessons + ' lessons' : '', s.teacher].filter(Boolean).join(' · '))}</small></a>`;
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Podcast</a><h1 style="margin:0">Verse By Verse</h1></div>
<p class="sub">Free verse-by-verse Bible teaching from <a href="https://versebyverseministry.org" target="_blank" rel="noopener" style="color:var(--accent)">Verse By Verse Ministry International</a>. Each study plays lesson after lesson, and most lessons have a handout.</p>
${recent.length ? `<h2>Continue listening</h2><div class="vrow">${recent.map(x => { const s = bySlug.get(x.slug), p = vbProg[x.key]; return `<a class="vcard wide" style="width:140px;flex:none" href="#/podcast/vbv/study/${esc(x.slug)}">${vbCover(s).replace('</span>', `<i class="vprog"><b style="width:${Math.min(100, p.pos / p.dur * 100)}%"></b></i></span>`)}<b>${esc(s.title)}</b><small>Lesson ${x.n + 1}</small></a>`; }).join('')}</div>` : ''}
<input type="search" id="vbq" placeholder="Search studies" autocomplete="off" autocapitalize="off" style="width:100%;margin-top:12px">
<div class="seg" id="vbchips"><a href="#" data-c="" class="on">All</a>${all.categories.map(c => `<a href="#" data-c="${esc(c.id)}">${esc(c.name)}</a>`).join('')}</div><div id="vbgrid" class="vgrid"></div></div>`;
const draw = () => { const q = S.q.toLowerCase(), list = all.studies.filter(s => (!S.cat || s.categories.includes(S.cat)) && (!q || (s.title + ' ' + s.teacher).toLowerCase().includes(q)));
$('#vbgrid').innerHTML = list.map(card).join('') || '<p class="empty">No studies match.</p>'; };
$('#vbq').oninput = e => { S.q = e.target.value.trim(); draw(); };
$('#vbchips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.cat = a.dataset.c; $('#vbchips').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); draw(); });
draw();
}
async function vbStudy(slug) {
const s = await vbApi('/study/' + encodeURIComponent(slug)); if (!location.hash.startsWith('#/podcast/vbv')) return;
const key = l => `vbv:${slug}:${l.n}`, partial = s.lessons.filter(l => vbLeft(key(l))).sort((a, b) => vbProg[key(b)].t - vbProg[key(a)].t)[0], started = s.lessons.some(l => vbProg[key(l)]);
const nextUp = () => partial || s.lessons.find(l => !vbDone(key(l))) || s.lessons[0];
const row = l => { const p = vbLeft(key(l)), done = vbDone(key(l)); return `<div class="row vbrow" data-n="${l.n}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play lesson ${l.n + 1}">${icon('play')}</button>
<span class="grow"><b${done ? ' style="color:var(--muted)"' : ''}>${l.n + 1}. ${esc(l.title)}</b><small>${esc([vbDate(l.date), done ? 'played' : p ? Math.max(1, Math.round((p.dur - p.pos) / 60)) + ' min left' : ''].filter(Boolean).join(' · '))}</small></span>
<a class="btn ghost" href="/api/vbv/notes/${esc(slug)}/${l.n}" target="_blank" rel="noopener" style="min-height:36px;padding:0 12px" title="Lesson handout (PDF)">Notes</a>${allowed('files') ? `<button class="ibtn" data-save aria-label="Save to Files" title="Save to Files">${icon('dl')}</button>` : ''}</div>`; };
const resume = nextUp(), rp = vbLeft(key(resume));
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast/vbv">${icon('back')}Studies</a></div>
<div style="display:flex;gap:16px;align-items:flex-start"><span style="width:120px;flex:none">${vbCover(s)}</span><div style="min-width:0"><h1 style="margin:0">${esc(s.title)}</h1><p class="sub" style="margin:2px 0 0">${esc([s.teacher, s.lessons.length + ' lessons'].filter(Boolean).join(' · '))}</p>
<p class="sub" style="margin:6px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden">${esc(s.description)}</p></div></div>
<div class="bar"><button class="btn" id="vbgo">${icon('play')}${started ? 'Continue' : 'Start'}${resume.n ? ' · lesson ' + (resume.n + 1) : ''}</button>${allowed('files') ? `<button class="btn ghost" id="vbsave">${icon('dl')}Save to Files</button>` : ''}</div><h2>Lessons</h2><div class="list">${s.lessons.map(row).join('')}</div>
<p class="sub" style="margin-top:14px">Teaching and handouts © Verse By Verse Ministry International, shared freely at <a href="https://versebyverseministry.org/bible-studies/${esc(slug)}" target="_blank" rel="noopener" style="color:var(--accent)">versebyverseministry.org</a>.</p></div>`;
const play = l => { // this lesson (from where you left off in it), then the rest of the study in order
const p = vbLeft(key(l)), np = vbPlaying(); if (np && np.key === key(l)) { if (pl.a.paused) pl.a.play().catch(() => {}); return; }
const q = s.lessons.slice(l.n).map(x => ({ key: key(x), title: x.title, artist: s.title, album: 'Verse By Verse Ministry', thumb: s.cover, src: `/api/vbv/audio/${encodeURIComponent(slug)}/${x.n}`, nocache: true, dur: 0 }));
playQueue(q, 0); if (p) pl.a.addEventListener('loadedmetadata', () => { const t = vbPlaying(); if (t && t.key === key(l)) pl.a.currentTime = p.pos; }, { once: true });
};
$('#vbgo').onclick = () => play(resume);
if ($('#vbsave')) $('#vbsave').onclick = () => actionSheet('Save “' + s.title + '” to Files', s.lessons.length + ' lessons. Whatever is already in the folder is skipped.', [
{ label: 'Lessons and handouts', icon: 'dl', run: () => saveToFiles(s.title, { kind: 'vbv-study', slug, what: 'both' }) },
{ label: 'Lesson audio only', icon: 'audio', run: () => saveToFiles(s.title, { kind: 'vbv-study', slug, what: 'audio' }) },
{ label: 'Handouts only', icon: 'doc', run: () => saveToFiles(s.title, { kind: 'vbv-study', slug, what: 'notes' }) }]);
document.querySelectorAll('.vbrow').forEach(r => { const l = s.lessons[+r.dataset.n]; r.querySelector('[data-play]').onclick = () => play(l);
const sv = r.querySelector('[data-save]'); if (sv) sv.onclick = () => actionSheet(s.title + ', lesson ' + (l.n + 1), l.title, [
{ label: 'Lesson and handout', icon: 'dl', run: () => saveToFiles(s.title + ' ' + (l.n + 1), { kind: 'vbv-lesson', slug, n: l.n, what: 'both' }) },
{ label: 'Lesson audio only', icon: 'audio', run: () => saveToFiles(s.title + ' ' + (l.n + 1), { kind: 'vbv-lesson', slug, n: l.n, what: 'audio' }) },
{ label: 'Handout only', icon: 'doc', run: () => saveToFiles(s.title + ' ' + (l.n + 1), { kind: 'vbv-notes', slug, n: l.n }) }]);
});
}
+1
View File
@@ -0,0 +1 @@
{"id":"BSB","name":"Berean Standard Bible","license":"Public domain (dedicated by Bible Hub / Berean Bible, April 30, 2023)","source":"https://berean.bible/","books":[{"id":"GEN","name":"Genesis","chapters":[[["h","The Creation"],["v",1,["In the beginning God created the heavens and the earth."]],["p"],["v",2,["Now the earth was formless and void, and darkness was over the surface of the deep. And the Spirit of God was hovering over the surface of the waters."]],["h","The First Day"],["p"],["v",3,["And God said, “Let there be light,”",1,"and there was light."],["Cited in 2 Corinthians 4:6"]],["v",4,["And God saw that the light was good, and He separated the light from the darkness."]],["v",5,["God called the light “day,” and the darkness He called “night.”","\n","And there was evening, and there was morning—the first day.",1],["Literally day one"]],["h","The Second Day"],["p"],["v",6,["And God said, “Let there be an expanse",1,"between the waters, to separate the waters from the waters.”"],["Or a canopy or a firmament or a vault; also in verses 7, 8, 14, 15, 17, and 20"]],["v",7,["So God made the expanse and separated the waters beneath it from the waters above. And it was so."]],["v",8,["God called the expanse “sky.”","\n","And there was evening, and there was morning—the second day."]],["h","The Third Day"],["p"],["v",9,["And God said, “Let the waters under the sky be gathered into one place, so that the dry land may appear.” And it was so."]],["v",10,["God called the dry land “earth,” and the gathering of waters He called “seas.” And God saw that it was good."]],["p"],["v",11,["Then God said, “Let the earth bring forth vegetation: seed-bearing plants and fruit trees, each bearing fruit with seed according to its kind.” And it was so."]],["v",12,["The earth produced vegetation: seed-bearing plants according to their kinds and trees bearing fruit with seed according to their kinds. And God saw that it was good."]],["p"],["v",13,["And there was evening, and there was morning—the third day."]],["h","The Fourth Day"],["p"],["v",14,["And God said, “Let there be lights in the expanse of the sky to distinguish between the day and the night, and let them be signs to mark the seasons and days and years."]],["v",15,["And let them serve as lights in the expanse of the sky to shine upon the earth.” And it was so."]],["p"],["v",16,["God made two great lights: the greater light to rule the day and the lesser light to rule the night. And He made the stars as well."]],["p"],["v",17,["God set these lights in the expanse of the sky to shine upon the earth,"]],["v",18,["to preside over the day and the night, and to separate the light from the darkness. And God saw that it was good."]],["p"],["v",19,["And there was evening, and there was morning—the fourth day."]],["h","The Fifth Day"],["p"],["v",20,["And God said, “Let the waters teem with living creatures, and let birds fly above the earth in the open expanse of the sky.”"]],["v",21,["So God created the great sea creatures and every living thing that moves, with which the waters teemed according to their kinds, and every winged bird after its kind. And God saw that it was good."]],["p"],["v",22,["Then God blessed them and said, “Be fruitful and multiply and fill the waters of the seas, and let birds multiply on the earth.”"]],["p"],["v",23,["And there was evening, and there was morning—the fifth day."]],["h","The Sixth Day"],["p"],["v",24,["And God said, “Let the earth bring forth living creatures according to their kinds: livestock, land crawlers, and beasts of the earth according to their kinds.” And it was so."]],["v",25,["God made the beasts of the earth according to their kinds, the livestock according to their kinds, and everything that crawls upon the earth according to its kind. And God saw that it was good."]],["p"],["v",26,["Then God said, “Let Us make man in Our image, after Our likeness, to rule over the fish of the sea and the birds of the air, over the livestock, and over all the earth itself",1,"and every creature that crawls upon it.”"],["MT; Syriac and over all the beasts of the earth"]],["p"],["v",27,[[1,"So God created man in His own image;"],[2,"in the image of God He created him;"],"\n",[2,"male and female He created them."],1],["Cited in Matthew 19:4 and Mark 10:6"]],["p"],["v",28,["God blessed them and said to them, “Be fruitful and multiply, and fill the earth and subdue it; rule over the fish of the sea and the birds of the air and every creature that crawls upon the earth.”"]],["p"],["v",29,["Then God said, “Behold, I have given you every seed-bearing plant on the face of all the earth, and every tree whose fruit contains seed. They will be yours for food."]],["v",30,["And to every beast of the earth and every bird of the air and every creature that crawls upon the earth—everything that has the breath of life in it—I have given every green plant for food.” And it was soLine truncated
+1
View File
@@ -0,0 +1 @@
!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e(require("JSZip")):"function"==typeof define&&define.amd?define(["JSZip"],e):"object"==typeof exports?exports.ePub=e(require("JSZip")):t.ePub=e(t.JSZip)}(window,(function(t){return function(t){var e={};function i(n){if(e[n])return e[n].exports;var s=e[n]={i:n,l:!1,exports:{}};return t[n].call(s.exports,s,s.exports,i),s.l=!0,s.exports}return i.m=t,i.c=e,i.d=function(t,e,n){i.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,e){if(1&e&&(t=i(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var s in t)i.d(n,s,function(e){return t[e]}.bind(null,s));return n},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="/dist/",i(i.s=30)}([function(t,e,i){"use strict";i.r(e),i.d(e,"requestAnimationFrame",(function(){return s})),i.d(e,"uuid",(function(){return o})),i.d(e,"documentHeight",(function(){return a})),i.d(e,"isElement",(function(){return h})),i.d(e,"isNumber",(function(){return l})),i.d(e,"isFloat",(function(){return c})),i.d(e,"prefixed",(function(){return u})),i.d(e,"defaults",(function(){return d})),i.d(e,"extend",(function(){return f})),i.d(e,"insert",(function(){return p})),i.d(e,"locationOf",(function(){return g})),i.d(e,"indexOfSorted",(function(){return m})),i.d(e,"bounds",(function(){return v})),i.d(e,"borders",(function(){return y})),i.d(e,"nodeBounds",(function(){return b})),i.d(e,"windowBounds",(function(){return w})),i.d(e,"indexOfNode",(function(){return x})),i.d(e,"indexOfTextNode",(function(){return E})),i.d(e,"indexOfElementNode",(function(){return S})),i.d(e,"isXml",(function(){return N})),i.d(e,"createBlob",(function(){return _})),i.d(e,"createBlobUrl",(function(){return T})),i.d(e,"revokeBlobUrl",(function(){return C})),i.d(e,"createBase64Url",(function(){return O})),i.d(e,"type",(function(){return I})),i.d(e,"parse",(function(){return R})),i.d(e,"qs",(function(){return k})),i.d(e,"qsa",(function(){return A})),i.d(e,"qsp",(function(){return L})),i.d(e,"sprint",(function(){return j})),i.d(e,"treeWalker",(function(){return D})),i.d(e,"walk",(function(){return P})),i.d(e,"blob2base64",(function(){return M})),i.d(e,"defer",(function(){return z})),i.d(e,"querySelectorByType",(function(){return B})),i.d(e,"findChildren",(function(){return q})),i.d(e,"parents",(function(){return F})),i.d(e,"filterChildren",(function(){return U})),i.d(e,"getParentByTagName",(function(){return W})),i.d(e,"RangeObject",(function(){return H}));var n=i(15);const s="undefined"!=typeof window&&(window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||window.msRequestAnimationFrame),r="undefined"!=typeof URL?URL:"undefined"!=typeof window?window.URL||window.webkitURL||window.mozURL:void 0;function o(){var t=(new Date).getTime();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(e){var i=(t+16*Math.random())%16|0;return t=Math.floor(t/16),("x"==e?i:7&i|8).toString(16)}))}function a(){return Math.max(document.documentElement.clientHeight,document.body.scrollHeight,document.documentElement.scrollHeight,document.body.offsetHeight,document.documentElement.offsetHeight)}function h(t){return!(!t||1!=t.nodeType)}function l(t){return!isNaN(parseFloat(t))&&isFinite(t)}function c(t){let e=parseFloat(t);return!1!==l(t)&&("string"==typeof t&&t.indexOf(".")>-1||Math.floor(e)!==e)}function u(t){var e=["-webkit-","-webkit-","-moz-","-o-","-ms-"],i=t.toLowerCase(),n=["Webkit","webkit","Moz","O","ms"].length;if("undefined"==typeof document||void 0!==document.body.style[i])return t;for(var s=0;s<n;s++)if(void 0!==document.body.style[e[s]+i])return e[s]+i;return t}function d(t){for(var e=1,i=arguments.length;e<i;e++){var n=arguments[e];for(var s in n)void 0===t[s]&&(t[s]=n[s])}return t}function f(t){var e=[].slice.call(arguments,1);return e.forEach((function(e){e&&Object.getOwnPropertyNames(e).forEach((function(i){Object.defineProperty(t,i,Object.getOwnPropertyDescriptor(e,i))}))})),t}function p(t,e,i){var n=g(t,e,i);return e.splice(n,0,t),n}function g(t,e,i,n,s){var r,o=n||0,a=s||e.length,h=parseInt(o+(a-o)/2);return i||(i=function(t,e){return t>e?1:t<e?-1:t==e?0:void 0}),a-o<=0?h:(r=i(e[h],t),a-o==1?r>=0?h:h+1:0===r?h:-1===r?g(t,e,i,h,a):g(t,e,i,o,h))}function m(t,e,i,n,s){var r,o=n||0,a=s||e.length,h=parseInt(o+(a-o)/2);return i||(i=function(t,e){return t>e?1:t<e?-1:t==e?0:void 0}),a-o<=0?-1:(r=i(e[h],t),a-o==1?0===r?h:-1:0===r?h:-1===r?m(t,e,i,h,a):m(t,e,i,o,h))}function v(t){var e=window.getComputedStyle(t),i=0,n=0;return["width","padLine truncated
+13
View File
@@ -0,0 +1,13 @@
/*!
JSZip v3.10.1 - A JavaScript class for generating and reading zip files
<http://stuartk.com/jszip>
(c) 2009-2016 Stuart Knightley <stuart [at] stuartk.com>
Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown.
JSZip uses the library pako released under the MIT license :
https://github.com/nodeca/pako/blob/main/LICENSE
*/
!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e<h.length;e++)u(h[e]);return u}({1:[function(e,t,r){"use strict";var d=e("./utils"),c=e("./support"),p="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";r.encode=function(e){for(var t,r,n,i,s,a,o,h=[],u=0,l=e.length,f=l,c="string"!==d.getTypeOf(e);u<e.length;)f=l-u,n=c?(t=e[u++],r=u<l?e[u++]:0,u<l?e[u++]:0):(t=e.charCodeAt(u++),r=u<l?e.charCodeAt(u++):0,u<l?e.charCodeAt(u++):0),i=t>>2,s=(3&t)<<4|r>>4,a=1<f?(15&r)<<2|n>>6:64,o=2<f?63&n:64,h.push(p.charAt(i)+p.charAt(s)+p.charAt(a)+p.charAt(o));return h.join("")},r.decode=function(e){var t,r,n,i,s,a,o=0,h=0,u="data:";if(e.substr(0,u.length)===u)throw new Error("Invalid base64 input, it looks like a data url.");var l,f=3*(e=e.replace(/[^A-Za-z0-9+/=]/g,"")).length/4;if(e.charAt(e.length-1)===p.charAt(64)&&f--,e.charAt(e.length-2)===p.charAt(64)&&f--,f%1!=0)throw new Error("Invalid base64 input, bad content length.");for(l=c.uint8array?new Uint8Array(0|f):new Array(0|f);o<e.length;)t=p.indexOf(e.charAt(o++))<<2|(i=p.indexOf(e.charAt(o++)))>>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a<s;a++)e=e>>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a<s;a++)e=e>>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(Line truncated
+21
View File
@@ -0,0 +1,21 @@
!function(f){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=f();else if("function"==typeof define&&define.amd)define([],f);else{var g;g="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,g.mammoth=f()}}(function(){var define,module,exports;return function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a="function"==typeof require&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}for(var i="function"==typeof require&&require,o=0;o<r.length;o++)s(r[o]);return s}({1:[function(require,module,exports){function Files(){function read(uri){return promises.reject(new Error("could not open external image: '"+uri+"'\ncannot open linked files from a web browser"))}return{read:read}}var promises=require("../../lib/promises");exports.Files=Files},{"../../lib/promises":23}],2:[function(require,module,exports){function openZip(options){return options.arrayBuffer?promises.resolve(zipfile.openArrayBuffer(options.arrayBuffer)):promises.reject(new Error("Could not find file in options"))}var promises=require("../lib/promises"),zipfile=require("../lib/zipfile");exports.openZip=openZip},{"../lib/promises":23,"../lib/zipfile":40}],3:[function(require,module,exports){function DocumentConverter(options){return{convertToHtml:function(element){var comments=_.indexBy(element.type===documents.types.document?element.comments:[],"commentId"),conversion=new DocumentConversion(options,comments);return conversion.convertToHtml(element)}}}function DocumentConversion(options,comments){function convertToHtml(document){var messages=[],html=elementToHtml(document,messages,{}),deferredNodes=[];walkHtml(html,function(node){"deferred"===node.type&&deferredNodes.push(node)});var deferredValues={};return promises.mapSeries(deferredNodes,function(deferred){return deferred.value().then(function(value){deferredValues[deferred.id]=value})}).then(function(){function replaceDeferred(nodes){return flatMap(nodes,function(node){return"deferred"===node.type?deferredValues[node.id]:node.children?[_.extend({},node,{children:replaceDeferred(node.children)})]:[node]})}var writer=writers.writer({prettyPrint:options.prettyPrint,outputFormat:options.outputFormat});return Html.write(writer,Html.simplify(replaceDeferred(html))),new results.Result(writer.asString(),messages)})}function convertElements(elements,messages,options){return flatMap(elements,function(element){return elementToHtml(element,messages,options)})}function elementToHtml(element,messages,options){if(!options)throw new Error("options not set");var handler=elementConverters[element.type];return handler?handler(element,messages,options):[]}function convertParagraph(element,messages,options){return htmlPathForParagraph(element,messages).wrap(function(){var content=convertElements(element.children,messages,options);return ignoreEmptyParagraphs?content:[Html.forceWrite].concat(content)})}function htmlPathForParagraph(element,messages){var style=findStyle(element);return style?style.to:(element.styleId&&messages.push(unrecognisedStyleWarning("paragraph",element)),defaultParagraphStyle)}function convertRun(run,messages,options){var nodes=function(){return convertElements(run.children,messages,options)},paths=[];if(null!==run.highlight){var path=findHtmlPath({type:"highlight",color:run.highlight});path&&paths.push(path)}run.isSmallCaps&&paths.push(findHtmlPathForRunProperty("smallCaps")),run.isAllCaps&&paths.push(findHtmlPathForRunProperty("allCaps")),run.isStrikethrough&&paths.push(findHtmlPathForRunProperty("strikethrough","s")),run.isUnderline&&paths.push(findHtmlPathForRunProperty("underline")),run.verticalAlignment===documents.verticalAlignment.subscript&&paths.push(htmlPaths.element("sub",{},{fresh:!1})),run.verticalAlignment===documents.verticalAlignment.superscript&&paths.push(htmlPaths.element("sup",{},{fresh:!1})),run.isItalic&&paths.push(findHtmlPathForRunProperty("italic","em")),run.isBold&&paths.push(findHtmlPathForRunProperty("bold","strong"));var stylePath=htmlPaths.empty,style=findStyle(run);return style?stylePath=style.to:run.styleId&&messages.push(unrecognisedStyleWarning("run",run)),paths.push(stylePath),paths.forEach(function(path){nodes=path.wrap.bind(path,nodes)}),nodes()}function findHtmlPathForRunProperty(elementType,defaultTagName){var path=findHtmlPath({type:elementType});return path?path:defaultTagName?htmlPaths.element(defaultTagName,{},{fresh:!1}):htmlPaths.empty}function findHtmlPath(element,defaultPath){var style=findStyle(element);return style?style.to:defaultPath}function findStyle(element){for(var i=0;i<styleMap.length;i++)if(styleMap[i].from.matches(element))return styleMap[i]}function recoveringConvertImage(convertImage){return function(image,messages){return promises.attempt(functionLine truncated
return new ReadResult(func(first.value,second.value),joinElements(first.extra,second.extra),first.messages.concat(second.messages))}},{"../documents":4,"../results":25,"./uris":16,"dingbat-to-unicode":85,underscore:102}],6:[function(require,module,exports){function createCommentsReader(bodyReader){function readCommentsXml(element){return Result.combine(element.getElementsByTagName("w:comment").map(readCommentElement))}function readCommentElement(element){function readOptionalAttribute(name){return(element.attributes[name]||"").trim()||null}var id=element.attributes["w:id"];return bodyReader.readXmlElements(element.children).map(function(body){return documents.comment({commentId:id,body:body,authorName:readOptionalAttribute("w:author"),authorInitials:readOptionalAttribute("w:initials")})})}return readCommentsXml}var documents=require("../documents"),Result=require("../results").Result;exports.createCommentsReader=createCommentsReader},{"../documents":4,"../results":25}],7:[function(require,module,exports){function readContentTypesFromXml(element){var extensionDefaults={},overrides={};return element.children.forEach(function(child){if("content-types:Default"===child.name&&(extensionDefaults[child.attributes.Extension]=child.attributes.ContentType),"content-types:Override"===child.name){var name=child.attributes.PartName;"/"===name.charAt(0)&&(name=name.substring(1)),overrides[name]=child.attributes.ContentType}}),contentTypes(overrides,extensionDefaults)}function contentTypes(overrides,extensionDefaults){return{findContentType:function(path){var overrideContentType=overrides[path];if(overrideContentType)return overrideContentType;var pathParts=path.split("."),extension=pathParts[pathParts.length-1];if(extensionDefaults.hasOwnProperty(extension))return extensionDefaults[extension];var fallback=fallbackContentTypes[extension.toLowerCase()];return fallback?"image/"+fallback:null}}}exports.readContentTypesFromXml=readContentTypesFromXml;var fallbackContentTypes={png:"png",gif:"gif",jpeg:"jpeg",jpg:"jpeg",tif:"tiff",tiff:"tiff",bmp:"bmp"};exports.defaultContentTypes=contentTypes({},{})},{}],8:[function(require,module,exports){function DocumentXmlReader(options){function convertXmlToDocument(element){var body=element.first("w:body");if(null==body)throw new Error("Could not find the body element: are you sure this is a docx file?");var result=bodyReader.readXmlElements(body.children).map(function(children){return new documents.Document(children,{notes:options.notes,comments:options.comments})});return new Result(result.value,result.messages)}var bodyReader=options.bodyReader;return{convertXmlToDocument:convertXmlToDocument}}exports.DocumentXmlReader=DocumentXmlReader;var documents=require("../documents"),Result=require("../results").Result},{"../documents":4,"../results":25}],9:[function(require,module,exports){function read(docxFile,input){return input=input||{},promises.props({contentTypes:readContentTypesFromZipFile(docxFile),partPaths:findPartPaths(docxFile),docxFile:docxFile,files:input.path?Files.relativeToFile(input.path):new Files(null)}).also(function(result){return{styles:readStylesFromZipFile(docxFile,result.partPaths.styles)}}).also(function(result){return{numbering:readNumberingFromZipFile(docxFile,result.partPaths.numbering,result.styles)}}).also(function(result){return{footnotes:readXmlFileWithBody(result.partPaths.footnotes,result,function(bodyReader,xml){return xml?notesReader.createFootnotesReader(bodyReader)(xml):new Result([])}),endnotes:readXmlFileWithBody(result.partPaths.endnotes,result,function(bodyReader,xml){return xml?notesReader.createEndnotesReader(bodyReader)(xml):new Result([])}),comments:readXmlFileWithBody(result.partPaths.comments,result,function(bodyReader,xml){return xml?commentsReader.createCommentsReader(bodyReader)(xml):new Result([])})}}).also(function(result){return{notes:result.footnotes.flatMap(function(footnotes){return result.endnotes.map(function(endnotes){return new documents.Notes(footnotes.concat(endnotes))})})}}).then(function(result){return readXmlFileWithBody(result.partPaths.mainDocument,result,function(bodyReader,xml){return result.notes.flatMap(function(notes){return result.comments.flatMap(function(comments){var reader=new DocumentXmlReader({bodyReader:bodyReader,notes:notes,comments:comments});return reader.convertXmlToDocument(xml)})})})})}function findPartPaths(docxFile){return readPackageRelationships(docxFile).then(function(packageRelationships){var mainDocumentPath=findPartPath({docxFile:docxFile,relationships:packageRelationships,relationshipType:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",basePath:"",fallbackPath:"word/document.xml"});if(!docxFile.exists(mainDocumentPath))throw new Error("Could not find main document part. Are you sure this is a valid .docx file?");return xmlFileReader({filename:relationshipsFilename(mainDocumentPath),readElement:relationshipsReader.readRelationships,defaultValue:relationshipsReLine truncated
return{from:documentMatcher,to:htmlPath.valueOrElse(htmlPaths.empty)}})}function readDocumentMatcher(string){return parseString(documentMatcherRule(),string)}function documentMatcherRule(){function createMatcherSuffixesRule(rules){var matcherSuffix=lop.rules.firstOf.apply(lop.rules.firstOf,["matcher suffix"].concat(rules)),matcherSuffixes=lop.rules.zeroOrMore(matcherSuffix);return lop.rules.then(matcherSuffixes,function(suffixes){var matcherOptions={};return suffixes.forEach(function(suffix){_.extend(matcherOptions,suffix)}),matcherOptions})}var sequence=lop.rules.sequence,identifierToConstant=function(identifier,constant){return lop.rules.then(lop.rules.token("identifier",identifier),function(){return constant})},paragraphRule=identifierToConstant("p",documentMatchers.paragraph),runRule=identifierToConstant("r",documentMatchers.run),elementTypeRule=lop.rules.firstOf("p or r or table",paragraphRule,runRule),styleIdRule=lop.rules.sequence(lop.rules.tokenOfType("dot"),lop.rules.sequence.cut(),lop.rules.sequence.capture(identifierRule)).map(function(styleId){return{styleId:styleId}}),styleNameMatcherRule=lop.rules.firstOf("style name matcher",lop.rules.then(lop.rules.sequence(lop.rules.tokenOfType("equals"),lop.rules.sequence.cut(),lop.rules.sequence.capture(stringRule)).head(),function(styleName){return{styleName:documentMatchers.equalTo(styleName)}}),lop.rules.then(lop.rules.sequence(lop.rules.tokenOfType("startsWith"),lop.rules.sequence.cut(),lop.rules.sequence.capture(stringRule)).head(),function(styleName){return{styleName:documentMatchers.startsWith(styleName)}})),styleNameRule=lop.rules.sequence(lop.rules.tokenOfType("open-square-bracket"),lop.rules.sequence.cut(),lop.rules.token("identifier","style-name"),lop.rules.sequence.capture(styleNameMatcherRule),lop.rules.tokenOfType("close-square-bracket")).head(),listTypeRule=lop.rules.firstOf("list type",identifierToConstant("ordered-list",{isOrdered:!0}),identifierToConstant("unordered-list",{isOrdered:!1})),listRule=sequence(lop.rules.tokenOfType("colon"),sequence.capture(listTypeRule),sequence.cut(),lop.rules.tokenOfType("open-paren"),sequence.capture(integerRule),lop.rules.tokenOfType("close-paren")).map(function(listType,levelNumber){return{list:{isOrdered:listType.isOrdered,levelIndex:levelNumber-1}}}),paragraphOrRun=sequence(sequence.capture(elementTypeRule),sequence.capture(createMatcherSuffixesRule([styleIdRule,styleNameRule,listRule]))).map(function(createMatcher,matcherOptions){return createMatcher(matcherOptions)}),table=sequence(lop.rules.token("identifier","table"),sequence.capture(createMatcherSuffixesRule([styleIdRule,styleNameRule]))).map(function(options){return documentMatchers.table(options)}),bold=identifierToConstant("b",documentMatchers.bold),italic=identifierToConstant("i",documentMatchers.italic),underline=identifierToConstant("u",documentMatchers.underline),strikethrough=identifierToConstant("strike",documentMatchers.strikethrough),allCaps=identifierToConstant("all-caps",documentMatchers.allCaps),smallCaps=identifierToConstant("small-caps",documentMatchers.smallCaps),highlight=sequence(lop.rules.token("identifier","highlight"),lop.rules.sequence.capture(lop.rules.optional(lop.rules.sequence(lop.rules.tokenOfType("open-square-bracket"),lop.rules.sequence.cut(),lop.rules.token("identifier","color"),lop.rules.tokenOfType("equals"),lop.rules.sequence.capture(stringRule),lop.rules.tokenOfType("close-square-bracket")).head()))).map(function(color){return documentMatchers.highlight({color:color.valueOrElse(void 0)})}),commentReference=identifierToConstant("comment-reference",documentMatchers.commentReference),breakMatcher=sequence(lop.rules.token("identifier","br"),sequence.cut(),lop.rules.tokenOfType("open-square-bracket"),lop.rules.token("identifier","type"),lop.rules.tokenOfType("equals"),sequence.capture(stringRule),lop.rules.tokenOfType("close-square-bracket")).map(function(breakType){switch(breakType){case"line":return documentMatchers.lineBreak;case"page":return documentMatchers.pageBreak;case"column":return documentMatchers.columnBreak}});return lop.rules.firstOf("element type",paragraphOrRun,table,bold,italic,underline,strikethrough,allCaps,smallCaps,highlight,commentReference,breakMatcher)}function readHtmlPath(string){return parseString(htmlPathRule(),string)}function htmlPathRule(){var capture=lop.rules.sequence.capture,whitespaceRule=lop.rules.tokenOfType("whitespace"),freshRule=lop.rules.then(lop.rules.optional(lop.rules.sequence(lop.rules.tokenOfType("colon"),lop.rules.token("identifier","fresh"))),function(option){return option.map(function(){return!0}).valueOrElse(!1)}),separatorRule=lop.rules.then(lop.rules.optional(lop.rules.sequence(lop.rules.tokenOfType("colon"),lop.rules.token("identifier","separator"),lop.rules.tokenOfType("open-paren"),capture(stringRule),lop.rules.tokenOfType("close-paren")).head()),function(option){return option.valueOrElse("")}),tagNamesRule=lop.rules.oneOrMoreWithSeparator(identifierRule,lop.ruLine truncated
},{"./conventions":41,"./dom":43,"./entities":44,"./sax":46}],43:[function(require,module,exports){function notEmptyString(input){return""!==input}function splitOnASCIIWhitespace(input){return input?input.split(/[\t\n\f\r ]+/).filter(notEmptyString):[]}function orderedSetReducer(current,element){return current.hasOwnProperty(element)||(current[element]=!0),current}function toOrderedSet(input){if(!input)return[];var list=splitOnASCIIWhitespace(input);return Object.keys(list.reduce(orderedSetReducer,{}))}function arrayIncludes(list){return function(element){return list&&-1!==list.indexOf(element)}}function copy(src,dest){for(var p in src)Object.prototype.hasOwnProperty.call(src,p)&&(dest[p]=src[p])}function _extends(Class,Super){function t(){}var pt=Class.prototype;pt instanceof Super||(t.prototype=Super.prototype,t=new t,copy(pt,t),Class.prototype=pt=t),pt.constructor!=Class&&("function"!=typeof Class&&console.error("unknown Class:"+Class),pt.constructor=Class)}function DOMException(code,message){if(message instanceof Error)var error=message;else error=this,Error.call(this,ExceptionMessage[code]),this.message=ExceptionMessage[code],Error.captureStackTrace&&Error.captureStackTrace(this,DOMException);return error.code=code,message&&(this.message=this.message+": "+message),error}function NodeList(){}function LiveNodeList(node,refresh){this._node=node,this._refresh=refresh,_updateLiveList(this)}function _updateLiveList(list){var inc=list._node._inc||list._node.ownerDocument._inc;if(list._inc!=inc){var ls=list._refresh(list._node);__set__(list,"length",ls.length),copy(ls,list),list._inc=inc}}function NamedNodeMap(){}function _findNodeIndex(list,node){for(var i=list.length;i--;)if(list[i]===node)return i}function _addNamedNode(el,list,newAttr,oldAttr){if(oldAttr?list[_findNodeIndex(list,oldAttr)]=newAttr:list[list.length++]=newAttr,el){newAttr.ownerElement=el;var doc=el.ownerDocument;doc&&(oldAttr&&_onRemoveAttribute(doc,el,oldAttr),_onAddAttribute(doc,el,newAttr))}}function _removeNamedNode(el,list,attr){var i=_findNodeIndex(list,attr);if(!(i>=0))throw new DOMException(NOT_FOUND_ERR,new Error(el.tagName+"@"+attr));for(var lastIndex=list.length-1;lastIndex>i;)list[i]=list[++i];if(list.length=lastIndex,el){var doc=el.ownerDocument;doc&&(_onRemoveAttribute(doc,el,attr),attr.ownerElement=null)}}function DOMImplementation(){}function Node(){}function _xmlEncoder(c){return"<"==c&&"&lt;"||">"==c&&"&gt;"||"&"==c&&"&amp;"||'"'==c&&"&quot;"||"&#"+c.charCodeAt()+";"}function _visitNode(node,callback){if(callback(node))return!0;if(node=node.firstChild)do if(_visitNode(node,callback))return!0;while(node=node.nextSibling)}function Document(){this.ownerDocument=this}function _onAddAttribute(doc,el,newAttr){doc&&doc._inc++;var ns=newAttr.namespaceURI;ns===NAMESPACE.XMLNS&&(el._nsMap[newAttr.prefix?newAttr.localName:""]=newAttr.value)}function _onRemoveAttribute(doc,el,newAttr,remove){doc&&doc._inc++;var ns=newAttr.namespaceURI;ns===NAMESPACE.XMLNS&&delete el._nsMap[newAttr.prefix?newAttr.localName:""]}function _onUpdateChild(doc,el,newChild){if(doc&&doc._inc){doc._inc++;var cs=el.childNodes;if(newChild)cs[cs.length++]=newChild;else{for(var child=el.firstChild,i=0;child;)cs[i++]=child,child=child.nextSibling;cs.length=i,delete cs[cs.length]}}}function _removeChild(parentNode,child){var previous=child.previousSibling,next=child.nextSibling;return previous?previous.nextSibling=next:parentNode.firstChild=next,next?next.previousSibling=previous:parentNode.lastChild=previous,child.parentNode=null,child.previousSibling=null,child.nextSibling=null,_onUpdateChild(parentNode.ownerDocument,parentNode),child}function hasValidParentNodeType(node){return node&&(node.nodeType===Node.DOCUMENT_NODE||node.nodeType===Node.DOCUMENT_FRAGMENT_NODE||node.nodeType===Node.ELEMENT_NODE)}function hasInsertableNodeType(node){return node&&(isElementNode(node)||isTextNode(node)||isDocTypeNode(node)||node.nodeType===Node.DOCUMENT_FRAGMENT_NODE||node.nodeType===Node.COMMENT_NODE||node.nodeType===Node.PROCESSING_INSTRUCTION_NODE)}function isDocTypeNode(node){return node&&node.nodeType===Node.DOCUMENT_TYPE_NODE}function isElementNode(node){return node&&node.nodeType===Node.ELEMENT_NODE}function isTextNode(node){return node&&node.nodeType===Node.TEXT_NODE}function isElementInsertionPossible(doc,child){var parentChildNodes=doc.childNodes||[];if(find(parentChildNodes,isElementNode)||isDocTypeNode(child))return!1;var docTypeNode=find(parentChildNodes,isDocTypeNode);return!(child&&docTypeNode&&parentChildNodes.indexOf(docTypeNode)>parentChildNodes.indexOf(child))}function isElementReplacementPossible(doc,child){function hasElementChildThatIsNotChild(node){return isElementNode(node)&&node!==child}var parentChildNodes=doc.childNodes||[];if(find(parentChildNodes,hasElementChildThatIsNotChild))return!1;var docTypeNode=find(parentChildNodes,isDocTypeNode);return!(child&&docTypeNode&&parentChildNodes.indexOf(docTypeNode)>parentChildNodes.indexOf(child))}function assertPreLine truncated
circ:"ˆ",tilde:"˜",ensp:" ",emsp:" ",thinsp:" ",zwnj:"‌",zwj:"‍",lrm:"‎",rlm:"‏",ndash:"–",mdash:"—",lsquo:"‘",rsquo:"’",sbquo:"‚",ldquo:"“",rdquo:"”",bdquo:"„",dagger:"†",Dagger:"‡",bull:"•",hellip:"…",permil:"‰",prime:"′",Prime:"″",lsaquo:"‹",rsaquo:"›",oline:"‾",euro:"€",trade:"™",larr:"←",uarr:"↑",rarr:"→",darr:"↓",harr:"↔",crarr:"↵",lceil:"⌈",rceil:"⌉",lfloor:"⌊",rfloor:"⌋",loz:"◊",spades:"♠",clubs:"♣",hearts:"♥",diams:"♦"}),exports.entityMap=exports.HTML_ENTITIES},{"./conventions":41}],45:[function(require,module,exports){var dom=require("./dom");exports.DOMImplementation=dom.DOMImplementation,exports.XMLSerializer=dom.XMLSerializer,exports.DOMParser=require("./dom-parser").DOMParser},{"./dom":43,"./dom-parser":42}],46:[function(require,module,exports){function ParseError(message,locator){this.message=message,this.locator=locator,Error.captureStackTrace&&Error.captureStackTrace(this,ParseError)}function XMLReader(){}function parse(source,defaultNSMapCopy,entityMap,domBuilder,errorHandler){function fixedFromCharCode(code){if(code>65535){code-=65536;var surrogate1=55296+(code>>10),surrogate2=56320+(1023&code);return String.fromCharCode(surrogate1,surrogate2)}return String.fromCharCode(code)}function entityReplacer(a){var k=a.slice(1,-1);return Object.hasOwnProperty.call(entityMap,k)?entityMap[k]:"#"===k.charAt(0)?fixedFromCharCode(parseInt(k.substr(1).replace("x","0x"))):(errorHandler.error("entity not found:"+a),a)}function appendText(end){if(end>start){var xt=source.substring(start,end).replace(/&#?\w+;/g,entityReplacer);locator&&position(start),domBuilder.characters(xt,0,end-start),start=end}}function position(p,m){for(;p>=lineEnd&&(m=linePattern.exec(source));)lineStart=m.index,lineEnd=lineStart+m[0].length,locator.lineNumber++;locator.columnNumber=p-lineStart+1}for(var lineStart=0,lineEnd=0,linePattern=/.*(?:\r\n?|\n)|.*$/g,locator=domBuilder.locator,parseStack=[{currentNSMap:defaultNSMapCopy}],closeMap={},start=0;;){try{var tagStart=source.indexOf("<",start);if(0>tagStart){if(!source.substr(start).match(/^\s*$/)){var doc=domBuilder.doc,text=doc.createTextNode(source.substr(start));doc.appendChild(text),domBuilder.currentElement=text}return}switch(tagStart>start&&appendText(tagStart),source.charAt(tagStart+1)){case"/":var end=source.indexOf(">",tagStart+3),tagName=source.substring(tagStart+2,end).replace(/[ \t\n\r]+$/g,""),config=parseStack.pop();0>end?(tagName=source.substring(tagStart+2).replace(/[\s<].*/,""),errorHandler.error("end tag name: "+tagName+" is not complete:"+config.tagName),end=tagStart+1+tagName.length):tagName.match(/\s</)&&(tagName=tagName.replace(/[\s<].*/,""),errorHandler.error("end tag name: "+tagName+" maybe not complete"),end=tagStart+1+tagName.length);var localNSMap=config.localNSMap,endMatch=config.tagName==tagName,endIgnoreCaseMach=endMatch||config.tagName&&config.tagName.toLowerCase()==tagName.toLowerCase();if(endIgnoreCaseMach){if(domBuilder.endElement(config.uri,config.localName,tagName),localNSMap)for(var prefix in localNSMap)Object.prototype.hasOwnProperty.call(localNSMap,prefix)&&domBuilder.endPrefixMapping(prefix);endMatch||errorHandler.fatalError("end tag name: "+tagName+" is not match the current start tagName:"+config.tagName)}else parseStack.push(config);end++;break;case"?":locator&&position(tagStart),end=parseInstruction(source,tagStart,domBuilder);break;case"!":locator&&position(tagStart),end=parseDCC(source,tagStart,domBuilder,errorHandler);break;default:locator&&position(tagStart);var el=new ElementAttributes,currentNSMap=parseStack[parseStack.length-1].currentNSMap,end=parseElementStartPart(source,tagStart,el,currentNSMap,entityReplacer,errorHandler),len=el.length;if(!el.closed&&fixSelfClosed(source,end,el.tagName,closeMap)&&(el.closed=!0,entityMap.nbsp||errorHandler.warning("unclosed xml attribute")),locator&&len){for(var locator2=copyLocator(locator,{}),i=0;len>i;i++){var a=el[i];position(a.offset),a.locator=copyLocator(locator,{})}domBuilder.locator=locator2,appendElement(el,domBuilder,currentNSMap)&&parseStack.push(el),domBuilder.locator=locator}else appendElement(el,domBuilder,currentNSMap)&&parseStack.push(el);NAMESPACE.isHTML(el.uri)&&!el.closed?end=parseHtmlSpecialContent(source,end,el.tagName,entityReplacer,domBuilder):end++}}catch(e){if(e instanceof ParseError)throw e;errorHandler.error("element parse error: "+e),end=-1}end>start?start=end:appendText(Math.max(tagStart,start)+1)}}function copyLocator(f,t){return t.lineNumber=f.lineNumber,t.columnNumber=f.columnNumber,t}function parseElementStartPart(source,start,el,currentNSMap,entityReplacer,errorHandler){function addAttribute(qname,value,startIndex){el.attributeNames.hasOwnProperty(qname)&&errorHandler.fatalError("Attribute "+qname+" redefined"),el.addValue(qname,value.replace(/[\t\n\r]/g," ").replace(/&#?\w+;/g,entityReplacer),startIndex)}for(var attrName,value,p=++start,s=S_TAG;;){var c=source.charAt(p);switLine truncated
i>0&&(creatorLine="\n"+traceLines[i-1]);break}}var msg="a promise was created in a "+name+"handler "+handlerLine+"but was not returned from it, see http://goo.gl/rRqMUw"+creatorLine;promise._warn(msg,!0,promiseCreated)}}function deprecated(name,replacement){var message=name+" is deprecated and will be removed in a future version.";return replacement&&(message+=" Use "+replacement+" instead."),warn(message)}function warn(message,shouldUseOwnTrace,promise){if(config.warnings){var ctx,warning=new Warning(message);if(shouldUseOwnTrace)promise._attachExtraTrace(warning);else if(config.longStackTraces&&(ctx=Promise._peekContext()))ctx.attachExtraTrace(warning);else{var parsed=parseStackAndMessage(warning);warning.stack=parsed.message+"\n"+parsed.stack.join("\n")}activeFireEvent("warning",warning)||formatAndLogError(warning,"",!0)}}function reconstructStack(message,stacks){for(var i=0;i<stacks.length-1;++i)stacks[i].push("From previous event:"),stacks[i]=stacks[i].join("\n");return i<stacks.length&&(stacks[i]=stacks[i].join("\n")),message+"\n"+stacks.join("\n")}function removeDuplicateOrEmptyJumps(stacks){for(var i=0;i<stacks.length;++i)(0===stacks[i].length||i+1<stacks.length&&stacks[i][0]===stacks[i+1][0])&&(stacks.splice(i,1),i--)}function removeCommonRoots(stacks){for(var current=stacks[0],i=1;i<stacks.length;++i){for(var prev=stacks[i],currentLastIndex=current.length-1,currentLastLine=current[currentLastIndex],commonRootMeetPoint=-1,j=prev.length-1;j>=0;--j)if(prev[j]===currentLastLine){commonRootMeetPoint=j;break}for(var j=commonRootMeetPoint;j>=0;--j){var line=prev[j];if(current[currentLastIndex]!==line)break;current.pop(),currentLastIndex--}current=prev}}function cleanStack(stack){for(var ret=[],i=0;i<stack.length;++i){var line=stack[i],isTraceLine=" (No stack trace)"===line||stackFramePattern.test(line),isInternalFrame=isTraceLine&&shouldIgnore(line);isTraceLine&&!isInternalFrame&&(indentStackFrames&&" "!==line.charAt(0)&&(line=" "+line),ret.push(line))}return ret}function stackFramesAsArray(error){for(var stack=error.stack.replace(/\s+$/g,"").split("\n"),i=0;i<stack.length;++i){var line=stack[i];if(" (No stack trace)"===line||stackFramePattern.test(line))break}return i>0&&"SyntaxError"!=error.name&&(stack=stack.slice(i)),stack}function parseStackAndMessage(error){var stack=error.stack,message=error.toString();return stack="string"==typeof stack&&stack.length>0?stackFramesAsArray(error):[" (No stack trace)"],{message:message,stack:"SyntaxError"==error.name?stack:cleanStack(stack)}}function formatAndLogError(error,title,isSoft){if("undefined"!=typeof console){var message;if(util.isObject(error)){var stack=error.stack;message=title+formatStack(stack,error)}else message=title+String(error);"function"==typeof printWarning?printWarning(message,isSoft):("function"==typeof console.log||"object"==typeof console.log)&&console.log(message)}}function fireRejectionEvent(name,localHandler,reason,promise){var localEventFired=!1;try{"function"==typeof localHandler&&(localEventFired=!0,"rejectionHandled"===name?localHandler(promise):localHandler(reason,promise))}catch(e){async.throwLater(e)}"unhandledRejection"===name?activeFireEvent(name,reason,promise)||localEventFired||formatAndLogError(reason,"Unhandled rejection "):activeFireEvent(name,promise)}function formatNonError(obj){var str;if("function"==typeof obj)str="[function "+(obj.name||"anonymous")+"]";else{str=obj&&"function"==typeof obj.toString?obj.toString():util.toString(obj);var ruselessToString=/\[object [a-zA-Z0-9$_]+\]/;if(ruselessToString.test(str))try{var newStr=JSON.stringify(obj);str=newStr}catch(e){}0===str.length&&(str="(empty array)")}return"(<"+snip(str)+">, no stack trace)"}function snip(str){var maxChars=41;return str.length<maxChars?str:str.substr(0,maxChars-3)+"..."}function longStackTracesIsSupported(){return"function"==typeof captureStackTrace}function parseLineInfo(line){var matches=line.match(parseLineInfoRegex);return matches?{fileName:matches[1],line:parseInt(matches[2],10)}:void 0}function setBounds(firstLineError,lastLineError){if(longStackTracesIsSupported()){for(var firstFileName,lastFileName,firstStackLines=firstLineError.stack.split("\n"),lastStackLines=lastLineError.stack.split("\n"),firstIndex=-1,lastIndex=-1,i=0;i<firstStackLines.length;++i){var result=parseLineInfo(firstStackLines[i]);if(result){firstFileName=result.fileName,firstIndex=result.line;break}}for(var i=0;i<lastStackLines.length;++i){var result=parseLineInfo(lastStackLines[i]);if(result){lastFileName=result.fileName,lastIndex=result.line;break}}0>firstIndex||0>lastIndex||!firstFileName||!lastFileName||firstFileName!==lastFileName||firstIndex>=lastIndex||(shouldIgnore=function(line){if(bluebirdFramePattern.test(line))return!0;var info=parseLineInfo(line);return info&&info.fileName===firstFileName&&firstIndex<=info.line&&info.line<=lastIndex?!0:!1})}}function CapturedTrace(parent){this._parent=parent,this._promisesCreated=0;var length=this._length=1+(void 0===Line truncated
},promiseSetter=function(i){return new Function("promise","holder"," \n 'use strict'; \n holder.pIndex = promise; \n ".replace(/Index/g,i))},generateHolderClass=function(total){for(var props=new Array(total),i=0;i<props.length;++i)props[i]="this.p"+(i+1);var assignment=props.join(" = ")+" = null;",cancellationCode="var promise;\n"+props.map(function(prop){return" \n promise = "+prop+"; \n if (promise instanceof Promise) { \n promise.cancel(); \n } \n "}).join("\n"),passedArguments=props.join(", "),name="Holder$"+total,code="return function(tryCatch, errorObj, Promise, async) { \n 'use strict'; \n function [TheName](fn) { \n [TheProperties] \n this.fn = fn; \n this.asyncNeeded = true; \n this.now = 0; \n } \n \n [TheName].prototype._callFunction = function(promise) { \n promise._pushContext(); \n var ret = tryCatch(this.fn)([ThePassedArguments]); \n promise._popContext(); \n if (ret === errorObj) { \n promise._rejectCallback(ret.e, false); \n } else { \n promise._resolveCallback(ret); \n } \n }; \n \n [TheName].prototype.checkFulfillment = function(promise) { \n var now = ++this.now; \n if (now === [TheTotal]) { \n if (this.asyncNeeded) { \n async.invoke(this._callFunction, this, promise); \n } else { \n this._callFunction(promise); \n } \n \n } \n }; \n \n [TheName].prototype._resultCancelled = function() { \n [CancellationCode] \n }; \n \n return [TheName]; \n }(tryCatch, errorObj, Promise, async); \n ";return code=code.replace(/\[TheName\]/g,name).replace(/\[TheTotal\]/g,total).replace(/\[ThePassedArguments\]/g,passedArguments).replace(/\[TheProperties\]/g,assignment).replace(/\[CancellationCode\]/g,cancellationCode),new Function("tryCatch","errorObj","Promise","async",code)(tryCatch,errorObj,Promise,async)},holderClasses=[],thenCallbacks=[],promiseSetters=[],i=0;8>i;++i)holderClasses.push(generateHolderClass(i+1)),thenCallbacks.push(thenCallback(i+1)),promiseSetters.push(promiseSetter(i+1));reject=function(reason){this._reject(reason)}}Promise.join=function(){var fn,last=arguments.length-1;if(last>0&&"function"==typeof arguments[last]&&(fn=arguments[last],8>=last&&canEvaluate)){var ret=new Promise(INTERNAL);ret._captureStackTrace();for(var HolderClass=holderClasses[last-1],holder=new HolderClass(fn),callbacks=thenCallbacks,i=0;last>i;++i){var maybePromise=tryConvertToPromise(arguments[i],ret);if(maybePromise instanceof Promise){maybePromise=maybePromise._target();var bitField=maybePromise._bitField;0===(50397184&bitLine truncated
var promise=this._promise=new Promise(INTERNAL);values instanceof Promise&&promise._propagateFrom(values,3),promise._setOnCancel(this),this._values=values,this._length=0,this._totalResolved=0,this._init(void 0,-2)}var util=require("./util");util.isArray;return util.inherits(PromiseArray,Proxyable),PromiseArray.prototype.length=function(){return this._length},PromiseArray.prototype.promise=function(){return this._promise},PromiseArray.prototype._init=function init(_,resolveValueIfEmpty){var values=tryConvertToPromise(this._values,this._promise);if(values instanceof Promise){values=values._target();var bitField=values._bitField;if(this._values=values,0===(50397184&bitField))return this._promise._setAsyncGuaranteed(),values._then(init,this._reject,void 0,this,resolveValueIfEmpty);if(0===(33554432&bitField))return 0!==(16777216&bitField)?this._reject(values._reason()):this._cancel();values=values._value()}if(values=util.asArray(values),null===values){var err=apiRejection("expecting an array or an iterable object but got "+util.classString(values)).reason();return void this._promise._rejectCallback(err,!1)}return 0===values.length?void(-5===resolveValueIfEmpty?this._resolveEmptyArray():this._resolve(toResolutionValue(resolveValueIfEmpty))):void this._iterate(values)},PromiseArray.prototype._iterate=function(values){var len=this.getActualLength(values.length);this._length=len,this._values=this.shouldCopyValues()?new Array(len):this._values;for(var result=this._promise,isResolved=!1,bitField=null,i=0;len>i;++i){var maybePromise=tryConvertToPromise(values[i],result);maybePromise instanceof Promise?(maybePromise=maybePromise._target(),bitField=maybePromise._bitField):bitField=null,isResolved?null!==bitField&&maybePromise.suppressUnhandledRejections():null!==bitField?0===(50397184&bitField)?(maybePromise._proxy(this,i),this._values[i]=maybePromise):isResolved=0!==(33554432&bitField)?this._promiseFulfilled(maybePromise._value(),i):0!==(16777216&bitField)?this._promiseRejected(maybePromise._reason(),i):this._promiseCancelled(i):isResolved=this._promiseFulfilled(maybePromise,i)}isResolved||result._setAsyncGuaranteed()},PromiseArray.prototype._isResolved=function(){return null===this._values},PromiseArray.prototype._resolve=function(value){this._values=null,this._promise._fulfill(value)},PromiseArray.prototype._cancel=function(){!this._isResolved()&&this._promise._isCancellable()&&(this._values=null,this._promise._cancel())},PromiseArray.prototype._reject=function(reason){this._values=null,this._promise._rejectCallback(reason,!1)},PromiseArray.prototype._promiseFulfilled=function(value,index){this._values[index]=value;var totalResolved=++this._totalResolved;return totalResolved>=this._length?(this._resolve(this._values),!0):!1},PromiseArray.prototype._promiseCancelled=function(){return this._cancel(),!0},PromiseArray.prototype._promiseRejected=function(reason){return this._totalResolved++,this._reject(reason),!0},PromiseArray.prototype._resultCancelled=function(){if(!this._isResolved()){var values=this._values;if(this._cancel(),values instanceof Promise)values.cancel();else for(var i=0;i<values.length;++i)values[i]instanceof Promise&&values[i].cancel()}},PromiseArray.prototype.shouldCopyValues=function(){return!0},PromiseArray.prototype.getActualLength=function(len){return len},PromiseArray}},{"./util":82}],70:[function(require,module,exports){"use strict";module.exports=function(Promise,INTERNAL){function propsFilter(key){return!noCopyPropsPattern.test(key)}function isPromisified(fn){try{return fn.__isPromisified__===!0}catch(e){return!1}}function hasPromisified(obj,key,suffix){var val=util.getDataPropertyOrDefault(obj,key+suffix,defaultPromisified);return val?isPromisified(val):!1}function checkValid(ret,suffix,suffixRegexp){for(var i=0;i<ret.length;i+=2){var key=ret[i];if(suffixRegexp.test(key))for(var keyWithoutAsyncSuffix=key.replace(suffixRegexp,""),j=0;j<ret.length;j+=2)if(ret[j]===keyWithoutAsyncSuffix)throw new TypeError("Cannot promisify an API that has normal methods with '%s'-suffix\n\n See http://goo.gl/MqrFmX\n".replace("%s",suffix))}}function promisifiableMethods(obj,suffix,suffixRegexp,filter){for(var keys=util.inheritedDataKeys(obj),ret=[],i=0;i<keys.length;++i){var key=keys[i],value=obj[key],passesDefaultFilter=filter===defaultFilter?!0:defaultFilter(key,value,obj);"function"!=typeof value||isPromisified(value)||hasPromisified(obj,key,suffix)||!filter(key,value,obj,passesDefaultFilter)||ret.push(key,value)}return checkValid(ret,suffix,suffixRegexp),ret}function makeNodePromisifiedClosure(callback,receiver,_,fn,__,multiArgs){function promisified(){var _receiver=receiver;receiver===THIS&&(_receiver=this);var promise=new Promise(INTERNAL);promise._captureStackTrace();var cb="string"==typeof method&&this!==defaultThis?this[method]:callback,fn=nodebackForPromise(promise,multiArgs);try{cb.apply(_receiver,withAppended(arguments,fn))}catch(e){promise._rejectCallback(maybeWrapAsError(e),!0,!0)}return promiseLine truncated
ret}}},{"./util":82}],81:[function(require,module,exports){"use strict";module.exports=function(Promise,apiRejection,tryConvertToPromise,createContext,INTERNAL,debug){function thrower(e){setTimeout(function(){throw e},0)}function castPreservingDisposable(thenable){var maybePromise=tryConvertToPromise(thenable);return maybePromise!==thenable&&"function"==typeof thenable._isDisposable&&"function"==typeof thenable._getDisposer&&thenable._isDisposable()&&maybePromise._setDisposable(thenable._getDisposer()),maybePromise}function dispose(resources,inspection){function iterator(){if(i>=len)return ret._fulfill();var maybePromise=castPreservingDisposable(resources[i++]);if(maybePromise instanceof Promise&&maybePromise._isDisposable()){try{maybePromise=tryConvertToPromise(maybePromise._getDisposer().tryDispose(inspection),resources.promise)}catch(e){return thrower(e)}if(maybePromise instanceof Promise)return maybePromise._then(iterator,thrower,null,null,null)}iterator()}var i=0,len=resources.length,ret=new Promise(INTERNAL);return iterator(),ret}function Disposer(data,promise,context){this._data=data,this._promise=promise,this._context=context}function FunctionDisposer(fn,promise,context){this.constructor$(fn,promise,context)}function maybeUnwrapDisposer(value){return Disposer.isDisposer(value)?(this.resources[this.index]._setDisposable(value),value.promise()):value}function ResourceList(length){this.length=length,this.promise=null,this[length-1]=null}var util=require("./util"),TypeError=require("./errors").TypeError,inherits=require("./util").inherits,errorObj=util.errorObj,tryCatch=util.tryCatch,NULL={};Disposer.prototype.data=function(){return this._data},Disposer.prototype.promise=function(){return this._promise},Disposer.prototype.resource=function(){return this.promise().isFulfilled()?this.promise().value():NULL},Disposer.prototype.tryDispose=function(inspection){var resource=this.resource(),context=this._context;void 0!==context&&context._pushContext();var ret=resource!==NULL?this.doDispose(resource,inspection):null;return void 0!==context&&context._popContext(),this._promise._unsetDisposable(),this._data=null,ret},Disposer.isDisposer=function(d){return null!=d&&"function"==typeof d.resource&&"function"==typeof d.tryDispose},inherits(FunctionDisposer,Disposer),FunctionDisposer.prototype.doDispose=function(resource,inspection){var fn=this.data();return fn.call(resource,resource,inspection)},ResourceList.prototype._resultCancelled=function(){for(var len=this.length,i=0;len>i;++i){var item=this[i];item instanceof Promise&&item.cancel()}},Promise.using=function(){var len=arguments.length;if(2>len)return apiRejection("you must pass at least 2 arguments to Promise.using");var fn=arguments[len-1];if("function"!=typeof fn)return apiRejection("expecting a function but got "+util.classString(fn));var input,spreadArgs=!0;2===len&&Array.isArray(arguments[0])?(input=arguments[0],len=input.length,spreadArgs=!1):(input=arguments,len--);for(var resources=new ResourceList(len),i=0;len>i;++i){var resource=input[i];if(Disposer.isDisposer(resource)){var disposer=resource;resource=resource.promise(),resource._setDisposable(disposer)}else{var maybePromise=tryConvertToPromise(resource);maybePromise instanceof Promise&&(resource=maybePromise._then(maybeUnwrapDisposer,null,null,{resources:resources,index:i},void 0))}resources[i]=resource}for(var reflectedResources=new Array(resources.length),i=0;i<reflectedResources.length;++i)reflectedResources[i]=Promise.resolve(resources[i]).reflect();var resultPromise=Promise.all(reflectedResources).then(function(inspections){for(var i=0;i<inspections.length;++i){var inspection=inspections[i];if(inspection.isRejected())return errorObj.e=inspection.error(),errorObj;if(!inspection.isFulfilled())return void resultPromise.cancel();inspections[i]=inspection.value()}promise._pushContext(),fn=tryCatch(fn);var ret=spreadArgs?fn.apply(void 0,inspections):fn(inspections),promiseCreated=promise._popContext();return debug.checkForgottenReturns(ret,promiseCreated,"Promise.using",promise),ret}),promise=resultPromise.lastly(function(){var inspection=new Promise.PromiseInspection(resultPromise);return dispose(resources,inspection)});return resources.promise=promise,promise._setOnCancel(resources),promise},Promise.prototype._setDisposable=function(disposer){this._bitField=131072|this._bitField,this._disposer=disposer},Promise.prototype._isDisposable=function(){return(131072&this._bitField)>0},Promise.prototype._getDisposer=function(){return this._disposer},Promise.prototype._unsetDisposable=function(){this._bitField=-131073&this._bitField,this._disposer=void 0},Promise.prototype.disposer=function(fn){if("function"==typeof fn)return new FunctionDisposer(fn,this,createContext());throw new TypeError}}},{"./errors":58,"./util":82}],82:[function(require,module,exports){(function(process,global){"use strict";function tryCatcher(){try{var target=tryCatchTarget;return tryCatchTarget=null,target.apply(this,arguments)}catcLine truncated
}return newBuf},Buffer.prototype.readUIntLE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var val=this[offset],mul=1,i=0;++i<byteLength&&(mul*=256);)val+=this[offset+i]*mul;return val},Buffer.prototype.readUIntBE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var val=this[offset+--byteLength],mul=1;byteLength>0&&(mul*=256);)val+=this[offset+--byteLength]*mul;return val},Buffer.prototype.readUInt8=function(offset,noAssert){return noAssert||checkOffset(offset,1,this.length),this[offset]},Buffer.prototype.readUInt16LE=function(offset,noAssert){return noAssert||checkOffset(offset,2,this.length),this[offset]|this[offset+1]<<8},Buffer.prototype.readUInt16BE=function(offset,noAssert){return noAssert||checkOffset(offset,2,this.length),this[offset]<<8|this[offset+1]},Buffer.prototype.readUInt32LE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),(this[offset]|this[offset+1]<<8|this[offset+2]<<16)+16777216*this[offset+3]},Buffer.prototype.readUInt32BE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),16777216*this[offset]+(this[offset+1]<<16|this[offset+2]<<8|this[offset+3])},Buffer.prototype.readIntLE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var val=this[offset],mul=1,i=0;++i<byteLength&&(mul*=256);)val+=this[offset+i]*mul;return mul*=128,val>=mul&&(val-=Math.pow(2,8*byteLength)),val},Buffer.prototype.readIntBE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var i=byteLength,mul=1,val=this[offset+--i];i>0&&(mul*=256);)val+=this[offset+--i]*mul;return mul*=128,val>=mul&&(val-=Math.pow(2,8*byteLength)),val},Buffer.prototype.readInt8=function(offset,noAssert){return noAssert||checkOffset(offset,1,this.length),128&this[offset]?-1*(255-this[offset]+1):this[offset]},Buffer.prototype.readInt16LE=function(offset,noAssert){noAssert||checkOffset(offset,2,this.length);var val=this[offset]|this[offset+1]<<8;return 32768&val?4294901760|val:val},Buffer.prototype.readInt16BE=function(offset,noAssert){noAssert||checkOffset(offset,2,this.length);var val=this[offset+1]|this[offset]<<8;return 32768&val?4294901760|val:val},Buffer.prototype.readInt32LE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),this[offset]|this[offset+1]<<8|this[offset+2]<<16|this[offset+3]<<24},Buffer.prototype.readInt32BE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),this[offset]<<24|this[offset+1]<<16|this[offset+2]<<8|this[offset+3]},Buffer.prototype.readFloatLE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),ieee754.read(this,offset,!0,23,4)},Buffer.prototype.readFloatBE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),ieee754.read(this,offset,!1,23,4)},Buffer.prototype.readDoubleLE=function(offset,noAssert){return noAssert||checkOffset(offset,8,this.length),ieee754.read(this,offset,!0,52,8)},Buffer.prototype.readDoubleBE=function(offset,noAssert){return noAssert||checkOffset(offset,8,this.length),ieee754.read(this,offset,!1,52,8)},Buffer.prototype.writeUIntLE=function(value,offset,byteLength,noAssert){if(value=+value,offset=0|offset,byteLength=0|byteLength,!noAssert){var maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}var mul=1,i=0;for(this[offset]=255&value;++i<byteLength&&(mul*=256);)this[offset+i]=value/mul&255;return offset+byteLength},Buffer.prototype.writeUIntBE=function(value,offset,byteLength,noAssert){if(value=+value,offset=0|offset,byteLength=0|byteLength,!noAssert){var maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}var i=byteLength-1,mul=1;for(this[offset+i]=255&value;--i>=0&&(mul*=256);)this[offset+i]=value/mul&255;return offset+byteLength},Buffer.prototype.writeUInt8=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,1,255,0),Buffer.TYPED_ARRAY_SUPPORT||(value=Math.floor(value)),this[offset]=255&value,offset+1},Buffer.prototype.writeUInt16LE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,2,65535,0),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=255&value,this[offset+1]=value>>>8):objectWriteUInt16(this,value,offset,!0),offset+2},Buffer.prototype.writeUInt16BE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,2,65535,0),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=value>>>8,this[offset+1]=255&value):objectWriteUInt16(this,value,offset,!1),offset+2},Buffer.prototype.writeUInt32LE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,4,4294967295,0),BufLine truncated
"Dingbat dec":"44","Dingbat hex":"2C","Unicode dec":"127798","Unicode hex":"1F336"},{"Typeface name":"Webdings","Dingbat dec":"45","Dingbat hex":"2D","Unicode dec":"127895","Unicode hex":"1F397"},{"Typeface name":"Webdings","Dingbat dec":"46","Dingbat hex":"2E","Unicode dec":"128638","Unicode hex":"1F67E"},{"Typeface name":"Webdings","Dingbat dec":"47","Dingbat hex":"2F","Unicode dec":"128636","Unicode hex":"1F67C"},{"Typeface name":"Webdings","Dingbat dec":"48","Dingbat hex":"30","Unicode dec":"128469","Unicode hex":"1F5D5"},{"Typeface name":"Webdings","Dingbat dec":"49","Dingbat hex":"31","Unicode dec":"128470","Unicode hex":"1F5D6"},{"Typeface name":"Webdings","Dingbat dec":"50","Dingbat hex":"32","Unicode dec":"128471","Unicode hex":"1F5D7"},{"Typeface name":"Webdings","Dingbat dec":"51","Dingbat hex":"33","Unicode dec":"9204","Unicode hex":"23F4"},{"Typeface name":"Webdings","Dingbat dec":"52","Dingbat hex":"34","Unicode dec":"9205","Unicode hex":"23F5"},{"Typeface name":"Webdings","Dingbat dec":"53","Dingbat hex":"35","Unicode dec":"9206","Unicode hex":"23F6"},{"Typeface name":"Webdings","Dingbat dec":"54","Dingbat hex":"36","Unicode dec":"9207","Unicode hex":"23F7"},{"Typeface name":"Webdings","Dingbat dec":"55","Dingbat hex":"37","Unicode dec":"9194","Unicode hex":"23EA"},{"Typeface name":"Webdings","Dingbat dec":"56","Dingbat hex":"38","Unicode dec":"9193","Unicode hex":"23E9"},{"Typeface name":"Webdings","Dingbat dec":"57","Dingbat hex":"39","Unicode dec":"9198","Unicode hex":"23EE"},{"Typeface name":"Webdings","Dingbat dec":"58","Dingbat hex":"3A","Unicode dec":"9197","Unicode hex":"23ED"},{"Typeface name":"Webdings","Dingbat dec":"59","Dingbat hex":"3B","Unicode dec":"9208","Unicode hex":"23F8"},{"Typeface name":"Webdings","Dingbat dec":"60","Dingbat hex":"3C","Unicode dec":"9209","Unicode hex":"23F9"},{"Typeface name":"Webdings","Dingbat dec":"61","Dingbat hex":"3D","Unicode dec":"9210","Unicode hex":"23FA"},{"Typeface name":"Webdings","Dingbat dec":"62","Dingbat hex":"3E","Unicode dec":"128474","Unicode hex":"1F5DA"},{"Typeface name":"Webdings","Dingbat dec":"63","Dingbat hex":"3F","Unicode dec":"128499","Unicode hex":"1F5F3"},{"Typeface name":"Webdings","Dingbat dec":"64","Dingbat hex":"40","Unicode dec":"128736","Unicode hex":"1F6E0"},{"Typeface name":"Webdings","Dingbat dec":"65","Dingbat hex":"41","Unicode dec":"127959","Unicode hex":"1F3D7"},{"Typeface name":"Webdings","Dingbat dec":"66","Dingbat hex":"42","Unicode dec":"127960","Unicode hex":"1F3D8"},{"Typeface name":"Webdings","Dingbat dec":"67","Dingbat hex":"43","Unicode dec":"127961","Unicode hex":"1F3D9"},{"Typeface name":"Webdings","Dingbat dec":"68","Dingbat hex":"44","Unicode dec":"127962","Unicode hex":"1F3DA"},{"Typeface name":"Webdings","Dingbat dec":"69","Dingbat hex":"45","Unicode dec":"127964","Unicode hex":"1F3DC"},{"Typeface name":"Webdings","Dingbat dec":"70","Dingbat hex":"46","Unicode dec":"127981","Unicode hex":"1F3ED"},{"Typeface name":"Webdings","Dingbat dec":"71","Dingbat hex":"47","Unicode dec":"127963","Unicode hex":"1F3DB"},{"Typeface name":"Webdings","Dingbat dec":"72","Dingbat hex":"48","Unicode dec":"127968","Unicode hex":"1F3E0"},{"Typeface name":"Webdings","Dingbat dec":"73","Dingbat hex":"49","Unicode dec":"127958","Unicode hex":"1F3D6"},{"Typeface name":"Webdings","Dingbat dec":"74","Dingbat hex":"4A","Unicode dec":"127965","Unicode hex":"1F3DD"},{"Typeface name":"Webdings","Dingbat dec":"75","Dingbat hex":"4B","Unicode dec":"128739","Unicode hex":"1F6E3"},{"Typeface name":"Webdings","Dingbat dec":"76","Dingbat hex":"4C","Unicode dec":"128269","Unicode hex":"1F50D"},{"Typeface name":"Webdings","Dingbat dec":"77","Dingbat hex":"4D","Unicode dec":"127956","Unicode hex":"1F3D4"},{"Typeface name":"Webdings","Dingbat dec":"78","Dingbat hex":"4E","Unicode dec":"128065","Unicode hex":"1F441"},{"Typeface name":"Webdings","Dingbat dec":"79","Dingbat hex":"4F","Unicode dec":"128066","Unicode hex":"1F442"},{"Typeface name":"Webdings","Dingbat dec":"80","Dingbat hex":"50","Unicode dec":"127966","Unicode hex":"1F3DE"},{"Typeface name":"Webdings","Dingbat dec":"81","Dingbat hex":"51","Unicode dec":"127957","Unicode hex":"1F3D5"},{"Typeface name":"Webdings","Dingbat dec":"82","Dingbat hex":"52","Unicode dec":"128740","Unicode hex":"1F6E4"},{"Typeface name":"Webdings","Dingbat dec":"83","Dingbat hex":"53","Unicode dec":"127967","Unicode hex":"1F3DF"},{"Typeface name":"Webdings","Dingbat dec":"84","Dingbat hex":"54","Unicode dec":"128755","Unicode hex":"1F6F3"},{"Typeface name":"Webdings","Dingbat dec":"85","Dingbat hex":"55","Unicode dec":"128364","Unicode hex":"1F56C"},{"Typeface name":"Webdings","Dingbat dec":"86","Dingbat hex":"56","Unicode dec":"128363","Unicode hex":"1F56B"},{"Typeface name":"Webdings","Dingbat dec":"87","Dingbat hex":"57","Unicode dec":"128360","Unicode hex":"1F568"},{"Typeface name":"Webdings","Dingbat dec":"88","Dingbat hex":"58","Unicode dec":"128264","Unicode hex":"1F508"},{"Typeface name":"WeLine truncated
"Dingbat hex":"6A","Unicode dec":"128624","Unicode hex":"1F670"},{"Typeface name":"Wingdings","Dingbat dec":"107","Dingbat hex":"6B","Unicode dec":"128629","Unicode hex":"1F675"},{"Typeface name":"Wingdings","Dingbat dec":"108","Dingbat hex":"6C","Unicode dec":"9899","Unicode hex":"26AB"},{"Typeface name":"Wingdings","Dingbat dec":"109","Dingbat hex":"6D","Unicode dec":"128318","Unicode hex":"1F53E"},{"Typeface name":"Wingdings","Dingbat dec":"110","Dingbat hex":"6E","Unicode dec":"9724","Unicode hex":"25FC"},{"Typeface name":"Wingdings","Dingbat dec":"111","Dingbat hex":"6F","Unicode dec":"128911","Unicode hex":"1F78F"},{"Typeface name":"Wingdings","Dingbat dec":"112","Dingbat hex":"70","Unicode dec":"128912","Unicode hex":"1F790"},{"Typeface name":"Wingdings","Dingbat dec":"113","Dingbat hex":"71","Unicode dec":"10065","Unicode hex":"2751"},{"Typeface name":"Wingdings","Dingbat dec":"114","Dingbat hex":"72","Unicode dec":"10066","Unicode hex":"2752"},{"Typeface name":"Wingdings","Dingbat dec":"115","Dingbat hex":"73","Unicode dec":"128927","Unicode hex":"1F79F"},{"Typeface name":"Wingdings","Dingbat dec":"116","Dingbat hex":"74","Unicode dec":"10731","Unicode hex":"29EB"},{"Typeface name":"Wingdings","Dingbat dec":"117","Dingbat hex":"75","Unicode dec":"9670","Unicode hex":"25C6"},{"Typeface name":"Wingdings","Dingbat dec":"118","Dingbat hex":"76","Unicode dec":"10070","Unicode hex":"2756"},{"Typeface name":"Wingdings","Dingbat dec":"119","Dingbat hex":"77","Unicode dec":"11049","Unicode hex":"2B29"},{"Typeface name":"Wingdings","Dingbat dec":"120","Dingbat hex":"78","Unicode dec":"8999","Unicode hex":"2327"},{"Typeface name":"Wingdings","Dingbat dec":"121","Dingbat hex":"79","Unicode dec":"11193","Unicode hex":"2BB9"},{"Typeface name":"Wingdings","Dingbat dec":"122","Dingbat hex":"7A","Unicode dec":"8984","Unicode hex":"2318"},{"Typeface name":"Wingdings","Dingbat dec":"123","Dingbat hex":"7B","Unicode dec":"127989","Unicode hex":"1F3F5"},{"Typeface name":"Wingdings","Dingbat dec":"124","Dingbat hex":"7C","Unicode dec":"127990","Unicode hex":"1F3F6"},{"Typeface name":"Wingdings","Dingbat dec":"125","Dingbat hex":"7D","Unicode dec":"128630","Unicode hex":"1F676"},{"Typeface name":"Wingdings","Dingbat dec":"126","Dingbat hex":"7E","Unicode dec":"128631","Unicode hex":"1F677"},{"Typeface name":"Wingdings","Dingbat dec":"127","Dingbat hex":"7F","Unicode dec":"9647","Unicode hex":"25AF"},{"Typeface name":"Wingdings","Dingbat dec":"128","Dingbat hex":"80","Unicode dec":"127243","Unicode hex":"1F10B"},{"Typeface name":"Wingdings","Dingbat dec":"129","Dingbat hex":"81","Unicode dec":"10112","Unicode hex":"2780"},{"Typeface name":"Wingdings","Dingbat dec":"130","Dingbat hex":"82","Unicode dec":"10113","Unicode hex":"2781"},{"Typeface name":"Wingdings","Dingbat dec":"131","Dingbat hex":"83","Unicode dec":"10114","Unicode hex":"2782"},{"Typeface name":"Wingdings","Dingbat dec":"132","Dingbat hex":"84","Unicode dec":"10115","Unicode hex":"2783"},{"Typeface name":"Wingdings","Dingbat dec":"133","Dingbat hex":"85","Unicode dec":"10116","Unicode hex":"2784"},{"Typeface name":"Wingdings","Dingbat dec":"134","Dingbat hex":"86","Unicode dec":"10117","Unicode hex":"2785"},{"Typeface name":"Wingdings","Dingbat dec":"135","Dingbat hex":"87","Unicode dec":"10118","Unicode hex":"2786"},{"Typeface name":"Wingdings","Dingbat dec":"136","Dingbat hex":"88","Unicode dec":"10119","Unicode hex":"2787"},{"Typeface name":"Wingdings","Dingbat dec":"137","Dingbat hex":"89","Unicode dec":"10120","Unicode hex":"2788"},{"Typeface name":"Wingdings","Dingbat dec":"138","Dingbat hex":"8A","Unicode dec":"10121","Unicode hex":"2789"},{"Typeface name":"Wingdings","Dingbat dec":"139","Dingbat hex":"8B","Unicode dec":"127244","Unicode hex":"1F10C"},{"Typeface name":"Wingdings","Dingbat dec":"140","Dingbat hex":"8C","Unicode dec":"10122","Unicode hex":"278A"},{"Typeface name":"Wingdings","Dingbat dec":"141","Dingbat hex":"8D","Unicode dec":"10123","Unicode hex":"278B"},{"Typeface name":"Wingdings","Dingbat dec":"142","Dingbat hex":"8E","Unicode dec":"10124","Unicode hex":"278C"},{"Typeface name":"Wingdings","Dingbat dec":"143","Dingbat hex":"8F","Unicode dec":"10125","Unicode hex":"278D"},{"Typeface name":"Wingdings","Dingbat dec":"144","Dingbat hex":"90","Unicode dec":"10126","Unicode hex":"278E"},{"Typeface name":"Wingdings","Dingbat dec":"145","Dingbat hex":"91","Unicode dec":"10127","Unicode hex":"278F"},{"Typeface name":"Wingdings","Dingbat dec":"146","Dingbat hex":"92","Unicode dec":"10128","Unicode hex":"2790"},{"Typeface name":"Wingdings","Dingbat dec":"147","Dingbat hex":"93","Unicode dec":"10129","Unicode hex":"2791"},{"Typeface name":"Wingdings","Dingbat dec":"148","Dingbat hex":"94","Unicode dec":"10130","Unicode hex":"2792"},{"Typeface name":"Wingdings","Dingbat dec":"149","Dingbat hex":"95","Unicode dec":"10131","Unicode hex":"2793"},{"Typeface name":"Wingdings","Dingbat dec":"150","Dingbat hex":"96","Unicode dec":"128610","Unicode heLine truncated
},{"Typeface name":"Wingdings 2","Dingbat dec":"166","Dingbat hex":"A6","Unicode dec":"128915","Unicode hex":"1F793"},{"Typeface name":"Wingdings 2","Dingbat dec":"167","Dingbat hex":"A7","Unicode dec":"128916","Unicode hex":"1F794"},{"Typeface name":"Wingdings 2","Dingbat dec":"168","Dingbat hex":"A8","Unicode dec":"9635","Unicode hex":"25A3"},{"Typeface name":"Wingdings 2","Dingbat dec":"169","Dingbat hex":"A9","Unicode dec":"128917","Unicode hex":"1F795"},{"Typeface name":"Wingdings 2","Dingbat dec":"170","Dingbat hex":"AA","Unicode dec":"128918","Unicode hex":"1F796"},{"Typeface name":"Wingdings 2","Dingbat dec":"171","Dingbat hex":"AB","Unicode dec":"128919","Unicode hex":"1F797"},{"Typeface name":"Wingdings 2","Dingbat dec":"172","Dingbat hex":"AC","Unicode dec":"128920","Unicode hex":"1F798"},{"Typeface name":"Wingdings 2","Dingbat dec":"173","Dingbat hex":"AD","Unicode dec":"11049","Unicode hex":"2B29"},{"Typeface name":"Wingdings 2","Dingbat dec":"174","Dingbat hex":"AE","Unicode dec":"11045","Unicode hex":"2B25"},{"Typeface name":"Wingdings 2","Dingbat dec":"175","Dingbat hex":"AF","Unicode dec":"9671","Unicode hex":"25C7"},{"Typeface name":"Wingdings 2","Dingbat dec":"176","Dingbat hex":"B0","Unicode dec":"128922","Unicode hex":"1F79A"},{"Typeface name":"Wingdings 2","Dingbat dec":"177","Dingbat hex":"B1","Unicode dec":"9672","Unicode hex":"25C8"},{"Typeface name":"Wingdings 2","Dingbat dec":"178","Dingbat hex":"B2","Unicode dec":"128923","Unicode hex":"1F79B"},{"Typeface name":"Wingdings 2","Dingbat dec":"179","Dingbat hex":"B3","Unicode dec":"128924","Unicode hex":"1F79C"},{"Typeface name":"Wingdings 2","Dingbat dec":"180","Dingbat hex":"B4","Unicode dec":"128925","Unicode hex":"1F79D"},{"Typeface name":"Wingdings 2","Dingbat dec":"181","Dingbat hex":"B5","Unicode dec":"128926","Unicode hex":"1F79E"},{"Typeface name":"Wingdings 2","Dingbat dec":"182","Dingbat hex":"B6","Unicode dec":"11050","Unicode hex":"2B2A"},{"Typeface name":"Wingdings 2","Dingbat dec":"183","Dingbat hex":"B7","Unicode dec":"11047","Unicode hex":"2B27"},{"Typeface name":"Wingdings 2","Dingbat dec":"184","Dingbat hex":"B8","Unicode dec":"9674","Unicode hex":"25CA"},{"Typeface name":"Wingdings 2","Dingbat dec":"185","Dingbat hex":"B9","Unicode dec":"128928","Unicode hex":"1F7A0"},{"Typeface name":"Wingdings 2","Dingbat dec":"186","Dingbat hex":"BA","Unicode dec":"9686","Unicode hex":"25D6"},{"Typeface name":"Wingdings 2","Dingbat dec":"187","Dingbat hex":"BB","Unicode dec":"9687","Unicode hex":"25D7"},{"Typeface name":"Wingdings 2","Dingbat dec":"188","Dingbat hex":"BC","Unicode dec":"11210","Unicode hex":"2BCA"},{"Typeface name":"Wingdings 2","Dingbat dec":"189","Dingbat hex":"BD","Unicode dec":"11211","Unicode hex":"2BCB"},{"Typeface name":"Wingdings 2","Dingbat dec":"190","Dingbat hex":"BE","Unicode dec":"11200","Unicode hex":"2BC0"},{"Typeface name":"Wingdings 2","Dingbat dec":"191","Dingbat hex":"BF","Unicode dec":"11201","Unicode hex":"2BC1"},{"Typeface name":"Wingdings 2","Dingbat dec":"192","Dingbat hex":"C0","Unicode dec":"11039","Unicode hex":"2B1F"},{"Typeface name":"Wingdings 2","Dingbat dec":"193","Dingbat hex":"C1","Unicode dec":"11202","Unicode hex":"2BC2"},{"Typeface name":"Wingdings 2","Dingbat dec":"194","Dingbat hex":"C2","Unicode dec":"11043","Unicode hex":"2B23"},{"Typeface name":"Wingdings 2","Dingbat dec":"195","Dingbat hex":"C3","Unicode dec":"11042","Unicode hex":"2B22"},{"Typeface name":"Wingdings 2","Dingbat dec":"196","Dingbat hex":"C4","Unicode dec":"11203","Unicode hex":"2BC3"},{"Typeface name":"Wingdings 2","Dingbat dec":"197","Dingbat hex":"C5","Unicode dec":"11204","Unicode hex":"2BC4"},{"Typeface name":"Wingdings 2","Dingbat dec":"198","Dingbat hex":"C6","Unicode dec":"128929","Unicode hex":"1F7A1"},{"Typeface name":"Wingdings 2","Dingbat dec":"199","Dingbat hex":"C7","Unicode dec":"128930","Unicode hex":"1F7A2"},{"Typeface name":"Wingdings 2","Dingbat dec":"200","Dingbat hex":"C8","Unicode dec":"128931","Unicode hex":"1F7A3"},{"Typeface name":"Wingdings 2","Dingbat dec":"201","Dingbat hex":"C9","Unicode dec":"128932","Unicode hex":"1F7A4"},{"Typeface name":"Wingdings 2","Dingbat dec":"202","Dingbat hex":"CA","Unicode dec":"128933","Unicode hex":"1F7A5"},{"Typeface name":"Wingdings 2","Dingbat dec":"203","Dingbat hex":"CB","Unicode dec":"128934","Unicode hex":"1F7A6"},{"Typeface name":"Wingdings 2","Dingbat dec":"204","Dingbat hex":"CC","Unicode dec":"128935","Unicode hex":"1F7A7"},{"Typeface name":"Wingdings 2","Dingbat dec":"205","Dingbat hex":"CD","Unicode dec":"128936","Unicode hex":"1F7A8"},{"Typeface name":"Wingdings 2","Dingbat dec":"206","Dingbat hex":"CE","Unicode dec":"128937","Unicode hex":"1F7A9"},{"Typeface name":"Wingdings 2","Dingbat dec":"207","Dingbat hex":"CF","Unicode dec":"128938","Unicode hex":"1F7AA"},{"Typeface name":"Wingdings 2","Dingbat dec":"208","Dingbat hex":"D0","Unicode dec":"128939","Unicode hex":"1F7AB"},{"Typeface name":"Wingdings 2","Dingbat dec":"209","DinLine truncated
"Unicode hex":"1F881"},{"Typeface name":"Wingdings 3","Dingbat dec":"228","Dingbat hex":"E4","Unicode dec":"129155","Unicode hex":"1F883"},{"Typeface name":"Wingdings 3","Dingbat dec":"229","Dingbat hex":"E5","Unicode dec":"129156","Unicode hex":"1F884"},{"Typeface name":"Wingdings 3","Dingbat dec":"230","Dingbat hex":"E6","Unicode dec":"129157","Unicode hex":"1F885"},{"Typeface name":"Wingdings 3","Dingbat dec":"231","Dingbat hex":"E7","Unicode dec":"129159","Unicode hex":"1F887"},{"Typeface name":"Wingdings 3","Dingbat dec":"232","Dingbat hex":"E8","Unicode dec":"129158","Unicode hex":"1F886"},{"Typeface name":"Wingdings 3","Dingbat dec":"233","Dingbat hex":"E9","Unicode dec":"129168","Unicode hex":"1F890"},{"Typeface name":"Wingdings 3","Dingbat dec":"234","Dingbat hex":"EA","Unicode dec":"129170","Unicode hex":"1F892"},{"Typeface name":"Wingdings 3","Dingbat dec":"235","Dingbat hex":"EB","Unicode dec":"129169","Unicode hex":"1F891"},{"Typeface name":"Wingdings 3","Dingbat dec":"236","Dingbat hex":"EC","Unicode dec":"129171","Unicode hex":"1F893"},{"Typeface name":"Wingdings 3","Dingbat dec":"237","Dingbat hex":"ED","Unicode dec":"129172","Unicode hex":"1F894"},{"Typeface name":"Wingdings 3","Dingbat dec":"238","Dingbat hex":"EE","Unicode dec":"129174","Unicode hex":"1F896"},{"Typeface name":"Wingdings 3","Dingbat dec":"239","Dingbat hex":"EF","Unicode dec":"129173","Unicode hex":"1F895"},{"Typeface name":"Wingdings 3","Dingbat dec":"240","Dingbat hex":"F0","Unicode dec":"129175","Unicode hex":"1F897"}];exports["default"]=dingbats},{}],85:[function(require,module,exports){"use strict";function codePoint(typeface,codePoint){return dingbatsByCodePoint[typeface.toUpperCase()+"_"+codePoint]}function dec(typeface,dec){return codePoint(typeface,parseInt(dec,10))}function hex(typeface,hex){return codePoint(typeface,parseInt(hex,16))}function fromCodePointPolyfill(codePoint){if(65535>=codePoint)return String.fromCharCode(codePoint);var highSurrogate=Math.floor((codePoint-65536)/1024)+55296,lowSurrogate=(codePoint-65536)%1024+56320;return String.fromCharCode(highSurrogate,lowSurrogate)}var __importDefault=this&&this.__importDefault||function(mod){return mod&&mod.__esModule?mod:{"default":mod}};Object.defineProperty(exports,"__esModule",{value:!0}),exports.hex=exports.dec=exports.codePoint=void 0;for(var dingbats_1=__importDefault(require("./dingbats")),dingbatsByCodePoint={},fromCodePoint=String.fromCodePoint?String.fromCodePoint:fromCodePointPolyfill,_i=0,dingbats_2=dingbats_1["default"];_i<dingbats_2.length;_i++){var dingbat=dingbats_2[_i],codePoint_1=parseInt(dingbat["Unicode dec"],10),scalarValue={codePoint:codePoint_1,string:fromCodePoint(codePoint_1)};dingbatsByCodePoint[dingbat["Typeface name"].toUpperCase()+"_"+dingbat["Dingbat dec"]]=scalarValue}exports.codePoint=codePoint,exports.dec=dec,exports.hex=hex},{"./dingbats":84}],86:[function(require,module,exports){exports.read=function(buffer,offset,isLE,mLen,nBytes){var e,m,eLen=8*nBytes-mLen-1,eMax=(1<<eLen)-1,eBias=eMax>>1,nBits=-7,i=isLE?nBytes-1:0,d=isLE?-1:1,s=buffer[offset+i];for(i+=d,e=s&(1<<-nBits)-1,s>>=-nBits,nBits+=eLen;nBits>0;e=256*e+buffer[offset+i],i+=d,nBits-=8);for(m=e&(1<<-nBits)-1,e>>=-nBits,nBits+=mLen;nBits>0;m=256*m+buffer[offset+i],i+=d,nBits-=8);if(0===e)e=1-eBias;else{if(e===eMax)return m?NaN:(s?-1:1)*(1/0);m+=Math.pow(2,mLen),e-=eBias}return(s?-1:1)*m*Math.pow(2,e-mLen)},exports.write=function(buffer,value,offset,isLE,mLen,nBytes){var e,m,c,eLen=8*nBytes-mLen-1,eMax=(1<<eLen)-1,eBias=eMax>>1,rt=23===mLen?Math.pow(2,-24)-Math.pow(2,-77):0,i=isLE?0:nBytes-1,d=isLE?1:-1,s=0>value||0===value&&0>1/value?1:0;for(value=Math.abs(value),isNaN(value)||value===1/0?(m=isNaN(value)?1:0,e=eMax):(e=Math.floor(Math.log(value)/Math.LN2),value*(c=Math.pow(2,-e))<1&&(e--,c*=2),value+=e+eBias>=1?rt/c:rt*Math.pow(2,1-eBias),value*c>=2&&(e++,c/=2),e+eBias>=eMax?(m=0,e=eMax):e+eBias>=1?(m=(value*c-1)*Math.pow(2,mLen),e+=eBias):(m=value*Math.pow(2,eBias-1)*Math.pow(2,mLen),e=0));mLen>=8;buffer[offset+i]=255&m,i+=d,m/=256,mLen-=8);for(e=e<<mLen|m,eLen+=mLen;eLen>0;buffer[offset+i]=255&e,i+=d,e/=256,eLen-=8);buffer[offset+i-d]|=128*s}},{}],87:[function(require,module,exports){var toString={}.toString;module.exports=Array.isArray||function(arr){return"[object Array]"==toString.call(arr)}},{}],88:[function(require,module,exports){(function(global,Buffer){!function(t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=t()}(function(){return function s(a,o,h){function u(r,t){if(!o[r]){if(!a[r]){var e="function"==typeof require&&require;if(!t&&e)return e(r,!0);if(l)return l(r,!0);var i=new Error("Cannot find module '"+r+"'");throw i.code="MODULE_NOT_FOUND",i}var n=o[r]={exports:{}};a[r][0].call(n.exports,function(t){var e=a[r][1][t];return u(e||t)},n,n.exports,s,a,o,h)}return o[r].Line truncated
h.delay(e,arguments,r)}),this},resume:function(){return h.delay(this._worker.resume,[],this._worker),this},pause:function(){return this._worker.pause(),this},toNodejsStream:function(t){if(h.checkSupport("nodestream"),"nodebuffer"!==this._outputType)throw new Error(this._outputType+" is not supported by this method");return new o(this,{objectMode:"nodebuffer"!==this._outputType},t)}},e.exports=f},{"../base64":1,"../external":6,"../nodejs/NodejsStreamOutputAdapter":13,"../support":30,"../utils":32,"./ConvertWorker":24,"./GenericWorker":28}],30:[function(t,e,r){"use strict";if(r.base64=!0,r.array=!0,r.string=!0,r.arraybuffer="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof Uint8Array,r.nodebuffer="undefined"!=typeof Buffer,r.uint8array="undefined"!=typeof Uint8Array,"undefined"==typeof ArrayBuffer)r.blob=!1;else{var i=new ArrayBuffer(0);try{r.blob=0===new Blob([i],{type:"application/zip"}).size}catch(t){try{var n=new(self.BlobBuilder||self.WebKitBlobBuilder||self.MozBlobBuilder||self.MSBlobBuilder);n.append(i),r.blob=0===n.getBlob("application/zip").size}catch(t){r.blob=!1}}}try{r.nodestream=!!t("readable-stream").Readable}catch(t){r.nodestream=!1}},{"readable-stream":16}],31:[function(t,e,s){"use strict";function a(){i.call(this,"utf-8 decode"),this.leftOver=null}function l(){i.call(this,"utf-8 encode")}for(var o=t("./utils"),h=t("./support"),r=t("./nodejsUtils"),i=t("./stream/GenericWorker"),u=new Array(256),n=0;256>n;n++)u[n]=n>=252?6:n>=248?5:n>=240?4:n>=224?3:n>=192?2:1;u[254]=u[254]=1,s.utf8encode=function(t){return h.nodebuffer?r.newBufferFrom(t,"utf-8"):function(t){var e,r,i,n,s,a=t.length,o=0;for(n=0;a>n;n++)55296==(64512&(r=t.charCodeAt(n)))&&a>n+1&&56320==(64512&(i=t.charCodeAt(n+1)))&&(r=65536+(r-55296<<10)+(i-56320),n++),o+=128>r?1:2048>r?2:65536>r?3:4;for(e=h.uint8array?new Uint8Array(o):new Array(o),n=s=0;o>s;n++)55296==(64512&(r=t.charCodeAt(n)))&&a>n+1&&56320==(64512&(i=t.charCodeAt(n+1)))&&(r=65536+(r-55296<<10)+(i-56320),n++),128>r?e[s++]=r:(2048>r?e[s++]=192|r>>>6:(65536>r?e[s++]=224|r>>>12:(e[s++]=240|r>>>18,e[s++]=128|r>>>12&63),e[s++]=128|r>>>6&63),e[s++]=128|63&r);return e}(t)},s.utf8decode=function(t){return h.nodebuffer?o.transformTo("nodebuffer",t).toString("utf-8"):function(t){var e,r,i,n,s=t.length,a=new Array(2*s);for(e=r=0;s>e;)if((i=t[e++])<128)a[r++]=i;else if(4<(n=u[i]))a[r++]=65533,e+=n-1;else{for(i&=2===n?31:3===n?15:7;n>1&&s>e;)i=i<<6|63&t[e++],n--;n>1?a[r++]=65533:65536>i?a[r++]=i:(i-=65536,a[r++]=55296|i>>10&1023,a[r++]=56320|1023&i)}return a.length!==r&&(a.subarray?a=a.subarray(0,r):a.length=r),o.applyFromCharCode(a)}(t=o.transformTo(h.uint8array?"uint8array":"array",t))},o.inherits(a,i),a.prototype.processChunk=function(t){var e=o.transformTo(h.uint8array?"uint8array":"array",t.data);if(this.leftOver&&this.leftOver.length){if(h.uint8array){var r=e;(e=new Uint8Array(r.length+this.leftOver.length)).set(this.leftOver,0),e.set(r,this.leftOver.length)}else e=this.leftOver.concat(e);this.leftOver=null}var i=function(t,e){var r;for((e=e||t.length)>t.length&&(e=t.length),r=e-1;r>=0&&128==(192&t[r]);)r--;return 0>r?e:0===r?e:r+u[t[r]]>e?r:e}(e),n=e;i!==e.length&&(h.uint8array?(n=e.subarray(0,i),this.leftOver=e.subarray(i,e.length)):(n=e.slice(0,i),this.leftOver=e.slice(i,e.length))),this.push({data:s.utf8decode(n),meta:t.meta})},a.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:s.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},s.Utf8DecodeWorker=a,o.inherits(l,i),l.prototype.processChunk=function(t){this.push({data:s.utf8encode(t.data),meta:t.meta})},s.Utf8EncodeWorker=l},{"./nodejsUtils":14,"./stream/GenericWorker":28,"./support":30,"./utils":32}],32:[function(t,e,a){"use strict";function n(t){return t}function l(t,e){for(var r=0;r<t.length;++r)e[r]=255&t.charCodeAt(r);return e}function f(t){var e=65536,r=a.getTypeOf(t),i=!0;if("uint8array"===r?i=s.applyCanBeUsed.uint8array:"nodebuffer"===r&&(i=s.applyCanBeUsed.nodebuffer),i)for(;e>1;)try{return s.stringifyByChunk(t,r,e)}catch(t){e=Math.floor(e/2)}return s.stringifyByChar(t)}function d(t,e){for(var r=0;r<t.length;r++)e[r]=t[r];return e}var o=t("./support"),h=t("./base64"),r=t("./nodejsUtils"),i=t("set-immediate-shim"),u=t("./external");a.newBlob=function(e,r){a.checkSupport("blob");try{return new Blob([e],{type:r})}catch(t){try{var i=new(self.BlobBuilder||self.WebKitBlobBuilder||self.MozBlobBuilder||self.MSBlobBuilder);return i.append(e),i.getBlob(r)}catch(t){throw new Error("Bug : can't construct the Blob.")}}};var s={stringifyByChunk:function(t,e,r){var i=[],n=0,s=t.length;if(r>=s)return String.fromCharCode.apply(null,t);for(;s>n;)"array"===e||"nodebuffer"===e?i.push(String.fromCharCode.apply(null,t.slice(n,Math.min(n+r,s)))):i.push(String.fromCharCode.apply(null,t.subarray(n,Math.min(n+r,s)))),n+=r;return i.join("")},stringifyByChar:function(t){for(var e="",r=0;r<t.length;r++)e+=String.fromCharCode(t[r]);return e},applyCanBeUsed:{uint8array:function(){try{return o.uint8array&&1=Line truncated
t.pending_buf[t.pending++]=e>>>8&255,t.pending_buf[t.pending++]=255&e}function L(t,e){var r,i,n=t.max_chain_length,s=t.strstart,a=t.prev_length,o=t.nice_match,h=t.strstart>t.w_size-z?t.strstart-(t.w_size-z):0,u=t.window,l=t.w_mask,f=t.prev,d=t.strstart+S,c=u[s+a-1],p=u[s+a];t.prev_length>=t.good_match&&(n>>=2),o>t.lookahead&&(o=t.lookahead);do if(u[(r=e)+a]===p&&u[r+a-1]===c&&u[r]===u[s]&&u[++r]===u[s+1]){s+=2,r++;do;while(u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&d>s);if(i=S-(d-s),s=d-S,i>a){if(t.match_start=e,o<=(a=i))break;c=u[s+a-1],p=u[s+a]}}while((e=f[e&l])>h&&0!=--n);return a<=t.lookahead?a:t.lookahead}function j(t){var e,r,i,n,s,a,o,h,u,l,f=t.w_size;do{if(n=t.window_size-t.lookahead-t.strstart,t.strstart>=f+(f-z)){for(d.arraySet(t.window,t.window,f,f,0),t.match_start-=f,t.strstart-=f,t.block_start-=f,e=r=t.hash_size;i=t.head[--e],t.head[e]=i>=f?i-f:0,--r;);for(e=r=f;i=t.prev[--e],t.prev[e]=i>=f?i-f:0,--r;);n+=f}if(0===t.strm.avail_in)break;if(a=t.strm,o=t.window,h=t.strstart+t.lookahead,u=n,l=void 0,l=a.avail_in,l>u&&(l=u),r=0===l?0:(a.avail_in-=l,d.arraySet(o,a.input,a.next_in,l,h),1===a.state.wrap?a.adler=c(a.adler,o,l,h):2===a.state.wrap&&(a.adler=p(a.adler,o,l,h)),a.next_in+=l,a.total_in+=l,l),t.lookahead+=r,t.lookahead+t.insert>=x)for(s=t.strstart-t.insert,t.ins_h=t.window[s],t.ins_h=(t.ins_h<<t.hash_shift^t.window[s+1])&t.hash_mask;t.insert&&(t.ins_h=(t.ins_h<<t.hash_shift^t.window[s+x-1])&t.hash_mask,t.prev[s&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=s,s++,t.insert--,!(t.lookahead+t.insert<x)););}while(t.lookahead<z&&0!==t.strm.avail_in)}function Z(t,e){for(var r,i;;){if(t.lookahead<z){if(j(t),t.lookahead<z&&e===l)return A;if(0===t.lookahead)break}if(r=0,t.lookahead>=x&&(t.ins_h=(t.ins_h<<t.hash_shift^t.window[t.strstart+x-1])&t.hash_mask,r=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),0!==r&&t.strstart-r<=t.w_size-z&&(t.match_length=L(t,r)),t.match_length>=x)if(i=u._tr_tally(t,t.strstart-t.match_start,t.match_length-x),t.lookahead-=t.match_length,t.match_length<=t.max_lazy_match&&t.lookahead>=x){for(t.match_length--;t.strstart++,t.ins_h=(t.ins_h<<t.hash_shift^t.window[t.strstart+x-1])&t.hash_mask,r=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart,0!=--t.match_length;);t.strstart++}else t.strstart+=t.match_length,t.match_length=0,t.ins_h=t.window[t.strstart],t.ins_h=(t.ins_h<<t.hash_shift^t.window[t.strstart+1])&t.hash_mask;else i=u._tr_tally(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++;if(i&&(N(t,!1),0===t.strm.avail_out))return A}return t.insert=t.strstart<x-1?t.strstart:x-1,e===f?(N(t,!0),0===t.strm.avail_out?O:B):t.last_lit&&(N(t,!1),0===t.strm.avail_out)?A:I}function W(t,e){for(var r,i,n;;){if(t.lookahead<z){if(j(t),t.lookahead<z&&e===l)return A;if(0===t.lookahead)break}if(r=0,t.lookahead>=x&&(t.ins_h=(t.ins_h<<t.hash_shift^t.window[t.strstart+x-1])&t.hash_mask,r=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),t.prev_length=t.match_length,t.prev_match=t.match_start,t.match_length=x-1,0!==r&&t.prev_length<t.max_lazy_match&&t.strstart-r<=t.w_size-z&&(t.match_length=L(t,r),t.match_length<=5&&(1===t.strategy||t.match_length===x&&4096<t.strstart-t.match_start)&&(t.match_length=x-1)),t.prev_length>=x&&t.match_length<=t.prev_length){for(n=t.strstart+t.lookahead-x,i=u._tr_tally(t,t.strstart-1-t.prev_match,t.prev_length-x),t.lookahead-=t.prev_length-1,t.prev_length-=2;++t.strstart<=n&&(t.ins_h=(t.ins_h<<t.hash_shift^t.window[t.strstart+x-1])&t.hash_mask,r=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),0!=--t.prev_length;);if(t.match_available=0,t.match_length=x-1,t.strstart++,i&&(N(t,!1),0===t.strm.avail_out))return A}else if(t.match_available){if((i=u._tr_tally(t,0,t.window[t.strstart-1]))&&N(t,!1),t.strstart++,t.lookahead--,0===t.strm.avail_out)return A}else t.match_available=1,t.strstart++,t.lookahead--}return t.match_available&&(i=u._tr_tally(t,0,t.window[t.strstart-1]),t.match_available=0),t.insert=t.strstart<x-1?t.strstart:x-1,e===f?(N(t,!0),0===t.strm.avail_out?O:B):t.last_lit&&(N(t,!1),0===t.strm.avail_out)?A:I}function M(t,e,r,i,n){this.good_length=t,this.max_lazy=e,this.nice_length=r,this.max_chain=i,this.func=n}function H(){this.strm=null,this.status=0,this.pending_buf=null,this.pending_buf_size=0,this.pending_out=0,this.pending=0,this.wrap=0,this.gzhead=null,this.gzindex=0,this.method=v,this.last_flush=-1,this.w_size=0,this.w_bits=0,this.w_mask=0,this.window=null,this.window_size=0,this.prev=null,this.head=null,this.ins_h=0,this.hash_size=0,this.hash_bits=0,this.hash_mask=0,this.hash_shift=0,this.block_start=0,this.match_length=0,this.prev_match=0,this.match_available=0,this.strstart=0,this.match_start=0,this.lookahead=0,this.prev_length=0,this.max_chain_length=0,this.max_lazy_match=0,this.level=0,this.strategy=0,this.good_match=0,this.nice_match=0,this.dyn_ltree=new d.Buf16(2*w),this.dynLine truncated
s=n,u=(o=0)===a?(h=138,3):n===a?(h=6,3):(h=7,4))}function V(t,e,r){var i,n,s=-1,a=e[1],o=0,h=7,u=4;for(0===a&&(h=138,u=3),i=0;r>=i;i++)if(n=a,a=e[2*(i+1)+1],!(++o<h&&n===a)){if(u>o)for(;L(t,n,t.bl_tree),0!=--o;);else 0!==n?(n!==s&&(L(t,n,t.bl_tree),o--),L(t,b,t.bl_tree),P(t,o-3,2)):10>=o?(L(t,v,t.bl_tree),P(t,o-3,3)):(L(t,y,t.bl_tree),P(t,o-11,7));s=n,u=(o=0)===a?(h=138,3):n===a?(h=6,3):(h=7,4)}}function J(t,e,r,i){P(t,(s<<1)+(i?1:0),3),function(t,e,r,i){M(t),i&&(U(t,r),U(t,~r)),n.arraySet(t.pending_buf,t.window,e,r,t.pending),t.pending+=r}(t,e,r,!0)}var n=t("../utils/common"),o=0,h=1,s=0,a=29,u=256,l=u+1+a,f=30,d=19,_=2*l+1,g=15,c=16,p=7,m=256,b=16,v=17,y=18,w=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0],k=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],x=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7],S=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],z=new Array(2*(l+2));i(z);var C=new Array(2*f);i(C);var E=new Array(512);i(E);var A=new Array(256);i(A);var I=new Array(a);i(I);var O,B,R,T=new Array(f);i(T);var q=!1;r._tr_init=function(t){q||(function(){var t,e,r,i,n,s=new Array(g+1);for(i=r=0;a-1>i;i++)for(I[i]=r,t=0;t<1<<w[i];t++)A[r++]=i;for(A[r-1]=i,i=n=0;16>i;i++)for(T[i]=n,t=0;t<1<<k[i];t++)E[n++]=i;for(n>>=7;f>i;i++)for(T[i]=n<<7,t=0;t<1<<k[i]-7;t++)E[256+n++]=i;for(e=0;g>=e;e++)s[e]=0;for(t=0;143>=t;)z[2*t+1]=8,t++,s[8]++;for(;255>=t;)z[2*t+1]=9,t++,s[9]++;for(;279>=t;)z[2*t+1]=7,t++,s[7]++;for(;287>=t;)z[2*t+1]=8,t++,s[8]++;for(Z(z,l+1,s),t=0;f>t;t++)C[2*t+1]=5,C[2*t]=j(t,5);O=new D(z,w,u+1,l,g),B=new D(C,k,0,f,g),R=new D(new Array(0),x,0,d,p)}(),q=!0),t.l_desc=new F(t.dyn_ltree,O),t.d_desc=new F(t.dyn_dtree,B),t.bl_desc=new F(t.bl_tree,R),t.bi_buf=0,t.bi_valid=0,W(t)},r._tr_stored_block=J,r._tr_flush_block=function(t,e,r,i){var n,s,a=0;0<t.level?(2===t.strm.data_type&&(t.strm.data_type=function(t){var e,r=4093624447;for(e=0;31>=e;e++,r>>>=1)if(1&r&&0!==t.dyn_ltree[2*e])return o;if(0!==t.dyn_ltree[18]||0!==t.dyn_ltree[20]||0!==t.dyn_ltree[26])return h;for(e=32;u>e;e++)if(0!==t.dyn_ltree[2*e])return h;return o}(t)),Y(t,t.l_desc),Y(t,t.d_desc),a=function(t){var e;for(X(t,t.dyn_ltree,t.l_desc.max_code),X(t,t.dyn_dtree,t.d_desc.max_code),Y(t,t.bl_desc),e=d-1;e>=3&&0===t.bl_tree[2*S[e]+1];e--);return t.opt_len+=3*(e+1)+5+5+4,e}(t),n=t.opt_len+3+7>>>3,(s=t.static_len+3+7>>>3)<=n&&(n=s)):n=s=r+5,n>=r+4&&-1!==e?J(t,e,r,i):4===t.strategy||s===n?(P(t,2+(i?1:0),3),K(t,z,C)):(P(t,4+(i?1:0),3),function(t,e,r,i){var n;for(P(t,e-257,5),P(t,r-1,5),P(t,i-4,4),n=0;i>n;n++)P(t,t.bl_tree[2*S[n]+1],3);V(t,t.dyn_ltree,e-1),V(t,t.dyn_dtree,r-1)}(t,t.l_desc.max_code+1,t.d_desc.max_code+1,a+1),K(t,t.dyn_ltree,t.dyn_dtree)),W(t),i&&M(t)},r._tr_tally=function(t,e,r){return t.pending_buf[t.d_buf+2*t.last_lit]=e>>>8&255,t.pending_buf[t.d_buf+2*t.last_lit+1]=255&e,t.pending_buf[t.l_buf+t.last_lit]=255&r,t.last_lit++,0===e?t.dyn_ltree[2*r]++:(t.matches++,e--,t.dyn_ltree[2*(A[r]+u+1)]++,t.dyn_dtree[2*N(e)]++),t.last_lit===t.lit_bufsize-1},r._tr_align=function(t){P(t,2,3),L(t,m,z),function(t){16===t.bi_valid?(U(t,t.bi_buf),t.bi_buf=0,t.bi_valid=0):8<=t.bi_valid&&(t.pending_buf[t.pending++]=255&t.bi_buf,t.bi_buf>>=8,t.bi_valid-=8)}(t)}},{"../utils/common":41}],53:[function(t,e,r){"use strict";e.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(t,e,r){"use strict";e.exports="function"==typeof setImmediate?setImmediate:function(){var t=[].slice.apply(arguments);t.splice(1,0,0),setTimeout.apply(null,t)}},{}]},{},[10])(10)})}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{},require("buffer").Buffer)},{buffer:83}],89:[function(require,module,exports){exports.Parser=require("./lib/parser").Parser,exports.rules=require("./lib/rules"),exports.errors=require("./lib/errors"),exports.results=require("./lib/parsing-results"),exports.StringSource=require("./lib/StringSource"),exports.Token=require("./lib/Token"),exports.bottomUp=require("./lib/bottom-up"),exports.RegexTokeniser=require("./lib/regex-tokeniser").RegexTokeniser,exports.rule=function(ruleBuilder){var rule;return function(input){return rule||(rule=ruleBuilder()),rule(input)}}},{"./lib/StringSource":90,"./lib/Token":91,"./lib/bottom-up":93,"./lib/errors":94,"./lib/parser":96,"./lib/parsing-results":97,"./lib/regex-tokeniser":98,"./lib/rules":99}],90:[function(require,module,exports){var util=require("util"),StringSourceRange=(module.exports=function(string,description){var self={asString:function(){return string},range:function(startIndex,endIndex){return new StringSourceRange(string,description,startIndex,endIndex)}};return self},function(string,description,startIndex,endIndex){this._string=string,this._description=description,this._startIndex=startIndex,this._endIndex=endIndex});StringSourceRange.prototype.to=functioLine truncated
for(var length=path.length,i=0;length>i;i++){if(null==obj)return void 0;obj=obj[path[i]]}return length?obj:void 0}function get(object,path,defaultValue){var value=deepGet(object,toPath(path));return isUndefined(value)?defaultValue:value}function has(obj,path){path=toPath(path);for(var length=path.length,i=0;length>i;i++){var key=path[i];if(!has$1(obj,key))return!1;obj=obj[key]}return!!length}function identity(value){return value}function matcher(attrs){return attrs=extendOwn({},attrs),function(obj){return isMatch(obj,attrs)}}function property(path){return path=toPath(path),function(obj){return deepGet(obj,path)}}function optimizeCb(func,context,argCount){if(void 0===context)return func;switch(null==argCount?3:argCount){case 1:return function(value){return func.call(context,value)};case 3:return function(value,index,collection){return func.call(context,value,index,collection)};case 4:return function(accumulator,value,index,collection){return func.call(context,accumulator,value,index,collection)}}return function(){return func.apply(context,arguments)}}function baseIteratee(value,context,argCount){return null==value?identity:isFunction$1(value)?optimizeCb(value,context,argCount):isObject(value)&&!isArray(value)?matcher(value):property(value)}function iteratee(value,context){return baseIteratee(value,context,1/0)}function cb(value,context,argCount){return _$1.iteratee!==iteratee?_$1.iteratee(value,context):baseIteratee(value,context,argCount)}function mapObject(obj,iteratee,context){iteratee=cb(iteratee,context);for(var _keys=keys(obj),length=_keys.length,results={},index=0;length>index;index++){var currentKey=_keys[index];results[currentKey]=iteratee(obj[currentKey],currentKey,obj)}return results}function noop(){}function propertyOf(obj){return null==obj?noop:function(path){return get(obj,path)}}function times(n,iteratee,context){var accum=Array(Math.max(0,n));iteratee=optimizeCb(iteratee,context,1);for(var i=0;n>i;i++)accum[i]=iteratee(i);return accum}function random(min,max){return null==max&&(max=min,min=0),min+Math.floor(Math.random()*(max-min+1))}function createEscaper(map){var escaper=function(match){return map[match]},source="(?:"+keys(map).join("|")+")",testRegexp=RegExp(source),replaceRegexp=RegExp(source,"g");return function(string){return string=null==string?"":""+string,testRegexp.test(string)?string.replace(replaceRegexp,escaper):string}}function escapeChar(match){return"\\"+escapes[match]}function template(text,settings,oldSettings){!settings&&oldSettings&&(settings=oldSettings),settings=defaults({},settings,_$1.templateSettings);var matcher=RegExp([(settings.escape||noMatch).source,(settings.interpolate||noMatch).source,(settings.evaluate||noMatch).source].join("|")+"|$","g"),index=0,source="__p+='";text.replace(matcher,function(match,escape,interpolate,evaluate,offset){return source+=text.slice(index,offset).replace(escapeRegExp,escapeChar),index=offset+match.length,escape?source+="'+\n((__t=("+escape+"))==null?'':_.escape(__t))+\n'":interpolate?source+="'+\n((__t=("+interpolate+"))==null?'':__t)+\n'":evaluate&&(source+="';\n"+evaluate+"\n__p+='"),match}),source+="';\n";var argument=settings.variable;if(argument){if(!bareIdentifier.test(argument))throw new Error("variable is not a bare identifier: "+argument)}else source="with(obj||{}){\n"+source+"}\n",argument="obj";source="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+source+"return __p;\n";var render;try{render=new Function(argument,"_",source)}catch(e){throw e.source=source,e}var template=function(data){return render.call(this,data,_$1)};return template.source="function("+argument+"){\n"+source+"}",template}function result(obj,path,fallback){path=toPath(path);var length=path.length;if(!length)return isFunction$1(fallback)?fallback.call(obj):fallback;for(var i=0;length>i;i++){var prop=null==obj?void 0:obj[path[i]];void 0===prop&&(prop=fallback,i=length),obj=isFunction$1(prop)?prop.call(obj):prop}return obj}function uniqueId(prefix){var id=++idCounter+"";return prefix?prefix+id:id}function chain(obj){var instance=_$1(obj);return instance._chain=!0,instance}function executeBound(sourceFunc,boundFunc,context,callingContext,args){if(!(callingContext instanceof boundFunc))return sourceFunc.apply(context,args);var self=baseCreate(sourceFunc.prototype),result=sourceFunc.apply(self,args);return isObject(result)?result:self}function flatten$1(input,depth,strict,output){if(output=output||[],depth||0===depth){if(0>=depth)return output.concat(input)}else depth=1/0;for(var idx=output.length,i=0,length=getLength(input);length>i;i++){var value=input[i];if(isArrayLike(value)&&(isArray(value)||isArguments$1(value)))if(depth>1)flatten$1(value,depth-1,strict,output),idx=output.length;else for(var j=0,len=value.length;len>j;)output[idx++]=value[j++];else strict||(output[idx++]=value)}return output}function memoize(func,hasher){var memoize=function(key){var cache=memoize.cache,address=""+(hasher?hasher.apply(this,argLine truncated
if(i>=len)return x;switch(x){case"%s":return String(args[i++]);case"%d":return Number(args[i++]);case"%j":try{return JSON.stringify(args[i++])}catch(_){return"[Circular]"}default:return x}}),x=args[i];len>i;x=args[++i])str+=isNull(x)||!isObject(x)?" "+x:" "+inspect(x);return str},exports.deprecate=function(fn,msg){function deprecated(){if(!warned){if(process.throwDeprecation)throw new Error(msg);process.traceDeprecation?console.trace(msg):console.error(msg),warned=!0}return fn.apply(this,arguments)}if(isUndefined(global.process))return function(){return exports.deprecate(fn,msg).apply(this,arguments)};if(process.noDeprecation===!0)return fn;var warned=!1;return deprecated};var debugEnviron,debugs={};exports.debuglog=function(set){if(isUndefined(debugEnviron)&&(debugEnviron=process.env.NODE_DEBUG||""),set=set.toUpperCase(),!debugs[set])if(new RegExp("\\b"+set+"\\b","i").test(debugEnviron)){var pid=process.pid;debugs[set]=function(){var msg=exports.format.apply(exports,arguments);console.error("%s %d: %s",set,pid,msg)}}else debugs[set]=function(){};return debugs[set]},exports.inspect=inspect,inspect.colors={bold:[1,22],italic:[3,23],underline:[4,24],inverse:[7,27],white:[37,39],grey:[90,39],black:[30,39],blue:[34,39],cyan:[36,39],green:[32,39],magenta:[35,39],red:[31,39],yellow:[33,39]},inspect.styles={special:"cyan",number:"yellow","boolean":"yellow",undefined:"grey","null":"bold",string:"green",date:"magenta",regexp:"red"},exports.isArray=isArray,exports.isBoolean=isBoolean,exports.isNull=isNull,exports.isNullOrUndefined=isNullOrUndefined,exports.isNumber=isNumber,exports.isString=isString,exports.isSymbol=isSymbol,exports.isUndefined=isUndefined,exports.isRegExp=isRegExp,exports.isObject=isObject,exports.isDate=isDate,exports.isError=isError,exports.isFunction=isFunction,exports.isPrimitive=isPrimitive,exports.isBuffer=require("./support/isBuffer");var months=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];exports.log=function(){console.log("%s - %s",timestamp(),exports.format.apply(exports,arguments))},exports.inherits=require("inherits"),exports._extend=function(origin,add){if(!add||!isObject(add))return origin;for(var keys=Object.keys(add),i=keys.length;i--;)origin[keys[i]]=add[keys[i]];return origin}}).call(this,require("_process"),"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./support/isBuffer":104,_process:101,inherits:103}],106:[function(require,module,exports){(function(){var assign,getValue,isArray,isEmpty,isFunction,isObject,isPlainObject,slice=[].slice,hasProp={}.hasOwnProperty;assign=function(){var i,key,len,source,sources,target;if(target=arguments[0],sources=2<=arguments.length?slice.call(arguments,1):[],isFunction(Object.assign))Object.assign.apply(null,arguments);else for(i=0,len=sources.length;len>i;i++)if(source=sources[i],null!=source)for(key in source)hasProp.call(source,key)&&(target[key]=source[key]);return target},isFunction=function(val){return!!val&&"[object Function]"===Object.prototype.toString.call(val)},isObject=function(val){var ref;return!!val&&("function"==(ref=typeof val)||"object"===ref)},isArray=function(val){return isFunction(Array.isArray)?Array.isArray(val):"[object Array]"===Object.prototype.toString.call(val)},isEmpty=function(val){var key;if(isArray(val))return!val.length;for(key in val)if(hasProp.call(val,key))return!1;return!0},isPlainObject=function(val){var ctor,proto;return isObject(val)&&(proto=Object.getPrototypeOf(val))&&(ctor=proto.constructor)&&"function"==typeof ctor&&ctor instanceof ctor&&Function.prototype.toString.call(ctor)===Function.prototype.toString.call(Object)},getValue=function(obj){return isFunction(obj.valueOf)?obj.valueOf():obj},module.exports.assign=assign,module.exports.isFunction=isFunction,module.exports.isObject=isObject,module.exports.isArray=isArray,module.exports.isEmpty=isEmpty,module.exports.isPlainObject=isPlainObject,module.exports.getValue=getValue}).call(this)},{}],107:[function(require,module,exports){(function(){var XMLAttribute;module.exports=XMLAttribute=function(){function XMLAttribute(parent,name,value){if(this.options=parent.options,this.stringify=parent.stringify,this.parent=parent,null==name)throw new Error("Missing attribute name. "+this.debugInfo(name));if(null==value)throw new Error("Missing attribute value. "+this.debugInfo(name));this.name=this.stringify.attName(name),this.value=this.stringify.attValue(value)}return XMLAttribute.prototype.clone=function(){return Object.create(this)},XMLAttribute.prototype.toString=function(options){return this.options.writer.set(options).attribute(this)},XMLAttribute.prototype.debugInfo=function(name){var ref,ref1;return name=name||this.name,null!=name||(null!=(ref=this.parent)?ref.name:void 0)?null==name?"parent: <"+this.parent.name+">":(null!=(ref1=this.parent)?ref1.name:void 0)?"attribute: {"+name+"}, parent: <"+this.parent.name+">":"attribute: {"+name+"}":""},XMLAttribute}()}).call(this)},{}Line truncated
len1=val.length;len1>k;k++)item=val[k],childNode={},childNode[key]=item,lastChild=this.element(childNode);else isObject(val)?(lastChild=this.element(key),lastChild.element(val)):lastChild=this.element(key,val)}else lastChild=!this.options.ignoreDecorators&&this.stringify.convertTextKey&&0===name.indexOf(this.stringify.convertTextKey)?this.text(text):!this.options.ignoreDecorators&&this.stringify.convertCDataKey&&0===name.indexOf(this.stringify.convertCDataKey)?this.cdata(text):!this.options.ignoreDecorators&&this.stringify.convertCommentKey&&0===name.indexOf(this.stringify.convertCommentKey)?this.comment(text):!this.options.ignoreDecorators&&this.stringify.convertRawKey&&0===name.indexOf(this.stringify.convertRawKey)?this.raw(text):!this.options.ignoreDecorators&&this.stringify.convertPIKey&&0===name.indexOf(this.stringify.convertPIKey)?this.instruction(name.substr(this.stringify.convertPIKey.length),text):this.node(name,attributes,text);if(null==lastChild)throw new Error("Could not create any elements with: "+name+". "+this.debugInfo());return lastChild},XMLNode.prototype.insertBefore=function(name,attributes,text){var child,i,removed;if(this.isRoot)throw new Error("Cannot insert elements at root level. "+this.debugInfo(name));return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i),child=this.parent.element(name,attributes,text),Array.prototype.push.apply(this.parent.children,removed),child},XMLNode.prototype.insertAfter=function(name,attributes,text){var child,i,removed;if(this.isRoot)throw new Error("Cannot insert elements at root level. "+this.debugInfo(name));return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i+1),child=this.parent.element(name,attributes,text),Array.prototype.push.apply(this.parent.children,removed),child},XMLNode.prototype.remove=function(){var i,ref1;if(this.isRoot)throw new Error("Cannot remove the root element. "+this.debugInfo());return i=this.parent.children.indexOf(this),[].splice.apply(this.parent.children,[i,i-i+1].concat(ref1=[])),ref1,this.parent},XMLNode.prototype.node=function(name,attributes,text){var child,ref1;return null!=name&&(name=getValue(name)),attributes||(attributes={}),attributes=getValue(attributes),isObject(attributes)||(ref1=[attributes,text],text=ref1[0],attributes=ref1[1]),child=new XMLElement(this,name,attributes),null!=text&&child.text(text),this.children.push(child),child},XMLNode.prototype.text=function(value){var child;return child=new XMLText(this,value),this.children.push(child),this},XMLNode.prototype.cdata=function(value){var child;return child=new XMLCData(this,value),this.children.push(child),this},XMLNode.prototype.comment=function(value){var child;return child=new XMLComment(this,value),this.children.push(child),this},XMLNode.prototype.commentBefore=function(value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i),child=this.parent.comment(value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.commentAfter=function(value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i+1),child=this.parent.comment(value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.raw=function(value){var child;return child=new XMLRaw(this,value),this.children.push(child),this},XMLNode.prototype.instruction=function(target,value){var insTarget,insValue,instruction,j,len;if(null!=target&&(target=getValue(target)),null!=value&&(value=getValue(value)),Array.isArray(target))for(j=0,len=target.length;len>j;j++)insTarget=target[j],this.instruction(insTarget);else if(isObject(target))for(insTarget in target)hasProp.call(target,insTarget)&&(insValue=target[insTarget],this.instruction(insTarget,insValue));else isFunction(value)&&(value=value.apply()),instruction=new XMLProcessingInstruction(this,target,value),this.children.push(instruction);return this},XMLNode.prototype.instructionBefore=function(target,value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i),child=this.parent.instruction(target,value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.instructionAfter=function(target,value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i+1),child=this.parent.instruction(target,value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.declaration=function(version,encoding,standalone){var doc,xmldec;return doc=this.document(),xmldec=new XMLDeclaration(doc,version,encoding,standalone),doc.children[0]instanceof XMLDeclaration?doc.children[0]=xmldec:doc.children.unshift(xmldec),doc.root()||doc},XMLNode.prototype.doctype=function(pubID,sysID){var child,doc,doctype,i,j,k,len,len1,ref1,ref2;for(doc=this.document(),doctype=new XMLDocType(doc,pubID,sysID),ref1=doc.children,i=j=0,len=ref1.length;len>jLine truncated
},{}],127:[function(require,module,exports){(function(){var XMLDocument,XMLDocumentCB,XMLStreamWriter,XMLStringWriter,assign,isFunction,ref;ref=require("./Utility"),assign=ref.assign,isFunction=ref.isFunction,XMLDocument=require("./XMLDocument"),XMLDocumentCB=require("./XMLDocumentCB"),XMLStringWriter=require("./XMLStringWriter"),XMLStreamWriter=require("./XMLStreamWriter"),module.exports.create=function(name,xmldec,doctype,options){var doc,root;if(null==name)throw new Error("Root element needs a name.");return options=assign({},xmldec,doctype,options),doc=new XMLDocument(options),root=doc.element(name),options.headless||(doc.declaration(options),(null!=options.pubID||null!=options.sysID)&&doc.doctype(options)),root},module.exports.begin=function(options,onData,onEnd){var ref1;return isFunction(options)&&(ref1=[options,onData],onData=ref1[0],onEnd=ref1[1],options={}),onData?new XMLDocumentCB(options,onData,onEnd):new XMLDocument(options)},module.exports.stringWriter=function(options){return new XMLStringWriter(options)},module.exports.streamWriter=function(stream,options){return new XMLStreamWriter(stream,options)}}).call(this)},{"./Utility":106,"./XMLDocument":116,"./XMLDocumentCB":117,"./XMLStreamWriter":122,"./XMLStringWriter":123}]},{},[21])(21)});
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 hhk-png
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
var MobiParser=(()=>{var Ut=Object.create;var Se=Object.defineProperty;var Mt=Object.getOwnPropertyDescriptor;var zt=Object.getOwnPropertyNames;var kt=Object.getPrototypeOf,Ht=Object.prototype.hasOwnProperty;var Gt=(r=>typeof require!="undefined"?require:typeof Proxy!="undefined"?new Proxy(r,{get:(e,t)=>(typeof require!="undefined"?require:e)[t]}):r)(function(r){if(typeof require!="undefined")return require.apply(this,arguments);throw Error('Dynamic require of "'+r+'" is not supported')});var Me=(r,e)=>()=>{try{return e||r((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}},jt=(r,e)=>{for(var t in e)Se(r,t,{get:e[t],enumerable:!0})},Ze=(r,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of zt(e))!Ht.call(r,o)&&o!==t&&Se(r,o,{get:()=>e[o],enumerable:!(s=Mt(e,o))||s.enumerable});return r};var ze=(r,e,t)=>(t=r!=null?Ut(kt(r)):{},Ze(e||!r||!r.__esModule?Se(t,"default",{value:r,enumerable:!0}):t,r)),Vt=r=>Ze(Se({},"__esModule",{value:!0}),r);var at=Me((sr,ot)=>{"use strict";function ie(r){if(typeof r!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(r))}function st(r,e){for(var t="",s=0,o=-1,l=0,a,u=0;u<=r.length;++u){if(u<r.length)a=r.charCodeAt(u);else{if(a===47)break;a=47}if(a===47){if(!(o===u-1||l===1))if(o!==u-1&&l===2){if(t.length<2||s!==2||t.charCodeAt(t.length-1)!==46||t.charCodeAt(t.length-2)!==46){if(t.length>2){var d=t.lastIndexOf("/");if(d!==t.length-1){d===-1?(t="",s=0):(t=t.slice(0,d),s=t.length-1-t.lastIndexOf("/")),o=u,l=0;continue}}else if(t.length===2||t.length===1){t="",s=0,o=u,l=0;continue}}e&&(t.length>0?t+="/..":t="..",s=2)}else t.length>0?t+="/"+r.slice(o+1,u):t=r.slice(o+1,u),s=u-o-1;o=u,l=0}else a===46&&l!==-1?++l:l=-1}return t}function on(r,e){var t=e.dir||e.root,s=e.base||(e.name||"")+(e.ext||"");return t?t===e.root?t+s:t+r+s:s}var he={resolve:function(){for(var e="",t=!1,s,o=arguments.length-1;o>=-1&&!t;o--){var l;o>=0?l=arguments[o]:(s===void 0&&(s=process.cwd()),l=s),ie(l),l.length!==0&&(e=l+"/"+e,t=l.charCodeAt(0)===47)}return e=st(e,!t),t?e.length>0?"/"+e:"/":e.length>0?e:"."},normalize:function(e){if(ie(e),e.length===0)return".";var t=e.charCodeAt(0)===47,s=e.charCodeAt(e.length-1)===47;return e=st(e,!t),e.length===0&&!t&&(e="."),e.length>0&&s&&(e+="/"),t?"/"+e:e},isAbsolute:function(e){return ie(e),e.length>0&&e.charCodeAt(0)===47},join:function(){if(arguments.length===0)return".";for(var e,t=0;t<arguments.length;++t){var s=arguments[t];ie(s),s.length>0&&(e===void 0?e=s:e+="/"+s)}return e===void 0?".":he.normalize(e)},relative:function(e,t){if(ie(e),ie(t),e===t||(e=he.resolve(e),t=he.resolve(t),e===t))return"";for(var s=1;s<e.length&&e.charCodeAt(s)===47;++s);for(var o=e.length,l=o-s,a=1;a<t.length&&t.charCodeAt(a)===47;++a);for(var u=t.length,d=u-a,m=l<d?l:d,v=-1,g=0;g<=m;++g){if(g===m){if(d>m){if(t.charCodeAt(a+g)===47)return t.slice(a+g+1);if(g===0)return t.slice(a+g)}else l>m&&(e.charCodeAt(s+g)===47?v=g:g===0&&(v=0));break}var p=e.charCodeAt(s+g),y=t.charCodeAt(a+g);if(p!==y)break;p===47&&(v=g)}var b="";for(g=s+v+1;g<=o;++g)(g===o||e.charCodeAt(g)===47)&&(b.length===0?b+="..":b+="/..");return b.length>0?b+t.slice(a+v):(a+=v,t.charCodeAt(a)===47&&++a,t.slice(a))},_makeLong:function(e){return e},dirname:function(e){if(ie(e),e.length===0)return".";for(var t=e.charCodeAt(0),s=t===47,o=-1,l=!0,a=e.length-1;a>=1;--a)if(t=e.charCodeAt(a),t===47){if(!l){o=a;break}}else l=!1;return o===-1?s?"/":".":s&&o===1?"//":e.slice(0,o)},basename:function(e,t){if(t!==void 0&&typeof t!="string")throw new TypeError('"ext" argument must be a string');ie(e);var s=0,o=-1,l=!0,a;if(t!==void 0&&t.length>0&&t.length<=e.length){if(t.length===e.length&&t===e)return"";var u=t.length-1,d=-1;for(a=e.length-1;a>=0;--a){var m=e.charCodeAt(a);if(m===47){if(!l){s=a+1;break}}else d===-1&&(l=!1,d=a+1),u>=0&&(m===t.charCodeAt(u)?--u===-1&&(o=a):(u=-1,o=d))}return s===o?o=d:o===-1&&(o=e.length),e.slice(s,o)}else{for(a=e.length-1;a>=0;--a)if(e.charCodeAt(a)===47){if(!l){s=a+1;break}}else o===-1&&(l=!1,o=a+1);return o===-1?"":e.slice(s,o)}},extname:function(e){ie(e);for(var t=-1,s=0,o=-1,l=!0,a=0,u=e.length-1;u>=0;--u){var d=e.charCodeAt(u);if(d===47){if(!l){s=u+1;break}continue}o===-1&&(l=!1,o=u+1),d===46?t===-1?t=u:a!==1&&(a=1):t!==-1&&(a=-1)}return t===-1||o===-1||a===0||a===1&&t===o-1&&t===s+1?"":e.slice(t,o)},format:function(e){if(e===null||typeof e!="object")throw new TypeError('The "pathObject" argument must be of type Object. Received type '+typeof e);return on("/",e)},parse:function(e){ie(e);var t={root:"",dir:"",base:"",ext:"",name:""};if(e.length===0)return t;var s=e.charCodeAt(0),o=s===47,l;o?(t.root="/",l=1):l=0;for(var a=-1,u=0,d=-1,m=!0,v=e.length-1,g=0;v>=l;--v){if(s=e.charCodeAt(v),s===47){if(!m){u=v+1;break}continue}d===-1&&(m=!1,d=v+1),s===46?a===-1?a=v:g!==1&&(g=1):a!==-1&&(g=-1)}return a===-1||d===-1||g===0||g===1&&a===d-1&&a===u+1?d!==-1&&(u===0&&o?t.base=t.name=e.slice(1,d):t.base=t.name=e.slice(u,d)):(u===0&&o?(t.name=e.slice(1,a),t.baseLine truncated
`||i==="\r"||i===" "}function R(i){return i==='"'||i==="'"}function X(i){return i===">"||I(i)}function F(i,n){return i.test(n)}function k(i,n){return!F(i,n)}var f=0;r.STATE={BEGIN:f++,BEGIN_WHITESPACE:f++,TEXT:f++,TEXT_ENTITY:f++,OPEN_WAKA:f++,SGML_DECL:f++,SGML_DECL_QUOTED:f++,DOCTYPE:f++,DOCTYPE_QUOTED:f++,DOCTYPE_DTD:f++,DOCTYPE_DTD_QUOTED:f++,COMMENT_STARTING:f++,COMMENT:f++,COMMENT_ENDING:f++,COMMENT_ENDED:f++,CDATA:f++,CDATA_ENDING:f++,CDATA_ENDING_2:f++,PROC_INST:f++,PROC_INST_BODY:f++,PROC_INST_ENDING:f++,OPEN_TAG:f++,OPEN_TAG_SLASH:f++,ATTRIB:f++,ATTRIB_NAME:f++,ATTRIB_NAME_SAW_WHITE:f++,ATTRIB_VALUE:f++,ATTRIB_VALUE_QUOTED:f++,ATTRIB_VALUE_CLOSED:f++,ATTRIB_VALUE_UNQUOTED:f++,ATTRIB_VALUE_ENTITY_Q:f++,ATTRIB_VALUE_ENTITY_U:f++,CLOSE_TAG:f++,CLOSE_TAG_SAW_WHITE:f++,SCRIPT:f++,SCRIPT_ENDING:f++},r.XML_ENTITIES=Object.assign(Object.create(null),{amp:"&",gt:">",lt:"<",quot:'"',apos:"'"}),r.ENTITIES=Object.assign(Object.create(null),{amp:"&",gt:">",lt:"<",quot:'"',apos:"'",AElig:198,Aacute:193,Acirc:194,Agrave:192,Aring:197,Atilde:195,Auml:196,Ccedil:199,ETH:208,Eacute:201,Ecirc:202,Egrave:200,Euml:203,Iacute:205,Icirc:206,Igrave:204,Iuml:207,Ntilde:209,Oacute:211,Ocirc:212,Ograve:210,Oslash:216,Otilde:213,Ouml:214,THORN:222,Uacute:218,Ucirc:219,Ugrave:217,Uuml:220,Yacute:221,aacute:225,acirc:226,aelig:230,agrave:224,aring:229,atilde:227,auml:228,ccedil:231,eacute:233,ecirc:234,egrave:232,eth:240,euml:235,iacute:237,icirc:238,igrave:236,iuml:239,ntilde:241,oacute:243,ocirc:244,ograve:242,oslash:248,otilde:245,ouml:246,szlig:223,thorn:254,uacute:250,ucirc:251,ugrave:249,uuml:252,yacute:253,yuml:255,copy:169,reg:174,nbsp:160,iexcl:161,cent:162,pound:163,curren:164,yen:165,brvbar:166,sect:167,uml:168,ordf:170,laquo:171,not:172,shy:173,macr:175,deg:176,plusmn:177,sup1:185,sup2:178,sup3:179,acute:180,micro:181,para:182,middot:183,cedil:184,ordm:186,raquo:187,frac14:188,frac12:189,frac34:190,iquest:191,times:215,divide:247,OElig:338,oelig:339,Scaron:352,scaron:353,Yuml:376,fnof:402,circ:710,tilde:732,Alpha:913,Beta:914,Gamma:915,Delta:916,Epsilon:917,Zeta:918,Eta:919,Theta:920,Iota:921,Kappa:922,Lambda:923,Mu:924,Nu:925,Xi:926,Omicron:927,Pi:928,Rho:929,Sigma:931,Tau:932,Upsilon:933,Phi:934,Chi:935,Psi:936,Omega:937,alpha:945,beta:946,gamma:947,delta:948,epsilon:949,zeta:950,eta:951,theta:952,iota:953,kappa:954,lambda:955,mu:956,nu:957,xi:958,omicron:959,pi:960,rho:961,sigmaf:962,sigma:963,tau:964,upsilon:965,phi:966,chi:967,psi:968,omega:969,thetasym:977,upsih:978,piv:982,ensp:8194,emsp:8195,thinsp:8201,zwnj:8204,zwj:8205,lrm:8206,rlm:8207,ndash:8211,mdash:8212,lsquo:8216,rsquo:8217,sbquo:8218,ldquo:8220,rdquo:8221,bdquo:8222,dagger:8224,Dagger:8225,bull:8226,hellip:8230,permil:8240,prime:8242,Prime:8243,lsaquo:8249,rsaquo:8250,oline:8254,frasl:8260,euro:8364,image:8465,weierp:8472,real:8476,trade:8482,alefsym:8501,larr:8592,uarr:8593,rarr:8594,darr:8595,harr:8596,crarr:8629,lArr:8656,uArr:8657,rArr:8658,dArr:8659,hArr:8660,forall:8704,part:8706,exist:8707,empty:8709,nabla:8711,isin:8712,notin:8713,ni:8715,prod:8719,sum:8721,minus:8722,lowast:8727,radic:8730,prop:8733,infin:8734,ang:8736,and:8743,or:8744,cap:8745,cup:8746,int:8747,there4:8756,sim:8764,cong:8773,asymp:8776,ne:8800,equiv:8801,le:8804,ge:8805,sub:8834,sup:8835,nsub:8836,sube:8838,supe:8839,oplus:8853,otimes:8855,perp:8869,sdot:8901,lceil:8968,rceil:8969,lfloor:8970,rfloor:8971,lang:9001,rang:9002,loz:9674,spades:9824,clubs:9827,hearts:9829,diams:9830}),Object.keys(r.ENTITIES).forEach(function(i){var n=r.ENTITIES[i],h=typeof n=="number"?String.fromCharCode(n):n;r.ENTITIES[i]=h});for(var W in r.STATE)r.STATE[r.STATE[W]]=W;f=r.STATE;function _(i,n,h){i[n]&&i[n](h)}function J(i){var n=i&&i.match(/(?:^|\s)encoding\s*=\s*(['"])([^'"]+)\1/i);return n?n[2]:null}function ce(i){return i?i.toLowerCase().replace(/[^a-z0-9]/g,""):null}function ne(i,n){let h=ce(i),c=ce(n);return!h||!c?!0:c==="utf16"?h==="utf16le"||h==="utf16be":h===c}function Y(i,n){if(!(!i.strict||!i.encoding||!n||n.name!=="xml")){var h=J(n.body);h&&!ne(i.encoding,h)&&N(i,"XML declaration encoding "+h+" does not match detected stream encoding "+i.encoding.toUpperCase())}}function A(i,n,h){i.textNode&&U(i),_(i,n,h)}function U(i){i.textNode=pe(i.opt,i.textNode),i.textNode&&_(i,"ontext",i.textNode),i.textNode=""}function pe(i,n){return i.trim&&(n=n.trim()),i.normalize&&(n=n.replace(/\s+/g," ")),n}function z(i,n){return U(i),i.trackPosition&&(n+=`
Line: `+i.line+`
Column: `+i.column+`
Char: `+i.c),n=new Error(n),i.error=n,_(i,"onerror",n),i}function xe(i){return i.sawRoot&&!i.closedRoot&&N(i,"Unclosed root tag"),i.state!==f.BEGIN&&i.state!==f.BEGIN_WHITESPACE&&i.state!==f.TEXT&&z(i,"Unexpected end"),U(i),i.c="",i.closed=!0,_(i,"onend"),t.call(i,i.strict,i.opt),i}function N(i,n){if(typeof i!="object"||!(i instanceof t))throw new Error("bad call to strictFail");i.strict&&z(i,n)}function me(i){i.strict||(i.tagName=i.tagName[i.looseCase]());var n=i.tags[i.tags.length-1]||i,h=i.tag={name:i.tagName,attributes:{}};i.opt.xmlns&&(h.ns=n.ns),i.attribList.length=0,A(i,"onopentagstart",h)}function re(i,n){var h=i.indexOf(":"),c=h<0?["",i]:i.split(":"),S=c[0],L=c[1];return n&&i==="xmlns"&&(S="xmlns",L=""),{prefix:S,local:L}}function fe(i){if(i.strict||(i.attribName=i.attribName[i.looseCase]()),i.attribList.indexOf(i.attribName)!==-1||i.tag.attributes.hasOwnProperty(i.attribName)){i.attribName=i.attribValue="";return}if(i.opt.xmlns){var n=re(i.attribName,!0),h=n.prefix,c=n.local;if(h==="xmlns")if(c==="xml"&&i.attribValue!==y)N(i,"xml: prefix must be bound to "+y+`
Actual: `+i.attribValue);else if(c==="xmlns"&&i.attribValue!==b)N(i,"xmlns: prefix must be bound to "+b+`
Actual: `+i.attribValue);else{var S=i.tag,L=i.tags[i.tags.length-1]||i;S.ns===L.ns&&(S.ns=Object.create(L.ns)),S.ns[c]=i.attribValue}i.attribList.push([i.attribName,i.attribValue])}else i.tag.attributes[i.attribName]=i.attribValue,A(i,"onattribute",{name:i.attribName,value:i.attribValue});i.attribName=i.attribValue=""}function q(i,n){if(i.opt.xmlns){var h=i.tag,c=re(i.tagName);h.prefix=c.prefix,h.local=c.local,h.uri=h.ns[c.prefix]||"",h.prefix&&!h.uri&&(N(i,"Unbound namespace prefix: "+JSON.stringify(i.tagName)),h.uri=c.prefix);var S=i.tags[i.tags.length-1]||i;h.ns&&S.ns!==h.ns&&Object.keys(h.ns).forEach(function($e){A(i,"onopennamespace",{prefix:$e,uri:h.ns[$e]})});for(var L=0,P=i.attribList.length;L<P;L++){var j=i.attribList[L],V=j[0],se=j[1],M=re(V,!0),oe=M.prefix,Bt=M.local,We=oe===""?"":h.ns[oe]||"",Ue={name:V,value:se,prefix:oe,local:Bt,uri:We};oe&&oe!=="xmlns"&&!We&&(N(i,"Unbound namespace prefix: "+JSON.stringify(oe)),Ue.uri=oe),i.tag.attributes[V]=Ue,A(i,"onattribute",Ue)}i.attribList.length=0}i.tag.isSelfClosing=!!n,i.sawRoot=!0,i.tags.push(i.tag),A(i,"onopentag",i.tag),n||(!i.noscript&&i.tagName.toLowerCase()==="script"?i.state=f.SCRIPT:i.state=f.TEXT,i.tag=null,i.tagName=""),i.attribName=i.attribValue="",i.attribList.length=0}function le(i){if(!i.tagName){N(i,"Weird empty close tag."),i.textNode+="</>",i.state=f.TEXT;return}if(i.script){if(i.tagName!=="script"){i.script+="</"+i.tagName+">",i.tagName="",i.state=f.SCRIPT;return}A(i,"onscript",i.script),i.script=""}var n=i.tags.length,h=i.tagName;i.strict||(h=h[i.looseCase]());for(var c=h;n--;){var S=i.tags[n];if(S.name!==c)N(i,"Unexpected close tag");else break}if(n<0){N(i,"Unmatched closing tag: "+i.tagName),i.textNode+="</"+i.tagName+">",i.state=f.TEXT;return}i.tagName=h;for(var L=i.tags.length;L-- >n;){var P=i.tag=i.tags.pop();i.tagName=i.tag.name,A(i,"onclosetag",i.tagName);var j={};for(var V in P.ns)j[V]=P.ns[V];var se=i.tags[i.tags.length-1]||i;i.opt.xmlns&&P.ns!==se.ns&&Object.keys(P.ns).forEach(function(M){var oe=P.ns[M];A(i,"onclosenamespace",{prefix:M,uri:oe})})}n===0&&(i.closedRoot=!0),i.tagName=i.attribValue=i.attribName="",i.attribList.length=0,i.state=f.TEXT}function ye(i){var n=i.entity,h=n.toLowerCase(),c,S="";return i.ENTITIES[n]?i.ENTITIES[n]:i.ENTITIES[h]?i.ENTITIES[h]:(n=h,n.charAt(0)==="#"&&(n.charAt(1)==="x"?(n=n.slice(2),c=parseInt(n,16),S=c.toString(16)):(n=n.slice(1),c=parseInt(n,10),S=c.toString(10))),n=n.replace(/^0+/,""),isNaN(c)||S.toLowerCase()!==n||c<0||c>1114111||!Ne(c)?(N(i,"Invalid character entity"),"&"+i.entity+";"):String.fromCodePoint(c))}function Ne(i){return i===9||i===10||i===13||i>=32&&i<=55295||i>=57344&&i<=65533||i>=65536&&i<=1114111}function Te(i,n){n==="<"?(i.state=f.OPEN_WAKA,i.startTagPosition=i.position):I(n)||(N(i,"Non-whitespace before first tag."),i.textNode=n,i.state=f.TEXT)}function be(i,n){var h="";return n<i.length&&(h=i.charAt(n)),h}function Ie(i){var n=this;if(this.error)throw this.error;if(n.closed)return z(n,"Cannot write after close. Assign an onready handler.");if(i===null)return xe(n);typeof i=="object"&&(i=i.toString());for(var h=0,c="";c=be(i,h++),n.c=c,!!c;)switch(n.trackPosition&&(n.position++,c===`
`?(n.line++,n.column=0):n.column++),n.state){case f.BEGIN:if(n.state=f.BEGIN_WHITESPACE,c==="\uFEFF")continue;Te(n,c);continue;case f.BEGIN_WHITESPACE:Te(n,c);continue;case f.TEXT:if(n.sawRoot&&!n.closedRoot){for(var L=h-1;c&&c!=="<"&&c!=="&";)c=be(i,h++),c&&n.trackPosition&&(n.position++,c===`
`?(n.line++,n.column=0):n.column++);n.textNode+=i.substring(L,h-1)}c==="<"&&!(n.sawRoot&&n.closedRoot&&!n.strict)?(n.state=f.OPEN_WAKA,n.startTagPosition=n.position):(!I(c)&&(!n.sawRoot||n.closedRoot)&&N(n,"Text data outside of root node."),c==="&"?n.state=f.TEXT_ENTITY:n.textNode+=c);continue;case f.SCRIPT:c==="<"?n.state=f.SCRIPT_ENDING:n.script+=c;continue;case f.SCRIPT_ENDING:c==="/"?n.state=f.CLOSE_TAG:(n.script+="<"+c,n.state=f.SCRIPT);continue;case f.OPEN_WAKA:if(c==="!")n.state=f.SGML_DECL,n.sgmlDecl="";else if(!I(c))if(F(w,c))n.state=f.OPEN_TAG,n.tagName=c;else if(c==="/")n.state=f.CLOSE_TAG,n.tagName="";else if(c==="?")n.state=f.PROC_INST,n.procInstName=n.procInstBody="";else{if(N(n,"Unencoded <"),n.startTagPosition+1<n.position){var S=n.position-n.startTagPosition;c=new Array(S).join(" ")+c}n.textNode+="<"+c,n.state=f.TEXT}continue;case f.SGML_DECL:if(n.sgmlDecl+c==="--"){n.state=f.COMMENT,n.comment="",n.sgmlDecl="";continue}n.doctype&&n.doctype!==!0&&n.sgmlDecl?(n.state=f.DOCTYPE_DTD,n.doctype+="<!"+n.sgmlDecl+c,n.sgmlDecl=""):g.test(n.sgmlDecl+c)?(A(n,"onopencdata"),n.state=f.CDATA,n.sgmlDecl="",n.cdata=""):p.test(n.sgmlDecl+c)?(n.state=f.DOCTYPE,(n.doctype||n.sawRoot)&&N(n,"Inappropriately located doctype declaration"),n.doctype="",n.sgmlDecl=""):c===">"?(A(n,"onsgmldeclaration",n.sgmlDecl),n.sgmlDecl="",n.state=f.TEXT):(R(c)&&(n.state=f.SGML_DECL_QUOTED),n.sgmlDecl+=c);continue;case f.SGML_DECL_QUOTED:c===n.q&&(n.state=f.SGML_DECL,n.q=""),n.sgmlDecl+=c;continue;case f.DOCTYPE:c===">"?(n.state=f.TEXT,A(n,"ondoctype",n.doctype),n.doctype=!0):(n.doctype+=c,c==="["?n.state=f.DOCTYPE_DTD:R(c)&&(n.state=f.DOCTYPE_QUOTED,n.q=c));continue;case f.DOCTYPE_QUOTED:n.doctype+=c,c===n.q&&(n.q="",n.state=f.DOCTYPE);continue;case f.DOCTYPE_DTD:c==="]"?(n.doctype+=c,n.state=f.DOCTYPE):c==="<"?(n.state=f.OPEN_WAKA,n.startTagPosition=n.position):R(c)?(n.doctype+=c,n.state=f.DOCTYPE_DTD_QUOTED,n.q=c):n.doctype+=c;continue;case f.DOCTYPE_DTD_QUOTED:n.doctype+=c,c===n.q&&(n.state=f.DOCTYPE_DTD,n.q="");continue;case f.COMMENT:c==="-"?n.state=f.COMMENT_ENDING:n.comment+=c;continue;case f.COMMENT_ENDING:c==="-"?(n.state=f.COMMENT_ENDED,n.comment=pe(n.opt,n.comment),n.comment&&A(n,"oncomment",n.comment),n.comment=""):(n.comment+="-"+c,n.state=f.COMMENT);continue;case f.COMMENT_ENDED:c!==">"?(N(n,"Malformed comment"),n.comment+="--"+c,n.state=f.COMMENT):n.doctype&&n.doctype!==!0?n.state=f.DOCTYPE_DTD:n.state=f.TEXT;continue;case f.CDATA:for(var L=h-1;c&&c!=="]";)c=be(i,h++),c&&n.trackPosition&&(n.position++,c===`
`?(n.line++,n.column=0):n.column++);n.cdata+=i.substring(L,h-1),c==="]"&&(n.state=f.CDATA_ENDING);continue;case f.CDATA_ENDING:c==="]"?n.state=f.CDATA_ENDING_2:(n.cdata+="]"+c,n.state=f.CDATA);continue;case f.CDATA_ENDING_2:c===">"?(n.cdata&&A(n,"oncdata",n.cdata),A(n,"onclosecdata"),n.cdata="",n.state=f.TEXT):c==="]"?n.cdata+="]":(n.cdata+="]]"+c,n.state=f.CDATA);continue;case f.PROC_INST:c==="?"?n.state=f.PROC_INST_ENDING:I(c)?n.state=f.PROC_INST_BODY:n.procInstName+=c;continue;case f.PROC_INST_BODY:if(!n.procInstBody&&I(c))continue;c==="?"?n.state=f.PROC_INST_ENDING:n.procInstBody+=c;continue;case f.PROC_INST_ENDING:if(c===">"){let se={name:n.procInstName,body:n.procInstBody};Y(n,se),A(n,"onprocessinginstruction",se),n.procInstName=n.procInstBody="",n.state=f.TEXT}else n.procInstBody+="?"+c,n.state=f.PROC_INST_BODY;continue;case f.OPEN_TAG:F(C,c)?n.tagName+=c:(me(n),c===">"?q(n):c==="/"?n.state=f.OPEN_TAG_SLASH:(I(c)||N(n,"Invalid character in tag name"),n.state=f.ATTRIB));continue;case f.OPEN_TAG_SLASH:c===">"?(q(n,!0),le(n)):(N(n,"Forward-slash in opening tag not followed by >"),n.state=f.ATTRIB);continue;case f.ATTRIB:if(I(c))continue;c===">"?q(n):c==="/"?n.state=f.OPEN_TAG_SLASH:F(w,c)?(n.attribName=c,n.attribValue="",n.state=f.ATTRIB_NAME):N(n,"Invalid attribute name");continue;case f.ATTRIB_NAME:c==="="?n.state=f.ATTRIB_VALUE:c===">"?(N(n,"Attribute without value"),n.attribValue=n.attribName,fe(n),q(n)):I(c)?n.state=f.ATTRIB_NAME_SAW_WHITE:F(C,c)?n.attribName+=c:N(n,"Invalid attribute name");continue;case f.ATTRIB_NAME_SAW_WHITE:if(c==="=")n.state=f.ATTRIB_VALUE;else{if(I(c))continue;N(n,"Attribute without value"),n.tag.attributes[n.attribName]="",n.attribValue="",A(n,"onattribute",{name:n.attribName,value:""}),n.attribName="",c===">"?q(n):F(w,c)?(n.attribName=c,n.state=f.ATTRIB_NAME):(N(n,"Invalid attribute name"),n.state=f.ATTRIB)}continue;case f.ATTRIB_VALUE:if(I(c))continue;R(c)?(n.q=c,n.state=f.ATTRIB_VALUE_QUOTED):(n.opt.unquotedAttributeValues||z(n,"Unquoted attribute value"),n.state=f.ATTRIB_VALUE_UNQUOTED,n.attribValue=c);continue;case f.ATTRIB_VALUE_QUOTED:if(c!==n.q){c==="&"?n.state=f.ATTRIB_VALUE_ENTITY_Q:n.attribValue+=c;continue}fe(n),n.q="",n.state=f.ATTRIB_VALUE_CLOSED;continue;case f.ATTRIB_VALUE_CLOSED:I(c)?n.state=f.ATTRIB:c===">"?q(n):c==="/"?n.state=f.OPEN_TAG_SLASH:F(w,c)?(N(n,"No whitespace between attributes"),n.attribName=c,n.attribValue="",n.state=f.ATTRIB_NAME):N(n,"Invalid attribute name");continue;case f.ATTRIB_VALUE_UNQUOTED:if(!X(c)){c==="&"?n.state=f.ATTRIB_VALUE_ENTITY_U:n.attribValue+=c;continue}fe(n),c===">"?q(n):n.state=f.ATTRIB;continue;case f.CLOSE_TAG:if(n.tagName)c===">"?le(n):F(C,c)?n.tagName+=c:n.script?(n.script+="</"+n.tagName+c,n.tagName="",n.state=f.SCRIPT):(I(c)||N(n,"Invalid tagname in closing tag"),n.state=f.CLOSE_TAG_SAW_WHITE);else{if(I(c))continue;k(w,c)?n.script?(n.script+="</"+c,n.state=f.SCRIPT):N(n,"Invalid tagname in closing tag."):n.tagName=c}continue;case f.CLOSE_TAG_SAW_WHITE:if(I(c))continue;c===">"?le(n):N(n,"Invalid characters in closing tag");continue;case f.TEXT_ENTITY:case f.ATTRIB_VALUE_ENTITY_Q:case f.ATTRIB_VALUE_ENTITY_U:var P,j;switch(n.state){case f.TEXT_ENTITY:P=f.TEXT,j="textNode";break;case f.ATTRIB_VALUE_ENTITY_Q:P=f.ATTRIB_VALUE_QUOTED,j="attribValue";break;case f.ATTRIB_VALUE_ENTITY_U:P=f.ATTRIB_VALUE_UNQUOTED,j="attribValue";break}if(c===";"){var V=ye(n);n.opt.unparsedEntities&&!Object.values(r.XML_ENTITIES).includes(V)?((n.entityCount+=1)>n.opt.maxEntityCount&&z(n,"Parsed entity count exceeds max entity count"),(n.entityDepth+=1)>n.opt.maxEntityDepth&&z(n,"Parsed entity depth exceeds max entity depth"),n.entity="",n.state=P,n.write(V),n.entityDepth-=1):(n[j]+=V,n.entity="",n.state=P)}else F(n.entity.length?E:O,c)?n.entity+=c:(N(n,"Invalid character in entity name"),n[j]+="&"+n.entity+c,n.entity="",n.state=P);continue;default:throw new Error(n,"Unknown state: "+n.state)}return n.position>=n.bufferCheckPosition&&s(n),n}String.fromCodePoint||(function(){var i=String.fromCharCode,n=Math.floor,h=function(){var c=16384,S=[],L,P,j=-1,V=arguments.length;if(!V)return"";for(var se="";++j<V;){var M=Number(arguments[j]);if(!isFinite(M)||M<0||M>1114111||n(M)!==M)throw RangeError("Invalid code point: "+M);M<=65535?S.push(M):(M-=65536,L=(M>>10)+55296,P=M%1024+56320,S.push(L,P)),(j+1===V||S.length>c)&&(se+=i.apply(null,S),S.length=0)}return se};Object.defineProperty?Object.defineProperty(String,"fromCodePoint",{value:h,configurable:!0,writable:!0}):String.fromCodePoint=h})()})(typeof _e=="undefined"?_e.sax={}:_e)});var tr={};jt(tr,{initKf8File:()=>Rt,initMobiFile:()=>Pt});var $=Uint8Array,ue=Uint16Array,Xt=Int32Array,Qe=new $([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),Ke=new $([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Yt=new $([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Je=function(r,e){for(var t=new ue(31),s=0;s<31;++s)t[s]=e+=1<<r[s-1];for(var o=new Xt(t[30]),s=1;s<30;++ Line truncated
`},headless:!1,chunkSize:1e4,emptyTag:"",cdata:!1}};function pn(r){return r.toLowerCase()}function mn(r){return typeof r=="object"&&r!==null&&Object.keys(r).length===0}function Fe(r,e,t){for(let s of r)e=s(e,t);return e}function de(r,e,t){let s=Object.create(null);s.value=t,s.writable=!0,s.enumerable=!0,s.configurable=!0,Object.defineProperty(r,e,s)}var Ve=class extends wt.EventEmitter{constructor(e){super(),Z(this,"options"),Z(this,"remaining",""),Z(this,"saxParser"),Z(this,"resultObject",null),Z(this,"EXPLICIT_CHARKEY"),Z(this,"errThrown",!1),Z(this,"ended",!1),Z(this,"processAsync",()=>{try{if(this.remaining.length<=this.options.chunkSize){let t=this.remaining;this.remaining="",this.saxParser=this.saxParser.write(t),this.saxParser.close()}else{let t=this.remaining.slice(0,this.options.chunkSize);this.remaining=this.remaining.slice(this.options.chunkSize),this.saxParser=this.saxParser.write(t),setTimeout(this.processAsync,0)}}catch(t){this.errThrown||(this.errThrown=!0,this.emit("error",t))}}),Z(this,"assignOrPush",(t,s,o)=>{s in t?(Array.isArray(t[s])||de(t,s,[t[s]]),t[s].push(o)):this.options.explicitArray?de(t,s,[o]):de(t,s,o)}),Z(this,"reset",()=>{this.removeAllListeners(),this.saxParser=Et.default.parser(this.options.strict,{trim:!1,normalize:!1,xmlns:this.options.xmlns}),this.errThrown=!1,this.saxParser.onerror=a=>{this.saxParser.resume(),this.errThrown||(this.errThrown=!0,this.emit("error",a))},this.saxParser.onend=()=>{this.ended||(this.ended=!0,this.emit("end",this.resultObject))},this.ended=!1,this.EXPLICIT_CHARKEY=this.options.explicitCharkey,this.resultObject=null;let t=[],s=this.options.attrkey,o=this.options.charkey;this.saxParser.onopentag=a=>{let u={};u[o]="",this.options.ignoreAttrs||Object.keys(a.attributes).forEach(d=>{!(s in u)&&!this.options.mergeAttrs&&(u[s]={});let m=this.options.attrValueProcessors?Fe(this.options.attrValueProcessors,a.attributes[d],d):a.attributes[d],v=this.options.attrNameProcessors?Fe(this.options.attrNameProcessors,d):d;this.options.mergeAttrs?this.assignOrPush(u,v,m):de(u[s],v,m)}),u["#name"]=this.options.tagNameProcessors?Fe(this.options.tagNameProcessors,a.name):a.name,this.options.xmlns&&(u[this.options.xmlnskey]={uri:a.uri,local:a.local}),t.push(u)},this.saxParser.onclosetag=()=>{let a=t.pop(),u=a["#name"];(!this.options.explicitChildren||!this.options.preserveChildrenOrder)&&delete a["#name"];let d;a.cdata===!0&&(d=a.cdata,delete a.cdata);let m=t[t.length-1],v="";if(a[o].match(/^\s*$/)&&!d?(v=a[o],delete a[o]):(this.options.trim&&(a[o]=a[o].trim()),this.options.normalize&&(a[o]=a[o].replace(/\s{2,}/g," ").trim()),a[o]=this.options.valueProcessors?Fe(this.options.valueProcessors,a[o],u):a[o],Object.keys(a).length===1&&o in a&&!this.EXPLICIT_CHARKEY&&(a=a[o])),mn(a)&&(typeof this.options.emptyTag=="function"?a=this.options.emptyTag():a=this.options.emptyTag!==""?this.options.emptyTag:v),this.options.validator){let g=`/${t.map(p=>p["#name"]).concat(u).join("/")}`;(()=>{try{a=this.options.validator(g,m&&m[u],a)}catch(p){this.emit("error",p)}})()}if(this.options.explicitChildren&&!this.options.mergeAttrs&&typeof a=="object")if(this.options.preserveChildrenOrder){if(m){m[this.options.childkey]=m[this.options.childkey]||[];let g={};Object.keys(a).forEach(p=>{de(g,p,a[p])}),m[this.options.childkey].push(g),delete a["#name"],Object.keys(a).length===1&&o in a&&!this.EXPLICIT_CHARKEY&&(a=a[o])}}else{let g={};this.options.attrkey in a&&(g[this.options.attrkey]=a[this.options.attrkey],delete a[this.options.attrkey]),!this.options.charsAsChildren&&this.options.charkey in a&&(g[this.options.charkey]=a[this.options.charkey],delete a[this.options.charkey]),Object.getOwnPropertyNames(a).length>0&&(g[this.options.childkey]=a),a=g}if(t.length>0)this.assignOrPush(m,u,a);else{if(this.options.explicitRoot){let g=a,p={};de(p,u,g),a=p}this.resultObject=a,this.ended=!0,this.emit("end",this.resultObject)}};let l=a=>{let u=t[t.length-1];if(u&&(u[o]+=a,this.options.explicitChildren&&this.options.preserveChildrenOrder&&this.options.charsAsChildren&&(this.options.includeWhiteChars||a.replace(/\\n/g,"").trim()!==""))){u[this.options.childkey]=u[this.options.childkey]||[];let d={"#name":"__text__"};d[o]=a,this.options.normalize&&(d[o]=d[o].replace(/\s{2,}/g," ").trim()),u[this.options.childkey].push(d)}return u};this.saxParser.ontext=l,this.saxParser.oncdata=a=>{let u=l(a);u&&(u.cdata=!0)}}),Z(this,"parseString",(t,s)=>{s&&typeof s=="function"&&(this.on("end",o=>{this.reset(),s(null,o)}),this.on("error",o=>{this.reset(),s(o)}));try{return t=t.toString(),t.trim()===""?(this.emit("end",null),!0):(t=vn(t),this.options.async?(this.remaining=t,setTimeout(this.processAsync,0),this.saxParser):this.saxParser.write(t).close())}catch(o){if(!this.errThrown&&!this.ended)this.emit("error",o),this.errThrown=!0;else if(this.ended)throw o}}),Z(this,"parseStringPromise",t=>new Promise((s,o)=>{this.parseString(t,(l,a)=>{l?o(l):s(a)})})),this.options={},Object.keys(bt["0.2"]).forEach(t=>{this.options[t]=b Line truncated
/*! Bundled license information:
sax/lib/sax.js:
(*! http://mths.be/fromcodepoint v0.1.0 by @mathias *)
*/
+22
View File
@@ -0,0 +1,22 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2023 Mozilla Foundation
*
* 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.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/
!function webpackUniversalModuleDefinition(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=t.pdfjsLib=e():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf",[],(()=>t.pdfjsLib=e())):"object"==typeof exports?exports["pdfjs-dist/build/pdf"]=t.pdfjsLib=e():t["pdfjs-dist/build/pdf"]=t.pdfjsLib=e()}(globalThis,(()=>(()=>{"use strict";var __webpack_modules__=[,(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.VerbosityLevel=e.Util=e.UnknownErrorException=e.UnexpectedResponseException=e.TextRenderingMode=e.RenderingIntentFlag=e.PromiseCapability=e.PermissionFlag=e.PasswordResponses=e.PasswordException=e.PageActionEventType=e.OPS=e.MissingPDFException=e.MAX_IMAGE_SIZE_TO_CACHE=e.LINE_FACTOR=e.LINE_DESCENT_FACTOR=e.InvalidPDFException=e.ImageKind=e.IDENTITY_MATRIX=e.FormatError=e.FeatureTest=e.FONT_IDENTITY_MATRIX=e.DocumentActionEventType=e.CMapCompressionType=e.BaseException=e.BASELINE_FACTOR=e.AnnotationType=e.AnnotationReplyType=e.AnnotationPrefix=e.AnnotationMode=e.AnnotationFlag=e.AnnotationFieldFlag=e.AnnotationEditorType=e.AnnotationEditorPrefix=e.AnnotationEditorParamsType=e.AnnotationBorderStyleType=e.AnnotationActionEventType=e.AbortException=void 0;e.assert=function assert(t,e){t||unreachable(e)};e.bytesToString=bytesToString;e.createValidAbsoluteUrl=function createValidAbsoluteUrl(t,e=null,i=null){if(!t)return null;try{if(i&&"string"==typeof t){if(i.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e?.length>=2&&(t=`http://${t}`)}if(i.tryConvertEncoding)try{t=stringToUTF8String(t)}catch{}}const s=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){switch(t?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(s))return s}catch{}return null};e.getModificationDate=function getModificationDate(t=new Date){return[t.getUTCFullYear().toString(),(t.getUTCMonth()+1).toString().padStart(2,"0"),t.getUTCDate().toString().padStart(2,"0"),t.getUTCHours().toString().padStart(2,"0"),t.getUTCMinutes().toString().padStart(2,"0"),t.getUTCSeconds().toString().padStart(2,"0")].join("")};e.getUuid=function getUuid(){if("undefined"!=typeof crypto&&"function"==typeof crypto?.randomUUID)return crypto.randomUUID();const t=new Uint8Array(32);if("undefined"!=typeof crypto&&"function"==typeof crypto?.getRandomValues)crypto.getRandomValues(t);else for(let e=0;e<32;e++)t[e]=Math.floor(255*Math.random());return bytesToString(t)};e.getVerbosityLevel=function getVerbosityLevel(){return n};e.info=function info(t){n>=s.INFOS&&console.log(`Info: ${t}`)};e.isArrayBuffer=function isArrayBuffer(t){return"object"==typeof t&&void 0!==t?.byteLength};e.isArrayEqual=function isArrayEqual(t,e){if(t.length!==e.length)return!1;for(let i=0,s=t.length;i<s;i++)if(t[i]!==e[i])return!1;return!0};e.isNodeJS=void 0;e.normalizeUnicode=function normalizeUnicode(t){if(!l){l=/([\u00a0\u00b5\u037e\u0eb3\u2000-\u200a\u202f\u2126\ufb00-\ufb04\ufb06\ufb20-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufba1\ufba4-\ufba9\ufbae-\ufbb1\ufbd3-\ufbdc\ufbde-\ufbe7\ufbea-\ufbf8\ufbfc-\ufbfd\ufc00-\ufc5d\ufc64-\ufcf1\ufcf5-\ufd3d\ufd88\ufdf4\ufdfa-\ufdfb\ufe71\ufe77\ufe79\ufe7b\ufe7d]+)|(\ufb05+)/gu;h=new Map([["ſt","ſt"]])}return t.replaceAll(l,((t,e,i)=>e?e.normalize("NFKC"):h.get(i)))};e.objectFromMap=function objectFromMap(t){const e=Object.create(null);for(const[i,s]of t)e[i]=s;return e};e.objectSize=function objectSize(t){return Object.keys(t).length};e.setVerbosityLevel=function setVerbosityLevel(t){Number.isInteger(t)&&(n=t)};e.shadow=shadow;e.string32=function string32(t){return String.fromCharCode(t>>24&255,t>>16&255,t>>8&255,255&t)};e.stringToBytes=stringToBytes;e.stringToPDFString=function stringToPDFString(t){if(t[0]>="ï"){let e;"þ"===t[0]&&"ÿ"===t[1]?e="utf-16be":"ÿ"===t[0]&&"þ"===t[1]?e="utf-16le":"ï"===t[0]&&"»"===t[1]&&"¿"===t[2]&&(e="utf-8");if(e)try{const i=new TextDecoder(e,{fatal:!0}),s=stringToBytes(t);return i.decode(s)}catch(t){warn(`stringToPDFString: "${t}".`)}}const e=[];for(let i=0,s=t.length;i<s;i++){const s=o[t.charCodeAt(i)];e.push(s?String.fromCharCode(s):t.charAt(i))}return e.join("")};e.stringToUTF8String=stringToUTF8String;e.unreachable=unreachable;e.utf8StringToString=function utf8StringToString(t){return unescape(encodeURIComponent(t))};e.warn=warn;const i=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type);e.isNodeJS=i;e.IDENTITY_MATRIX=[1,0,0,1,0,0];e.FONT_IDENTITY_MATRIX=[.001,0,0,.001,0,0];e.MAX_IMAGE_SIZE_TO_CACHE=1e7;e.LINE_FACTOR=1.35;e.LINE_DESCENT_FACTOR=.35;e.BASELINE_FACTOR=.25925925925925924;e.RenderingIntentFlag={ANY:1,DISPLAY:2,PRINT:4,SAVE:8,ANNOTATIONS_FORMS:16,ANNOTATIONS_STORAGE:32,ANNOTATIONS_DISABLE:64,OPLIST:256};e.AnnotationMode={DISABLE:0,ENABLE:1,ENABLE_FORMS:2,ENABLE_STORAGE:3};e.AnnotationEditorPrefix="pdfjs_internal_editor_";e.AnnotationEditorType={DISABLE:-1,NONE:0,FREETEXT:3,STLine truncated
+22
View File
@@ -0,0 +1,22 @@
/**
* @licstart The following is the entire license notice for the
* JavaScript code in this page
*
* Copyright 2023 Mozilla Foundation
*
* 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.
*
* @licend The above is the entire license notice for the
* JavaScript code in this page
*/
!function webpackUniversalModuleDefinition(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=e.pdfjsWorker=t():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf.worker",[],(()=>e.pdfjsWorker=t())):"object"==typeof exports?exports["pdfjs-dist/build/pdf.worker"]=e.pdfjsWorker=t():e["pdfjs-dist/build/pdf.worker"]=e.pdfjsWorker=t()}(globalThis,(()=>(()=>{"use strict";var e=[,(e,t,a)=>{Object.defineProperty(t,"__esModule",{value:!0});t.WorkerTask=t.WorkerMessageHandler=void 0;var r=a(2),n=a(3),i=a(4),s=a(6),o=a(10),c=a(68),l=a(73),h=a(104),u=a(105),d=a(72);class WorkerTask{constructor(e){this.name=e;this.terminated=!1;this._capability=new r.PromiseCapability}get finished(){return this._capability.promise}finish(){this._capability.resolve()}terminate(){this.terminated=!0}ensureNotTerminated(){if(this.terminated)throw new Error("Worker task was terminated")}}t.WorkerTask=WorkerTask;class WorkerMessageHandler{static setup(e,t){let a=!1;e.on("test",(function(t){if(!a){a=!0;e.send("test",t instanceof Uint8Array)}}));e.on("configure",(function(e){(0,r.setVerbosityLevel)(e.verbosity)}));e.on("GetDocRequest",(function(e){return WorkerMessageHandler.createDocumentHandler(e,t)}))}static createDocumentHandler(e,t){let a,f=!1,g=null;const p=new Set,m=(0,r.getVerbosityLevel)(),{docId:b,apiVersion:y}=e,w="3.11.174";if(y!==w)throw new Error(`The API version "${y}" does not match the Worker version "${w}".`);const S=[];for(const e in[])S.push(e);if(S.length)throw new Error("The `Array.prototype` contains unexpected enumerable properties: "+S.join(", ")+"; thus breaking e.g. `for...in` iteration of `Array`s.");const x=b+"_worker";let C=new h.MessageHandler(x,b,t);function ensureNotTerminated(){if(f)throw new Error("Worker was terminated")}function startWorkerTask(e){p.add(e)}function finishWorkerTask(e){e.finish();p.delete(e)}async function loadDocument(e){await a.ensureDoc("checkHeader");await a.ensureDoc("parseStartXRef");await a.ensureDoc("parse",[e]);await a.ensureDoc("checkFirstPage",[e]);await a.ensureDoc("checkLastPage",[e]);const t=await a.ensureDoc("isPureXfa");if(t){const e=new WorkerTask("loadXfaFonts");startWorkerTask(e);await Promise.all([a.loadXfaFonts(C,e).catch((e=>{})).then((()=>finishWorkerTask(e))),a.loadXfaImages()])}const[r,n]=await Promise.all([a.ensureDoc("numPages"),a.ensureDoc("fingerprints")]);return{numPages:r,fingerprints:n,htmlForXfa:t?await a.ensureDoc("htmlForXfa"):null}}function getPdfManager({data:e,password:t,disableAutoFetch:a,rangeChunkSize:i,length:o,docBaseUrl:c,enableXfa:l,evaluatorOptions:h}){const d={source:null,disableAutoFetch:a,docBaseUrl:c,docId:b,enableXfa:l,evaluatorOptions:h,handler:C,length:o,password:t,rangeChunkSize:i},f=new r.PromiseCapability;let p;if(e){try{d.source=e;p=new s.LocalPdfManager(d);f.resolve(p)}catch(e){f.reject(e)}return f.promise}let m,y=[];try{m=new u.PDFWorkerStream(C)}catch(e){f.reject(e);return f.promise}const w=m.getFullReader();w.headersReady.then((function(){if(w.isRangeSupported){d.source=m;d.length=w.contentLength;d.disableAutoFetch||=w.isStreamingSupported;p=new s.NetworkPdfManager(d);for(const e of y)p.sendProgressiveData(e);y=[];f.resolve(p);g=null}})).catch((function(e){f.reject(e);g=null}));let S=0;new Promise((function(e,t){const readChunk=function({value:e,done:a}){try{ensureNotTerminated();if(a){p||function(){const e=(0,n.arrayBuffersToBytes)(y);o&&e.length!==o&&(0,r.warn)("reported HTTP length is different from actual");try{d.source=e;p=new s.LocalPdfManager(d);f.resolve(p)}catch(e){f.reject(e)}y=[]}();g=null;return}S+=e.byteLength;w.isStreamingSupported||C.send("DocProgress",{loaded:S,total:Math.max(S,w.contentLength||0)});p?p.sendProgressiveData(e):y.push(e);w.read().then(readChunk,t)}catch(e){t(e)}};w.read().then(readChunk,t)})).catch((function(e){f.reject(e);g=null}));g=function(e){m.cancelAllRequests(e)};return f.promise}C.on("GetPage",(function(e){return a.getPage(e.pageIndex).then((function(e){return Promise.all([a.ensure(e,"rotate"),a.ensure(e,"ref"),a.ensure(e,"userUnit"),a.ensure(e,"view")]).then((function([e,t,a,r]){return{rotate:e,ref:t,userUnit:a,view:r}}))}))}));C.on("GetPageIndex",(function(e){const t=i.Ref.get(e.num,e.gen);return a.ensureCatalog("getPageIndex",[t])}));C.on("GetDestinations",(function(e){return a.ensureCatalog("destinations")}));C.on("GetDestination",(function(e){return a.ensureCatalog("getDestination",[e.id])}));C.on("GetPageLabels",(function(e){return a.ensureCatalog("pageLabels")}));C.on("GetPageLayout",(function(e){return a.ensureCatalog("pageLayout")}));C.on("GetPageMode",(function(e){return a.ensureCatalog("pageMode")}));C.on("GetViewerPreferences",(function(e){return a.ensureCatalog("viewerPreferences")}));C.on("GetOpenAction",(function(e){return a.ensureCatalog("openAction")}));C.on("GetAttachments",(function(e){return a.ensureCatalog("attachments")}));C.on("GetDocJSActions",(function(e){return a.ensureCatalog("jsActions")}));C.on("GetPageJSActions",(function({pageInLine truncated
+232 -47
View File
@@ -43,46 +43,111 @@ async function viewVideo(args = []) {
}
// ---- Home: Continue Watching, then a library at a time
const vdCard = (m, wide) => {
const vdLeft = m => m.viewOffset && m.duration ? Math.max(1, Math.round((m.duration - m.viewOffset) / 60000)) : 0;
const vdCard = (m, wide, deck) => {
const href = m.type === 'episode' && !wide ? `#/video/item/${m.ratingKey}` : `#/video/item/${m.ratingKey}`;
const pct = vdPct(m), seen = m.viewCount > 0 && !m.viewOffset;
const thumb = wide ? (m.type === 'episode' ? m.thumb : m.art || m.thumb) : (m.parentThumb && m.type === 'season' ? m.thumb || m.parentThumb : m.thumb);
return `<a class="vcard${wide ? ' wide' : ''}" href="${href}" data-q="${esc((m.title + ' ' + (m.grandparentTitle || '')).toLowerCase())}">
const html = `<a class="vcard${wide ? ' wide' : ''}" data-rk="${m.ratingKey}" data-type="${m.type}" data-show="${m.grandparentRatingKey || ''}" data-seen="${seen ? 1 : 0}" href="${href}" data-q="${esc((m.title + ' ' + (m.grandparentTitle || '')).toLowerCase())}">
<span class="vimg" style="aspect-ratio:${wide ? '16/9' : '2/3'}">${thumb ? `<img loading="lazy" alt="" src="${vdArt(thumb, wide ? 400 : 300, wide ? 225 : 450)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}${seen ? `<em class="vseen" title="Watched">${icon('check')}</em>` : ''}${pct ? `<i class="vprog"><b style="width:${pct}%"></b></i>` : ''}</span>
<b>${esc(m.type === 'episode' && wide ? m.title : m.title)}</b><small>${m.type === 'episode' ? vdEp(m) : esc([m.year, m.type === 'show' && m.leafCount ? (m.leafCount - (m.viewedLeafCount || 0)) + ' unwatched' : ''].filter(Boolean).join(' · '))}</small></a>`;
<b>${esc(m.type === 'episode' && wide ? m.title : m.title)}</b><small>${m.type === 'episode' ? vdEp(m) + (vdLeft(m) ? ' · ' + vdLeft(m) + ' min left' : '') : esc([m.year, vdLeft(m) ? vdLeft(m) + ' min left' : '', m.type === 'show' && m.leafCount ? (m.leafCount - (m.viewedLeafCount || 0)) + ' unwatched' : ''].filter(Boolean).join(' · '))}</small></a>`;
return deck ? `<div class="vdeckc">${html}<button class="vx" data-rm="${m.ratingKey}" aria-label="Remove from Continue watching" title="Remove from Continue watching">${icon('x')}</button></div>` : html;
};
// ---- Watch later: a normal Plex video playlist called "Watch later" (so it follows the Plex person you use, and shows in Plex's own apps too)
const wl = { id: '' };
async function wlItems() {
if (!wl.id) { const p = ((await px.get('/playlists?playlistType=video')).Metadata || []).find(x => x.title === 'Watch later' && !x.smart); if (!p) return []; wl.id = p.ratingKey; }
return (await px.get(`/playlists/${wl.id}/items`)).Metadata || [];
}
async function wlAdd(rk) {
const uri = await plx.uri([rk]);
if (!wl.id) { await wlItems(); }
if (wl.id) return plx.call('PUT', `/playlists/${wl.id}/items?uri=${encodeURIComponent(uri)}`);
const r = await plx.call('POST', `/playlists?type=video&smart=0&title=${encodeURIComponent('Watch later')}&uri=${encodeURIComponent(uri)}`); wl.id = (((r.MediaContainer || {}).Metadata || [])[0] || {}).ratingKey || '';
}
async function wlRemove(rk) { const it = (await wlItems()).find(x => x.ratingKey === String(rk)); if (it) await plx.call('DELETE', `/playlists/${wl.id}/items/${it.playlistItemID}`); }
/** Take an item off Continue watching. Newer Plex servers can just hide it; an older one cannot, so it is marked watched instead (and we say so). */
async function vdRemove(m, quiet) {
try { const r = await fetch('/plex/actions/removeFromContinueWatching?ratingKey=' + encodeURIComponent(m.ratingKey), { method: 'PUT' }); if (!r.ok) throw new Error(r.status); if (!quiet) toast('Removed from Continue watching'); }
catch { try { await px.get(`/:/scrobble?key=${m.ratingKey}&identifier=com.plexapp.plugins.library`); if (!quiet) toast('Marked watched (this Plex server cannot just hide it)'); } catch (e) { toast(e.message); } }
vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey);
try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {}
}
async function videoHome(st, libKey) {
const v = $('#view');
const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show');
if (!secs.length) { v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">This Plex server has no movie or TV libraries (or your Plex user cannot see them).</p></div>'; return; }
const cur = secs.find(s => s.key === (libKey || store.get('video_lib'))) || secs[0];
store.set('video_lib', cur.key);
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1>${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 movies and shows" autocomplete="off" aria-label="Search"><div id="vsr"></div>
<div id="vdeck"></div><h2 id="vlt">${esc(cur.title)}</h2><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
// Continue watching and the library load side by side
px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=12').then(mc => {
const items = (mc.Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode'); if (!items.length || !$('#vdeck')) return;
$('#vdeck').innerHTML = `<h2>Continue watching</h2><div class="vrow">${items.map(m => vdCard(m, true)).join('')}</div>`;
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="vwlr"></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><label class="vunw"><input type="checkbox" id="vunwc"> Unwatched only</label></div><div id="vgrid" class="vgrid"><p class="empty">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;
const vcGet = k => { try { return JSON.parse(localStorage.getItem(vcK(k))); } catch { return null; } };
const vcPut = (k, items) => { try { localStorage.setItem(vcK(k), JSON.stringify(items.map(m => ({ ratingKey: m.ratingKey, type: m.type, title: m.title, year: m.year, thumb: m.thumb, art: m.art, parentThumb: m.parentThumb, grandparentTitle: m.grandparentTitle, grandparentRatingKey: m.grandparentRatingKey, parentIndex: m.parentIndex, index: m.index, viewOffset: m.viewOffset, viewCount: m.viewCount, duration: m.duration, leafCount: m.leafCount, viewedLeafCount: m.viewedLeafCount })))); } catch {} };
const rowOf = (id, title, items, deck) => { const el = $(id); if (!el) return; el.innerHTML = items.length ? `<h2>${title}${deck ? '<button class="btn ghost vclear" id="vclr" style="min-height:32px;margin-left:10px">Clear all</button>' : ''}</h2><div class="vrow">${items.map(m => vdCard(m, true, deck)).join('')}</div>` : ''; };
const drawDeck = items => { vd.deck = items; rowOf('#vdeck', 'Continue watching', items, true);
const h = items[0], hb = $('#vhero'); // the most recent one gets a banner with a Resume button
if (hb) hb.innerHTML = h ? `<a class="vban" href="#/video/watch/${h.ratingKey}" aria-label="Resume ${esc(h.title)}">${(h.art || h.thumb) ? `<img alt="" src="${vdArt(h.art || h.thumb, 900, 380)}" onerror="this.remove()">` : ''}<span class="vbant"><small>${vdLeft(h) ? 'Pick up where you left off · ' + vdLeft(h) + ' min left' : 'Up next'}</small><b>${esc(h.type === 'episode' ? h.grandparentTitle : h.title)}</b>${h.type === 'episode' ? `<small>${vdEp(h).replace(/^.*· /, '')} · ${esc(h.title)}</small>` : ''}<em class="btn">${icon('play')}Resume</em></span></a>` : ''; if ($('#vclr')) $('#vclr').onclick = () => confirmSheet('Clear Continue watching?', 'Takes everything off this row. Your library and watch history stay as they are.', 'Clear all', async () => { for (const m of vd.deck.slice()) await vdRemove(m, true); loadDeck(); }); };
const loadDeck = () => px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=16').then(mc => {
const items = (mc.Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode'); vcPut('deck', items); if ($('#vdeck')) drawDeck(items);
}).catch(() => {});
const type = cur.type === 'movie' ? 1 : 2, size = 120; let start = 0, total = 0, all = [];
const cd = vcGet('deck'); if (cd) drawDeck(cd); loadDeck();
const type = cur.type === 'movie' ? 1 : 2, size = 120;
for (const [id, key, title, q] of [['#vrecent', 'recent:' + cur.key, 'Recently added', 'sort=addedAt:desc'], ['#vunw', 'unw:' + cur.key, 'Unwatched', 'unwatched=1&sort=addedAt:desc']]) {
const c = vcGet(key); if (c) { const el = $(id); if (el) el.innerHTML = `<h2>${title}</h2><div class="vrow">${c.map(m => vdCard(m, false)).join('')}</div>`; }
px.get(`/library/sections/${cur.key}/all?type=${type}&${q}&X-Plex-Container-Start=0&X-Plex-Container-Size=14`).then(mc => {
const items = mc.Metadata || []; vcPut(key, items); const el = $(id); if (el) el.innerHTML = items.length ? `<h2>${title}</h2><div class="vrow">${items.map(m => vdCard(m, false)).join('')}</div>` : '';
}).catch(() => {});
}
wlItems().then(items => { const el = $('#vwlr'); if (el) el.innerHTML = items.length ? `<h2>Watch later</h2><div class="vrow">${items.slice(0, 20).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `<h2>Collections</h2><div class="vrow">${items.slice(0, 30).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
// sort and filter the library below
const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = '';
$('#vsort').value = fs; $('#vunwc').checked = fu;
px.get(`/library/sections/${cur.key}/decade`).then(mc => { const g = $('#vdec'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).sort((a, b) => b.key - a.key).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
px.get(`/library/sections/${cur.key}/contentRating`).then(mc => { const g = $('#vcr'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
px.get(`/library/sections/${cur.key}/genre`).then(mc => { const g = $('#vgen'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
let start = 0, total = 0, all = [], gen = 0;
const load = async () => {
const mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=titleSort&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`);
if (!$('#vgrid')) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items);
$('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .empty')?.remove();
const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vunwc').checked || $('#vwatch').value === '1' ? '&unwatched=1' : ''}${$('#vwatch').value === '0' ? '&unwatched=0' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}${$('#vdec').value ? '&decade=' + vdEnc($('#vdec').value) : ''}${$('#vcr').value ? '&contentRating=' + vdEnc($('#vcr').value) : ''}${vd.person ? '&' + vd.person.filter : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`);
if (!$('#vgrid') || my !== gen) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items);
$('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .vsk') && $('#vgrid').querySelectorAll('.vsk').forEach(x => x.remove());
$('#vmore').innerHTML = start < total ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="vmb">Show more (${total - start} left)</button></div>` : '';
if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message));
if (!all.length) $('#vgrid').innerHTML = '<p class="empty">Nothing in this library.</p>';
if (!all.length) { $('#vgrid').innerHTML = `<div class="vempty">${icon('film')}<p>Nothing matches.</p>${$('#vunwc').checked || genre ? '<button class="btn ghost" id="vfc">Clear filters</button>' : ''}</div>`; if ($('#vfc')) $('#vfc').onclick = () => { $('#vunwc').checked = false; store.set('video_unw', '0'); genre = ''; $('#vgen').value = ''; reload(); }; }
};
$('#vgrid').innerHTML = ''; load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; });
const reload = () => { gen++; start = 0; total = 0; all = []; $('#vmore').innerHTML = ''; $('#vgrid').innerHTML = '<i class="vsk"></i>'.repeat(12); load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; }); };
$('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vunwc').onchange = () => { store.set('video_unw', $('#vunwc').checked ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); }; for (const id of ['#vdec', '#vcr', '#vwatch']) $(id).onchange = () => reload();
reload();
// press and hold (or right-click) a card: quick actions
const vv = $('#view .wrap') || $('#view'); let hold = 0, held = false; // (the page's own wrapper, new on every draw, so the handlers never pile up)
const menu = card => {
const rk = card.dataset.rk, seen = card.dataset.seen === '1', acts = [{ label: seen ? 'Mark unwatched' : 'Mark watched', icon: 'check', run: async () => { try { await px.get(`/:/${seen ? 'unscrobble' : 'scrobble'}?key=${rk}&identifier=com.plexapp.plugins.library`); toast(seen ? 'Marked unwatched' : 'Marked watched'); route(); } catch (e) { toast(e.message); } } }];
if (card.dataset.show) acts.unshift({ label: 'Go to the show', icon: 'film', run: () => { location.hash = '#/video/item/' + card.dataset.show; } });
if (card.closest('.vdeckc')) acts.push({ label: 'Remove from Continue watching', icon: 'x', run: async () => { await vdRemove((vd.deck || []).find(m => m.ratingKey === rk) || { ratingKey: rk, type: card.dataset.type }); loadDeck(); } });
actionSheet(card.querySelector('b').textContent, '', acts);
};
vv.addEventListener('contextmenu', e => { const c = e.target.closest('.vcard'); if (c && c.dataset.rk) { e.preventDefault(); menu(c); } });
vv.addEventListener('touchstart', e => { const c = e.target.closest('.vcard'); held = false; clearTimeout(hold); if (c && c.dataset.rk) hold = setTimeout(() => { held = true; menu(c); }, 550); }, { passive: true });
for (const ev of ['touchend', 'touchmove', 'touchcancel']) vv.addEventListener(ev, () => clearTimeout(hold), { passive: true });
vv.addEventListener('click', e => { if (held) { held = false; e.preventDefault(); e.stopPropagation(); return; } const x = e.target.closest('.vx'); if (x) { e.preventDefault(); const rk = x.dataset.rm; vdRemove((vd.deck || []).find(m => m.ratingKey === rk) || { ratingKey: rk, type: 'movie' }).then(loadDeck); } }, true);
const personBar = () => { const t = $('#vlt'); if (t) t.innerHTML = vd.person ? `${esc(vd.person.name)} <button class="btn ghost" id="vpc" style="min-height:32px;margin-left:8px">Show everything</button>` : esc(cur.title); const c = $('#vpc'); if (c) c.onclick = () => { vd.person = null; personBar(); reload(); }; };
vd.person = null;
let t; $('#vq').oninput = e => {
clearTimeout(t); const q = e.target.value.trim(); if (q.length < 2) { $('#vsr').innerHTML = ''; return; }
t = setTimeout(async () => {
try {
const mc = await px.get(`/hubs/search?query=${vdEnc(q)}&limit=10`), rows = [];
for (const h of mc.Hub || []) if (['movie', 'show', 'episode'].includes(h.type)) for (const m of h.Metadata || []) rows.push(m);
const mc = await px.get(`/hubs/search?query=${vdEnc(q)}&limit=24`), groups = [];
for (const h of mc.Hub || []) { const items = h.Metadata || []; if (!items.length) continue; if (h.type === 'movie') groups.push(['Movies', items, 'card']); else if (h.type === 'show') groups.push(['TV shows', items, 'card']); else if (h.type === 'episode') groups.push(['Episodes', items, 'card']); else if (h.type === 'actor' || h.type === 'director') groups.push([h.type === 'actor' ? 'Actors' : 'Directors', items.filter(m => m.filter), 'person']); }
if (!$('#vsr') || $('#vq').value.trim() !== q) return;
$('#vsr').innerHTML = rows.length ? `<h2>Results</h2><div class="vgrid">${rows.map(m => vdCard(m)).join('')}</div>` : '<p class="empty">Nothing found.</p>';
const order = ['Movies', 'TV shows', 'Actors', 'Directors', 'Episodes'];
groups.sort((x, y) => order.indexOf(x[0]) - order.indexOf(y[0]));
$('#vsr').innerHTML = groups.some(g => g[1].length) ? groups.filter(g => g[1].length).map(([title, items, kind]) => kind === 'card' ? `<h2>${title} <small class="sub">${items.length}</small></h2><div class="vgrid">${items.map(m => vdCard(m)).join('')}</div>` : `<h2>${title}</h2><div class="seg" style="flex-wrap:wrap">${items.map(m => `<a href="#" data-vp="${esc(m.filter)}" data-vn="${esc(m.tag || m.title || '')}">${esc(m.tag || m.title || '')}</a>`).join('')}</div>`).join('') : '<p class="empty">Nothing found. Try fewer words, or part of a name.</p>';
$('#vsr').querySelectorAll('[data-vp]').forEach(a => a.onclick = e => { e.preventDefault(); vd.person = { filter: a.dataset.vp, name: a.dataset.vn }; $('#vq').value = ''; $('#vsr').innerHTML = ''; personBar(); reload(); $('#vlt').scrollIntoView({ behavior: 'smooth' }); });
} catch (err) { if ($('#vsr')) $('#vsr').innerHTML = `<p class="err">${esc(err.message)}</p>`; }
}, 350);
};
@@ -105,10 +170,32 @@ async function videoItem(id) {
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
${m.type !== 'season' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
<p class="vsum">${esc(m.summary || '')}</p>
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
<div id="vkids"></div></div>`;
jfetch('/api/plex/status').then(st => { // Plex only lets its owner edit details, so the button is offered only then
if (st.canEdit && $('#vact') && !$('#vedit')) { $('#vact').insertAdjacentHTML('beforeend', '<button class="btn ghost" id="vedit">Edit</button>'); $('#vedit').onclick = () => videoEdit(m); }
}).catch(() => {});
if (m.type === 'movie' || m.type === 'show') px.get(`/library/metadata/${id}/extras`).catch(() => ({})).then(mc => { // Trailer: one from Plex when it has any, otherwise a YouTube search
const box = $('#vact'); if (!box || $('#vtr')) return;
const ex = (mc.Metadata || []).filter(x => (x.Media || []).length), tr = ex.filter(x => (x.subtype || '') === 'trailer'), list = tr.length ? tr : ex;
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vtr">${icon('play')}Trailer</button>`);
$('#vtr').onclick = () => {
const yt = () => window.open('https://www.youtube.com/results?search_query=' + vdEnc(`${m.title} ${m.year || ''} ${m.type === 'show' ? 'tv series' : 'movie'} official trailer`), '_blank', 'noopener');
if (!list.length) return yt();
const go = x => { store.set('video_back', location.hash); location.hash = '#/video/watch/' + x.ratingKey; };
if (list.length === 1) return go(list[0]);
actionSheet('Trailers', m.title, [...list.slice(0, 8).map(x => ({ label: x.title || 'Trailer', icon: 'play', run: () => go(x) })), { label: 'Search YouTube instead', icon: 'search', run: yt }]);
};
});
if (m.type === 'movie' || m.type === 'show') wlItems().then(list => { // Watch later
const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return;
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vwl">${on ? '✓ On Watch later' : '+ Watch later'}</button>`);
$('#vwl').onclick = async () => { try { on ? await wlRemove(id) : await wlAdd(id); toast(on ? 'Removed from Watch later' : 'Added to Watch later'); route(); } catch (e) { toast(e.message); } };
}).catch(() => {});
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); location.hash = `#/video/watch/${id}`; };
if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };
@@ -120,10 +207,10 @@ async function videoItem(id) {
const e = (mc.Metadata || [])[0]; if (!e || !$('#vact')) return;
$('#vact').insertAdjacentHTML('afterbegin', `<button class="btn" id="vfirst">${icon('play')}Play S${e.parentIndex}E${e.index}</button>`); $('#vfirst').onclick = () => { location.hash = `#/video/watch/${e.ratingKey}`; };
}).catch(() => {});
if (m.type === 'show' || m.type === 'season') {
if (m.type === 'show' || m.type === 'season' || m.type === 'collection') {
const kids = (await px.get(`/library/metadata/${id}/children`)).Metadata || [];
if (!$('#vkids')) return;
$('#vkids').innerHTML = m.type === 'show' ? `<h2>Seasons</h2><div class="vgrid">${kids.map(k => vdCard(k)).join('')}</div>`
$('#vkids').innerHTML = m.type === 'show' || m.type === 'collection' ? `<h2>${m.type === 'show' ? 'Seasons' : 'In this collection'}</h2><div class="vgrid">${kids.map(k => vdCard(k)).join('')}</div>`
: `<h2>Episodes</h2><div class="list">${kids.map(e => `<a class="row vep" href="#/video/item/${e.ratingKey}"><span class="vimg" style="aspect-ratio:16/9;width:120px;flex:none">${e.thumb ? `<img loading="lazy" alt="" src="${vdArt(e.thumb, 240, 135)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}${e.viewCount > 0 && !e.viewOffset ? `<em class="vseen">${icon('check')}</em>` : ''}${vdPct(e) ? `<i class="vprog"><b style="width:${vdPct(e)}%"></b></i>` : ''}</span><span class="grow"><b>${e.index}. ${esc(e.title)}</b><small style="white-space:normal">${esc(vdTime(e.duration))}${e.summary ? ' · ' + esc(e.summary.slice(0, 110)) + (e.summary.length > 110 ? '…' : '') : ''}</small></span></a>`).join('')}</div>`;
}
}
@@ -141,34 +228,36 @@ const loadHls = () => hlsLib || (hlsLib = new Promise((ok, no) => { if (window.H
let vdHooked = false;
async function videoWatch(id) {
if (vp && vp.id === id && vp.el) { videoMount(); return; } // already playing this one (in the mini player): bring it back
const v = $('#view'), mc = await px.get(`/library/metadata/${id}`), m = (mc.Metadata || [])[0];
const v = $('#view'), mc = await px.get(`/library/metadata/${id}?includeMarkers=1`), m = (mc.Metadata || [])[0];
if (!m || !(m.Media || []).length) { v.innerHTML = '<div class="wrap"><p class="empty">That item has no playable file.</p><div class="bar"><a class="btn ghost" href="#/video">Back</a></div></div>'; return; }
videoStop();
try { pl.a.pause(); } catch {}
if (!vdHooked) { vdHooked = true; pl.a.addEventListener('play', () => { if (vp && vp.el && !vp.el.paused) vp.el.pause(); }); } // one thing at a time: music starting pauses the video
if (!vdHooked) { vdHooked = true; pl.a.addEventListener('play', () => { if (vp) videoStop(); }); } // one thing at a time: music starting ends the video
if (typeof actLog === 'function') actLog('watch', (m.grandparentTitle ? m.grandparentTitle + ': ' : '') + (m.title || ''));
const media = m.Media[0], part = media.Part[0], streams = part.Stream || [], fromStart = store.get('video_from_start') === id;
if (fromStart) store.del('video_from_start');
const el = document.createElement('video'); el.playsInline = true; el.autoplay = true; el.id = 'vv';
el.volume = Math.max(0, Math.min(1, +store.get('video_vol', 1)));
vp = { id, m, media, part, streams, el, base: 0, hls: null, mode: '', q: store.get('video_q', 'auto'), audio: 0, sub: 0, session: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()), pos: fromStart ? 0 : (m.viewOffset || 0) / 1000, last: 0, timer: null, done: false, sibs: null, speed: 1 };
videoBindMedia(el);
vp.marks = (m.Marker || []).filter(x => x.type === 'intro' || x.type === 'credits').map(x => ({ type: x.type, a: x.startTimeOffset / 1000, b: x.endTimeOffset / 1000 }));
vp.timer = setInterval(() => { if (vp && vp.el === el && !el.paused && !el.ended) vdReport('playing'); }, 10000);
if (m.type === 'episode') px.get(`/library/metadata/${m.grandparentRatingKey}/allLeaves`).then(c => { if (vp && vp.id === id) { vp.sibs = c.Metadata || []; vp.sibIdx = vp.sibs.findIndex(x => x.ratingKey === id); vdUi(); } }).catch(() => {});
videoMount(); videoStart(vp.pos); videoMediaSession();
videoBar(); videoMount(); videoStart(vp.pos); videoMediaSession();
}
const vdTitle = m => m.type === 'episode' ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index} · ${m.title}` : m.title;
/** Draw the player page and put the (persistent) video element into it. */
function videoMount() {
if (!vp) return; videoMini(false);
const v = $('#view'), m = vp.m, id = vp.id, ep = m.type === 'episode', title = vdTitle(m), el = vp.el;
document.body.classList.add('onvideo');
document.body.classList.add('onvideo'); // (for a trailer or other extra, Back returns to the page you came from)
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>
<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 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 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')}${b('vset', 'Quality & tracks', 'Quality and tracks', ' txt')}${document.pictureInPictureEnabled ? b('vpip', icon('pip'), 'Picture in picture') : ''}${b('vfs', icon('full'), 'Full screen')}</div></div></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);
@@ -180,9 +269,11 @@ function videoMount() {
$('#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');
@@ -190,7 +281,7 @@ function videoMount() {
let lastTap = 0, lastDbl = 0;
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
stage.addEventListener('pointerup', e => {
if (e.target.closest('.vctl, .vtop, #vnextbox')) return;
if (e.target.closest('.vctl, .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; }
@@ -198,12 +289,16 @@ function videoMount() {
} 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);
if (m.type === 'clip' || m.extraType) { const bk = $('#vback'); if (bk) bk.setAttribute('href', store.get('video_back') || '#/video'); } // a trailer: Back returns to its movie
vdUi(); wake();
}
/** Media events, bound once to the video element (which outlives the player page while the mini player is showing). */
function videoBindMedia(el) {
const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; };
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; vdUi(); });
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && vp.mode === 'hls' && vp.learn && el.currentTime > 0 && !el.seeking) { // Plex's converted stream may count from 0 (what we asked for starts there) or carry on from the real position: tell which, once
const first = el.buffered.length ? el.buffered.start(0) : el.currentTime;
vp.base = vp.learn > 5 ? (first > vp.learn - 10 ? 0 : Math.floor(vp.learn)) : 0; vp.learn = 0; }
if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; vdUi(); vdMarks(); });
el.addEventListener('play', () => { vdUi(); if (vp && vp.wake) vp.wake(); });
el.addEventListener('pause', () => { vdUi(); if (vp && vp.wake) vp.wake(); if (!el.ended) vdReport('paused'); });
el.addEventListener('volumechange', () => { const mu = $('#vmute'); if (mu) mu.innerHTML = icon(el.muted || el.volume === 0 ? 'mute' : 'vol'); });
@@ -220,7 +315,19 @@ function vdUi() {
if (r) { if (!drag) r.value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; const shown = drag ? r.value / 1000 * t : p; $('#vcur').textContent = vdClock(shown); $('#vrem').textContent = '-' + vdClock(Math.max(0, t - shown)); }
const pp = $('#vpp'); if (pp) { pp.innerHTML = icon(el.paused ? 'play' : 'pause'); pp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); }
const pv = $('#vprev'), nx = $('#vnextep'); if (pv) pv.disabled = !vp.sibs || vp.sibIdx <= 0; if (nx) nx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1;
const mp = $('#vmpp'); if (mp) mp.innerHTML = icon(el.paused ? 'play' : 'pause'); const pr = $('#vmprog b'); if (pr) pr.style.width = (t ? Math.min(100, 100 * p / t) : 0) + '%';
const bp = $('#vbpp'); if (bp) { bp.innerHTML = icon(el.paused ? 'play' : 'pause'); bp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); }
const bi = vp.barDrag ? null : $('#vbp'); if (bi) bi.style.width = (t ? Math.min(100, 100 * p / t) : 0) + '%';
const bt = vp.barDrag ? null : $('#vbtm'); if (bt) bt.textContent = vdClock(p) + ' / ' + vdClock(t);
const bpv = $('#vbprev'), bnx = $('#vbnext'); if (bpv) bpv.disabled = !vp.sibs || vp.sibIdx <= 0; if (bnx) bnx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1;
}
/** "Skip intro" / "Skip credits" while the position is inside one of Plex's markers for this title. */
function vdMarks() {
if (!vp || !vp.marks || !vp.marks.length) return;
const p = vp.base + vp.el.currentTime, mk = vp.marks.find(x => p >= x.a - 1 && p < x.b - 1), st = $('#vstage'); let b = $('#vmark');
if (!mk || !st) { if (b) b.remove(); return; }
if (!b) { b = document.createElement('button'); b.id = 'vmark'; b.className = 'btn vmark'; st.appendChild(b); }
b.textContent = mk.type === 'intro' ? 'Skip intro' : 'Skip credits';
b.onclick = e => { e.stopPropagation(); if (mk.type === 'credits' && vp.m.duration && mk.b >= vp.m.duration / 1000 - 3) { videoSeek(Math.max(0, vp.m.duration / 1000 - 0.5)); } else videoSeek(mk.b); b.remove(); };
}
/** The mini player: the video keeps playing in a small window while you use other tabs. */
function videoMini(on) {
@@ -228,19 +335,40 @@ function videoMini(on) {
if (!on) { if (box) box.hidden = true; return; }
if (!vp || !vp.el) return;
if (!box) {
box = document.createElement('div'); box.id = 'vmini'; box.setAttribute('role', 'region'); box.setAttribute('aria-label', 'Video mini player');
box.innerHTML = `<div class="vmslot"></div><div class="vmover"><b class="vmtitle"></b><div class="vmbtns"><button id="vmpp" aria-label="Play or pause"></button><button id="vmx" aria-label="Back to the player">${icon('full')}</button><button id="vmc" aria-label="Close">${icon('x')}</button></div></div><i class="vmprog" id="vmprog"><b></b></i>`;
document.body.appendChild(box);
box.querySelector('#vmpp').onclick = e => { e.stopPropagation(); const el = vp && vp.el; if (el) el.paused ? el.play().catch(() => {}) : el.pause(); };
box.querySelector('#vmc').onclick = e => { e.stopPropagation(); videoStop(); };
const open = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
box.querySelector('#vmx').onclick = e => { e.stopPropagation(); open(); }; box.querySelector('.vmslot').onclick = open;
box = document.createElement('div'); box.id = 'vmini'; box.setAttribute('role', 'region'); box.setAttribute('aria-label', 'Video (click to open the player)');
box.innerHTML = '<div class="vmslot" title="Open the player"></div>';
document.body.appendChild(box); box.querySelector('.vmslot').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
}
box.hidden = false; box.querySelector('.vmtitle').textContent = vdTitle(vp.m);
vp.el.controls = false; box.querySelector('.vmslot').appendChild(vp.el); document.body.classList.remove('onvideo'); document.body.classList.remove('vfullwin');
box.hidden = false; vp.el.controls = false; box.querySelector('.vmslot').appendChild(vp.el); document.body.classList.remove('onvideo'); document.body.classList.remove('vfullwin');
const st = document.querySelector('.vstage.vfull'); if (st) st.classList.remove('vfull');
vdUi();
}
/** The bottom bar (the same one Music uses) shows the video's controls on every tab: poster, title, time, previous and next episode, back and forward 10 s, play/pause, expand, close. */
const videoBarOn = () => !!(vp && vp.el);
function videoBar() {
const el = $('#player'); if (!el || !vp) return; const m = vp.m, ep = m.type === 'episode';
el.hidden = false;
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="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);
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)
const sk = q('#vbseek'), frac = e => { const r = sk.getBoundingClientRect(); return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)); }, total = () => (vp.m.duration || 0) / 1000 || vp.el.duration || 0;
const preview = f => { const t = total(); q('#vbp').style.width = (f * 100) + '%'; q('#vbtm').textContent = vdClock(f * t) + ' / ' + vdClock(t); };
sk.onpointerdown = e => {
if (!vp) return; sk.setPointerCapture(e.pointerId); vp.barDrag = true; let f = frac(e); preview(f);
sk.onpointermove = ev => { f = frac(ev); preview(f); };
sk.onpointerup = ev => { sk.onpointermove = sk.onpointerup = sk.onpointercancel = null; f = frac(ev); vp.barDrag = false; videoSeek(f * total()); vdUi(); };
sk.onpointercancel = () => { sk.onpointermove = sk.onpointerup = sk.onpointercancel = null; vp.barDrag = false; vdUi(); };
};
vdUi();
}
/** Lock-screen and media-key controls. */
function videoMediaSession() {
if (!('mediaSession' in navigator) || !vp) return;
@@ -256,11 +384,15 @@ function videoSeek(abs) {
if (!vp) return; const el = vp.el, t = (vp.m.duration || 0) / 1000;
abs = Math.max(0, t ? Math.min(abs, t - 1) : abs);
if (vp.mode === 'direct') { el.currentTime = abs; return; }
const rel = abs - vp.base;
if (rel >= 0 && isFinite(el.duration) && rel < el.duration - 2) el.currentTime = rel; else videoStart(abs);
const rel = abs - vp.base, sk = el.seekable;
// Only inside what the converted stream really has (its range may begin late when it started part-way); anything else restarts the conversion there.
const inside = sk && sk.length ? rel >= sk.start(0) - 0.5 && rel <= sk.end(sk.length - 1) - 2 : rel >= 0 && isFinite(el.duration) && rel < el.duration - 2;
if (inside) el.currentTime = rel; else videoStart(abs);
}
function videoSkip(d) {
if (!vp) return; videoSeek(vp.base + vp.el.currentTime + d);
if (!vp) return; const to = Math.max(0, vp.base + vp.el.currentTime + d), t = (vp.m.duration || 0) / 1000 || vp.el.duration || 0;
const bp = $('#vbp'), bt = $('#vbtm'); if (t) { if (bp) bp.style.width = Math.min(100, 100 * to / t) + '%'; if (bt) bt.textContent = vdClock(to) + ' / ' + vdClock(t); } // (the bar moves before the video has finished seeking)
videoSeek(to);
if (vp.flash) vp.flash((d < 0 ? '−' : '+') + Math.abs(d) + ' s');
}
/** Previous or next episode (or, for the previous one, back to the start if you are more than 5 seconds in). */
@@ -293,10 +425,10 @@ async function videoStart(sec) {
vp.session = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
const q = QUALITIES.find(x => x[0] === vp.q) || QUALITIES[0], direct = !vp.forceHls && (vp.q === 'original' || (vp.q === 'auto' && canDirect(media) && !vp.audio && !vp.sub));
if (direct) {
vp.mode = 'direct'; vp.base = 0; el.src = '/plex' + part.key; if (sec > 1) el.addEventListener('loadedmetadata', () => { el.currentTime = sec; }, { once: true });
vp.mode = 'direct'; vp.base = 0; vp.learn = 0; el.src = '/plex' + part.key; if (sec > 1) el.addEventListener('loadedmetadata', () => { el.currentTime = sec; }, { once: true });
const t = $('#vmsg'); if (t) t.textContent = '';
} else {
vp.mode = 'hls'; vp.base = sec;
vp.mode = 'hls'; vp.base = sec; vp.learn = sec || 0.001;
const o = q[2] || QUALITIES[0][2], p = new URLSearchParams({ path: '/library/metadata/' + vp.id, mediaIndex: 0, partIndex: 0, protocol: 'hls', fastSeek: 1, directPlay: 0, directStream: 1, subtitleSize: 100, audioBoost: 100, offset: Math.floor(sec), session: vp.session, maxVideoBitrate: o.kbps, 'X-Plex-Platform': 'Chrome' });
if (o.res) p.set('videoResolution', o.res); if (vp.audio) p.set('audioStreamID', vp.audio); if (vp.sub) { p.set('subtitleStreamID', vp.sub); p.set('subtitles', 'burn'); }
const url = '/plex/video/:/transcode/universal/start.m3u8?' + p; const t = $('#vmsg'); if (t) t.textContent = 'Preparing the stream…';
@@ -345,6 +477,7 @@ function videoStop() {
try { if (s.hls) s.hls.destroy(); if (s.el) { s.el.pause(); s.el.removeAttribute('src'); s.el.load(); } } catch {}
fetch('/plex/video/:/transcode/universal/stop?session=' + s.session).catch(() => {});
const mb = document.getElementById('vmini'); if (mb) { mb.hidden = true; mb.querySelector('.vmslot').innerHTML = ''; }
try { renderMini(); } catch {} // the bar goes back to Music (or hides)
}
addEventListener('hashchange', () => { if (vp && !location.hash.startsWith('#/video/watch/')) videoMini(true); });
addEventListener('pagehide', () => { if (vp) { try { navigator.sendBeacon && navigator.sendBeacon('/plex/video/:/transcode/universal/stop?session=' + vp.session); } catch {} vdReport('stopped'); } });
@@ -352,7 +485,7 @@ addEventListener('keydown', e => {
if (!vp || !vp.el || !location.hash.startsWith('#/video/watch/') || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '') && e.target.type !== 'range' || e.metaKey || e.ctrlKey || e.altKey || document.getElementById('sheet')) return;
const el = vp.el, k = e.key; if (vp.wake) vp.wake();
if (e.target.type === 'range' && (k === 'ArrowLeft' || k === 'ArrowRight')) return; // (the sliders use their own arrows)
if (k === ' ' || k === 'k') { e.preventDefault(); el.paused ? el.play() : el.pause(); }
if (k === ' ' || k === 'k') { e.preventDefault(); if (e.target.tagName === 'BUTTON') addEventListener('keyup', ev => ev.preventDefault(), { once: true, capture: true }); el.paused ? el.play() : el.pause(); } // (a focused button would also click on key-up: that is the double toggle)
else if (k === 'ArrowRight' || k === 'l') { e.preventDefault(); videoSkip(k === 'l' || e.shiftKey ? 30 : 10); } else if (k === 'ArrowLeft' || k === 'j') { e.preventDefault(); videoSkip(k === 'j' || e.shiftKey ? -30 : -10); }
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); }
@@ -361,3 +494,55 @@ addEventListener('keydown', e => {
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);
// ---- Edit a title's details the way Plex's Edit dialog does: only what changed is saved, and each saved field is locked so a Plex refresh keeps it.
const VD_TYPE = { movie: 1, show: 2, season: 3, episode: 4 };
async function videoEdit(m) {
const kind = m.type, rich = kind === 'movie' || kind === 'show', genres = (m.Genre || []).map(g => g.tag);
const F = [['title', 'Title'], ['titleSort', 'Sort title'], ['originalTitle', 'Original title'], ['originallyAvailableAt', kind === 'show' ? 'First aired' : 'Release date', 'date'], ['contentRating', 'Content rating'], ...(rich ? [['studio', 'Studio'], ['tagline', 'Tagline']] : []), ['summary', 'Summary', 'area']]
.filter(([k]) => kind !== 'season' || ['title', 'titleSort', 'summary'].includes(k));
const old = k => String(m[k] || ''), dateOf = v => String(v || '').slice(0, 10);
const el = openSheet('Edit ' + (m.title || ''), '', `<div class="sgrp" role="tablist" style="margin-bottom:8px"><a href="#" data-t="d" aria-selected="true" class="on">Details</a><a href="#" data-t="p" aria-selected="false">Poster</a><a href="#" data-t="a" aria-selected="false">Background</a></div>
<form id="vf"><div id="vtab-d">${F.map(([k, l, t]) => `<label for="ve-${k}">${l}</label>${t === 'area' ? `<textarea id="ve-${k}" rows="5">${esc(old(k))}</textarea>` : `<input type="${t || 'text'}" id="ve-${k}" value="${esc(t === 'date' ? dateOf(m[k]) : old(k))}" autocomplete="off">`}`).join('')}
${rich ? `<label for="ve-genre">Genres <small>(separate with commas)</small></label><input type="text" id="ve-genre" value="${esc(genres.join(', '))}" autocomplete="off">` : ''}
<p class="sub" style="margin:10px 0 0">Changed fields are locked in Plex, so scans and refreshes will not put the old values back.</p></div>
<div id="vtab-p" hidden></div><div id="vtab-a" hidden></div>
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="vs" style="flex:1;justify-content:center">Save</button></div><div class="err" id="ve"></div></form>`);
el.querySelector('.sheet').classList.add('wide');
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>';
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>
<label>Add from an image link</label><div class="bar" style="margin-top:4px"><input type="url" class="vurl" placeholder="https://…" autocomplete="off" style="flex:1"><button type="button" class="btn ghost vadd">Add</button></div><div class="err vie"></div>`;
const put = async (url, add) => {
const r = await fetch(`/plex/library/metadata/${m.ratingKey}/${add ? what : what === 'posters' ? 'poster' : 'art'}?url=${encodeURIComponent(url)}`, { method: add ? 'POST' : 'PUT' });
if (!r.ok) throw new Error(r.status === 403 || r.status === 404 ? 'Only the Plex owner can change images' : 'Plex would not take that (' + r.status + ')');
};
box.querySelectorAll('.vpick').forEach(b => b.onclick = async () => { try { await put(list[+b.dataset.i].ratingKey || list[+b.dataset.i].key); box.querySelectorAll('.vpick').forEach(x => x.classList.toggle('on', x === b)); toast('Image changed'); changed = true; } catch (e) { box.querySelector('.vie').textContent = e.message; } });
box.querySelector('.vadd').onclick = async () => { const u = box.querySelector('.vurl').value.trim(); if (!/^https:\/\//.test(u)) { box.querySelector('.vie').textContent = 'Use a link that starts with https://'; return; } try { await put(u, true); toast('Image added'); changed = true; loaded[tab] = false; images(tab, what); } catch (e) { box.querySelector('.vie').textContent = e.message; } };
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
};
let changed = false;
el.querySelectorAll('.sgrp a[data-t]').forEach(a => a.onclick = ev => {
ev.preventDefault(); const t = a.dataset.t;
el.querySelectorAll('.sgrp a[data-t]').forEach(x => { x.classList.toggle('on', x === a); x.setAttribute('aria-selected', x === a); });
['d', 'p', 'a'].forEach(k => { el.querySelector('#vtab-' + k).hidden = k !== t; }); el.querySelector('#vs').hidden = t !== 'd';
if (t !== 'd' && !loaded[t]) { loaded[t] = true; images(t, t === 'p' ? 'posters' : 'arts'); }
});
el.querySelector('#vf').onsubmit = async ev => {
ev.preventDefault(); const btn = el.querySelector('#vs'); btn.disabled = true; el.querySelector('#ve').textContent = '';
const q = new URLSearchParams({ type: VD_TYPE[kind] || 1, id: m.ratingKey, includeExternalMedia: 1 }); let n = 0;
for (const [k, , t] of F) { const v = el.querySelector('#ve-' + k).value.trim(); if (v !== (t === 'date' ? dateOf(m[k]) : old(k))) { q.set(k + '.value', v); q.set(k + '.locked', 1); n++; } }
if (rich) { const g = [...new Set(el.querySelector('#ve-genre').value.split(',').map(x => x.trim()).filter(Boolean))]; if (g.join('|') !== genres.join('|')) { g.forEach((x, i) => q.set(`genre[${i}].tag.tag`, x)); q.set('genre.locked', 1); n++; } }
if (!n) { closeSheet(); if (changed) route(); return; }
try {
const r = await fetch(`/plex/library/sections/${m.librarySectionID}/all?${q}`, { method: 'PUT' });
if (!r.ok) throw new Error(r.status === 403 || r.status === 404 ? 'Only the Plex owner can change details' : 'Plex would not save that (' + r.status + ')');
closeSheet(); toast('Saved in Plex'); route();
} catch (e) { btn.disabled = false; el.querySelector('#ve').textContent = e.message; }
};
}
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more